dbgiveaway.py 24 KB

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