giveaway.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. from __future__ import annotations
  2. import asyncio
  3. import re
  4. from datetime import timedelta
  5. from html import escape
  6. from typing import Any
  7. from pyrogram import filters
  8. from pyrogram.enums import ParseMode
  9. from pyrogram.types import CallbackQuery, Message
  10. from wbb import app, log
  11. from wbb.core.decorators.permissions import adminsOnly
  12. from wbb.services.giveaways import (
  13. GiveawayServiceError,
  14. cancel_and_refund_giveaway,
  15. create_and_publish_giveaway,
  16. finish_and_publish_giveaway,
  17. join_giveaway,
  18. remove_and_optionally_refund_participant,
  19. render_winners,
  20. reroll_giveaway,
  21. resume_pending_giveaway,
  22. )
  23. from wbb.services.member_identity import display_name
  24. from wbb.utils.dbgiveaway import (
  25. add_giveaway_ban,
  26. count_participants,
  27. get_giveaway,
  28. list_due_giveaways,
  29. list_participants,
  30. list_pending_cancellations,
  31. list_running_giveaways,
  32. normalize_giveaway_id,
  33. remove_giveaway_ban,
  34. utc_now,
  35. )
  36. from wbb.utils.functions import extract_user_and_reason
  37. __MODULE__ = "抽奖管理"
  38. __HELP__ = """/giveaway <时长> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励]
  39. 在当前群创建抽奖。
  40. 奖项格式:`1`、`一等奖:1, 二等奖:3`,或 `一等奖:1:50`;
  41. 最后的 50 表示该奖项每位中奖者获得 50 积分。
  42. 时长单位:s 秒、m 分钟、h 小时、d 天、w 周。
  43. /gjoin <编号> - 报名参加抽奖。
  44. /glist - 查看进行中的抽奖。
  45. /gparticipants <编号> - 查看参与者。
  46. /gend <编号> - 立即开奖。
  47. /gcancel <编号> - 取消抽奖并退还报名积分。
  48. /greroll <编号> [奖项] - 排除历次中奖者后重抽。
  49. /gremove <编号> <用户 ID> [no-refund] - 移除参与者,可选择不退款。
  50. /gban <成员> [原因] - 禁止成员参加本群抽奖。
  51. /gunban <成员> - 解除抽奖参与限制。"""
  52. MAX_TOTAL_WINNERS = 100
  53. PARTICIPANT_PREVIEW_LIMIT = 30
  54. SWEEP_INTERVAL_SECONDS = 30
  55. _DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhdwSMHDW])\s*$")
  56. _DURATION_UNITS = {
  57. "s": "seconds",
  58. "m": "minutes",
  59. "h": "hours",
  60. "d": "days",
  61. "w": "weeks",
  62. }
  63. _sweeper_started = False
  64. def _parse_duration(raw: str):
  65. match = _DURATION_RE.match(raw)
  66. if not match:
  67. return None
  68. amount = int(match.group(1))
  69. if amount <= 0:
  70. return None
  71. return utc_now() + timedelta(**{_DURATION_UNITS[match.group(2).lower()]: amount})
  72. def _parse_prizes(raw: str) -> list[dict[str, Any]] | None:
  73. raw = raw.strip()
  74. if raw.isdigit():
  75. count = int(raw)
  76. if 0 < count <= MAX_TOTAL_WINNERS:
  77. return [{"name": "中奖者", "count": count, "points_reward": 0}]
  78. return None
  79. prizes: list[dict[str, Any]] = []
  80. for piece in re.split(r"[,;\n]+", raw):
  81. parts = [part.strip() for part in re.split(r"[::]", piece.strip())]
  82. if len(parts) not in {2, 3} or not parts[0] or not parts[1].isdigit():
  83. return None
  84. count = int(parts[1])
  85. points_reward = int(parts[2]) if len(parts) == 3 and parts[2].isdigit() else 0
  86. if count <= 0 or points_reward < 0:
  87. return None
  88. prizes.append(
  89. {"name": parts[0], "count": count, "points_reward": points_reward}
  90. )
  91. if not prizes or sum(item["count"] for item in prizes) > MAX_TOTAL_WINNERS:
  92. return None
  93. return prizes
  94. def _parse_optional_points(pieces: list[str], index: int) -> int | None:
  95. if len(pieces) <= index or not pieces[index]:
  96. return 0
  97. if not pieces[index].isdigit():
  98. return None
  99. return int(pieces[index])
  100. def _create_usage() -> str:
  101. return (
  102. "<b>用法</b>\n"
  103. "/giveaway 1h | 1 | 月度会员抽奖\n"
  104. "/giveaway 2d | 一等奖:1:50, 二等奖:3:10 | 活动抽奖 | "
  105. "抽奖说明 | 20 | 5 | 2"
  106. )
  107. def _actor_id(message: Message) -> int:
  108. if message.from_user:
  109. return message.from_user.id
  110. if message.sender_chat:
  111. return message.sender_chat.id
  112. return 0
  113. def _actor_name(message: Message) -> str:
  114. if message.from_user:
  115. return message.from_user.first_name or str(message.from_user.id)
  116. if message.sender_chat:
  117. return message.sender_chat.title or str(message.sender_chat.id)
  118. return "匿名管理员"
  119. def _participant_mention(participant: dict[str, Any]) -> str:
  120. user_id = int(participant["user_id"])
  121. label = (
  122. participant.get("display_name")
  123. or ("@" + str(participant["username"]) if participant.get("username") else None)
  124. or participant.get("first_name")
  125. or str(user_id)
  126. )
  127. return f'<a href="tg://user?id={user_id}">{escape(str(label))}</a>'
  128. async def _join_user(giveaway_id: str, user) -> str:
  129. status, _ = await join_giveaway(
  130. giveaway_id,
  131. user_id=user.id,
  132. username=user.username,
  133. first_name=user.first_name,
  134. display_name=display_name(user.first_name, user.last_name),
  135. )
  136. return {
  137. "ok": "报名成功。",
  138. "duplicate": "你已经报名过该抽奖。",
  139. "missing": "未找到该抽奖。",
  140. "closed": "该抽奖已经关闭。",
  141. "ended": "该抽奖已经结束。",
  142. "banned": "你已被禁止参加本群抽奖。",
  143. "not_member": "只有当前群成员可以参加该抽奖。",
  144. "insufficient_points": "你的积分不足,无法报名。",
  145. }.get(status, "报名失败,请稍后重试。")
  146. async def _sweep_due_giveaways() -> None:
  147. await asyncio.sleep(5)
  148. while True:
  149. try:
  150. pending = await list_due_giveaways()
  151. pending.extend(await list_pending_cancellations())
  152. for giveaway in pending:
  153. await resume_pending_giveaway(giveaway)
  154. except Exception as exc:
  155. log.error(f"抽奖定时任务失败:{exc}")
  156. await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
  157. def _start_sweeper() -> None:
  158. global _sweeper_started
  159. if _sweeper_started:
  160. return
  161. try:
  162. loop = asyncio.get_running_loop()
  163. except RuntimeError:
  164. return
  165. _sweeper_started = True
  166. loop.create_task(_sweep_due_giveaways(), name="giveaway-sweeper")
  167. @app.on_message(filters.command(["giveaway", "gcreate"]) & ~filters.private)
  168. @adminsOnly("can_change_info")
  169. async def create_giveaway_command(_, message: Message):
  170. payload = message.text.split(None, 1)[1].strip() if message.text and " " in message.text else ""
  171. pieces = [piece.strip() for piece in payload.split("|")]
  172. if len(pieces) < 3:
  173. return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
  174. ends_at = _parse_duration(pieces[0])
  175. prizes = _parse_prizes(pieces[1])
  176. minimum_points = _parse_optional_points(pieces, 4)
  177. entry_cost = _parse_optional_points(pieces, 5)
  178. participation_reward = _parse_optional_points(pieces, 6)
  179. if (
  180. not ends_at
  181. or not prizes
  182. or not pieces[2]
  183. or minimum_points is None
  184. or entry_cost is None
  185. or participation_reward is None
  186. ):
  187. return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
  188. giveaway = await create_and_publish_giveaway(
  189. chat_id=message.chat.id,
  190. creator_id=_actor_id(message),
  191. creator_name=_actor_name(message),
  192. title=pieces[2],
  193. description=pieces[3] if len(pieces) > 3 else "",
  194. prizes=prizes,
  195. ends_at=ends_at,
  196. minimum_points=minimum_points,
  197. entry_cost=entry_cost,
  198. participation_reward=participation_reward,
  199. )
  200. return await message.reply_text(f"抽奖 #{giveaway['giveaway_id']} 已创建。")
  201. @app.on_callback_query(filters.regex(r"^giveaway_join:"))
  202. async def giveaway_join_callback(_, query: CallbackQuery):
  203. giveaway_id = normalize_giveaway_id(query.data.split(":", 1)[1])
  204. await query.answer(await _join_user(giveaway_id, query.from_user), show_alert=True)
  205. @app.on_message(filters.command("gjoin") & ~filters.private)
  206. async def giveaway_join_command(_, message: Message):
  207. if not message.from_user:
  208. return await message.reply_text("只有普通用户身份可以参加抽奖。")
  209. if len(message.command) < 2:
  210. return await message.reply_text("用法:/gjoin <抽奖编号>")
  211. return await message.reply_text(await _join_user(message.command[1], message.from_user))
  212. @app.on_message(filters.command("glist") & ~filters.private)
  213. @adminsOnly("can_change_info")
  214. async def list_giveaways_command(_, message: Message):
  215. giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=20)
  216. if not giveaways:
  217. return await message.reply_text("本群当前没有进行中的抽奖。")
  218. lines = ["<b>进行中的抽奖</b>"]
  219. for giveaway in giveaways:
  220. lines.append(
  221. "#{id} - {title} - {count} 人参与 - 截止于 {ends_at}".format(
  222. id=escape(giveaway["giveaway_id"]),
  223. title=escape(giveaway["title"]),
  224. count=await count_participants(giveaway["giveaway_id"]),
  225. ends_at=giveaway["ends_at"].strftime("%Y-%m-%d %H:%M UTC"),
  226. )
  227. )
  228. return await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
  229. @app.on_message(filters.command("gparticipants") & ~filters.private)
  230. @adminsOnly("can_change_info")
  231. async def participants_command(_, message: Message):
  232. if len(message.command) < 2:
  233. return await message.reply_text("用法:/gparticipants <抽奖编号>")
  234. giveaway_id = normalize_giveaway_id(message.command[1])
  235. giveaway = await get_giveaway(giveaway_id)
  236. if not giveaway or giveaway["chat_id"] != message.chat.id:
  237. return await message.reply_text("本群中未找到该抽奖。")
  238. total = await count_participants(giveaway_id)
  239. participants = await list_participants(giveaway_id, limit=PARTICIPANT_PREVIEW_LIMIT)
  240. lines = [f"<b>抽奖 #{escape(giveaway_id)} 的参与者</b>", f"共 {total} 人"]
  241. lines.extend("- " + _participant_mention(item) for item in participants)
  242. if total > len(participants):
  243. lines.append(f"另有 {total - len(participants)} 人未显示。")
  244. return await message.reply_text(
  245. "\n".join(lines), parse_mode=ParseMode.HTML, disable_web_page_preview=True
  246. )
  247. @app.on_message(filters.command("gend") & ~filters.private)
  248. @adminsOnly("can_change_info")
  249. async def end_giveaway_command(_, message: Message):
  250. if len(message.command) < 2:
  251. return await message.reply_text("用法:/gend <抽奖编号>")
  252. giveaway_id = normalize_giveaway_id(message.command[1])
  253. giveaway = await get_giveaway(giveaway_id)
  254. if not giveaway or giveaway["chat_id"] != message.chat.id:
  255. return await message.reply_text("本群中未找到该抽奖。")
  256. _, text, _ = await finish_and_publish_giveaway(giveaway_id)
  257. return await message.reply_text(text)
  258. @app.on_message(filters.command("gcancel") & ~filters.private)
  259. @adminsOnly("can_change_info")
  260. async def cancel_giveaway_command(_, message: Message):
  261. if len(message.command) < 2:
  262. return await message.reply_text("用法:/gcancel <抽奖编号>")
  263. giveaway_id = normalize_giveaway_id(message.command[1])
  264. giveaway = await get_giveaway(giveaway_id)
  265. if not giveaway or giveaway["chat_id"] != message.chat.id:
  266. return await message.reply_text("本群中未找到该抽奖。")
  267. _, text, _ = await cancel_and_refund_giveaway(
  268. giveaway_id, chat_id=message.chat.id
  269. )
  270. return await message.reply_text(text)
  271. @app.on_message(filters.command("greroll") & ~filters.private)
  272. @adminsOnly("can_change_info")
  273. async def reroll_giveaway_command(_, message: Message):
  274. parts = message.text.split(None, 2) if message.text else []
  275. if len(parts) < 2:
  276. return await message.reply_text("用法:/greroll <抽奖编号> [奖项名称]")
  277. giveaway_id = normalize_giveaway_id(parts[1])
  278. giveaway = await get_giveaway(giveaway_id)
  279. if not giveaway or giveaway["chat_id"] != message.chat.id:
  280. return await message.reply_text("本群中未找到该抽奖。")
  281. try:
  282. winners, _ = await reroll_giveaway(
  283. giveaway_id=giveaway_id,
  284. moderator_id=_actor_id(message),
  285. tier_name=parts[2].strip() if len(parts) > 2 else None,
  286. publish=False,
  287. )
  288. except GiveawayServiceError as exc:
  289. return await message.reply_text(str(exc))
  290. return await message.reply_text(
  291. render_winners(
  292. giveaway,
  293. winners,
  294. reroll=True,
  295. tier_name=parts[2].strip() if len(parts) > 2 else None,
  296. ),
  297. parse_mode=ParseMode.HTML,
  298. disable_web_page_preview=True,
  299. )
  300. @app.on_message(filters.command("gremove") & ~filters.private)
  301. @adminsOnly("can_change_info")
  302. async def remove_participant_command(_, message: Message):
  303. if len(message.command) < 3 or not message.command[2].lstrip("-").isdigit():
  304. return await message.reply_text(
  305. "用法:/gremove <抽奖编号> <用户 ID> [no-refund]"
  306. )
  307. giveaway_id = normalize_giveaway_id(message.command[1])
  308. giveaway = await get_giveaway(giveaway_id)
  309. if not giveaway or giveaway["chat_id"] != message.chat.id:
  310. return await message.reply_text("本群中未找到该抽奖。")
  311. refund = not (len(message.command) > 3 and message.command[3].lower() == "no-refund")
  312. removed = await remove_and_optionally_refund_participant(
  313. giveaway_id=giveaway_id,
  314. user_id=int(message.command[2]),
  315. moderator_id=_actor_id(message),
  316. reason="由群管理员移除",
  317. refund=refund,
  318. )
  319. return await message.reply_text(
  320. "参与者已移除。"
  321. if removed
  322. else "未找到该参与者,或该参与者此前已被移除。"
  323. )
  324. @app.on_message(filters.command("gban") & ~filters.private)
  325. @adminsOnly("can_change_info")
  326. async def giveaway_ban_command(_, message: Message):
  327. user_id, reason = await extract_user_and_reason(message)
  328. if not user_id:
  329. return await message.reply_text("用法:/gban <成员> [原因]")
  330. await add_giveaway_ban(
  331. chat_id=message.chat.id,
  332. user_id=int(user_id),
  333. moderator_id=_actor_id(message),
  334. reason=reason,
  335. )
  336. return await message.reply_text(f"已禁止用户 {user_id} 参加本群抽奖。")
  337. @app.on_message(filters.command("gunban") & ~filters.private)
  338. @adminsOnly("can_change_info")
  339. async def giveaway_unban_command(_, message: Message):
  340. user_id, _ = await extract_user_and_reason(message)
  341. if not user_id:
  342. return await message.reply_text("用法:/gunban <成员>")
  343. removed = await remove_giveaway_ban(message.chat.id, int(user_id))
  344. return await message.reply_text(
  345. f"已允许用户 {user_id} 参加本群抽奖。"
  346. if removed
  347. else "该用户没有被限制参加抽奖。"
  348. )
  349. _start_sweeper()