Explorar o código

fix: publish recurring giveaway announcements with bot-compatible API

AI-Co-Authored-By: Codex
chendeben hai 1 semana
pai
achega
a2d6c29074

+ 3 - 0
admin-web/src/pages/Giveaways.tsx

@@ -824,6 +824,9 @@ export default function GiveawaysPage() {
               <Descriptions.Item label="资格组合">{selected.eligibility_mode === 'any' ? '任一满足' : '全部满足'}</Descriptions.Item>
               <Descriptions.Item label="资格目标">{selected.eligibility_targets?.map((target) => target.title || target.chat_id).join('、') || '无'}</Descriptions.Item>
               {selected.eligibility_error ? <Descriptions.Item label="核验异常">{selected.eligibility_error}</Descriptions.Item> : null}
+              {selected.cancellation_reason === 'announcement_not_published' ? (
+                <Descriptions.Item label="取消原因">报名公告未成功发布,已自动取消本期</Descriptions.Item>
+              ) : null}
             </Descriptions>
             <Divider orientation="left">参与者({participants.total})</Divider>
             <Table

+ 1 - 0
admin-web/src/types.ts

@@ -413,6 +413,7 @@ export interface Giveaway {
   template_id?: string;
   period_key?: string;
   eligibility_error?: string;
+  cancellation_reason?: string;
   participation_reward: number;
   starts_at?: string;
   ends_at: string;

+ 76 - 2
tests/test_giveaway_recurring_eligibility.py

@@ -201,9 +201,15 @@ async def test_paused_preselection_draw_can_be_canceled(app_modules):
     assert current["status"] == database.STATUS_CANCELED
 
 
-async def test_month_end_schedule_and_materialization_is_idempotent(app_modules):
+async def test_month_end_schedule_and_materialization_is_idempotent(app_modules, monkeypatch):
     templates = app_modules.load("wbb.services.giveaway_templates")
     database = app_modules.load("wbb.utils.dbgiveaway")
+
+    async def unsupported_history(*_args, **_kwargs):
+        raise AssertionError("Bot accounts must not request chat history")
+        yield
+
+    monkeypatch.setattr(app_modules.app, "get_chat_history", unsupported_history)
     body = {
         "chat_id": -100, "title": "Monthly", "description": "",
         "prizes": [{"name": "First", "count": 1}],
@@ -228,12 +234,80 @@ async def test_month_end_schedule_and_materialization_is_idempotent(app_modules)
     items = [item async for item in database.giveawaysdb.find({"template_id": template["template_id"]})]
     assert len(items) == 1
     assert items[0]["period_key"] == local.strftime("%Y-%m")
+    assert items[0]["message_id"] == 1
+    assert len(app_modules.app.sent_messages) == 1
+
+
+async def test_announcement_send_failure_retries_same_period(app_modules, monkeypatch):
+    templates = app_modules.load("wbb.services.giveaway_templates")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    now = datetime.now(UTC)
+    local = now.astimezone(templates.TIMEZONE)
+    template = await templates.create_template({
+        "chat_id": -100, "title": "Retry", "description": "",
+        "prizes": [{"name": "First", "count": 1}],
+        "day_of_month": local.day, "start_time": "00:00",
+        "draw_day_offset": 1, "draw_time": "20:00",
+    }, "Admin")
+    original_send = app_modules.app.send_message
+    failed_once = False
+
+    async def flaky_send(*args, **kwargs):
+        nonlocal failed_once
+        if not failed_once:
+            failed_once = True
+            raise RuntimeError("temporary Telegram failure")
+        return await original_send(*args, **kwargs)
+
+    monkeypatch.setattr(app_modules.app, "send_message", flaky_send)
+    with pytest.raises(RuntimeError, match="temporary Telegram failure"):
+        await templates.materialize_template(template, now=now)
+    await templates.materialize_template(template, now=now)
+    items = [item async for item in database.giveawaysdb.find({
+        "template_id": template["template_id"]
+    })]
+    assert len(items) == 1
+    assert items[0]["message_id"] == 1
     assert len(app_modules.app.sent_messages) == 1
 
 
-async def test_due_period_recovers_draw_and_pins_result_once(app_modules):
+async def test_due_unannounced_period_cancels_instead_of_drawing(app_modules):
     database = app_modules.load("wbb.utils.dbgiveaway")
     service = app_modules.load("wbb.services.giveaways")
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Unannounced",
+        description="", prizes=[{"name": "First", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+        template_id="monthly-template", period_key="2026-09",
+    )
+    accepted, reason, _ = await service.finish_and_publish_giveaway(
+        giveaway["giveaway_id"], publish=False
+    )
+    assert accepted is False
+    assert "报名公告未发布" in reason
+    await database.giveawaysdb.update_one(
+        {"giveaway_id": giveaway["giveaway_id"]},
+        {"$set": {"ends_at": datetime.now(UTC) - timedelta(seconds=1)}},
+    )
+    due = await database.list_due_giveaways()
+    await service.resume_pending_giveaway(due[0])
+    canceled = await database.get_giveaway(giveaway["giveaway_id"])
+    assert canceled["status"] == database.STATUS_CANCELED
+    assert canceled["cancellation_reason"] == "announcement_not_published"
+    assert canceled["winners"] == []
+    assert not app_modules.app.sent_messages
+    assert not app_modules.app.pinned_messages
+
+
+async def test_due_period_recovers_draw_and_pins_result_once(app_modules, monkeypatch):
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+
+    async def unsupported_history(*_args, **_kwargs):
+        raise AssertionError("Bot accounts must not request chat history")
+        yield
+
+    monkeypatch.setattr(app_modules.app, "get_chat_history", unsupported_history)
     giveaway = await database.create_giveaway(
         chat_id=-100, creator_id=1, creator_name="Admin", title="Catch-up",
         description="", prizes=[{"name": "First", "count": 1}],

+ 15 - 16
wbb/services/giveaway_templates.py

@@ -161,19 +161,18 @@ async def materialize_template(template: dict[str, Any], now: datetime | None =
         )
         if giveaway.get("message_id"):
             continue
-        # A send may have succeeded just before a restart; inspect recent history first.
-        try:
-            async for message in app.get_chat_history(int(template["chat_id"]), limit=100):
-                if f"抽奖 #{giveaway['giveaway_id']}" in str(getattr(message, "text", "")):
-                    await attach_giveaway_message(giveaway["giveaway_id"], int(template["chat_id"]), message.id)
-                    break
-            else:
-                sent = await app.send_message(
-                    int(template["chat_id"]), await render_giveaway(giveaway),
-                    parse_mode=ParseMode.HTML, reply_markup=join_markup(giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))),
-                    disable_web_page_preview=True,
-                )
-                await attach_giveaway_message(giveaway["giveaway_id"], int(template["chat_id"]), sent.id)
-        except Exception:
-            # Keep the instance for a later retry instead of losing its period key.
-            raise
+        if as_utc(giveaway["ends_at"]) <= utc_now():
+            continue
+        # Bot accounts cannot call messages.GetHistory. The persisted message ID
+        # is the only reliable indicator that this period was announced.
+        sent = await app.send_message(
+            int(template["chat_id"]), await render_giveaway(giveaway),
+            parse_mode=ParseMode.HTML,
+            reply_markup=join_markup(
+                giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))
+            ),
+            disable_web_page_preview=True,
+        )
+        await attach_giveaway_message(
+            giveaway["giveaway_id"], int(template["chat_id"]), sent.id
+        )

+ 36 - 8
wbb/services/giveaways.py

@@ -728,6 +728,14 @@ async def finish_and_publish_giveaway(
     giveaway_id: str, *, publish: bool = True
 ) -> tuple[bool, str, dict[str, Any] | None]:
     async with _giveaway_locks[giveaway_id]:
+        current = await get_giveaway(giveaway_id)
+        if (
+            current
+            and current["status"] in {STATUS_RUNNING, STATUS_DRAWING}
+            and current.get("template_id")
+            and not current.get("message_id")
+        ):
+            return False, "报名公告未发布,本期不能开奖。", current
         giveaway = await claim_giveaway_draw(giveaway_id)
         if not giveaway:
             existing = await get_giveaway(giveaway_id)
@@ -825,14 +833,6 @@ async def publish_finished(
         from types import SimpleNamespace
 
         result_message = SimpleNamespace(id=int(giveaway["result_message_id"]))
-    else:
-        try:
-            async for recent in app.get_chat_history(chat_id, limit=100):
-                if f"抽奖结果 #{giveaway['giveaway_id']}" in str(getattr(recent, "text", "")):
-                    result_message = recent
-                    break
-        except Exception:
-            pass
     if message_id:
         with suppress(Exception):
             await app.edit_message_text(
@@ -1066,6 +1066,34 @@ async def reroll_giveaway(
 
 async def resume_pending_giveaway(giveaway: dict[str, Any]) -> None:
     try:
+        if (
+            giveaway["status"] == STATUS_RUNNING
+            and giveaway.get("template_id")
+            and not giveaway.get("message_id")
+            and as_utc(giveaway["ends_at"]) <= utc_now()
+        ):
+            canceled, _, _ = await cancel_and_refund_giveaway(
+                giveaway["giveaway_id"], chat_id=int(giveaway["chat_id"]),
+                publish=False,
+            )
+            if canceled:
+                await giveawaysdb.update_one(
+                    {"giveaway_id": giveaway["giveaway_id"], "status": STATUS_CANCELED},
+                    {"$set": {
+                        "cancellation_reason": "announcement_not_published",
+                        "updated_at": utc_now(),
+                    }},
+                )
+                with suppress(Exception):
+                    await record_audit(
+                        source="system", actor_id=None, actor_name="抽奖定时任务",
+                        action="giveaway.announcement_missed",
+                        chat_id=int(giveaway["chat_id"]),
+                        target_id=giveaway["giveaway_id"],
+                        summary="报名公告未发布,开奖时间已到,本期自动取消",
+                        success=False,
+                    )
+            return
         if giveaway["status"] in {STATUS_RUNNING, STATUS_DRAWING}:
             await finish_and_publish_giveaway(giveaway["giveaway_id"])
         elif giveaway["status"] == STATUS_CANCELING: