giveaway.py 15 KB

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