giveaways.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. from __future__ import annotations
  2. import asyncio
  3. import secrets
  4. from collections import defaultdict
  5. from contextlib import suppress
  6. from datetime import UTC, datetime
  7. from html import escape
  8. from typing import Any
  9. from zoneinfo import ZoneInfo
  10. from pyrogram.enums import ChatMemberStatus, ParseMode
  11. from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup
  12. from wbb import app, log
  13. from wbb.utils.dbgiveaway import (
  14. STATUS_CANCELED,
  15. STATUS_CANCELING,
  16. STATUS_DRAWING,
  17. STATUS_FINISHED,
  18. STATUS_RUNNING,
  19. add_participant,
  20. as_utc,
  21. attach_giveaway_message,
  22. claim_giveaway_cancel,
  23. claim_giveaway_draw,
  24. count_participants,
  25. create_giveaway,
  26. finalize_giveaway_cancel,
  27. finish_giveaway,
  28. get_giveaway,
  29. get_participant,
  30. list_participants,
  31. mark_participant_refunded,
  32. record_reroll,
  33. remove_participant,
  34. save_pending_winners,
  35. update_running_giveaway,
  36. utc_now,
  37. )
  38. from wbb.utils.dbpoints import (
  39. SOURCE_GIVEAWAY_ENTRY,
  40. SOURCE_GIVEAWAY_PARTICIPATION,
  41. SOURCE_GIVEAWAY_REFUND,
  42. SOURCE_GIVEAWAY_WINNER,
  43. InsufficientPoints,
  44. adjust_points,
  45. get_point_account,
  46. get_point_transaction_by_key,
  47. )
  48. from wbb.utils.i18n import giveaway_status_label
  49. _RANDOM = secrets.SystemRandom()
  50. _giveaway_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
  51. GIVEAWAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
  52. class GiveawayServiceError(RuntimeError):
  53. def __init__(self, code: str, message: str, *, status: int = 400):
  54. super().__init__(message)
  55. self.code = code
  56. self.status = status
  57. def _mongo_datetime_millis(value: datetime) -> int:
  58. return int(as_utc(value).timestamp() * 1000)
  59. def parse_giveaway_time(raw: str) -> datetime | None:
  60. try:
  61. local_time = datetime.strptime(raw.strip(), "%Y-%m-%d %H:%M")
  62. except ValueError:
  63. return None
  64. return local_time.replace(tzinfo=GIVEAWAY_TIMEZONE).astimezone(UTC)
  65. def format_giveaway_time(value: datetime) -> str:
  66. return as_utc(value).astimezone(GIVEAWAY_TIMEZONE).strftime("%Y-%m-%d %H:%M")
  67. def giveaway_starts_at(giveaway: dict[str, Any]) -> datetime:
  68. return as_utc(giveaway.get("starts_at") or giveaway["created_at"])
  69. def _participant_mention(participant: dict[str, Any]) -> str:
  70. user_id = int(participant["user_id"])
  71. label = (
  72. participant.get("display_name")
  73. or ("@" + str(participant["username"]) if participant.get("username") else None)
  74. or participant.get("first_name")
  75. or str(user_id)
  76. )
  77. return f'<a href="tg://user?id={user_id}">{escape(str(label))}</a>'
  78. def _format_prizes(prizes: list[dict[str, Any]]) -> str:
  79. lines: list[str] = []
  80. for prize in prizes:
  81. points = int(prize.get("points_reward", 0))
  82. suffix = f"(每人 +{points} 积分)" if points else ""
  83. lines.append(
  84. f"- {escape(str(prize['name']))}:{int(prize['count'])} 人{suffix}"
  85. )
  86. return "\n".join(lines)
  87. async def render_giveaway(giveaway: dict[str, Any]) -> str:
  88. participants = await count_participants(giveaway["giveaway_id"])
  89. status_label = giveaway_status_label(giveaway["status"])
  90. if giveaway["status"] == STATUS_RUNNING and giveaway_starts_at(giveaway) > utc_now():
  91. status_label = "待报名"
  92. text = [
  93. f"<b>抽奖 #{escape(giveaway['giveaway_id'])}</b>",
  94. f"<b>状态:</b>{escape(status_label)}",
  95. f"<b>标题:</b>{escape(giveaway['title'])}",
  96. ]
  97. if giveaway.get("description"):
  98. text.append(f"<b>说明:</b>{escape(giveaway['description'])}")
  99. text.extend(
  100. [
  101. "<b>奖项:</b>",
  102. _format_prizes(giveaway["prizes"]),
  103. f"<b>报名开始:</b>{format_giveaway_time(giveaway_starts_at(giveaway))}",
  104. f"<b>开奖时间:</b>{format_giveaway_time(giveaway['ends_at'])}",
  105. "<b>时区:</b>北京时间",
  106. f"<b>参与人数:</b>{participants}",
  107. ]
  108. )
  109. minimum = int(giveaway.get("minimum_points", 0))
  110. cost = int(giveaway.get("entry_cost", 0))
  111. reward = int(giveaway.get("participation_reward", 0))
  112. if minimum:
  113. text.append(f"<b>最低积分:</b>{minimum}")
  114. if cost:
  115. text.append(f"<b>报名消耗:</b>{cost}")
  116. if reward:
  117. text.append(f"<b>参与奖励:</b>+{reward}")
  118. return "\n".join(text)
  119. def join_markup(giveaway_id: str) -> InlineKeyboardMarkup:
  120. return InlineKeyboardMarkup(
  121. [
  122. [
  123. InlineKeyboardButton(
  124. "立即报名", callback_data=f"giveaway_join:{giveaway_id}"
  125. )
  126. ],
  127. [
  128. InlineKeyboardButton(
  129. "查看参与者",
  130. callback_data=f"giveaway_participants:{giveaway_id}",
  131. )
  132. ],
  133. ]
  134. )
  135. async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
  136. message_id = giveaway.get("message_id")
  137. if not message_id:
  138. return True
  139. try:
  140. await app.edit_message_text(
  141. int(giveaway["chat_id"]),
  142. int(message_id),
  143. await render_giveaway(giveaway),
  144. parse_mode=ParseMode.HTML,
  145. reply_markup=join_markup(giveaway["giveaway_id"]),
  146. )
  147. except Exception:
  148. return False
  149. return True
  150. async def update_and_refresh_giveaway(
  151. giveaway_id: str,
  152. *,
  153. title: str,
  154. description: str,
  155. starts_at: datetime,
  156. ends_at: datetime,
  157. expected_updated_at: datetime,
  158. ) -> dict[str, Any]:
  159. async with _giveaway_locks[giveaway_id]:
  160. giveaway = await get_giveaway(giveaway_id)
  161. if not giveaway:
  162. raise GiveawayServiceError(
  163. "giveaway_not_found", "未找到该抽奖。", status=404
  164. )
  165. if giveaway["status"] != STATUS_RUNNING:
  166. raise GiveawayServiceError(
  167. "giveaway_not_running", "只有进行中的抽奖可以编辑。", status=409
  168. )
  169. if _mongo_datetime_millis(giveaway["updated_at"]) != _mongo_datetime_millis(
  170. expected_updated_at
  171. ):
  172. raise GiveawayServiceError(
  173. "giveaway_stale",
  174. "抽奖已被其他操作更新,请刷新页面后重试。",
  175. status=409,
  176. )
  177. current_starts_at = giveaway_starts_at(giveaway)
  178. if current_starts_at <= utc_now() and _mongo_datetime_millis(
  179. starts_at
  180. ) != _mongo_datetime_millis(current_starts_at):
  181. raise GiveawayServiceError(
  182. "giveaway_registration_started",
  183. "报名开始后不能修改报名开始时间。",
  184. status=409,
  185. )
  186. try:
  187. updated = await update_running_giveaway(
  188. giveaway_id,
  189. title=title,
  190. description=description,
  191. starts_at=starts_at,
  192. ends_at=ends_at,
  193. expected_updated_at=giveaway["updated_at"],
  194. )
  195. except ValueError as exc:
  196. raise GiveawayServiceError("invalid_giveaway", str(exc)) from exc
  197. if not updated:
  198. raise GiveawayServiceError(
  199. "giveaway_stale",
  200. "抽奖状态或内容已变化,请刷新页面后重试。",
  201. status=409,
  202. )
  203. if not await refresh_giveaway_message(updated):
  204. raise GiveawayServiceError(
  205. "giveaway_message_refresh_failed",
  206. "抽奖内容已保存,但群消息刷新失败,请刷新页面后重试保存。",
  207. status=502,
  208. )
  209. return updated
  210. def pick_winners(
  211. participants: list[dict[str, Any]], prizes: list[dict[str, Any]]
  212. ) -> list[dict[str, Any]]:
  213. winners: list[dict[str, Any]] = []
  214. picked_user_ids: set[int] = set()
  215. for tier_index, prize in enumerate(prizes):
  216. remaining = [
  217. participant
  218. for participant in participants
  219. if int(participant["user_id"]) not in picked_user_ids
  220. ]
  221. picked = _RANDOM.sample(
  222. remaining, min(int(prize["count"]), len(remaining))
  223. )
  224. for participant in picked:
  225. user_id = int(participant["user_id"])
  226. picked_user_ids.add(user_id)
  227. winners.append(
  228. {
  229. "user_id": user_id,
  230. "username": participant.get("username"),
  231. "first_name": participant.get("first_name"),
  232. "display_name": participant.get("display_name"),
  233. "tier": str(prize["name"]),
  234. "tier_index": tier_index,
  235. "points_reward": int(prize.get("points_reward", 0)),
  236. }
  237. )
  238. return winners
  239. def render_winners(
  240. giveaway: dict[str, Any],
  241. winners: list[dict[str, Any]],
  242. *,
  243. reroll: bool = False,
  244. tier_name: str | None = None,
  245. ) -> str:
  246. title = "抽奖重抽结果" if reroll else "抽奖结果"
  247. lines = [
  248. f"<b>{title} #{escape(giveaway['giveaway_id'])}</b>",
  249. f"<b>标题:</b>{escape(giveaway['title'])}",
  250. ]
  251. if tier_name:
  252. lines.append(f"<b>奖项:</b>{escape(tier_name)}")
  253. if not winners:
  254. lines.append("没有符合条件的参与者。")
  255. return "\n".join(lines)
  256. lines.append("<b>中奖者:</b>")
  257. for winner in winners:
  258. points = int(winner.get("points_reward", 0))
  259. suffix = f"(+{points} 积分)" if points else ""
  260. lines.append(
  261. f"- {escape(str(winner['tier']))}:{_participant_mention(winner)}{suffix}"
  262. )
  263. return "\n".join(lines)
  264. async def create_and_publish_giveaway(**values: Any) -> dict[str, Any]:
  265. giveaway = await create_giveaway(**values)
  266. try:
  267. sent = await app.send_message(
  268. giveaway["chat_id"],
  269. await render_giveaway(giveaway),
  270. parse_mode=ParseMode.HTML,
  271. reply_markup=join_markup(giveaway["giveaway_id"]),
  272. disable_web_page_preview=True,
  273. )
  274. except Exception:
  275. await claim_giveaway_cancel(giveaway["giveaway_id"], giveaway["chat_id"])
  276. await finalize_giveaway_cancel(giveaway["giveaway_id"])
  277. raise
  278. await attach_giveaway_message(giveaway["giveaway_id"], sent.chat.id, sent.id)
  279. giveaway["message_id"] = sent.id
  280. return giveaway
  281. async def _is_current_member(chat_id: int, user_id: int) -> bool:
  282. try:
  283. member = await app.get_chat_member(chat_id, user_id)
  284. except Exception:
  285. return False
  286. return member.status not in {ChatMemberStatus.LEFT, ChatMemberStatus.BANNED}
  287. async def join_giveaway(
  288. giveaway_id: str,
  289. *,
  290. user_id: int,
  291. username: str | None,
  292. first_name: str | None,
  293. display_name: str | None = None,
  294. verify_membership: bool = True,
  295. ) -> tuple[str, dict[str, Any] | None]:
  296. async with _giveaway_locks[giveaway_id]:
  297. giveaway = await get_giveaway(giveaway_id)
  298. if not giveaway:
  299. return "missing", None
  300. if giveaway["status"] != STATUS_RUNNING:
  301. return "closed", giveaway
  302. now = utc_now()
  303. if giveaway_starts_at(giveaway) > now:
  304. return "not_started", giveaway
  305. if as_utc(giveaway["ends_at"]) <= now:
  306. return "ended", giveaway
  307. existing = await get_participant(giveaway_id, user_id)
  308. if existing:
  309. return "duplicate", giveaway
  310. if verify_membership and not await _is_current_member(
  311. int(giveaway["chat_id"]), user_id
  312. ):
  313. return "not_member", giveaway
  314. minimum = int(giveaway.get("minimum_points", 0))
  315. entry_cost = int(giveaway.get("entry_cost", 0))
  316. account = await get_point_account(int(giveaway["chat_id"]), user_id)
  317. debit_key: str | None = None
  318. refund_key: str | None = None
  319. pending_debit = False
  320. if entry_cost:
  321. debit_key, refund_key, pending_debit = await _entry_attempt_keys(
  322. giveaway_id, user_id
  323. )
  324. available_balance = int(account.get("balance", 0)) + (
  325. entry_cost if pending_debit else 0
  326. )
  327. if available_balance < max(minimum, entry_cost):
  328. return "insufficient_points", giveaway
  329. if entry_cost and debit_key:
  330. try:
  331. await adjust_points(
  332. chat_id=int(giveaway["chat_id"]),
  333. user_id=user_id,
  334. delta=-entry_cost,
  335. source=SOURCE_GIVEAWAY_ENTRY,
  336. idempotency_key=debit_key,
  337. reference_id=giveaway_id,
  338. reason=f"抽奖 #{giveaway_id} 报名消耗",
  339. username=username,
  340. first_name=first_name,
  341. display_name=display_name,
  342. )
  343. except InsufficientPoints:
  344. return "insufficient_points", giveaway
  345. try:
  346. result = await add_participant(
  347. giveaway_id=giveaway_id,
  348. user_id=user_id,
  349. username=username,
  350. first_name=first_name,
  351. display_name=display_name,
  352. )
  353. except Exception:
  354. if entry_cost:
  355. await _refund_entry(
  356. giveaway,
  357. user_id=user_id,
  358. username=username,
  359. first_name=first_name,
  360. display_name=display_name,
  361. key_suffix="join-failed",
  362. idempotency_key=refund_key,
  363. )
  364. raise
  365. if result != "ok" and entry_cost:
  366. await _refund_entry(
  367. giveaway,
  368. user_id=user_id,
  369. username=username,
  370. first_name=first_name,
  371. display_name=display_name,
  372. key_suffix=f"join-{result}",
  373. idempotency_key=refund_key,
  374. )
  375. if result == "ok":
  376. await refresh_giveaway_message(giveaway)
  377. return result, giveaway
  378. async def _entry_attempt_keys(
  379. giveaway_id: str, user_id: int
  380. ) -> tuple[str, str, bool]:
  381. base = f"giveaway-entry:{giveaway_id}:{user_id}"
  382. for attempt in range(100):
  383. suffix = "" if attempt == 0 else f":{attempt}"
  384. debit_key = f"{base}{suffix}"
  385. refund_key = f"giveaway-refund:{giveaway_id}:{user_id}:entry-attempt:{attempt}"
  386. debit = await get_point_transaction_by_key(debit_key)
  387. if not debit:
  388. return debit_key, refund_key, False
  389. if not await get_point_transaction_by_key(refund_key):
  390. return debit_key, refund_key, True
  391. raise GiveawayServiceError(
  392. "entry_retry_limit", "报名重试次数过多,请稍后再试。", status=409
  393. )
  394. async def _refund_entry(
  395. giveaway: dict[str, Any],
  396. *,
  397. user_id: int,
  398. username: str | None,
  399. first_name: str | None,
  400. display_name: str | None,
  401. key_suffix: str,
  402. idempotency_key: str | None = None,
  403. ) -> bool:
  404. entry_cost = int(giveaway.get("entry_cost", 0))
  405. if entry_cost <= 0:
  406. return False
  407. _, created = await adjust_points(
  408. chat_id=int(giveaway["chat_id"]),
  409. user_id=user_id,
  410. delta=entry_cost,
  411. source=SOURCE_GIVEAWAY_REFUND,
  412. idempotency_key=idempotency_key
  413. or f"giveaway-refund:{giveaway['giveaway_id']}:{user_id}:{key_suffix}",
  414. reference_id=giveaway["giveaway_id"],
  415. reason=f"抽奖 #{giveaway['giveaway_id']} 报名退款",
  416. username=username,
  417. first_name=first_name,
  418. display_name=display_name,
  419. )
  420. if created:
  421. await mark_participant_refunded(
  422. giveaway["giveaway_id"], user_id, key_suffix
  423. )
  424. return created
  425. async def _award_draw_points(
  426. giveaway: dict[str, Any],
  427. participants: list[dict[str, Any]],
  428. winners: list[dict[str, Any]],
  429. ) -> None:
  430. participation_reward = int(giveaway.get("participation_reward", 0))
  431. if participation_reward:
  432. for participant in participants:
  433. await adjust_points(
  434. chat_id=int(giveaway["chat_id"]),
  435. user_id=int(participant["user_id"]),
  436. delta=participation_reward,
  437. source=SOURCE_GIVEAWAY_PARTICIPATION,
  438. idempotency_key=(
  439. f"giveaway-participation:{giveaway['giveaway_id']}:"
  440. f"{participant['user_id']}"
  441. ),
  442. reference_id=giveaway["giveaway_id"],
  443. reason=f"抽奖 #{giveaway['giveaway_id']} 参与奖励",
  444. username=participant.get("username"),
  445. first_name=participant.get("first_name"),
  446. display_name=participant.get("display_name"),
  447. )
  448. for winner in winners:
  449. reward = int(winner.get("points_reward", 0))
  450. if not reward:
  451. continue
  452. await adjust_points(
  453. chat_id=int(giveaway["chat_id"]),
  454. user_id=int(winner["user_id"]),
  455. delta=reward,
  456. source=SOURCE_GIVEAWAY_WINNER,
  457. idempotency_key=(
  458. f"giveaway-winner:{giveaway['giveaway_id']}:"
  459. f"{winner.get('tier_index', 0)}:{winner['user_id']}"
  460. ),
  461. reference_id=giveaway["giveaway_id"],
  462. reason=(
  463. f"抽奖 #{giveaway['giveaway_id']} 中奖奖励"
  464. f"({winner['tier']})"
  465. ),
  466. username=winner.get("username"),
  467. first_name=winner.get("first_name"),
  468. display_name=winner.get("display_name"),
  469. )
  470. async def finish_and_publish_giveaway(
  471. giveaway_id: str, *, publish: bool = True
  472. ) -> tuple[bool, str, dict[str, Any] | None]:
  473. async with _giveaway_locks[giveaway_id]:
  474. giveaway = await claim_giveaway_draw(giveaway_id)
  475. if not giveaway:
  476. existing = await get_giveaway(giveaway_id)
  477. if not existing:
  478. return False, "未找到该抽奖。", None
  479. if existing["status"] == STATUS_FINISHED:
  480. return False, "该抽奖已经完成开奖。", existing
  481. return False, "该抽奖当前不在进行中。", existing
  482. participants = await list_participants(giveaway_id)
  483. if giveaway.get("pending_winners") is None:
  484. pending = pick_winners(participants, giveaway["prizes"])
  485. giveaway = await save_pending_winners(giveaway_id, pending) or giveaway
  486. winners = list(giveaway.get("pending_winners", []))
  487. await _award_draw_points(giveaway, participants, winners)
  488. marked = await finish_giveaway(giveaway_id, winners)
  489. if not marked:
  490. current = await get_giveaway(giveaway_id)
  491. if not current or current["status"] != STATUS_FINISHED:
  492. return False, "抽奖无法完成,请稍后重试。", current
  493. giveaway = current
  494. winners = list(current.get("winners", []))
  495. else:
  496. giveaway = {**giveaway, "status": STATUS_FINISHED, "winners": winners}
  497. if publish:
  498. await publish_finished(giveaway, winners)
  499. return True, f"抽奖 #{giveaway_id} 已完成开奖。", giveaway
  500. async def publish_finished(
  501. giveaway: dict[str, Any], winners: list[dict[str, Any]]
  502. ) -> None:
  503. chat_id = int(giveaway["chat_id"])
  504. message_id = giveaway.get("message_id")
  505. text = render_winners(giveaway, winners)
  506. result_message = None
  507. if message_id:
  508. with suppress(Exception):
  509. await app.edit_message_text(
  510. chat_id,
  511. int(message_id),
  512. await render_giveaway({**giveaway, "status": STATUS_FINISHED}),
  513. parse_mode=ParseMode.HTML,
  514. reply_markup=None,
  515. )
  516. with suppress(Exception):
  517. result_message = await app.send_message(
  518. chat_id,
  519. text,
  520. parse_mode=ParseMode.HTML,
  521. reply_to_message_id=int(message_id),
  522. )
  523. if result_message is None:
  524. with suppress(Exception):
  525. result_message = await app.send_message(
  526. chat_id, text, parse_mode=ParseMode.HTML
  527. )
  528. result_pinned = False
  529. if result_message is not None:
  530. try:
  531. await app.pin_chat_message(
  532. chat_id,
  533. int(result_message.id),
  534. disable_notification=True,
  535. )
  536. result_pinned = True
  537. except Exception as exc:
  538. log.error(
  539. f"抽奖 {giveaway['giveaway_id']} 结果置顶失败:{exc}"
  540. )
  541. if result_pinned and message_id and int(message_id) != int(result_message.id):
  542. try:
  543. await app.delete_messages(chat_id, int(message_id))
  544. except Exception:
  545. # If Telegram refuses deletion, at least remove the stale pinned entry.
  546. with suppress(Exception):
  547. await app.unpin_chat_message(chat_id, int(message_id))
  548. for winner in winners:
  549. with suppress(Exception):
  550. await app.send_message(
  551. int(winner["user_id"]),
  552. (
  553. f"恭喜你在 <b>{escape(giveaway['title'])}</b> 中获得"
  554. f"<b>{escape(str(winner['tier']))}</b>。"
  555. ),
  556. parse_mode=ParseMode.HTML,
  557. )
  558. async def cancel_and_refund_giveaway(
  559. giveaway_id: str, *, chat_id: int, publish: bool = True
  560. ) -> tuple[bool, str, dict[str, Any] | None]:
  561. async with _giveaway_locks[giveaway_id]:
  562. giveaway = await claim_giveaway_cancel(giveaway_id, chat_id)
  563. if not giveaway:
  564. existing = await get_giveaway(giveaway_id)
  565. if not existing:
  566. return False, "未找到该抽奖。", None
  567. if existing["status"] == STATUS_CANCELED:
  568. return False, "该抽奖已经取消。", existing
  569. return False, "该抽奖当前不在进行中。", existing
  570. participants = await list_participants(giveaway_id)
  571. for participant in participants:
  572. await _refund_entry(
  573. giveaway,
  574. user_id=int(participant["user_id"]),
  575. username=participant.get("username"),
  576. first_name=participant.get("first_name"),
  577. display_name=participant.get("display_name"),
  578. key_suffix="cancel",
  579. )
  580. await finalize_giveaway_cancel(giveaway_id)
  581. giveaway = {**giveaway, "status": STATUS_CANCELED}
  582. if publish and giveaway.get("message_id"):
  583. with suppress(Exception):
  584. await app.edit_message_text(
  585. int(giveaway["chat_id"]),
  586. int(giveaway["message_id"]),
  587. await render_giveaway(giveaway),
  588. parse_mode=ParseMode.HTML,
  589. reply_markup=None,
  590. )
  591. return True, f"抽奖 #{giveaway_id} 已取消,报名积分已退还。", giveaway
  592. async def remove_and_optionally_refund_participant(
  593. *,
  594. giveaway_id: str,
  595. user_id: int,
  596. moderator_id: int,
  597. reason: str,
  598. refund: bool = True,
  599. ) -> bool:
  600. giveaway = await get_giveaway(giveaway_id)
  601. if not giveaway or giveaway["status"] != STATUS_RUNNING:
  602. raise GiveawayServiceError(
  603. "giveaway_not_running", "该抽奖当前不在进行中。"
  604. )
  605. participant = await remove_participant(
  606. giveaway_id=giveaway_id,
  607. user_id=user_id,
  608. moderator_id=moderator_id,
  609. reason=reason,
  610. refund=refund,
  611. )
  612. if not participant:
  613. return False
  614. if refund:
  615. await _refund_entry(
  616. giveaway,
  617. user_id=user_id,
  618. username=participant.get("username"),
  619. first_name=participant.get("first_name"),
  620. display_name=participant.get("display_name"),
  621. key_suffix="participant-removed",
  622. )
  623. await refresh_giveaway_message(giveaway)
  624. return True
  625. async def reroll_giveaway(
  626. *,
  627. giveaway_id: str,
  628. moderator_id: int,
  629. tier_name: str | None = None,
  630. reroll_id: str | None = None,
  631. publish: bool = True,
  632. ) -> tuple[list[dict[str, Any]], str]:
  633. reroll_id = reroll_id or secrets.token_hex(8)
  634. async with _giveaway_locks[giveaway_id]:
  635. giveaway = await get_giveaway(giveaway_id)
  636. if not giveaway or giveaway["status"] != STATUS_FINISHED:
  637. raise GiveawayServiceError(
  638. "giveaway_not_finished", "只有已开奖的抽奖可以重抽。"
  639. )
  640. for previous in giveaway.get("rerolls", []):
  641. if previous.get("reroll_id") == reroll_id:
  642. return list(previous.get("winners", [])), reroll_id
  643. prizes = list(giveaway["prizes"])
  644. if tier_name:
  645. prizes = [
  646. prize
  647. for prize in prizes
  648. if str(prize["name"]).strip().lower() == tier_name.strip().lower()
  649. ]
  650. if not prizes:
  651. raise GiveawayServiceError("tier_not_found", "未找到该奖项。")
  652. excluded = {int(winner["user_id"]) for winner in giveaway.get("winners", [])}
  653. for previous in giveaway.get("rerolls", []):
  654. excluded.update(
  655. int(winner["user_id"]) for winner in previous.get("winners", [])
  656. )
  657. participants = [
  658. participant
  659. for participant in await list_participants(giveaway_id)
  660. if int(participant["user_id"]) not in excluded
  661. ]
  662. winners = pick_winners(participants, prizes)
  663. await record_reroll(
  664. giveaway_id,
  665. winners,
  666. tier_name,
  667. moderator_id,
  668. reroll_id=reroll_id,
  669. )
  670. for winner in winners:
  671. reward = int(winner.get("points_reward", 0))
  672. if reward:
  673. await adjust_points(
  674. chat_id=int(giveaway["chat_id"]),
  675. user_id=int(winner["user_id"]),
  676. delta=reward,
  677. source=SOURCE_GIVEAWAY_WINNER,
  678. idempotency_key=(
  679. f"giveaway-reroll-winner:{giveaway_id}:{reroll_id}:"
  680. f"{winner.get('tier_index', 0)}:{winner['user_id']}"
  681. ),
  682. reference_id=giveaway_id,
  683. reason=f"抽奖 #{giveaway_id} 重抽中奖奖励",
  684. username=winner.get("username"),
  685. first_name=winner.get("first_name"),
  686. display_name=winner.get("display_name"),
  687. )
  688. if publish:
  689. with suppress(Exception):
  690. await app.send_message(
  691. int(giveaway["chat_id"]),
  692. render_winners(giveaway, winners, reroll=True, tier_name=tier_name),
  693. parse_mode=ParseMode.HTML,
  694. disable_web_page_preview=True,
  695. )
  696. return winners, reroll_id
  697. async def resume_pending_giveaway(giveaway: dict[str, Any]) -> None:
  698. try:
  699. if giveaway["status"] in {STATUS_RUNNING, STATUS_DRAWING}:
  700. await finish_and_publish_giveaway(giveaway["giveaway_id"])
  701. elif giveaway["status"] == STATUS_CANCELING:
  702. await cancel_and_refund_giveaway(
  703. giveaway["giveaway_id"], chat_id=int(giveaway["chat_id"])
  704. )
  705. except Exception as exc:
  706. log.error(
  707. f"抽奖 {giveaway.get('giveaway_id')} 恢复任务失败:{exc}"
  708. )