dbgiveaway.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  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. minimum_points: int = 0,
  89. entry_cost: int = 0,
  90. participation_reward: int = 0,
  91. ) -> dict[str, Any]:
  92. await ensure_giveaway_indexes()
  93. title = title.strip()
  94. if not title:
  95. raise ValueError("必须填写抽奖标题。")
  96. normalized_ends_at = as_utc(ends_at)
  97. if normalized_ends_at <= utc_now():
  98. raise ValueError("抽奖截止时间必须晚于当前时间。")
  99. normalized_prizes = normalize_prizes(prizes)
  100. now = utc_now()
  101. for _ in range(20):
  102. giveaway_id = token_hex(3)
  103. doc = {
  104. "giveaway_id": giveaway_id,
  105. "bot_id": BOT_PROFILE_ID,
  106. "chat_id": int(chat_id),
  107. "creator_id": int(creator_id),
  108. "creator_name": creator_name.strip()[:100],
  109. "title": title[:200],
  110. "description": description.strip()[:4000],
  111. "prizes": normalized_prizes,
  112. "minimum_points": _nonnegative_int(minimum_points),
  113. "entry_cost": _nonnegative_int(entry_cost),
  114. "participation_reward": _nonnegative_int(participation_reward),
  115. "status": STATUS_RUNNING,
  116. "created_at": now,
  117. "updated_at": now,
  118. "ends_at": normalized_ends_at,
  119. "message_id": None,
  120. "winners": [],
  121. "rerolls": [],
  122. }
  123. try:
  124. await giveawaysdb.insert_one(doc)
  125. return doc
  126. except DuplicateKeyError:
  127. continue
  128. raise RuntimeError("无法生成唯一的抽奖编号,请重试。")
  129. async def attach_giveaway_message(
  130. giveaway_id: str, chat_id: int, message_id: int
  131. ) -> None:
  132. await ensure_giveaway_indexes()
  133. await giveawaysdb.update_one(
  134. {"giveaway_id": normalize_giveaway_id(giveaway_id)},
  135. {
  136. "$set": {
  137. "chat_id": int(chat_id),
  138. "message_id": int(message_id),
  139. "updated_at": utc_now(),
  140. }
  141. },
  142. )
  143. async def get_giveaway(giveaway_id: str) -> dict[str, Any] | None:
  144. await ensure_giveaway_indexes()
  145. return await giveawaysdb.find_one(
  146. {
  147. "giveaway_id": normalize_giveaway_id(giveaway_id),
  148. "bot_id": BOT_PROFILE_ID,
  149. }
  150. )
  151. async def list_running_giveaways(
  152. *, chat_id: int | None = None, creator_id: int | None = None, limit: int = 20
  153. ) -> list[dict[str, Any]]:
  154. await ensure_giveaway_indexes()
  155. query: dict[str, Any] = {"status": STATUS_RUNNING, "bot_id": BOT_PROFILE_ID}
  156. if chat_id is not None:
  157. query["chat_id"] = int(chat_id)
  158. if creator_id is not None:
  159. query["creator_id"] = int(creator_id)
  160. cursor = giveawaysdb.find(query).sort("created_at", DESCENDING).limit(limit)
  161. return [doc async for doc in cursor]
  162. async def list_giveaways(
  163. *, chat_id: int | None = None, status: str | None = None, limit: int = 50
  164. ) -> list[dict[str, Any]]:
  165. items, _ = await list_giveaways_page(
  166. chat_id=chat_id, status=status, page=1, page_size=limit
  167. )
  168. return items
  169. async def list_giveaways_page(
  170. *,
  171. chat_id: int | None = None,
  172. status: str | None = None,
  173. query: str = "",
  174. page: int = 1,
  175. page_size: int = 20,
  176. all_bots: bool = False,
  177. ) -> tuple[list[dict[str, Any]], int]:
  178. await ensure_giveaway_indexes()
  179. filters: dict[str, Any] = {} if all_bots else {"bot_id": BOT_PROFILE_ID}
  180. if chat_id is not None:
  181. filters["chat_id"] = int(chat_id)
  182. if status:
  183. if status not in ALL_STATUSES:
  184. return [], 0
  185. filters["status"] = status
  186. query = query.strip()
  187. if query:
  188. giveaway_id_pattern = re.escape(query.lstrip("#"))
  189. title_pattern = re.escape(query)
  190. filters["$or"] = [
  191. {"giveaway_id": {"$regex": giveaway_id_pattern, "$options": "i"}},
  192. {"title": {"$regex": title_pattern, "$options": "i"}},
  193. ]
  194. page = max(1, int(page))
  195. page_size = max(1, min(int(page_size), 100))
  196. total = await giveawaysdb.count_documents(filters)
  197. cursor = (
  198. giveawaysdb.find(filters)
  199. .sort("created_at", DESCENDING)
  200. .skip((page - 1) * page_size)
  201. .limit(page_size)
  202. )
  203. return [doc async for doc in cursor], total
  204. async def list_due_giveaways(limit: int = 20) -> list[dict[str, Any]]:
  205. await ensure_giveaway_indexes()
  206. cursor = (
  207. giveawaysdb.find(
  208. {
  209. "bot_id": BOT_PROFILE_ID,
  210. "$or": [
  211. {"status": STATUS_RUNNING, "ends_at": {"$lte": utc_now()}},
  212. {"status": STATUS_DRAWING},
  213. ]
  214. }
  215. )
  216. .sort("ends_at", ASCENDING)
  217. .limit(limit)
  218. )
  219. return [doc async for doc in cursor]
  220. async def list_pending_cancellations(limit: int = 20) -> list[dict[str, Any]]:
  221. await ensure_giveaway_indexes()
  222. cursor = (
  223. giveawaysdb.find({"status": STATUS_CANCELING, "bot_id": BOT_PROFILE_ID})
  224. .sort("updated_at", ASCENDING)
  225. .limit(limit)
  226. )
  227. return [doc async for doc in cursor]
  228. async def claim_giveaway_draw(giveaway_id: str) -> dict[str, Any] | None:
  229. await ensure_giveaway_indexes()
  230. normalized_id = normalize_giveaway_id(giveaway_id)
  231. claimed = await giveawaysdb.find_one_and_update(
  232. {"giveaway_id": normalized_id, "status": STATUS_RUNNING},
  233. {
  234. "$set": {
  235. "status": STATUS_DRAWING,
  236. "draw_started_at": utc_now(),
  237. "updated_at": utc_now(),
  238. }
  239. },
  240. return_document=ReturnDocument.AFTER,
  241. )
  242. if claimed:
  243. return claimed
  244. return await giveawaysdb.find_one(
  245. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  246. )
  247. async def save_pending_winners(
  248. giveaway_id: str, winners: list[dict[str, Any]]
  249. ) -> dict[str, Any] | None:
  250. await ensure_giveaway_indexes()
  251. normalized_id = normalize_giveaway_id(giveaway_id)
  252. updated = await giveawaysdb.find_one_and_update(
  253. {
  254. "giveaway_id": normalized_id,
  255. "status": STATUS_DRAWING,
  256. "pending_winners": {"$exists": False},
  257. },
  258. {
  259. "$set": {
  260. "pending_winners": winners,
  261. "draw_result_created_at": utc_now(),
  262. "updated_at": utc_now(),
  263. }
  264. },
  265. return_document=ReturnDocument.AFTER,
  266. )
  267. return updated or await giveawaysdb.find_one(
  268. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  269. )
  270. async def finish_giveaway(
  271. giveaway_id: str, winners: list[dict[str, Any]] | None = None
  272. ) -> bool:
  273. await ensure_giveaway_indexes()
  274. values: dict[str, Any] = {
  275. "status": STATUS_FINISHED,
  276. "finished_at": utc_now(),
  277. "updated_at": utc_now(),
  278. }
  279. if winners is not None:
  280. values["winners"] = winners
  281. result = await giveawaysdb.update_one(
  282. {
  283. "giveaway_id": normalize_giveaway_id(giveaway_id),
  284. "status": STATUS_DRAWING,
  285. },
  286. {"$set": values, "$unset": {"pending_winners": ""}},
  287. )
  288. return result.modified_count == 1
  289. async def claim_giveaway_cancel(
  290. giveaway_id: str, chat_id: int
  291. ) -> dict[str, Any] | None:
  292. await ensure_giveaway_indexes()
  293. normalized_id = normalize_giveaway_id(giveaway_id)
  294. claimed = await giveawaysdb.find_one_and_update(
  295. {
  296. "giveaway_id": normalized_id,
  297. "chat_id": int(chat_id),
  298. "status": STATUS_RUNNING,
  299. },
  300. {
  301. "$set": {
  302. "status": STATUS_CANCELING,
  303. "cancel_started_at": utc_now(),
  304. "updated_at": utc_now(),
  305. }
  306. },
  307. return_document=ReturnDocument.AFTER,
  308. )
  309. if claimed:
  310. return claimed
  311. return await giveawaysdb.find_one(
  312. {
  313. "giveaway_id": normalized_id,
  314. "chat_id": int(chat_id),
  315. "status": STATUS_CANCELING,
  316. }
  317. )
  318. async def cancel_giveaway(giveaway_id: str, chat_id: int) -> bool:
  319. claimed = await claim_giveaway_cancel(giveaway_id, chat_id)
  320. if not claimed:
  321. return False
  322. return await finalize_giveaway_cancel(giveaway_id)
  323. async def finalize_giveaway_cancel(giveaway_id: str) -> bool:
  324. await ensure_giveaway_indexes()
  325. result = await giveawaysdb.update_one(
  326. {
  327. "giveaway_id": normalize_giveaway_id(giveaway_id),
  328. "status": STATUS_CANCELING,
  329. },
  330. {
  331. "$set": {
  332. "status": STATUS_CANCELED,
  333. "canceled_at": utc_now(),
  334. "updated_at": utc_now(),
  335. }
  336. },
  337. )
  338. return result.modified_count == 1
  339. async def record_reroll(
  340. giveaway_id: str,
  341. winners: list[dict[str, Any]],
  342. tier_name: str | None,
  343. moderator_id: int,
  344. reroll_id: str | None = None,
  345. ) -> str:
  346. await ensure_giveaway_indexes()
  347. reroll_id = reroll_id or token_hex(8)
  348. await giveawaysdb.update_one(
  349. {
  350. "giveaway_id": normalize_giveaway_id(giveaway_id),
  351. "status": STATUS_FINISHED,
  352. "rerolls.reroll_id": {"$ne": reroll_id},
  353. },
  354. {
  355. "$push": {
  356. "rerolls": {
  357. "reroll_id": reroll_id,
  358. "tier_name": tier_name,
  359. "moderator_id": int(moderator_id),
  360. "winners": winners,
  361. "created_at": utc_now(),
  362. }
  363. },
  364. "$set": {"updated_at": utc_now()},
  365. },
  366. )
  367. return reroll_id
  368. async def is_giveaway_banned(chat_id: int, user_id: int) -> bool:
  369. await ensure_giveaway_indexes()
  370. return (
  371. await giveaway_bansdb.find_one(
  372. {"chat_id": int(chat_id), "user_id": int(user_id)}
  373. )
  374. ) is not None
  375. async def add_giveaway_ban(
  376. *, chat_id: int, user_id: int, moderator_id: int, reason: str | None
  377. ) -> None:
  378. await ensure_giveaway_indexes()
  379. now = utc_now()
  380. await giveaway_bansdb.update_one(
  381. {"chat_id": int(chat_id), "user_id": int(user_id)},
  382. {
  383. "$set": {
  384. "moderator_id": int(moderator_id),
  385. "reason": (reason or "").strip()[:500],
  386. "updated_at": now,
  387. },
  388. "$setOnInsert": {"created_at": now},
  389. },
  390. upsert=True,
  391. )
  392. async def remove_giveaway_ban(chat_id: int, user_id: int) -> bool:
  393. await ensure_giveaway_indexes()
  394. result = await giveaway_bansdb.delete_one(
  395. {"chat_id": int(chat_id), "user_id": int(user_id)}
  396. )
  397. return result.deleted_count == 1
  398. async def list_giveaway_bans(
  399. *, chat_id: int, page: int = 1, page_size: int = 20
  400. ) -> tuple[list[dict[str, Any]], int]:
  401. await ensure_giveaway_indexes()
  402. filters = {"chat_id": int(chat_id)}
  403. page = max(1, int(page))
  404. page_size = max(1, min(int(page_size), 100))
  405. total = await giveaway_bansdb.count_documents(filters)
  406. cursor = (
  407. giveaway_bansdb.find(filters)
  408. .sort("created_at", DESCENDING)
  409. .skip((page - 1) * page_size)
  410. .limit(page_size)
  411. )
  412. return [doc async for doc in cursor], total
  413. async def add_participant(
  414. *,
  415. giveaway_id: str,
  416. user_id: int,
  417. username: str | None,
  418. first_name: str | None,
  419. display_name: str | None = None,
  420. ) -> str:
  421. await ensure_giveaway_indexes()
  422. giveaway = await get_giveaway(giveaway_id)
  423. if not giveaway:
  424. return "missing"
  425. if giveaway["status"] != STATUS_RUNNING:
  426. return "closed"
  427. if as_utc(giveaway["ends_at"]) <= utc_now():
  428. return "ended"
  429. if await is_giveaway_banned(giveaway["chat_id"], user_id):
  430. return "banned"
  431. try:
  432. await participantsdb.insert_one(
  433. {
  434. "giveaway_id": giveaway["giveaway_id"],
  435. "chat_id": int(giveaway["chat_id"]),
  436. "user_id": int(user_id),
  437. "username": username,
  438. "first_name": first_name,
  439. "display_name": display_name or first_name,
  440. "entry_cost": int(giveaway.get("entry_cost", 0)),
  441. "active": True,
  442. "joined_at": utc_now(),
  443. }
  444. )
  445. except DuplicateKeyError:
  446. return "duplicate"
  447. return "ok"
  448. async def get_participant(giveaway_id: str, user_id: int) -> dict[str, Any] | None:
  449. await ensure_giveaway_indexes()
  450. return await participantsdb.find_one(
  451. {
  452. "giveaway_id": normalize_giveaway_id(giveaway_id),
  453. "user_id": int(user_id),
  454. }
  455. )
  456. async def remove_participant(
  457. *,
  458. giveaway_id: str,
  459. user_id: int,
  460. moderator_id: int,
  461. reason: str,
  462. refund: bool,
  463. ) -> dict[str, Any] | None:
  464. await ensure_giveaway_indexes()
  465. return await participantsdb.find_one_and_update(
  466. {
  467. "giveaway_id": normalize_giveaway_id(giveaway_id),
  468. "user_id": int(user_id),
  469. "active": {"$ne": False},
  470. },
  471. {
  472. "$set": {
  473. "active": False,
  474. "removed_at": utc_now(),
  475. "removed_by": int(moderator_id),
  476. "removal_reason": reason.strip()[:500],
  477. "refund_requested": bool(refund),
  478. }
  479. },
  480. return_document=ReturnDocument.BEFORE,
  481. )
  482. async def mark_participant_refunded(
  483. giveaway_id: str, user_id: int, refund_reason: str
  484. ) -> None:
  485. await ensure_giveaway_indexes()
  486. await participantsdb.update_one(
  487. {
  488. "giveaway_id": normalize_giveaway_id(giveaway_id),
  489. "user_id": int(user_id),
  490. },
  491. {
  492. "$set": {
  493. "refunded_at": utc_now(),
  494. "refund_reason": refund_reason[:100],
  495. }
  496. },
  497. )
  498. async def count_participants(giveaway_id: str, *, active_only: bool = True) -> int:
  499. await ensure_giveaway_indexes()
  500. filters: dict[str, Any] = {
  501. "giveaway_id": normalize_giveaway_id(giveaway_id)
  502. }
  503. if active_only:
  504. filters["active"] = {"$ne": False}
  505. return await participantsdb.count_documents(filters)
  506. async def list_participants(
  507. giveaway_id: str, limit: int | None = None, *, active_only: bool = True
  508. ) -> list[dict[str, Any]]:
  509. await ensure_giveaway_indexes()
  510. filters: dict[str, Any] = {
  511. "giveaway_id": normalize_giveaway_id(giveaway_id)
  512. }
  513. if active_only:
  514. filters["active"] = {"$ne": False}
  515. cursor = participantsdb.find(filters).sort("joined_at", ASCENDING)
  516. if limit is not None:
  517. cursor = cursor.limit(max(1, int(limit)))
  518. return [doc async for doc in cursor]
  519. async def list_participants_page(
  520. *,
  521. giveaway_id: str,
  522. active_only: bool = False,
  523. page: int = 1,
  524. page_size: int = 20,
  525. ) -> tuple[list[dict[str, Any]], int]:
  526. await ensure_giveaway_indexes()
  527. filters: dict[str, Any] = {
  528. "giveaway_id": normalize_giveaway_id(giveaway_id)
  529. }
  530. if active_only:
  531. filters["active"] = {"$ne": False}
  532. page = max(1, int(page))
  533. page_size = max(1, min(int(page_size), 100))
  534. total = await participantsdb.count_documents(filters)
  535. cursor = (
  536. participantsdb.find(filters)
  537. .sort("joined_at", ASCENDING)
  538. .skip((page - 1) * page_size)
  539. .limit(page_size)
  540. )
  541. return [doc async for doc in cursor], total