giveaway.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  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 (
  9. CallbackQuery,
  10. InlineKeyboardButton,
  11. InlineKeyboardMarkup,
  12. Message,
  13. )
  14. from wbb import app, log
  15. from wbb.core.decorators.permissions import adminsOnly
  16. from wbb.services.giveaway_templates import list_templates, materialize_template
  17. from wbb.services.giveaways import (
  18. GiveawayServiceError,
  19. buy_additional_tickets,
  20. cancel_and_refund_giveaway,
  21. create_and_publish_giveaway,
  22. finish_and_publish_giveaway,
  23. format_giveaway_time,
  24. giveaway_starts_at,
  25. join_giveaway,
  26. parse_giveaway_time,
  27. publish_finished,
  28. reconcile_pending_ticket_orders,
  29. refresh_giveaway_message,
  30. remove_and_optionally_refund_participant,
  31. render_winners,
  32. reroll_giveaway,
  33. resume_pending_giveaway,
  34. )
  35. from wbb.services.interaction_settings import command_enabled
  36. from wbb.services.member_identity import display_name
  37. from wbb.utils.dbgiveaway import (
  38. add_giveaway_ban,
  39. as_utc,
  40. count_participants,
  41. get_giveaway,
  42. giveawaysdb,
  43. list_due_giveaways,
  44. list_giveaways_page,
  45. list_participants_page,
  46. list_pending_cancellations,
  47. list_pending_publications,
  48. list_running_giveaways,
  49. list_unannounced_started_giveaways,
  50. mark_giveaway_start_announced,
  51. normalize_giveaway_id,
  52. remove_giveaway_ban,
  53. utc_now,
  54. )
  55. from wbb.utils.functions import extract_user_and_reason
  56. __MODULE__ = "抽奖管理"
  57. __HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励]
  58. 在当前群创建抽奖。
  59. 群成员也可以直接发送“抽奖”查看当前抽奖、报名和参与者,
  60. 或发送“开奖”“中奖名单”查看最近一次开奖结果。
  61. 奖项格式:`1`、`一等奖:1, 二等奖:3`,或 `一等奖:1:50`;
  62. 最后的 50 表示该奖项每位中奖者获得 50 积分。
  63. 时间格式:`YYYY-MM-DD HH:mm`,按北京时间计算。
  64. /gjoin <编号> [张数] - 报名并购买奖票;免费抽奖只能领一张。
  65. /glist - 查看进行中的抽奖。
  66. /gparticipants <编号> - 查看参与者。
  67. /gend <编号> - 立即开奖。
  68. /gcancel <编号> - 取消抽奖并退还报名积分。
  69. /greroll <编号> [奖项] - 排除历次中奖者后重抽。
  70. /gremove <编号> <用户 ID> [no-refund] - 移除参与者,可选择不退款。
  71. /gban <成员> [原因] - 禁止成员参加本群抽奖。
  72. /gunban <成员> - 解除抽奖参与限制。"""
  73. MAX_TOTAL_WINNERS = 100
  74. PARTICIPANTS_PAGE_SIZE = 10
  75. SWEEP_INTERVAL_SECONDS = 30
  76. _sweeper_started = False
  77. def _parse_schedule(raw: str):
  78. pieces = re.split(r"\s*(?:~|至)\s*", raw.strip())
  79. if len(pieces) != 2:
  80. return None
  81. starts_at = parse_giveaway_time(pieces[0])
  82. ends_at = parse_giveaway_time(pieces[1])
  83. if not starts_at or not ends_at or starts_at >= ends_at or ends_at <= utc_now():
  84. return None
  85. return starts_at, ends_at
  86. def _parse_prizes(raw: str) -> list[dict[str, Any]] | None:
  87. raw = raw.strip()
  88. if raw.isdigit():
  89. count = int(raw)
  90. if 0 < count <= MAX_TOTAL_WINNERS:
  91. return [{"name": "中奖者", "count": count, "points_reward": 0}]
  92. return None
  93. prizes: list[dict[str, Any]] = []
  94. for piece in re.split(r"[,;\n]+", raw):
  95. parts = [part.strip() for part in re.split(r"[::]", piece.strip())]
  96. if len(parts) not in {2, 3} or not parts[0] or not parts[1].isdigit():
  97. return None
  98. count = int(parts[1])
  99. points_reward = int(parts[2]) if len(parts) == 3 and parts[2].isdigit() else 0
  100. if count <= 0 or points_reward < 0:
  101. return None
  102. prizes.append(
  103. {"name": parts[0], "count": count, "points_reward": points_reward}
  104. )
  105. if not prizes or sum(item["count"] for item in prizes) > MAX_TOTAL_WINNERS:
  106. return None
  107. return prizes
  108. def _parse_optional_points(pieces: list[str], index: int) -> int | None:
  109. if len(pieces) <= index or not pieces[index]:
  110. return 0
  111. if not pieces[index].isdigit():
  112. return None
  113. return int(pieces[index])
  114. def _create_usage() -> str:
  115. return (
  116. "<b>用法</b>\n"
  117. "/giveaway 2026-09-22 00:00 ~ 2026-09-22 23:59 | 1 | 月度会员抽奖\n"
  118. "/giveaway 2026-09-22 00:00 ~ 2026-09-23 20:00 | "
  119. "一等奖:1:50, 二等奖:3:10 | 活动抽奖 | "
  120. "抽奖说明 | 20 | 5 | 2"
  121. )
  122. def _actor_id(message: Message) -> int:
  123. if message.from_user:
  124. return message.from_user.id
  125. if message.sender_chat:
  126. return message.sender_chat.id
  127. return 0
  128. def _actor_name(message: Message) -> str:
  129. if message.from_user:
  130. return message.from_user.first_name or str(message.from_user.id)
  131. if message.sender_chat:
  132. return message.sender_chat.title or str(message.sender_chat.id)
  133. return "匿名管理员"
  134. def _participant_mention(participant: dict[str, Any]) -> str:
  135. user_id = int(participant["user_id"])
  136. label = (
  137. participant.get("display_name")
  138. or ("@" + str(participant["username"]) if participant.get("username") else None)
  139. or participant.get("first_name")
  140. or str(user_id)
  141. )
  142. return f'<a href="tg://user?id={user_id}">{escape(str(label))}</a>'
  143. def _participants_markup(
  144. giveaway_id: str, *, page: int, total_pages: int
  145. ) -> InlineKeyboardMarkup | None:
  146. if total_pages <= 1:
  147. return None
  148. buttons: list[InlineKeyboardButton] = []
  149. if page > 1:
  150. buttons.append(
  151. InlineKeyboardButton(
  152. "‹ 上一页",
  153. callback_data=f"giveaway_participants_page:{giveaway_id}:{page - 1}",
  154. )
  155. )
  156. buttons.append(
  157. InlineKeyboardButton(
  158. f"{page}/{total_pages}",
  159. callback_data=f"giveaway_participants_noop:{giveaway_id}",
  160. )
  161. )
  162. if page < total_pages:
  163. buttons.append(
  164. InlineKeyboardButton(
  165. "下一页 ›",
  166. callback_data=f"giveaway_participants_page:{giveaway_id}:{page + 1}",
  167. )
  168. )
  169. return InlineKeyboardMarkup([buttons])
  170. async def _render_participants_page(
  171. giveaway_id: str, page: int
  172. ) -> tuple[str, InlineKeyboardMarkup | None]:
  173. page = max(1, page)
  174. participants, total = await list_participants_page(
  175. giveaway_id=giveaway_id,
  176. active_only=True,
  177. page=page,
  178. page_size=PARTICIPANTS_PAGE_SIZE,
  179. )
  180. total_pages = max(1, (total + PARTICIPANTS_PAGE_SIZE - 1) // PARTICIPANTS_PAGE_SIZE)
  181. if page > total_pages:
  182. page = total_pages
  183. participants, total = await list_participants_page(
  184. giveaway_id=giveaway_id,
  185. active_only=True,
  186. page=page,
  187. page_size=PARTICIPANTS_PAGE_SIZE,
  188. )
  189. lines = [
  190. f"<b>抽奖 #{escape(giveaway_id)} 的参与者</b>",
  191. f"第 {page}/{total_pages} 页 · 共 {total} 人",
  192. ]
  193. if participants:
  194. offset = (page - 1) * PARTICIPANTS_PAGE_SIZE
  195. lines.extend(
  196. f"{offset + index}. {_participant_mention(item)}"
  197. f" · {int(item.get('paid_ticket_count', item.get('ticket_count', 1)))} 票"
  198. for index, item in enumerate(participants, 1)
  199. )
  200. else:
  201. lines.append("暂时还没有参与者。")
  202. return "\n".join(lines), _participants_markup(
  203. giveaway_id, page=page, total_pages=total_pages
  204. )
  205. def _keyword_giveaways_markup(
  206. giveaways: list[dict[str, Any]],
  207. ) -> InlineKeyboardMarkup:
  208. rows: list[list[InlineKeyboardButton]] = []
  209. for giveaway in giveaways:
  210. giveaway_id = giveaway["giveaway_id"]
  211. title = str(giveaway["title"])
  212. short_title = title if len(title) <= 12 else title[:11] + "…"
  213. rows.append(
  214. [
  215. InlineKeyboardButton(
  216. f"报名 · {short_title}",
  217. callback_data=f"giveaway_join:{giveaway_id}",
  218. ),
  219. InlineKeyboardButton(
  220. "查看参与者",
  221. callback_data=f"giveaway_participants:{giveaway_id}",
  222. ),
  223. ]
  224. )
  225. return InlineKeyboardMarkup(rows)
  226. def _winners_markup(
  227. giveaway_id: str, *, page: int, total_pages: int
  228. ) -> InlineKeyboardMarkup | None:
  229. if total_pages <= 1:
  230. return None
  231. buttons: list[InlineKeyboardButton] = []
  232. if page > 1:
  233. buttons.append(
  234. InlineKeyboardButton(
  235. "‹ 上一页",
  236. callback_data=f"giveaway_winners_page:{giveaway_id}:{page - 1}",
  237. )
  238. )
  239. buttons.append(
  240. InlineKeyboardButton(
  241. f"{page}/{total_pages}",
  242. callback_data=f"giveaway_winners_noop:{giveaway_id}",
  243. )
  244. )
  245. if page < total_pages:
  246. buttons.append(
  247. InlineKeyboardButton(
  248. "下一页 ›",
  249. callback_data=f"giveaway_winners_page:{giveaway_id}:{page + 1}",
  250. )
  251. )
  252. return InlineKeyboardMarkup([buttons])
  253. def _render_winners_page(
  254. giveaway: dict[str, Any], page: int
  255. ) -> tuple[str, InlineKeyboardMarkup | None]:
  256. winners = list(giveaway.get("winners", []))
  257. total = len(winners)
  258. total_pages = max(1, (total + PARTICIPANTS_PAGE_SIZE - 1) // PARTICIPANTS_PAGE_SIZE)
  259. page = min(max(1, page), total_pages)
  260. offset = (page - 1) * PARTICIPANTS_PAGE_SIZE
  261. current = winners[offset : offset + PARTICIPANTS_PAGE_SIZE]
  262. lines = [
  263. f"<b>抽奖结果 #{escape(giveaway['giveaway_id'])}</b>",
  264. f"<b>标题:</b>{escape(giveaway['title'])}",
  265. f"第 {page}/{total_pages} 页 · 共 {total} 位中奖者",
  266. ]
  267. if current:
  268. for index, winner in enumerate(current, offset + 1):
  269. points = int(winner.get("points_reward", 0))
  270. suffix = f"(+{points} 积分)" if points else ""
  271. lines.append(
  272. f"{index}. {escape(str(winner['tier']))}:"
  273. f"{_participant_mention(winner)}{suffix}"
  274. )
  275. else:
  276. lines.append("本次没有符合条件的中奖者。")
  277. return "\n".join(lines), _winners_markup(
  278. giveaway["giveaway_id"], page=page, total_pages=total_pages
  279. )
  280. async def _join_user(
  281. giveaway_id: str, user, *, quantity: int = 1, request_id: str = ""
  282. ) -> str:
  283. giveaway = await get_giveaway(giveaway_id)
  284. if not giveaway:
  285. return "未找到该抽奖。"
  286. paid = int(giveaway.get("entry_cost", 0)) > 0
  287. if paid:
  288. status, _ = await buy_additional_tickets(
  289. giveaway_id, user_id=user.id, quantity=quantity,
  290. request_id=request_id or f"join:{user.id}",
  291. username=user.username, first_name=user.first_name,
  292. display_name=display_name(user.first_name, user.last_name),
  293. )
  294. elif quantity != 1:
  295. status = "free_single_ticket"
  296. else:
  297. status, _ = await join_giveaway(
  298. giveaway_id,
  299. user_id=user.id,
  300. username=user.username,
  301. first_name=user.first_name,
  302. display_name=display_name(user.first_name, user.last_name),
  303. )
  304. return {
  305. "ok": f"已购买 {quantity} 张奖票。" if paid else "报名成功。",
  306. "duplicate": "该次购买已经处理。" if paid else "你已经报名过该抽奖。",
  307. "missing": "未找到该抽奖。",
  308. "closed": "该抽奖已经关闭。",
  309. "not_started": "该抽奖尚未开始报名。",
  310. "ended": "该抽奖已经结束。",
  311. "banned": "你已被禁止参加本群抽奖。",
  312. "not_member": "只有当前群成员可以参加该抽奖。",
  313. "not_eligible": "尚未满足抽奖要求的频道或群成员资格。",
  314. "eligibility_unavailable": "资格核验暂不可用,请稍后重试。",
  315. "insufficient_points": "你的积分不足,无法报名。",
  316. "ticket_limit": "已达到本期每人奖票上限。",
  317. "free_single_ticket": "免费抽奖每人只能领取一张奖票。",
  318. "invalid_quantity": "奖票数量无效。",
  319. "not_joined": "该成员已被移除,不能继续购买奖票。",
  320. }.get(status, "报名失败,请稍后重试。")
  321. async def _sweep_due_giveaways() -> None:
  322. await asyncio.sleep(5)
  323. while True:
  324. try:
  325. await reconcile_pending_ticket_orders()
  326. for template in await list_templates():
  327. try:
  328. await materialize_template(template)
  329. except Exception as exc:
  330. log.error(f"循环抽奖模板 {template.get('template_id')} 生成失败:{exc}")
  331. for giveaway in await list_unannounced_started_giveaways():
  332. try:
  333. if giveaway.get("template_id"):
  334. previous = await giveawaysdb.find_one(
  335. {
  336. "template_id": giveaway["template_id"],
  337. "period_key": {"$lt": giveaway["period_key"]},
  338. "result_pinned": True,
  339. },
  340. sort=[("period_key", -1)],
  341. )
  342. if previous and previous.get("result_message_id"):
  343. await app.unpin_chat_message(
  344. int(giveaway["chat_id"]), int(previous["result_message_id"])
  345. )
  346. await giveawaysdb.update_one(
  347. {"giveaway_id": previous["giveaway_id"]},
  348. {"$set": {"result_pinned": False}},
  349. )
  350. if await refresh_giveaway_message(giveaway):
  351. await mark_giveaway_start_announced(giveaway["giveaway_id"])
  352. except Exception as exc:
  353. log.error(f"抽奖 {giveaway.get('giveaway_id')} 开始处理失败:{exc}")
  354. pending = await list_due_giveaways()
  355. pending.extend(await list_pending_cancellations())
  356. for giveaway in pending:
  357. await resume_pending_giveaway(giveaway)
  358. for giveaway in await list_pending_publications():
  359. await publish_finished(
  360. giveaway, list(giveaway.get("winners", []))
  361. )
  362. except Exception as exc:
  363. log.error(f"抽奖定时任务失败:{exc}")
  364. await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
  365. def _start_sweeper() -> None:
  366. global _sweeper_started
  367. if _sweeper_started:
  368. return
  369. try:
  370. loop = asyncio.get_running_loop()
  371. except RuntimeError:
  372. return
  373. _sweeper_started = True
  374. loop.create_task(_sweep_due_giveaways(), name="giveaway-sweeper")
  375. @app.on_message(filters.command(["giveaway", "gcreate"]) & ~filters.private)
  376. @adminsOnly("can_change_info")
  377. async def create_giveaway_command(_, message: Message):
  378. payload = message.text.split(None, 1)[1].strip() if message.text and " " in message.text else ""
  379. pieces = [piece.strip() for piece in payload.split("|")]
  380. if len(pieces) < 3:
  381. return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
  382. schedule = _parse_schedule(pieces[0])
  383. prizes = _parse_prizes(pieces[1])
  384. minimum_points = _parse_optional_points(pieces, 4)
  385. entry_cost = _parse_optional_points(pieces, 5)
  386. participation_reward = _parse_optional_points(pieces, 6)
  387. if (
  388. not schedule
  389. or not prizes
  390. or not pieces[2]
  391. or minimum_points is None
  392. or entry_cost is None
  393. or participation_reward is None
  394. ):
  395. return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
  396. starts_at, ends_at = schedule
  397. giveaway = await create_and_publish_giveaway(
  398. chat_id=message.chat.id,
  399. creator_id=_actor_id(message),
  400. creator_name=_actor_name(message),
  401. title=pieces[2],
  402. description=pieces[3] if len(pieces) > 3 else "",
  403. prizes=prizes,
  404. starts_at=starts_at,
  405. ends_at=ends_at,
  406. minimum_points=minimum_points,
  407. entry_cost=entry_cost,
  408. participation_reward=participation_reward,
  409. )
  410. return await message.reply_text(f"抽奖 #{giveaway['giveaway_id']} 已创建。")
  411. @app.on_callback_query(filters.regex(r"^giveaway_join:"))
  412. async def giveaway_join_callback(_, query: CallbackQuery):
  413. if not await command_enabled(query.message.chat.id, "giveaway"):
  414. return await query.answer("本群已关闭抽奖指令。", show_alert=True)
  415. giveaway_id = normalize_giveaway_id(query.data.split(":", 1)[1])
  416. await query.answer(
  417. await _join_user(giveaway_id, query.from_user, request_id=str(query.id)),
  418. show_alert=True,
  419. )
  420. @app.on_callback_query(filters.regex(r"^giveaway_buy:[a-f0-9]+:(?:1|5)$"))
  421. async def giveaway_buy_callback(_, query: CallbackQuery):
  422. if not await command_enabled(query.message.chat.id, "giveaway"):
  423. return await query.answer("本群已关闭抽奖指令。", show_alert=True)
  424. _, giveaway_id, quantity = query.data.split(":")
  425. await query.answer(
  426. await _join_user(
  427. giveaway_id, query.from_user,
  428. quantity=int(quantity), request_id=str(query.id),
  429. ),
  430. show_alert=True,
  431. )
  432. @app.on_callback_query(filters.regex(r"^giveaway_participants:[a-f0-9]+$"))
  433. async def giveaway_participants_callback(_, query: CallbackQuery):
  434. giveaway_id = normalize_giveaway_id(query.data.rsplit(":", 1)[1])
  435. giveaway = await get_giveaway(giveaway_id)
  436. if not giveaway or int(giveaway["chat_id"]) != int(query.message.chat.id):
  437. return await query.answer("本群中未找到该抽奖。", show_alert=True)
  438. text, markup = await _render_participants_page(giveaway_id, 1)
  439. await query.message.reply_text(
  440. text,
  441. parse_mode=ParseMode.HTML,
  442. reply_markup=markup,
  443. disable_web_page_preview=True,
  444. )
  445. return await query.answer()
  446. @app.on_callback_query(
  447. filters.regex(r"^giveaway_participants_page:[a-f0-9]+:\d+$")
  448. )
  449. async def giveaway_participants_page_callback(_, query: CallbackQuery):
  450. _, giveaway_id, raw_page = query.data.split(":", 2)
  451. giveaway_id = normalize_giveaway_id(giveaway_id)
  452. giveaway = await get_giveaway(giveaway_id)
  453. if not giveaway or int(giveaway["chat_id"]) != int(query.message.chat.id):
  454. return await query.answer("本群中未找到该抽奖。", show_alert=True)
  455. text, markup = await _render_participants_page(giveaway_id, int(raw_page))
  456. await query.message.edit_text(
  457. text,
  458. parse_mode=ParseMode.HTML,
  459. reply_markup=markup,
  460. disable_web_page_preview=True,
  461. )
  462. return await query.answer()
  463. @app.on_callback_query(filters.regex(r"^giveaway_participants_noop:"))
  464. async def giveaway_participants_noop_callback(_, query: CallbackQuery):
  465. return await query.answer()
  466. @app.on_callback_query(filters.regex(r"^giveaway_winners_page:[a-f0-9]+:\d+$"))
  467. async def giveaway_winners_page_callback(_, query: CallbackQuery):
  468. _, giveaway_id, raw_page = query.data.split(":", 2)
  469. giveaway_id = normalize_giveaway_id(giveaway_id)
  470. giveaway = await get_giveaway(giveaway_id)
  471. if (
  472. not giveaway
  473. or giveaway["status"] != "finished"
  474. or int(giveaway["chat_id"]) != int(query.message.chat.id)
  475. ):
  476. return await query.answer("本群中未找到该开奖结果。", show_alert=True)
  477. text, markup = _render_winners_page(giveaway, int(raw_page))
  478. await query.message.edit_text(
  479. text,
  480. parse_mode=ParseMode.HTML,
  481. reply_markup=markup,
  482. disable_web_page_preview=True,
  483. )
  484. return await query.answer()
  485. @app.on_callback_query(filters.regex(r"^giveaway_winners_noop:"))
  486. async def giveaway_winners_noop_callback(_, query: CallbackQuery):
  487. return await query.answer()
  488. @app.on_message(filters.command("gjoin") & ~filters.private)
  489. async def giveaway_join_command(_, message: Message):
  490. if not await command_enabled(message.chat.id, "giveaway"):
  491. return None
  492. if not message.from_user:
  493. return await message.reply_text("只有普通用户身份可以参加抽奖。")
  494. if len(message.command) < 2:
  495. return await message.reply_text("用法:/gjoin <抽奖编号> [奖票张数]")
  496. try:
  497. quantity = int(message.command[2]) if len(message.command) > 2 else 1
  498. except ValueError:
  499. return await message.reply_text("奖票张数必须是整数。")
  500. return await message.reply_text(await _join_user(
  501. message.command[1], message.from_user, quantity=quantity,
  502. request_id=f"message:{message.chat.id}:{message.id}",
  503. ))
  504. @app.on_message(filters.regex(r"^抽奖$") & filters.group)
  505. async def giveaway_keyword_command(_, message: Message):
  506. if not await command_enabled(message.chat.id, "giveaway"):
  507. return None
  508. giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=10)
  509. now = utc_now()
  510. giveaways = [
  511. giveaway for giveaway in giveaways if as_utc(giveaway["ends_at"]) > now
  512. ]
  513. if not giveaways:
  514. return await message.reply_text("本群当前没有进行中的抽奖。")
  515. lines = ["<b>本群抽奖</b>"]
  516. for giveaway in giveaways:
  517. state = "报名中" if giveaway_starts_at(giveaway) <= now else "待报名"
  518. lines.append(
  519. "#{id} · {title} · {state} · {count} 人参与 · {ends_at} 开奖".format(
  520. id=escape(giveaway["giveaway_id"]),
  521. title=escape(giveaway["title"]),
  522. state=state,
  523. count=await count_participants(giveaway["giveaway_id"]),
  524. ends_at=format_giveaway_time(giveaway["ends_at"]),
  525. )
  526. )
  527. return await message.reply_text(
  528. "\n".join(lines),
  529. parse_mode=ParseMode.HTML,
  530. reply_markup=_keyword_giveaways_markup(giveaways),
  531. disable_web_page_preview=True,
  532. )
  533. @app.on_message(filters.regex(r"^(?:开奖|中奖名单)$") & filters.group)
  534. async def giveaway_winners_keyword_command(_, message: Message):
  535. if not await command_enabled(message.chat.id, "giveaway"):
  536. return None
  537. giveaways, _ = await list_giveaways_page(
  538. chat_id=message.chat.id,
  539. status="finished",
  540. page=1,
  541. page_size=20,
  542. )
  543. if not giveaways:
  544. return await message.reply_text("本群暂无已开奖的抽奖。")
  545. giveaway = max(
  546. giveaways,
  547. key=lambda item: as_utc(item.get("finished_at") or item["updated_at"]),
  548. )
  549. text, markup = _render_winners_page(giveaway, 1)
  550. return await message.reply_text(
  551. text,
  552. parse_mode=ParseMode.HTML,
  553. reply_markup=markup,
  554. disable_web_page_preview=True,
  555. )
  556. @app.on_message(filters.command("glist") & ~filters.private)
  557. @adminsOnly("can_change_info")
  558. async def list_giveaways_command(_, message: Message):
  559. giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=20)
  560. if not giveaways:
  561. return await message.reply_text("本群当前没有进行中的抽奖。")
  562. lines = ["<b>进行中的抽奖</b>"]
  563. for giveaway in giveaways:
  564. lines.append(
  565. "#{id} - {title} - {count} 人参与 - 开奖于 {ends_at}".format(
  566. id=escape(giveaway["giveaway_id"]),
  567. title=escape(giveaway["title"]),
  568. count=await count_participants(giveaway["giveaway_id"]),
  569. ends_at=format_giveaway_time(giveaway["ends_at"]),
  570. )
  571. )
  572. return await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
  573. @app.on_message(filters.command("gparticipants") & ~filters.private)
  574. @adminsOnly("can_change_info")
  575. async def participants_command(_, message: Message):
  576. if len(message.command) < 2:
  577. return await message.reply_text("用法:/gparticipants <抽奖编号>")
  578. giveaway_id = normalize_giveaway_id(message.command[1])
  579. giveaway = await get_giveaway(giveaway_id)
  580. if not giveaway or giveaway["chat_id"] != message.chat.id:
  581. return await message.reply_text("本群中未找到该抽奖。")
  582. text, markup = await _render_participants_page(giveaway_id, 1)
  583. return await message.reply_text(
  584. text,
  585. parse_mode=ParseMode.HTML,
  586. reply_markup=markup,
  587. disable_web_page_preview=True,
  588. )
  589. @app.on_message(filters.command("gend") & ~filters.private)
  590. @adminsOnly("can_change_info")
  591. async def end_giveaway_command(_, message: Message):
  592. if len(message.command) < 2:
  593. return await message.reply_text("用法:/gend <抽奖编号>")
  594. giveaway_id = normalize_giveaway_id(message.command[1])
  595. giveaway = await get_giveaway(giveaway_id)
  596. if not giveaway or giveaway["chat_id"] != message.chat.id:
  597. return await message.reply_text("本群中未找到该抽奖。")
  598. _, text, _ = await finish_and_publish_giveaway(giveaway_id)
  599. return await message.reply_text(text)
  600. @app.on_message(filters.command("gcancel") & ~filters.private)
  601. @adminsOnly("can_change_info")
  602. async def cancel_giveaway_command(_, message: Message):
  603. if len(message.command) < 2:
  604. return await message.reply_text("用法:/gcancel <抽奖编号>")
  605. giveaway_id = normalize_giveaway_id(message.command[1])
  606. giveaway = await get_giveaway(giveaway_id)
  607. if not giveaway or giveaway["chat_id"] != message.chat.id:
  608. return await message.reply_text("本群中未找到该抽奖。")
  609. _, text, _ = await cancel_and_refund_giveaway(
  610. giveaway_id, chat_id=message.chat.id
  611. )
  612. return await message.reply_text(text)
  613. @app.on_message(filters.command("greroll") & ~filters.private)
  614. @adminsOnly("can_change_info")
  615. async def reroll_giveaway_command(_, message: Message):
  616. parts = message.text.split(None, 2) if message.text else []
  617. if len(parts) < 2:
  618. return await message.reply_text("用法:/greroll <抽奖编号> [奖项名称]")
  619. giveaway_id = normalize_giveaway_id(parts[1])
  620. giveaway = await get_giveaway(giveaway_id)
  621. if not giveaway or giveaway["chat_id"] != message.chat.id:
  622. return await message.reply_text("本群中未找到该抽奖。")
  623. try:
  624. winners, _ = await reroll_giveaway(
  625. giveaway_id=giveaway_id,
  626. moderator_id=_actor_id(message),
  627. tier_name=parts[2].strip() if len(parts) > 2 else None,
  628. publish=False,
  629. )
  630. except GiveawayServiceError as exc:
  631. return await message.reply_text(str(exc))
  632. return await message.reply_text(
  633. render_winners(
  634. giveaway,
  635. winners,
  636. reroll=True,
  637. tier_name=parts[2].strip() if len(parts) > 2 else None,
  638. ),
  639. parse_mode=ParseMode.HTML,
  640. disable_web_page_preview=True,
  641. )
  642. @app.on_message(filters.command("gremove") & ~filters.private)
  643. @adminsOnly("can_change_info")
  644. async def remove_participant_command(_, message: Message):
  645. if len(message.command) < 3 or not message.command[2].lstrip("-").isdigit():
  646. return await message.reply_text(
  647. "用法:/gremove <抽奖编号> <用户 ID> [no-refund]"
  648. )
  649. giveaway_id = normalize_giveaway_id(message.command[1])
  650. giveaway = await get_giveaway(giveaway_id)
  651. if not giveaway or giveaway["chat_id"] != message.chat.id:
  652. return await message.reply_text("本群中未找到该抽奖。")
  653. refund = not (len(message.command) > 3 and message.command[3].lower() == "no-refund")
  654. removed = await remove_and_optionally_refund_participant(
  655. giveaway_id=giveaway_id,
  656. user_id=int(message.command[2]),
  657. moderator_id=_actor_id(message),
  658. reason="由群管理员移除",
  659. refund=refund,
  660. )
  661. return await message.reply_text(
  662. "参与者已移除。"
  663. if removed
  664. else "未找到该参与者,或该参与者此前已被移除。"
  665. )
  666. @app.on_message(filters.command("gban") & ~filters.private)
  667. @adminsOnly("can_change_info")
  668. async def giveaway_ban_command(_, message: Message):
  669. user_id, reason = await extract_user_and_reason(message)
  670. if not user_id:
  671. return await message.reply_text("用法:/gban <成员> [原因]")
  672. await add_giveaway_ban(
  673. chat_id=message.chat.id,
  674. user_id=int(user_id),
  675. moderator_id=_actor_id(message),
  676. reason=reason,
  677. )
  678. return await message.reply_text(f"已禁止用户 {user_id} 参加本群抽奖。")
  679. @app.on_message(filters.command("gunban") & ~filters.private)
  680. @adminsOnly("can_change_info")
  681. async def giveaway_unban_command(_, message: Message):
  682. user_id, _ = await extract_user_and_reason(message)
  683. if not user_id:
  684. return await message.reply_text("用法:/gunban <成员>")
  685. removed = await remove_giveaway_ban(message.chat.id, int(user_id))
  686. return await message.reply_text(
  687. f"已允许用户 {user_id} 参加本群抽奖。"
  688. if removed
  689. else "该用户没有被限制参加抽奖。"
  690. )
  691. _start_sweeper()