dbgiveaway.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. from __future__ import annotations
  2. import re
  3. from asyncio import Lock
  4. from datetime import UTC, datetime, timedelta
  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 update_running_giveaway(
  159. giveaway_id: str,
  160. *,
  161. title: str,
  162. description: str,
  163. starts_at: datetime,
  164. ends_at: datetime,
  165. expected_updated_at: datetime,
  166. ) -> dict[str, Any] | None:
  167. await ensure_giveaway_indexes()
  168. normalized_title = title.strip()
  169. normalized_description = description.strip()
  170. normalized_starts_at = as_utc(starts_at)
  171. normalized_ends_at = as_utc(ends_at)
  172. now = utc_now()
  173. previous_updated_at = as_utc(expected_updated_at)
  174. if int(now.timestamp() * 1000) <= int(previous_updated_at.timestamp() * 1000):
  175. now = previous_updated_at + timedelta(milliseconds=1)
  176. if not normalized_title:
  177. raise ValueError("必须填写抽奖标题。")
  178. if len(normalized_title) > 200:
  179. raise ValueError("抽奖标题不能超过 200 个字符。")
  180. if len(normalized_description) > 4000:
  181. raise ValueError("抽奖说明不能超过 4000 个字符。")
  182. if normalized_ends_at <= now:
  183. raise ValueError("开奖时间必须晚于当前时间。")
  184. if normalized_starts_at >= normalized_ends_at:
  185. raise ValueError("报名开始时间必须早于开奖时间。")
  186. return await giveawaysdb.find_one_and_update(
  187. {
  188. "giveaway_id": normalize_giveaway_id(giveaway_id),
  189. "bot_id": BOT_PROFILE_ID,
  190. "status": STATUS_RUNNING,
  191. "updated_at": expected_updated_at,
  192. },
  193. {
  194. "$set": {
  195. "title": normalized_title,
  196. "description": normalized_description,
  197. "starts_at": normalized_starts_at,
  198. "ends_at": normalized_ends_at,
  199. "updated_at": now,
  200. }
  201. },
  202. return_document=ReturnDocument.AFTER,
  203. )
  204. async def list_running_giveaways(
  205. *, chat_id: int | None = None, creator_id: int | None = None, limit: int = 20
  206. ) -> list[dict[str, Any]]:
  207. await ensure_giveaway_indexes()
  208. query: dict[str, Any] = {"status": STATUS_RUNNING, "bot_id": BOT_PROFILE_ID}
  209. if chat_id is not None:
  210. query["chat_id"] = int(chat_id)
  211. if creator_id is not None:
  212. query["creator_id"] = int(creator_id)
  213. cursor = giveawaysdb.find(query).sort("created_at", DESCENDING).limit(limit)
  214. return [doc async for doc in cursor]
  215. async def list_giveaways(
  216. *, chat_id: int | None = None, status: str | None = None, limit: int = 50
  217. ) -> list[dict[str, Any]]:
  218. items, _ = await list_giveaways_page(
  219. chat_id=chat_id, status=status, page=1, page_size=limit
  220. )
  221. return items
  222. async def list_giveaways_page(
  223. *,
  224. chat_id: int | None = None,
  225. status: str | None = None,
  226. query: str = "",
  227. page: int = 1,
  228. page_size: int = 20,
  229. all_bots: bool = False,
  230. ) -> tuple[list[dict[str, Any]], int]:
  231. await ensure_giveaway_indexes()
  232. filters: dict[str, Any] = {} if all_bots else {"bot_id": BOT_PROFILE_ID}
  233. if chat_id is not None:
  234. filters["chat_id"] = int(chat_id)
  235. if status:
  236. if status not in ALL_STATUSES:
  237. return [], 0
  238. filters["status"] = status
  239. query = query.strip()
  240. if query:
  241. giveaway_id_pattern = re.escape(query.lstrip("#"))
  242. title_pattern = re.escape(query)
  243. filters["$or"] = [
  244. {"giveaway_id": {"$regex": giveaway_id_pattern, "$options": "i"}},
  245. {"title": {"$regex": title_pattern, "$options": "i"}},
  246. ]
  247. page = max(1, int(page))
  248. page_size = max(1, min(int(page_size), 100))
  249. total = await giveawaysdb.count_documents(filters)
  250. cursor = (
  251. giveawaysdb.find(filters)
  252. .sort("created_at", DESCENDING)
  253. .skip((page - 1) * page_size)
  254. .limit(page_size)
  255. )
  256. return [doc async for doc in cursor], total
  257. async def list_due_giveaways(limit: int = 20) -> list[dict[str, Any]]:
  258. await ensure_giveaway_indexes()
  259. cursor = (
  260. giveawaysdb.find(
  261. {
  262. "bot_id": BOT_PROFILE_ID,
  263. "$or": [
  264. {"status": STATUS_RUNNING, "ends_at": {"$lte": utc_now()}},
  265. {"status": STATUS_DRAWING},
  266. ]
  267. }
  268. )
  269. .sort("ends_at", ASCENDING)
  270. .limit(limit)
  271. )
  272. return [doc async for doc in cursor]
  273. async def list_unannounced_started_giveaways(limit: int = 20) -> list[dict[str, Any]]:
  274. await ensure_giveaway_indexes()
  275. now = utc_now()
  276. cursor = (
  277. giveawaysdb.find(
  278. {
  279. "bot_id": BOT_PROFILE_ID,
  280. "status": STATUS_RUNNING,
  281. "starts_at": {"$lte": now},
  282. "ends_at": {"$gt": now},
  283. "start_announced_at": {"$exists": False},
  284. }
  285. )
  286. .sort("starts_at", ASCENDING)
  287. .limit(limit)
  288. )
  289. return [doc async for doc in cursor]
  290. async def mark_giveaway_start_announced(giveaway_id: str) -> None:
  291. await ensure_giveaway_indexes()
  292. now = utc_now()
  293. await giveawaysdb.update_one(
  294. {
  295. "giveaway_id": normalize_giveaway_id(giveaway_id),
  296. "status": STATUS_RUNNING,
  297. "start_announced_at": {"$exists": False},
  298. },
  299. {"$set": {"start_announced_at": now, "updated_at": now}},
  300. )
  301. async def list_pending_cancellations(limit: int = 20) -> list[dict[str, Any]]:
  302. await ensure_giveaway_indexes()
  303. cursor = (
  304. giveawaysdb.find({"status": STATUS_CANCELING, "bot_id": BOT_PROFILE_ID})
  305. .sort("updated_at", ASCENDING)
  306. .limit(limit)
  307. )
  308. return [doc async for doc in cursor]
  309. async def claim_giveaway_draw(giveaway_id: str) -> dict[str, Any] | None:
  310. await ensure_giveaway_indexes()
  311. normalized_id = normalize_giveaway_id(giveaway_id)
  312. claimed = await giveawaysdb.find_one_and_update(
  313. {"giveaway_id": normalized_id, "status": STATUS_RUNNING},
  314. {
  315. "$set": {
  316. "status": STATUS_DRAWING,
  317. "draw_started_at": utc_now(),
  318. "updated_at": utc_now(),
  319. }
  320. },
  321. return_document=ReturnDocument.AFTER,
  322. )
  323. if claimed:
  324. return claimed
  325. return await giveawaysdb.find_one(
  326. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  327. )
  328. async def save_pending_winners(
  329. giveaway_id: str, winners: list[dict[str, Any]]
  330. ) -> dict[str, Any] | None:
  331. await ensure_giveaway_indexes()
  332. normalized_id = normalize_giveaway_id(giveaway_id)
  333. updated = await giveawaysdb.find_one_and_update(
  334. {
  335. "giveaway_id": normalized_id,
  336. "status": STATUS_DRAWING,
  337. "pending_winners": {"$exists": False},
  338. },
  339. {
  340. "$set": {
  341. "pending_winners": winners,
  342. "draw_result_created_at": utc_now(),
  343. "updated_at": utc_now(),
  344. }
  345. },
  346. return_document=ReturnDocument.AFTER,
  347. )
  348. return updated or await giveawaysdb.find_one(
  349. {"giveaway_id": normalized_id, "status": STATUS_DRAWING}
  350. )
  351. async def finish_giveaway(
  352. giveaway_id: str, winners: list[dict[str, Any]] | None = None
  353. ) -> bool:
  354. await ensure_giveaway_indexes()
  355. values: dict[str, Any] = {
  356. "status": STATUS_FINISHED,
  357. "finished_at": utc_now(),
  358. "updated_at": utc_now(),
  359. }
  360. if winners is not None:
  361. values["winners"] = winners
  362. result = await giveawaysdb.update_one(
  363. {
  364. "giveaway_id": normalize_giveaway_id(giveaway_id),
  365. "status": STATUS_DRAWING,
  366. },
  367. {"$set": values, "$unset": {"pending_winners": ""}},
  368. )
  369. return result.modified_count == 1
  370. async def claim_giveaway_cancel(
  371. giveaway_id: str, chat_id: int
  372. ) -> dict[str, Any] | None:
  373. await ensure_giveaway_indexes()
  374. normalized_id = normalize_giveaway_id(giveaway_id)
  375. claimed = await giveawaysdb.find_one_and_update(
  376. {
  377. "giveaway_id": normalized_id,
  378. "chat_id": int(chat_id),
  379. "status": STATUS_RUNNING,
  380. },
  381. {
  382. "$set": {
  383. "status": STATUS_CANCELING,
  384. "cancel_started_at": utc_now(),
  385. "updated_at": utc_now(),
  386. }
  387. },
  388. return_document=ReturnDocument.AFTER,
  389. )
  390. if claimed:
  391. return claimed
  392. return await giveawaysdb.find_one(
  393. {
  394. "giveaway_id": normalized_id,
  395. "chat_id": int(chat_id),
  396. "status": STATUS_CANCELING,
  397. }
  398. )
  399. async def cancel_giveaway(giveaway_id: str, chat_id: int) -> bool:
  400. claimed = await claim_giveaway_cancel(giveaway_id, chat_id)
  401. if not claimed:
  402. return False
  403. return await finalize_giveaway_cancel(giveaway_id)
  404. async def finalize_giveaway_cancel(giveaway_id: str) -> bool:
  405. await ensure_giveaway_indexes()
  406. result = await giveawaysdb.update_one(
  407. {
  408. "giveaway_id": normalize_giveaway_id(giveaway_id),
  409. "status": STATUS_CANCELING,
  410. },
  411. {
  412. "$set": {
  413. "status": STATUS_CANCELED,
  414. "canceled_at": utc_now(),
  415. "updated_at": utc_now(),
  416. }
  417. },
  418. )
  419. return result.modified_count == 1
  420. async def record_reroll(
  421. giveaway_id: str,
  422. winners: list[dict[str, Any]],
  423. tier_name: str | None,
  424. moderator_id: int,
  425. reroll_id: str | None = None,
  426. ) -> str:
  427. await ensure_giveaway_indexes()
  428. reroll_id = reroll_id or token_hex(8)
  429. await giveawaysdb.update_one(
  430. {
  431. "giveaway_id": normalize_giveaway_id(giveaway_id),
  432. "status": STATUS_FINISHED,
  433. "rerolls.reroll_id": {"$ne": reroll_id},
  434. },
  435. {
  436. "$push": {
  437. "rerolls": {
  438. "reroll_id": reroll_id,
  439. "tier_name": tier_name,
  440. "moderator_id": int(moderator_id),
  441. "winners": winners,
  442. "created_at": utc_now(),
  443. }
  444. },
  445. "$set": {"updated_at": utc_now()},
  446. },
  447. )
  448. return reroll_id
  449. async def is_giveaway_banned(chat_id: int, user_id: int) -> bool:
  450. await ensure_giveaway_indexes()
  451. return (
  452. await giveaway_bansdb.find_one(
  453. {"chat_id": int(chat_id), "user_id": int(user_id)}
  454. )
  455. ) is not None
  456. async def add_giveaway_ban(
  457. *, chat_id: int, user_id: int, moderator_id: int, reason: str | None
  458. ) -> None:
  459. await ensure_giveaway_indexes()
  460. now = utc_now()
  461. await giveaway_bansdb.update_one(
  462. {"chat_id": int(chat_id), "user_id": int(user_id)},
  463. {
  464. "$set": {
  465. "moderator_id": int(moderator_id),
  466. "reason": (reason or "").strip()[:500],
  467. "updated_at": now,
  468. },
  469. "$setOnInsert": {"created_at": now},
  470. },
  471. upsert=True,
  472. )
  473. async def remove_giveaway_ban(chat_id: int, user_id: int) -> bool:
  474. await ensure_giveaway_indexes()
  475. result = await giveaway_bansdb.delete_one(
  476. {"chat_id": int(chat_id), "user_id": int(user_id)}
  477. )
  478. return result.deleted_count == 1
  479. async def list_giveaway_bans(
  480. *, chat_id: int, page: int = 1, page_size: int = 20
  481. ) -> tuple[list[dict[str, Any]], int]:
  482. await ensure_giveaway_indexes()
  483. filters = {"chat_id": int(chat_id)}
  484. page = max(1, int(page))
  485. page_size = max(1, min(int(page_size), 100))
  486. total = await giveaway_bansdb.count_documents(filters)
  487. cursor = (
  488. giveaway_bansdb.find(filters)
  489. .sort("created_at", DESCENDING)
  490. .skip((page - 1) * page_size)
  491. .limit(page_size)
  492. )
  493. return [doc async for doc in cursor], total
  494. async def add_participant(
  495. *,
  496. giveaway_id: str,
  497. user_id: int,
  498. username: str | None,
  499. first_name: str | None,
  500. display_name: str | None = None,
  501. ) -> str:
  502. await ensure_giveaway_indexes()
  503. giveaway = await get_giveaway(giveaway_id)
  504. if not giveaway:
  505. return "missing"
  506. if giveaway["status"] != STATUS_RUNNING:
  507. return "closed"
  508. starts_at = giveaway.get("starts_at") or giveaway.get("created_at")
  509. if starts_at and as_utc(starts_at) > utc_now():
  510. return "not_started"
  511. if as_utc(giveaway["ends_at"]) <= utc_now():
  512. return "ended"
  513. if await is_giveaway_banned(giveaway["chat_id"], user_id):
  514. return "banned"
  515. try:
  516. await participantsdb.insert_one(
  517. {
  518. "giveaway_id": giveaway["giveaway_id"],
  519. "chat_id": int(giveaway["chat_id"]),
  520. "user_id": int(user_id),
  521. "username": username,
  522. "first_name": first_name,
  523. "display_name": display_name or first_name,
  524. "entry_cost": int(giveaway.get("entry_cost", 0)),
  525. "active": True,
  526. "joined_at": utc_now(),
  527. }
  528. )
  529. except DuplicateKeyError:
  530. return "duplicate"
  531. return "ok"
  532. async def get_participant(giveaway_id: str, user_id: int) -> dict[str, Any] | None:
  533. await ensure_giveaway_indexes()
  534. return await participantsdb.find_one(
  535. {
  536. "giveaway_id": normalize_giveaway_id(giveaway_id),
  537. "user_id": int(user_id),
  538. }
  539. )
  540. async def remove_participant(
  541. *,
  542. giveaway_id: str,
  543. user_id: int,
  544. moderator_id: int,
  545. reason: str,
  546. refund: bool,
  547. ) -> dict[str, Any] | None:
  548. await ensure_giveaway_indexes()
  549. return await participantsdb.find_one_and_update(
  550. {
  551. "giveaway_id": normalize_giveaway_id(giveaway_id),
  552. "user_id": int(user_id),
  553. "active": {"$ne": False},
  554. },
  555. {
  556. "$set": {
  557. "active": False,
  558. "removed_at": utc_now(),
  559. "removed_by": int(moderator_id),
  560. "removal_reason": reason.strip()[:500],
  561. "refund_requested": bool(refund),
  562. }
  563. },
  564. return_document=ReturnDocument.BEFORE,
  565. )
  566. async def mark_participant_refunded(
  567. giveaway_id: str, user_id: int, refund_reason: str
  568. ) -> None:
  569. await ensure_giveaway_indexes()
  570. await participantsdb.update_one(
  571. {
  572. "giveaway_id": normalize_giveaway_id(giveaway_id),
  573. "user_id": int(user_id),
  574. },
  575. {
  576. "$set": {
  577. "refunded_at": utc_now(),
  578. "refund_reason": refund_reason[:100],
  579. }
  580. },
  581. )
  582. async def count_participants(giveaway_id: str, *, active_only: bool = True) -> int:
  583. await ensure_giveaway_indexes()
  584. filters: dict[str, Any] = {
  585. "giveaway_id": normalize_giveaway_id(giveaway_id)
  586. }
  587. if active_only:
  588. filters["active"] = {"$ne": False}
  589. return await participantsdb.count_documents(filters)
  590. async def list_participants(
  591. giveaway_id: str, limit: int | None = None, *, active_only: bool = True
  592. ) -> list[dict[str, Any]]:
  593. await ensure_giveaway_indexes()
  594. filters: dict[str, Any] = {
  595. "giveaway_id": normalize_giveaway_id(giveaway_id)
  596. }
  597. if active_only:
  598. filters["active"] = {"$ne": False}
  599. cursor = participantsdb.find(filters).sort("joined_at", ASCENDING)
  600. if limit is not None:
  601. cursor = cursor.limit(max(1, int(limit)))
  602. return [doc async for doc in cursor]
  603. async def list_participants_page(
  604. *,
  605. giveaway_id: str,
  606. active_only: bool = False,
  607. page: int = 1,
  608. page_size: int = 20,
  609. ) -> tuple[list[dict[str, Any]], int]:
  610. await ensure_giveaway_indexes()
  611. filters: dict[str, Any] = {
  612. "giveaway_id": normalize_giveaway_id(giveaway_id)
  613. }
  614. if active_only:
  615. filters["active"] = {"$ne": False}
  616. page = max(1, int(page))
  617. page_size = max(1, min(int(page_size), 100))
  618. total = await participantsdb.count_documents(filters)
  619. cursor = (
  620. participantsdb.find(filters)
  621. .sort("joined_at", ASCENDING)
  622. .skip((page - 1) * page_size)
  623. .limit(page_size)
  624. )
  625. return [doc async for doc in cursor], total