giveaway_templates.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. from __future__ import annotations
  2. from calendar import monthrange
  3. from datetime import datetime, timedelta
  4. from secrets import token_hex
  5. from typing import Any
  6. from zoneinfo import ZoneInfo
  7. from wbb import BOT_PROFILE_ID, app
  8. from wbb.services.giveaway_eligibility import validate_targets
  9. from wbb.utils.dbgiveaway import (
  10. as_utc,
  11. attach_giveaway_message,
  12. create_giveaway,
  13. ensure_giveaway_indexes,
  14. giveawaysdb,
  15. normalize_prizes,
  16. templatesdb,
  17. utc_now,
  18. )
  19. TIMEZONE = ZoneInfo("Asia/Shanghai")
  20. def _time(value: str) -> tuple[int, int]:
  21. try:
  22. parsed = datetime.strptime(value, "%H:%M")
  23. except (TypeError, ValueError) as exc:
  24. raise ValueError("时间格式必须为 HH:mm。") from exc
  25. return parsed.hour, parsed.minute
  26. def period_schedule(template: dict[str, Any], year: int, month: int) -> tuple[datetime, datetime]:
  27. day = min(int(template["day_of_month"]), monthrange(year, month)[1])
  28. start_hour, start_minute = _time(template["start_time"])
  29. draw_hour, draw_minute = _time(template["draw_time"])
  30. local_start = datetime(year, month, day, start_hour, start_minute, tzinfo=TIMEZONE)
  31. local_draw = datetime(year, month, day, draw_hour, draw_minute, tzinfo=TIMEZONE)
  32. local_draw += timedelta(days=int(template["draw_day_offset"]))
  33. if local_draw <= local_start:
  34. raise ValueError("开奖时间必须晚于报名开始时间。")
  35. return as_utc(local_start), as_utc(local_draw)
  36. def normalize_template(body: dict[str, Any]) -> dict[str, Any]:
  37. day = int(body.get("day_of_month", 1))
  38. offset = int(body.get("draw_day_offset", 0))
  39. if not 1 <= day <= 31 or offset not in {0, 1}:
  40. raise ValueError("每月日期必须为 1 至 31,开奖只能在当天或次日。")
  41. mode = str(body.get("eligibility_mode", "all"))
  42. if mode not in {"all", "any"}:
  43. raise ValueError("资格条件组合方式必须是全部或任一。")
  44. cost = int(body.get("entry_cost", 0))
  45. limit = int(body.get("max_tickets_per_user", 10))
  46. if cost < 0 or not 1 <= limit <= 100:
  47. raise ValueError("积分和奖票上限无效。")
  48. values = {
  49. "chat_id": int(body["chat_id"]),
  50. "title": str(body.get("title") or "").strip()[:200],
  51. "description": str(body.get("description") or "").strip()[:4000],
  52. "prizes": normalize_prizes(body["prizes"]),
  53. "minimum_points": int(body.get("minimum_points", 0)),
  54. "entry_cost": cost,
  55. "participation_reward": int(body.get("participation_reward", 0)),
  56. "max_tickets_per_user": limit if cost else 1,
  57. "eligibility_targets": list(body.get("eligibility_targets") or []),
  58. "eligibility_mode": mode,
  59. "day_of_month": day,
  60. "start_time": str(body.get("start_time", "00:00")),
  61. "draw_day_offset": offset,
  62. "draw_time": str(body.get("draw_time", "20:00")),
  63. }
  64. if not values["title"] or any(values[key] < 0 for key in ("minimum_points", "participation_reward")):
  65. raise ValueError("标题和积分配置无效。")
  66. period_schedule(values, 2024, 2)
  67. return values
  68. async def create_template(body: dict[str, Any], creator_name: str) -> dict[str, Any]:
  69. await ensure_giveaway_indexes()
  70. values = normalize_template(body)
  71. now = utc_now()
  72. template = {
  73. **values, "template_id": token_hex(8), "bot_id": BOT_PROFILE_ID,
  74. "creator_name": creator_name, "active": True,
  75. "created_at": now, "updated_at": now,
  76. }
  77. await templatesdb.insert_one(template)
  78. return template
  79. async def list_templates(chat_id: int | None = None) -> list[dict[str, Any]]:
  80. await ensure_giveaway_indexes()
  81. query: dict[str, Any] = {"bot_id": BOT_PROFILE_ID}
  82. if chat_id is not None:
  83. query["chat_id"] = int(chat_id)
  84. return [item async for item in templatesdb.find(query).sort("created_at", -1)]
  85. async def update_template(template_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
  86. await ensure_giveaway_indexes()
  87. changes = normalize_template(body)
  88. changes["updated_at"] = utc_now()
  89. if "active" in body:
  90. changes["active"] = bool(body["active"])
  91. from pymongo import ReturnDocument
  92. return await templatesdb.find_one_and_update(
  93. {"template_id": template_id, "bot_id": BOT_PROFILE_ID},
  94. {"$set": changes}, return_document=ReturnDocument.AFTER,
  95. )
  96. async def materialize_template(template: dict[str, Any], now: datetime | None = None) -> None:
  97. await ensure_giveaway_indexes()
  98. if not template.get("active"):
  99. return
  100. now = now or utc_now()
  101. local = now.astimezone(TIMEZONE)
  102. periods = {(local.year, local.month)}
  103. previous = local.replace(day=1) - timedelta(days=1)
  104. periods.add((previous.year, previous.month))
  105. from pyrogram.enums import ParseMode
  106. from wbb.services.giveaways import join_markup, render_giveaway
  107. for year, month in sorted(periods):
  108. starts_at, ends_at = period_schedule(template, year, month)
  109. if starts_at > now or ends_at <= now or as_utc(template["created_at"]) > ends_at:
  110. continue
  111. period_key = f"{year:04d}-{month:02d}"
  112. existing = await giveawaysdb.find_one(
  113. {"template_id": template["template_id"], "period_key": period_key}
  114. )
  115. if existing and (existing.get("message_id") or existing["status"] != "running"):
  116. continue
  117. if not existing:
  118. try:
  119. await validate_targets(list(template.get("eligibility_targets") or []))
  120. except ValueError as exc:
  121. await templatesdb.update_one(
  122. {"template_id": template["template_id"]},
  123. {"$set": {"last_error": str(exc), "last_error_at": utc_now()}},
  124. )
  125. raise
  126. await templatesdb.update_one(
  127. {"template_id": template["template_id"]},
  128. {"$unset": {"last_error": "", "last_error_at": ""}},
  129. )
  130. giveaway = existing or await create_giveaway(
  131. chat_id=int(template["chat_id"]), creator_id=0,
  132. creator_name=template["creator_name"], title=template["title"],
  133. description=template["description"], prizes=template["prizes"],
  134. starts_at=starts_at, ends_at=ends_at,
  135. minimum_points=template["minimum_points"], entry_cost=template["entry_cost"],
  136. participation_reward=template["participation_reward"],
  137. max_tickets_per_user=template["max_tickets_per_user"],
  138. eligibility_targets=template["eligibility_targets"],
  139. eligibility_mode=template["eligibility_mode"],
  140. template_id=template["template_id"], period_key=period_key,
  141. )
  142. if giveaway.get("message_id"):
  143. continue
  144. if as_utc(giveaway["ends_at"]) <= utc_now():
  145. continue
  146. # Bot accounts cannot call messages.GetHistory. The persisted message ID
  147. # is the only reliable indicator that this period was announced.
  148. sent = await app.send_message(
  149. int(template["chat_id"]), await render_giveaway(giveaway),
  150. parse_mode=ParseMode.HTML,
  151. reply_markup=join_markup(
  152. giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))
  153. ),
  154. disable_web_page_preview=True,
  155. )
  156. await attach_giveaway_message(
  157. giveaway["giveaway_id"], int(template["chat_id"]), sent.id
  158. )