dbgiveaway.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. from __future__ import annotations
  2. import re
  3. from asyncio import Lock
  4. from datetime import UTC, datetime
  5. from secrets import token_hex
  6. from typing import Any
  7. from pymongo import ASCENDING, DESCENDING, ReturnDocument
  8. from pymongo.errors import DuplicateKeyError
  9. from wbb import BOT_PROFILE_ID, db
  10. STATUS_RUNNING = "running"
  11. STATUS_DRAWING = "drawing"
  12. STATUS_CANCELING = "canceling"
  13. STATUS_FINISHED = "finished"
  14. STATUS_CANCELED = "canceled"
  15. ACTIVE_STATUSES = {STATUS_RUNNING, STATUS_DRAWING, STATUS_CANCELING}
  16. ALL_STATUSES = ACTIVE_STATUSES | {STATUS_FINISHED, STATUS_CANCELED}
  17. giveawaysdb = db.giveaways
  18. participantsdb = db.giveaway_participants
  19. giveaway_bansdb = db.giveaway_bans
  20. _index_lock = Lock()
  21. _indexes_ready = False
  22. def utc_now() -> datetime:
  23. return datetime.now(UTC)
  24. def normalize_giveaway_id(giveaway_id: str) -> str:
  25. return giveaway_id.strip().lower().lstrip("#")
  26. def as_utc(value: datetime) -> datetime:
  27. if value.tzinfo is None:
  28. return value.replace(tzinfo=UTC)
  29. return value.astimezone(UTC)
  30. def _nonnegative_int(value: Any, *, maximum: int = 1_000_000_000) -> int:
  31. try:
  32. parsed = int(value)
  33. except (TypeError, ValueError) as exc:
  34. raise ValueError("积分数值必须是整数。") from exc
  35. if parsed < 0 or parsed > maximum:
  36. raise ValueError(f"积分数值必须在 0 到 {maximum} 之间。")
  37. return parsed
  38. def normalize_prizes(prizes: list[dict[str, Any]]) -> list[dict[str, Any]]:
  39. normalized: list[dict[str, Any]] = []
  40. total_winners = 0
  41. for item in prizes:
  42. name = str(item.get("name") or "").strip()
  43. count = _nonnegative_int(item.get("count"), maximum=100)
  44. points_reward = _nonnegative_int(item.get("points_reward", 0))
  45. if not name or count < 1:
  46. raise ValueError("每个奖项都必须填写名称,且中奖人数至少为 1。")
  47. total_winners += count
  48. normalized.append(
  49. {"name": name[:100], "count": count, "points_reward": points_reward}
  50. )
  51. if not normalized or total_winners > 100:
  52. raise ValueError("抽奖总中奖人数必须在 1 到 100 人之间。")
  53. return normalized
  54. async def ensure_giveaway_indexes() -> None:
  55. global _indexes_ready
  56. if _indexes_ready:
  57. return
  58. async with _index_lock:
  59. if _indexes_ready:
  60. return
  61. await giveawaysdb.create_index([("giveaway_id", ASCENDING)], unique=True)
  62. await giveawaysdb.create_index([("status", ASCENDING), ("ends_at", ASCENDING)])
  63. await giveawaysdb.create_index(
  64. [("chat_id", ASCENDING), ("created_at", DESCENDING)]
  65. )
  66. await participantsdb.create_index(
  67. [("giveaway_id", ASCENDING), ("user_id", ASCENDING)], unique=True
  68. )
  69. await participantsdb.create_index(
  70. [("giveaway_id", ASCENDING), ("active", ASCENDING), ("joined_at", ASCENDING)]
  71. )
  72. await giveaway_bansdb.create_index(
  73. [("chat_id", ASCENDING), ("user_id", ASCENDING)], unique=True
  74. )
  75. await giveaway_bansdb.create_index(
  76. [("chat_id", ASCENDING), ("created_at", DESCENDING)]
  77. )
  78. _indexes_ready = True
  79. async def create_giveaway(
  80. *,
  81. chat_id: int,
  82. creator_id: int,
  83. creator_name: str,
  84. title: str,
  85. description: str,
  86. prizes: list[dict[str, Any]],
  87. ends_at: datetime,
  88. starts_at: datetime | None = None,
  89. minimum_points: int = 0,
  90. entry_cost: int = 0,
  91. participation_reward: int = 0,
  92. ) -> dict[str, Any]:
  93. await ensure_giveaway_indexes()
  94. title = title.strip()
  95. if not title:
  96. raise ValueError("必须填写抽奖标题。")
  97. now = utc_now()
  98. normalized_starts_at = as_utc(starts_at) if starts_at else now
  99. normalized_ends_at = as_utc(ends_at)
  100. if normalized_ends_at <= now:
  101. raise ValueError("开奖时间必须晚于当前时间。")
  102. if normalized_starts_at >= normalized_ends_at:
  103. raise ValueError("报名开始时间必须早于开奖时间。")
  104. normalized_prizes = normalize_prizes(prizes)
  105. for _ in range(20):
  106. giveaway_id = token_hex(3)
  107. doc = {
  108. "giveaway_id": giveaway_id,
  109. "bot_id": BOT_PROFILE_ID,
  110. "chat_id": int(chat_id),
  111. "creator_id": int(creator_id),
  112. "creator_name": creator_name.strip()[:100],
  113. "title": title[:200],
  114. "description": description.strip()[:4000],
  115. "prizes": normalized_prizes,
  116. "minimum_points": _nonnegative_int(minimum_points),
  117. "entry_cost": _nonnegative_int(entry_cost),
  118. "participation_reward": _nonnegative_int(participation_reward),
  119. "status": STATUS_RUNNING,
  120. "created_at": now,
  121. "updated_at": now,
  122. "starts_at": normalized_starts_at,
  123. "ends_at": normalized_ends_at,
  124. "message_id": None,
  125. "winners": [],
  126. "rerolls": [],
  127. }
  128. if normalized_starts_at <= now:
  129. doc["start_announced_at"] = now
  130. try:
  131. await giveawaysdb.insert_one(doc)
  132. return doc
  133. except DuplicateKeyError:
  134. continue
  135. raise RuntimeError("无法生成唯一的抽奖编号,请重试。")
  136. async def attach_giveaway_message(
  137. giveaway_id: str, chat_id: int, message_id: int
  138. ) -> None:
  139. await ensure_giveaway_indexes()
  140. await giveawaysdb.update_one(
  141. {"giveaway_id": normalize_giveaway_id(giveaway_id)},
  142. {
  143. "$set": {
  144. "chat_id": int(chat_id),
  145. "message_id": int(message_id),
  146. "updated_at": utc_now(),
  147. }
  148. },
  149. )
  150. async def get_giveaway(giveaway_id: str) -> dict[str, Any] | None:
  151. await ensure_giveaway_indexes()
  152. return await giveawaysdb.find_one(
  153. {
  154. "giveaway_id": normalize_giveaway_id(giveaway_id),
  155. "bot_id": BOT_PROFILE_ID,
  156. }
  157. )
  158. async def list_running_giveaways(
  159. *, chat_id: int | None = None, creator_id: int | None = None, limit: int = 20
  160. ) -> list[dict[str, Any]]:
  161. await ensure_giveaway_indexes()
  162. query: dict[str, Any] = {"status": STATUS_RUNNING, "bot_id": BOT_PROFILE_ID}
  163. if chat_id is not None:
  164. query["chat_id"] = int(chat_id)
  165. if creator_id is not None:
  166. query["creator_id"] = int(creator_id)
  167. cursor = giveawaysdb.find(query).sort("created_at", DESCENDING).limit(limit)
  168. return [doc async for doc in cursor]
  169. async def list_giveaways(
  170. *, chat_id: int | None = None, status: str | None = None, limit: int = 50
  171. ) -> list[dict[str, Any]]:
  172. items, _ = await list_giveaways_page(
  173. chat_id=chat_id, status=status, page=1, page_size=limit
  174. )
  175. return items
  176. async def list_giveaways_page(
  177. *,
  178. chat_id: int | None = None,
  179. status: str | None = None,
  180. query: str = "",
  181. page: int = 1,
  182. page_size: int = 20,
  183. all_bots: bool = False,
  184. ) -> tuple[list[dict[str, Any]], int]:
  185. await ensure_giveaway_indexes()
  186. filters: dict[str, Any] = {} if all_bots else {"bot_id": BOT_PROFILE_ID}
  187. if chat_id is not None:
  188. filters["chat_id"] = int(chat_id)
  189. if status:
  190. if status not in ALL_STATUSES:
  191. return [], 0
  192. filters["status"] = status
  193. query = query.strip()
  194. if query:
  195. giveaway_id_pattern = re.escape(query.lstrip("#"))
  196. title_pattern = re.escape(query)
  197. filters["$or"] = [
  198. {"giveaway_id": {"$regex": giveaway_id_pattern, "$options": "i"}},
  199. {"title": {"$regex": title_pattern, "$options": "i"}},
  200. ]
  201. page = max(1, int(page))
  202. page_size = max(1, min(int(page_size), 100))
  203. total = await giveawaysdb.count_documents(filters)
  204. cursor = (
  205. giveawaysdb.find(filters)
  206. .sort("created_at", DESCENDING)
  207. .skip((page - 1) * page_size)
  208. .limit(page_size)
  209. )
  210. return [doc async for doc in cursor], total
  211. async def list_due_giveaways(limit: int = 20) -> list[dict[str, Any]]:
  212. await ensure_giveaway_indexes()
  213. cursor = (
  214. giveawaysdb.find(
  215. {
  216. "bot_id": BOT_PROFILE_ID,
  217. "$or": [
  218. {"status": STATUS_RUNNING, "ends_at": {"$lte": utc_now()}},
  219. {"status": STATUS_DRAWING},
  220. ]
  221. }
  222. )
  223. .sort("ends_at", ASCENDING)
  224. .limit(limit)
  225. )
  226. return [doc async for doc in cursor]
  227. async def list_unannounced_started_giveaways(limit: int = 20) -> list[dict[str, Any]]:
  228. await ensure_giveaway_indexes()
  229. now = utc_now()
  230. cursor = (
  231. giveawaysdb.find(
  232. {
  233. "bot_id": BOT_PROFILE_ID,
  234. "status": STATUS_RUNNING,
  235. "starts_at": {"$lte": now},
  236. "ends_at": {"$gt": now},
  237. "start_announced_at": {"$exists": False},
  238. }
  239. )
  240. .sort("starts_at", ASCENDING)
  241. .limit(limit)
  242. )
  243. return [doc async for doc in cursor]
  244. async def mark_giveaway_start_announced(giveaway_id: str) -> None:
  245. await ensure_giveaway_indexes()
  246. now = utc_now()
  247. await giveawaysdb.update_one(
  248. {
  249. "giveaway_id": normalize_giveaway_id(giveaway_id),
  250. "status": STATUS_RUNNING,
  251. "start_announced_at": {"$exists": False},
  252. },
  253. {"$set": {"start_announced_at": now, "updated_at": now}},
  254. )
  255. async def list_pending_cancellations(limit: int = 20) -> list[dict[str, Any]]:
  256. await ensure_giveaway_indexes()
  257. cursor = (
  258. giveawaysdb.find({"status": STATUS_CANCELING, "bot_id": BOT_PROFILE_ID})
  259. .sort("updated_at", ASCENDING)
  260. .limit(limit)
  261. )
  262. return [doc async for doc in cursor]
  263. async def claim_giveaway_draw(giveaway_id: str) -> dict[str, Any] | None:
  264. await ensure_giveaway_indexes()
  265. normalized_id = normalize_giveaway_id(giveaway_id)
  266. claimed = await giveawaysdb.find_one_and_update(
  267. {"giveaway_id": normalized_id, "status": STATUS_RUNNING},
  268. {
  269. "$set": {
  270. "status": STATUS_DRAWING,
  271. "draw_started_at": utc_now(),
  272. "updated_at": utc_now(),
  273. }
  274. },
  275. return_document=ReturnDocument.AFTER,
  276. )
  277. if claimed:
  278. return claimed
  279. return await giveawaysdb.find_one(
  280. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  281. )
  282. async def save_pending_winners(
  283. giveaway_id: str, winners: list[dict[str, Any]]
  284. ) -> dict[str, Any] | None:
  285. await ensure_giveaway_indexes()
  286. normalized_id = normalize_giveaway_id(giveaway_id)
  287. updated = await giveawaysdb.find_one_and_update(
  288. {
  289. "giveaway_id": normalized_id,
  290. "status": STATUS_DRAWING,
  291. "pending_winners": {"$exists": False},
  292. },
  293. {
  294. "$set": {
  295. "pending_winners": winners,
  296. "draw_result_created_at": utc_now(),
  297. "updated_at": utc_now(),
  298. }
  299. },
  300. return_document=ReturnDocument.AFTER,
  301. )
  302. return updated or await giveawaysdb.find_one(
  303. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  304. )
  305. async def finish_giveaway(
  306. giveaway_id: str, winners: list[dict[str, Any]] | None = None
  307. ) -> bool:
  308. await ensure_giveaway_indexes()
  309. values: dict[str, Any] = {
  310. "status": STATUS_FINISHED,
  311. "finished_at": utc_now(),
  312. "updated_at": utc_now(),
  313. }
  314. if winners is not None:
  315. values["winners"] = winners
  316. result = await giveawaysdb.update_one(
  317. {
  318. "giveaway_id": normalize_giveaway_id(giveaway_id),
  319. "status": STATUS_DRAWING,
  320. },
  321. {"$set": values, "$unset": {"pending_winners": ""}},
  322. )
  323. return result.modified_count == 1
  324. async def claim_giveaway_cancel(
  325. giveaway_id: str, chat_id: int
  326. ) -> dict[str, Any] | None:
  327. await ensure_giveaway_indexes()
  328. normalized_id = normalize_giveaway_id(giveaway_id)
  329. claimed = await giveawaysdb.find_one_and_update(
  330. {
  331. "giveaway_id": normalized_id,
  332. "chat_id": int(chat_id),
  333. "status": STATUS_RUNNING,
  334. },
  335. {
  336. "$set": {
  337. "status": STATUS_CANCELING,
  338. "cancel_started_at": utc_now(),
  339. "updated_at": utc_now(),
  340. }
  341. },
  342. return_document=ReturnDocument.AFTER,
  343. )
  344. if claimed:
  345. return claimed
  346. return await giveawaysdb.find_one(
  347. {
  348. "giveaway_id": normalized_id,
  349. "chat_id": int(chat_id),
  350. "status": STATUS_CANCELING,
  351. }
  352. )
  353. async def cancel_giveaway(giveaway_id: str, chat_id: int) -> bool:
  354. claimed = await claim_giveaway_cancel(giveaway_id, chat_id)
  355. if not claimed:
  356. return False
  357. return await finalize_giveaway_cancel(giveaway_id)
  358. async def finalize_giveaway_cancel(giveaway_id: str) -> bool:
  359. await ensure_giveaway_indexes()
  360. result = await giveawaysdb.update_one(
  361. {
  362. "giveaway_id": normalize_giveaway_id(giveaway_id),
  363. "status": STATUS_CANCELING,
  364. },
  365. {
  366. "$set": {
  367. "status": STATUS_CANCELED,
  368. "canceled_at": utc_now(),
  369. "updated_at": utc_now(),
  370. }
  371. },
  372. )
  373. return result.modified_count == 1
  374. async def record_reroll(
  375. giveaway_id: str,
  376. winners: list[dict[str, Any]],
  377. tier_name: str | None,
  378. moderator_id: int,
  379. reroll_id: str | None = None,
  380. ) -> str:
  381. await ensure_giveaway_indexes()
  382. reroll_id = reroll_id or token_hex(8)
  383. await giveawaysdb.update_one(
  384. {
  385. "giveaway_id": normalize_giveaway_id(giveaway_id),
  386. "status": STATUS_FINISHED,
  387. "rerolls.reroll_id": {"$ne": reroll_id},
  388. },
  389. {
  390. "$push": {
  391. "rerolls": {
  392. "reroll_id": reroll_id,
  393. "tier_name": tier_name,
  394. "moderator_id": int(moderator_id),
  395. "winners": winners,
  396. "created_at": utc_now(),
  397. }
  398. },
  399. "$set": {"updated_at": utc_now()},
  400. },
  401. )
  402. return reroll_id
  403. async def is_giveaway_banned(chat_id: int, user_id: int) -> bool:
  404. await ensure_giveaway_indexes()
  405. return (
  406. await giveaway_bansdb.find_one(
  407. {"chat_id": int(chat_id), "user_id": int(user_id)}
  408. )
  409. ) is not None
  410. async def add_giveaway_ban(
  411. *, chat_id: int, user_id: int, moderator_id: int, reason: str | None
  412. ) -> None:
  413. await ensure_giveaway_indexes()
  414. now = utc_now()
  415. await giveaway_bansdb.update_one(
  416. {"chat_id": int(chat_id), "user_id": int(user_id)},
  417. {
  418. "$set": {
  419. "moderator_id": int(moderator_id),
  420. "reason": (reason or "").strip()[:500],
  421. "updated_at": now,
  422. },
  423. "$setOnInsert": {"created_at": now},
  424. },
  425. upsert=True,
  426. )
  427. async def remove_giveaway_ban(chat_id: int, user_id: int) -> bool:
  428. await ensure_giveaway_indexes()
  429. result = await giveaway_bansdb.delete_one(
  430. {"chat_id": int(chat_id), "user_id": int(user_id)}
  431. )
  432. return result.deleted_count == 1
  433. async def list_giveaway_bans(
  434. *, chat_id: int, page: int = 1, page_size: int = 20
  435. ) -> tuple[list[dict[str, Any]], int]:
  436. await ensure_giveaway_indexes()
  437. filters = {"chat_id": int(chat_id)}
  438. page = max(1, int(page))
  439. page_size = max(1, min(int(page_size), 100))
  440. total = await giveaway_bansdb.count_documents(filters)
  441. cursor = (
  442. giveaway_bansdb.find(filters)
  443. .sort("created_at", DESCENDING)
  444. .skip((page - 1) * page_size)
  445. .limit(page_size)
  446. )
  447. return [doc async for doc in cursor], total
  448. async def add_participant(
  449. *,
  450. giveaway_id: str,
  451. user_id: int,
  452. username: str | None,
  453. first_name: str | None,
  454. display_name: str | None = None,
  455. ) -> str:
  456. await ensure_giveaway_indexes()
  457. giveaway = await get_giveaway(giveaway_id)
  458. if not giveaway:
  459. return "missing"
  460. if giveaway["status"] != STATUS_RUNNING:
  461. return "closed"
  462. starts_at = giveaway.get("starts_at") or giveaway.get("created_at")
  463. if starts_at and as_utc(starts_at) > utc_now():
  464. return "not_started"
  465. if as_utc(giveaway["ends_at"]) <= utc_now():
  466. return "ended"
  467. if await is_giveaway_banned(giveaway["chat_id"], user_id):
  468. return "banned"
  469. try:
  470. await participantsdb.insert_one(
  471. {
  472. "giveaway_id": giveaway["giveaway_id"],
  473. "chat_id": int(giveaway["chat_id"]),
  474. "user_id": int(user_id),
  475. "username": username,
  476. "first_name": first_name,
  477. "display_name": display_name or first_name,
  478. "entry_cost": int(giveaway.get("entry_cost", 0)),
  479. "active": True,
  480. "joined_at": utc_now(),
  481. }
  482. )
  483. except DuplicateKeyError:
  484. return "duplicate"
  485. return "ok"
  486. async def get_participant(giveaway_id: str, user_id: int) -> dict[str, Any] | None:
  487. await ensure_giveaway_indexes()
  488. return await participantsdb.find_one(
  489. {
  490. "giveaway_id": normalize_giveaway_id(giveaway_id),
  491. "user_id": int(user_id),
  492. }
  493. )
  494. async def remove_participant(
  495. *,
  496. giveaway_id: str,
  497. user_id: int,
  498. moderator_id: int,
  499. reason: str,
  500. refund: bool,
  501. ) -> dict[str, Any] | None:
  502. await ensure_giveaway_indexes()
  503. return await participantsdb.find_one_and_update(
  504. {
  505. "giveaway_id": normalize_giveaway_id(giveaway_id),
  506. "user_id": int(user_id),
  507. "active": {"$ne": False},
  508. },
  509. {
  510. "$set": {
  511. "active": False,
  512. "removed_at": utc_now(),
  513. "removed_by": int(moderator_id),
  514. "removal_reason": reason.strip()[:500],
  515. "refund_requested": bool(refund),
  516. }
  517. },
  518. return_document=ReturnDocument.BEFORE,
  519. )
  520. async def mark_participant_refunded(
  521. giveaway_id: str, user_id: int, refund_reason: str
  522. ) -> None:
  523. await ensure_giveaway_indexes()
  524. await participantsdb.update_one(
  525. {
  526. "giveaway_id": normalize_giveaway_id(giveaway_id),
  527. "user_id": int(user_id),
  528. },
  529. {
  530. "$set": {
  531. "refunded_at": utc_now(),
  532. "refund_reason": refund_reason[:100],
  533. }
  534. },
  535. )
  536. async def count_participants(giveaway_id: str, *, active_only: bool = True) -> int:
  537. await ensure_giveaway_indexes()
  538. filters: dict[str, Any] = {
  539. "giveaway_id": normalize_giveaway_id(giveaway_id)
  540. }
  541. if active_only:
  542. filters["active"] = {"$ne": False}
  543. return await participantsdb.count_documents(filters)
  544. async def list_participants(
  545. giveaway_id: str, limit: int | None = None, *, active_only: bool = True
  546. ) -> list[dict[str, Any]]:
  547. await ensure_giveaway_indexes()
  548. filters: dict[str, Any] = {
  549. "giveaway_id": normalize_giveaway_id(giveaway_id)
  550. }
  551. if active_only:
  552. filters["active"] = {"$ne": False}
  553. cursor = participantsdb.find(filters).sort("joined_at", ASCENDING)
  554. if limit is not None:
  555. cursor = cursor.limit(max(1, int(limit)))
  556. return [doc async for doc in cursor]
  557. async def list_participants_page(
  558. *,
  559. giveaway_id: str,
  560. active_only: bool = False,
  561. page: int = 1,
  562. page_size: int = 20,
  563. ) -> tuple[list[dict[str, Any]], int]:
  564. await ensure_giveaway_indexes()
  565. filters: dict[str, Any] = {
  566. "giveaway_id": normalize_giveaway_id(giveaway_id)
  567. }
  568. if active_only:
  569. filters["active"] = {"$ne": False}
  570. page = max(1, int(page))
  571. page_size = max(1, min(int(page_size), 100))
  572. total = await participantsdb.count_documents(filters)
  573. cursor = (
  574. participantsdb.find(filters)
  575. .sort("joined_at", ASCENDING)
  576. .skip((page - 1) * page_size)
  577. .limit(page_size)
  578. )
  579. return [doc async for doc in cursor], total