points.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. from __future__ import annotations
  2. import asyncio
  3. import re
  4. from html import escape
  5. from pyrogram import filters
  6. from pyrogram.enums import ParseMode
  7. from pyrogram.types import Message, User
  8. from wbb import app, log
  9. from wbb.core.decorators.permissions import adminsOnly
  10. from wbb.services.interaction_settings import command_enabled, get_interaction_settings
  11. from wbb.services.member_identity import non_mention_account_name
  12. from wbb.services.message_cleanup import schedule_group_cleanup
  13. from wbb.services.point_settings import (
  14. CHECKIN_BUTTON_TEXT,
  15. apply_point_rules,
  16. checkin_button_active,
  17. refresh_active_checkin_keyboards,
  18. )
  19. from wbb.utils.dbpoints import (
  20. DEFAULT_CHECKIN_ALREADY_MESSAGE,
  21. DEFAULT_CHECKIN_SUCCESS_MESSAGE,
  22. SOURCE_ADMIN,
  23. InsufficientPoints,
  24. PointsError,
  25. adjust_points,
  26. award_activity,
  27. award_checkin,
  28. get_checkin_metrics,
  29. get_point_account,
  30. get_point_rules,
  31. list_point_accounts,
  32. list_point_transactions,
  33. reconcile_all_accounts,
  34. set_points,
  35. utc_now,
  36. )
  37. from wbb.utils.i18n import point_source_label
  38. __MODULE__ = "积分管理"
  39. __HELP__ = """/points - 查看自己或被回复成员的积分余额。
  40. /checkin - 领取每日签到积分。
  41. /points_rank - 查看本群积分排行榜,也可直接发送“积分排行”。
  42. /points_history - 查看自己或被回复成员的最近积分流水。
  43. /points_add <成员> <数值> <原因> - 增加积分。
  44. /points_deduct <成员> <数值> <原因> - 扣减积分。
  45. /points_set <成员> <余额> <原因> - 通过新流水设置余额。
  46. /points_toggle <开启|关闭> - 开关本群积分系统。
  47. /checkin_button <开启|关闭> - 开关输入框底部的签到按钮。
  48. 群成员也可以直接发送“签到”“积分”“流水”触发对应功能。"""
  49. _VOTE_RE = re.compile(
  50. r"^(\++|\+1|thx|tnx|tq|ty|thankyou|thank you|thanx|thanks|pro|cool|good|agree|👍|-+|-1|not cool|disagree|worst|bad|👎)$",
  51. re.IGNORECASE,
  52. )
  53. _reconciler_started = False
  54. RECONCILE_INTERVAL_SECONDS = 3600
  55. _TOGGLE_CHOICES = {
  56. "enable": True,
  57. "enabled": True,
  58. "on": True,
  59. "开启": True,
  60. "打开": True,
  61. "disable": False,
  62. "disabled": False,
  63. "off": False,
  64. "关闭": False,
  65. }
  66. def _mention(user_id: int, label: str | None) -> str:
  67. return f'<a href="tg://user?id={user_id}">{escape(label or str(user_id))}</a>'
  68. def _user_display_name(user: User) -> str:
  69. return " ".join(
  70. value for value in (user.first_name, user.last_name) if value
  71. ) or (f"@{user.username}" if user.username else str(user.id))
  72. def _message_content(message: Message) -> str:
  73. text = (message.text or message.caption or "").strip()
  74. if text:
  75. return text
  76. media = (
  77. message.photo
  78. or message.animation
  79. or message.video
  80. or message.document
  81. or message.audio
  82. or message.voice
  83. or message.sticker
  84. )
  85. unique_id = getattr(media, "file_unique_id", None)
  86. return f"media:{unique_id}" if unique_id else ""
  87. async def _reply_checkin(message: Message, text: str) -> Message:
  88. response = await message.reply_text(text, parse_mode=ParseMode.DISABLED)
  89. settings = await get_interaction_settings(message.chat.id)
  90. schedule_group_cleanup(message, response, delay_seconds=settings["cleanup_seconds"])
  91. return response
  92. async def _reply_balance(message: Message, text: str) -> Message:
  93. response = await message.reply_text(text, parse_mode=ParseMode.HTML)
  94. settings = await get_interaction_settings(message.chat.id)
  95. schedule_group_cleanup(message, response, delay_seconds=settings["cleanup_seconds"])
  96. return response
  97. def _checkin_message(
  98. template: str,
  99. *,
  100. points: int,
  101. balance: int,
  102. rank: int,
  103. streak: int,
  104. month_days: int,
  105. ) -> str:
  106. return (
  107. template.replace("{points}", str(points))
  108. .replace("{balance}", str(balance))
  109. .replace("{rank}", str(rank))
  110. .replace("{streak}", str(streak))
  111. .replace("{month_days}", str(month_days))
  112. )
  113. async def _resolve_target(message: Message, raw: str | None = None) -> User | None:
  114. if message.reply_to_message and message.reply_to_message.from_user:
  115. return message.reply_to_message.from_user
  116. if not raw:
  117. return message.from_user
  118. try:
  119. return await app.get_users(int(raw) if raw.lstrip("-").isdigit() else raw)
  120. except Exception:
  121. return None
  122. def _adjustment_parts(message: Message) -> tuple[str | None, str | None, str]:
  123. parts = message.text.split() if message.text else []
  124. if message.reply_to_message:
  125. amount = parts[1] if len(parts) > 1 else None
  126. reason = " ".join(parts[2:]).strip()
  127. return None, amount, reason
  128. target = parts[1] if len(parts) > 1 else None
  129. amount = parts[2] if len(parts) > 2 else None
  130. reason = " ".join(parts[3:]).strip()
  131. return target, amount, reason
  132. @app.on_message(
  133. filters.group & (filters.command("points") | filters.regex(r"^积分$"))
  134. )
  135. async def points_balance_command(_, message: Message):
  136. if not await command_enabled(message.chat.id, "points"):
  137. return None
  138. target = await _resolve_target(message)
  139. if not target:
  140. return await _reply_balance(message, "未找到该成员。")
  141. account = await get_point_account(message.chat.id, target.id)
  142. return await _reply_balance(
  143. message,
  144. f"{_mention(target.id, target.first_name)} 当前有 "
  145. f"<b>{int(account['balance'])}</b> 积分。",
  146. )
  147. @app.on_message(
  148. filters.group
  149. & (filters.command("checkin") | filters.regex(r"^签到$"))
  150. )
  151. async def points_checkin_command(_, message: Message):
  152. if not await command_enabled(message.chat.id, "checkin"):
  153. return None
  154. if not message.from_user:
  155. return await _reply_checkin(message, "匿名管理员无法签到。")
  156. now = utc_now()
  157. try:
  158. account, created = await award_checkin(
  159. chat_id=message.chat.id,
  160. user_id=message.from_user.id,
  161. username=message.from_user.username,
  162. first_name=message.from_user.first_name,
  163. display_name=_user_display_name(message.from_user),
  164. now=now,
  165. )
  166. metrics = await get_checkin_metrics(
  167. chat_id=message.chat.id,
  168. user_id=message.from_user.id,
  169. now=now,
  170. )
  171. except PointsError as exc:
  172. return await _reply_checkin(message, str(exc))
  173. rules = await get_point_rules(message.chat.id)
  174. points = int(rules.get("checkin_points", 0))
  175. balance = int(account["balance"])
  176. if not created:
  177. return await _reply_checkin(
  178. message,
  179. _checkin_message(
  180. str(
  181. rules.get("checkin_already_message")
  182. or DEFAULT_CHECKIN_ALREADY_MESSAGE
  183. ),
  184. points=points,
  185. balance=balance,
  186. **metrics,
  187. ),
  188. )
  189. return await _reply_checkin(
  190. message,
  191. _checkin_message(
  192. str(
  193. rules.get("checkin_success_message")
  194. or DEFAULT_CHECKIN_SUCCESS_MESSAGE
  195. ),
  196. points=points,
  197. balance=balance,
  198. **metrics,
  199. ),
  200. )
  201. @app.on_message(
  202. filters.group
  203. & (filters.command("points_rank") | filters.regex(r"^积分排行$"))
  204. )
  205. async def points_rank_command(_, message: Message):
  206. if not await command_enabled(message.chat.id, "points"):
  207. return None
  208. accounts, _ = await list_point_accounts(
  209. chat_id=message.chat.id, page=1, page_size=10
  210. )
  211. if not accounts:
  212. return await message.reply_text("本群尚无积分记录。")
  213. lines = ["<b>本群积分排行榜</b>"]
  214. for index, account in enumerate(accounts, 1):
  215. lines.append(
  216. f"{index}. {escape(non_mention_account_name(account))} - "
  217. f"<b>{int(account['balance'])}</b> 积分"
  218. )
  219. return await message.reply_text(
  220. "\n".join(lines), parse_mode=ParseMode.HTML, disable_web_page_preview=True
  221. )
  222. @app.on_message(
  223. filters.group
  224. & (filters.command("points_history") | filters.regex(r"^流水$"))
  225. )
  226. async def points_history_command(_, message: Message):
  227. if not await command_enabled(message.chat.id, "history"):
  228. return None
  229. target = await _resolve_target(message)
  230. if not target:
  231. return await message.reply_text("未找到该成员。")
  232. items, _ = await list_point_transactions(
  233. chat_id=message.chat.id, user_id=target.id, page=1, page_size=10
  234. )
  235. if not items:
  236. return await message.reply_text("未找到积分流水。")
  237. lines = [f"<b>{escape(target.first_name)} 的最近积分流水</b>"]
  238. for item in items:
  239. sign = "+" if int(item["delta"]) > 0 else ""
  240. created_at = item["created_at"].strftime("%Y-%m-%d %H:%M UTC")
  241. lines.append(
  242. f"{sign}{int(item['delta'])} · "
  243. f"{escape(point_source_label(item['source']))} · "
  244. f"余额 {int(item['balance_after'])} · {created_at}"
  245. )
  246. return await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
  247. async def _adjust_points_command(message: Message, operation: str):
  248. target_raw, amount_raw, reason = _adjustment_parts(message)
  249. if not amount_raw or not amount_raw.isdigit() or not reason:
  250. operation_labels = {
  251. "add": "add",
  252. "deduct": "deduct",
  253. "set": "set",
  254. }
  255. return await message.reply_text(
  256. f"用法:/points_{operation_labels[operation]} "
  257. "<成员> <积分数值> <原因>(也可以回复成员消息后使用)"
  258. )
  259. target = await _resolve_target(message, target_raw)
  260. if not target:
  261. return await message.reply_text("未找到该成员。")
  262. value = int(amount_raw)
  263. if operation == "deduct":
  264. value = -value
  265. actor_id = message.from_user.id if message.from_user else message.chat.id
  266. key = f"admin-points:{message.chat.id}:{message.id}:{operation}:{target.id}"
  267. try:
  268. if operation == "set":
  269. account, created = await set_points(
  270. chat_id=message.chat.id,
  271. user_id=target.id,
  272. balance=value,
  273. actor_id=actor_id,
  274. reason=reason,
  275. idempotency_key=key,
  276. username=target.username,
  277. first_name=target.first_name,
  278. display_name=_user_display_name(target),
  279. )
  280. else:
  281. account, created = await adjust_points(
  282. chat_id=message.chat.id,
  283. user_id=target.id,
  284. delta=value,
  285. source=SOURCE_ADMIN,
  286. idempotency_key=key,
  287. actor_id=actor_id,
  288. reason=reason,
  289. username=target.username,
  290. first_name=target.first_name,
  291. display_name=_user_display_name(target),
  292. )
  293. except (PointsError, InsufficientPoints) as exc:
  294. return await message.reply_text(str(exc))
  295. state = "已更新" if created else "此前已处理"
  296. return await message.reply_text(
  297. f"积分{state}。{target.first_name} 的当前余额:"
  298. f"{int(account['balance'])}。"
  299. )
  300. @app.on_message(filters.command("points_add") & ~filters.private)
  301. @adminsOnly("can_change_info")
  302. async def points_add_command(_, message: Message):
  303. return await _adjust_points_command(message, "add")
  304. @app.on_message(filters.command("points_deduct") & ~filters.private)
  305. @adminsOnly("can_change_info")
  306. async def points_deduct_command(_, message: Message):
  307. return await _adjust_points_command(message, "deduct")
  308. @app.on_message(filters.command("points_set") & ~filters.private)
  309. @adminsOnly("can_change_info")
  310. async def points_set_command(_, message: Message):
  311. return await _adjust_points_command(message, "set")
  312. @app.on_message(filters.command("points_toggle") & ~filters.private)
  313. @adminsOnly("can_change_info")
  314. async def points_toggle_command(_, message: Message):
  315. choice = message.command[1].lower() if len(message.command) == 2 else ""
  316. if choice not in _TOGGLE_CHOICES:
  317. return await message.reply_text("用法:/points_toggle <开启|关闭>")
  318. enabled = _TOGGLE_CHOICES[choice]
  319. try:
  320. await apply_point_rules(message.chat.id, {"enabled": enabled})
  321. except PointsError as exc:
  322. return await message.reply_text(str(exc))
  323. return await message.reply_text(
  324. f"本群积分系统已{'开启' if enabled else '关闭'}。"
  325. )
  326. @app.on_message(filters.command("checkin_button") & ~filters.private)
  327. @adminsOnly("can_change_info")
  328. async def checkin_button_command(_, message: Message):
  329. choice = message.command[1].lower() if len(message.command) == 2 else ""
  330. if choice not in _TOGGLE_CHOICES:
  331. return await message.reply_text("用法:/checkin_button <开启|关闭>")
  332. enabled = _TOGGLE_CHOICES[choice]
  333. try:
  334. rules = await apply_point_rules(
  335. message.chat.id, {"checkin_button_enabled": enabled}
  336. )
  337. except PointsError as exc:
  338. return await message.reply_text(str(exc))
  339. if enabled and not checkin_button_active(rules):
  340. return await message.reply_text(
  341. "底部签到按钮设置已开启;启用积分系统和签到规则后会自动显示。"
  342. )
  343. return await message.reply_text(
  344. f"群底部签到按钮已{'开启' if enabled else '关闭'}。"
  345. )
  346. @app.on_message(filters.group & filters.incoming, group=13)
  347. async def points_activity_listener(_, message: Message):
  348. if not message.from_user or message.from_user.is_bot:
  349. return
  350. if getattr(message, "service", None):
  351. return
  352. content = _message_content(message)
  353. if not content or content.startswith("/"):
  354. return
  355. if content == CHECKIN_BUTTON_TEXT:
  356. return
  357. if message.reply_to_message and _VOTE_RE.fullmatch(content.strip()):
  358. return
  359. await award_activity(
  360. chat_id=message.chat.id,
  361. user_id=message.from_user.id,
  362. message_id=message.id,
  363. content=content,
  364. username=message.from_user.username,
  365. first_name=message.from_user.first_name,
  366. display_name=_user_display_name(message.from_user),
  367. )
  368. async def _reconcile_points_loop() -> None:
  369. try:
  370. refreshed = await refresh_active_checkin_keyboards()
  371. if refreshed:
  372. log.info(f"已刷新 {refreshed} 个群的签到快捷按钮")
  373. except Exception as exc:
  374. log.error(f"刷新签到快捷按钮失败:{exc}")
  375. await asyncio.sleep(60)
  376. while True:
  377. try:
  378. await reconcile_all_accounts(limit=10000)
  379. except Exception as exc:
  380. log.error(f"Points reconciliation failed: {exc}")
  381. await asyncio.sleep(RECONCILE_INTERVAL_SECONDS)
  382. def _start_reconciler() -> None:
  383. global _reconciler_started
  384. if _reconciler_started:
  385. return
  386. try:
  387. loop = asyncio.get_running_loop()
  388. except RuntimeError:
  389. return
  390. _reconciler_started = True
  391. loop.create_task(_reconcile_points_loop(), name="points-reconciler")
  392. _start_reconciler()