From 4a9c37f2f3a796ad7243caf5160609358ccfd494 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 20:45:57 +0800 Subject: [PATCH] feat(telegram): candidate list/delete/clear commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 私聊新增 /candidate [页码](待决策列表)、/candi_del(标题按钮逐条删除,删后原地重建键盘、可翻页连续删)、/candi_clear(全部/待决策/取消)。重构 handle_callback 为前缀分派(cdel/cpage/cclr,保留 add/ign/blk/fav);repository 加 list_pending_candidates/delete_all_candidates;LibraryService 加 pending_candidates/delete_candidate/clear_candidates 并刷看板缓存。 --- README.md | 6 +- project.md | 6 + reaction_board/notify/telegram.py | 77 +++++++++- reaction_board/repository.py | 20 +++ reaction_board/services/library.py | 26 ++++ reaction_board/services/telegram_poller.py | 157 ++++++++++++++++++--- tests/test_candidates.py | 22 +++ tests/test_library.py | 28 ++++ tests/test_telegram.py | 154 +++++++++++++++++++- 9 files changed, 473 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e550da5..9453037 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 | | 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 | | 搜索发现与候选池 | 可用(P3) | 每小时按 `discover` 配置搜索并分类打分后写入候选池。 | -| Telegram 通知与入库 | 可用(P3) | 入库 / 忽略 / 拉黑按钮;私聊消息手动入库。 | +| Telegram 通知与入库 | 可用(P3) | 入库 / 忽略 / 拉黑按钮;私聊消息手动入库;候选查看 / 删除 / 清空命令。 | | 本地数据看板 | 可用(P2/P9) | 只读总览、视频榜、UP 主榜、内容效率、视频/UP 详情、粉丝曲线、数据说明、候选池和系统状态。 | | 访问统计(PV/UV) | 可用 | 前端埋点上报,按客户端 IP 与页面去重;先落内存、每分钟批量入库;系统页展示近 30 天。 | @@ -142,6 +142,10 @@ uv run python app.py run - 每轮按 `discover.keywords` 搜索前 `discover.pages` 页,过滤已入库/已见/超时间窗/黑名单/标题不含要求词的结果; - 分数规则与 `classify` 配置一致;`score >= discover.candidate_min_score` 才写入候选池,`score >= notify.min_score` 才发送 Telegram; - 通知按钮:**入库**、**忽略**、**拉黑**;回调只接受 `telegram.chat_id` 对应的私聊用户; +- 私聊命令(仅本人可用): + - `/candidate [页码]`:列出待决策候选标题(每页 10 条,用 `/candidate 2` 翻页); + - `/candi_del`:每页 10 个标题按钮,点击即删除该候选;删后原地重建本页键盘,可连续删;带上一页/下一页; + - `/candi_clear`:给出「全部 / 待决策 / 取消」按钮,清空整表或只清待决策; - 「入库」把视频写入本地库并置为 `active`,**不写 B 站收藏夹**;同时用取到的 `view` 数据补一条当天快照; - 私聊文本消息会自动解析其中的视频链接 / BV 号 / av 号 / `b23.tv` 短链并逐条入库,回执汇总结果; - 忽略写 `candidate.decision='rejected'`;拉黑写 `up_blacklist` 并拒绝该 UP 的其他待决策候选; diff --git a/project.md b/project.md index f381267..f2ea3c4 100644 --- a/project.md +++ b/project.md @@ -308,6 +308,12 @@ score >= 通知线 → 通知 - **「拉黑」**:把该视频的 **UP 拉入 `up_blacklist`**,同时把该 UP 的其他待决策候选一并标记为 `rejected`;**已入库/收藏夹里的该 UP 视频不受影响** - 「打开视频」链接(`https://www.bilibili.com/video/`) +**私聊管理命令**(仅 `telegram.chat_id` 本人可用): + +- `/candidate [页码]`:纯文本列出待决策候选标题,每页 10 条; +- `/candi_del`:每页 10 个**标题按钮**(`cdel::`)+ 上一页/下一页;点击删除该候选后用 `editMessageReplyMarkup` **原地重建**本页键盘,可连续删;删空本页则回退上一页; +- `/candi_clear`:`[全部][待决策][取消]`(`cclr:all|pending|cancel`);「全部」清整张 `candidate` 表,「待决策」只清 `decision IS NULL`。 + **黑名单语义(明确边界)**: 1. 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知; diff --git a/reaction_board/notify/telegram.py b/reaction_board/notify/telegram.py index 9c03d0e..2ad1d6a 100644 --- a/reaction_board/notify/telegram.py +++ b/reaction_board/notify/telegram.py @@ -89,10 +89,85 @@ def format_manual_reply(manual_result: Any) -> str: HELP_TEXT = ( "发送 B 站视频链接、BV 号或 av 号即可手动入库并开始追踪。\n" - "例如:https://www.bilibili.com/video/BV1x8x166EzY 或 BV1x8x166EzY" + "例如:https://www.bilibili.com/video/BV1x8x166EzY 或 BV1x8x166EzY\n" + "\n" + "候选管理命令:\n" + "/candidate [页码] 查看待决策候选列表\n" + "/candi_del 用按钮删除指定候选\n" + "/candi_clear 清空候选库" ) +CANDIDATE_PAGE_SIZE = 10 +TITLE_BUTTON_LIMIT = 28 +CANDIDATE_DELETE_HEADER = "待决策候选:点击标题删除对应候选。" + + +def truncate_title(title: object, limit: int = TITLE_BUTTON_LIMIT) -> str: + """把标题截断到适合作为按钮文案的长度(超出补 …)。""" + text = str(title or "").strip() or "(无标题)" + if len(text) <= limit: + return text + return text[: max(1, limit - 1)] + "…" + + +def _page_bounds(total: int, page: int, page_size: int) -> tuple[int, int, int]: + total_pages = max(1, (total + page_size - 1) // page_size) + try: + page = int(page) + except (TypeError, ValueError): + page = 1 + page = min(max(1, page), total_pages) + return page, total_pages, (page - 1) * page_size + + +def format_candidate_list( + items: Sequence[Mapping[str, Any]], + page: int = 1, + page_size: int = CANDIDATE_PAGE_SIZE, +) -> str: + """待决策候选的纯文本列表(每页 ``page_size`` 条)。""" + total = len(items) + if total == 0: + return "暂无待决策候选。" + page, total_pages, start = _page_bounds(total, page, page_size) + lines = [f"待决策候选 共 {total} 条 · 第 {page}/{total_pages} 页"] + for item in items[start:start + page_size]: + title = html.escape(str(item.get("title") or "(无标题)")) + lines.append(f"· {title}") + return "\n".join(lines) + + +def candidate_delete_keyboard( + items: Sequence[Mapping[str, Any]], + page: int = 1, + page_size: int = CANDIDATE_PAGE_SIZE, +) -> dict[str, Any]: + """每页每条标题一个按钮(``cdel::``)+ 翻页按钮。""" + total = len(items) + page, total_pages, start = _page_bounds(total, page, page_size) + rows: list[list[Mapping[str, Any]]] = [] + for item in items[start:start + page_size]: + rows.append([{ + "text": truncate_title(item.get("title")), + "callback_data": f"cdel:{page}:{item.get('aid')}", + }]) + if total_pages > 1: + rows.append([ + {"text": "上一页", "callback_data": f"cpage:del:{page - 1}"}, + {"text": "下一页", "callback_data": f"cpage:del:{page + 1}"}, + ]) + return {"inline_keyboard": rows} + + +def clear_candidates_keyboard() -> dict[str, Any]: + return {"inline_keyboard": [[ + {"text": "全部", "callback_data": "cclr:all"}, + {"text": "待决策", "callback_data": "cclr:pending"}, + {"text": "取消", "callback_data": "cclr:cancel"}, + ]]} + + def format_candidate(candidate: Mapping[str, Any]) -> str: title = html.escape(str(candidate.get("title") or "(无标题)")) up_name = html.escape(str(candidate.get("up_name") or "未知UP")) diff --git a/reaction_board/repository.py b/reaction_board/repository.py index e3d67d2..928815c 100644 --- a/reaction_board/repository.py +++ b/reaction_board/repository.py @@ -919,6 +919,16 @@ class Repository: return [dict(row) for row in rows] return self.db.read(run) + def list_pending_candidates(self) -> list[dict[str, Any]]: + """待决策候选(``decision IS NULL``),按分数倒序。""" + def run(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT * FROM candidate WHERE decision IS NULL " + "ORDER BY COALESCE(score, 0) DESC, first_seen DESC, aid" + ).fetchall() + return [dict(row) for row in rows] + return self.db.read(run) + def mark_candidate_decision( self, aid: int, decision: str, *, now: int | None = None ) -> None: @@ -944,6 +954,16 @@ class Repository: "DELETE FROM candidate WHERE aid = ?", (int(aid),) )) + def delete_all_candidates(self, *, pending_only: bool = False) -> int: + """删除候选:``pending_only=True`` 只删待决策,否则清空整表;返回删除数。""" + def run(conn: sqlite3.Connection) -> int: + if pending_only: + cur = conn.execute("DELETE FROM candidate WHERE decision IS NULL") + else: + cur = conn.execute("DELETE FROM candidate") + return max(cur.rowcount, 0) + return self.db.write(run) + def delete_expired_candidates( self, *, ttl_days: int, now: int | None = None ) -> int: diff --git a/reaction_board/services/library.py b/reaction_board/services/library.py index 2d5de4b..6291b26 100644 --- a/reaction_board/services/library.py +++ b/reaction_board/services/library.py @@ -302,6 +302,32 @@ class LibraryService: message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选", ) + # ------------------------------------------------------------ candidate admin + + def pending_candidates(self) -> list[dict[str, Any]]: + return self.repo.list_pending_candidates() + + def delete_candidate(self, aid: int) -> dict[str, Any] | None: + """删除单条候选;返回被删记录(不存在则 None)。""" + aid = int(aid) + candidate = self.repo.get_candidate(aid) + if candidate is None: + return None + self.repo.delete_candidate(aid) + self.repo.bump_cache_version("videos") + self._log("info", "candidate.delete", aid=aid) + return candidate + + def clear_candidates(self, *, pending_only: bool = False) -> int: + """删除候选(``pending_only=True`` 只删待决策);返回删除数。""" + removed = self.repo.delete_all_candidates(pending_only=pending_only) + if removed: + self.repo.bump_cache_version("videos") + self._log( + "info", "candidate.clear", pending_only=pending_only, removed=removed, + ) + return removed + # ------------------------------------------------------------ internals def _result_from_outcome(self, outcome, *, title: str, up_name: str) -> IngestResult: diff --git a/reaction_board/services/telegram_poller.py b/reaction_board/services/telegram_poller.py index 584f612..2295baa 100644 --- a/reaction_board/services/telegram_poller.py +++ b/reaction_board/services/telegram_poller.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- """Telegram getUpdates long polling 与回调/文本消息分发。 -回调按钮处理候选「入库 / 忽略 / 拉黑」;私聊文本消息按视频链接、BV 号或 av 号 -手动入库。两类操作都只写本地库,不写 B 站收藏夹。 +回调按钮处理候选「入库 / 忽略 / 拉黑」以及候选管理(查看 / 删除 / 清空); +私聊文本消息按视频链接、BV 号或 av 号手动入库。所有操作都只写本地库,不写 +B 站收藏夹。 """ from __future__ import annotations @@ -10,7 +11,19 @@ from __future__ import annotations import time from typing import Any, Mapping -from ..notify.telegram import HELP_TEXT, format_ingest_result, format_manual_reply +from ..notify.telegram import ( + CANDIDATE_DELETE_HEADER, + HELP_TEXT, + candidate_delete_keyboard, + clear_candidates_keyboard, + format_candidate_list, + format_ingest_result, + format_manual_reply, + truncate_title, +) + +CANDIDATE_PAGE_SIZE = 10 +_INGEST_ACTIONS = ("add", "fav", "ign", "blk") class TelegramPoller: @@ -42,6 +55,8 @@ class TelegramPoller: def _authorized(self, chat_id, from_id) -> bool: return chat_id == self.bot.chat_id and from_id == self.bot.chat_id + # ------------------------------------------------------------ callbacks + def handle_callback(self, callback: Mapping[str, Any]) -> None: callback_id = str(callback.get("id") or "") message = callback.get("message") or {} @@ -55,9 +70,26 @@ class TelegramPoller: self._answer(callback_id, "无权限") return - action, _, raw_aid = str(callback.get("data") or "").partition(":") + message_id = message.get("message_id") + if isinstance(message_id, bool) or not isinstance(message_id, int): + message_id = None + + data = str(callback.get("data") or "") + action, _, payload = data.partition(":") + if action in _INGEST_ACTIONS: + self._handle_ingest_action(callback_id, action, payload, message_id) + elif action == "cdel": + self._handle_candidate_delete(callback_id, payload, message_id) + elif action == "cpage": + self._handle_candidate_page(callback_id, payload, message_id) + elif action == "cclr": + self._handle_candidate_clear(callback_id, payload, message_id) + else: + self._answer(callback_id, "未知操作") + + def _handle_ingest_action(self, callback_id, action, payload, message_id): try: - aid = int(raw_aid) + aid = int(payload) except (TypeError, ValueError): self._answer(callback_id, "未知操作") return @@ -67,11 +99,8 @@ class TelegramPoller: result = self.actions.ingest_candidate(aid) elif action == "ign": result = self.actions.ignore(aid) - elif action == "blk": + else: # blk result = self.actions.blacklist(aid) - else: - self._answer(callback_id, "未知操作") - return text = format_ingest_result(result) self._log( @@ -84,12 +113,67 @@ class TelegramPoller: self.bot.send_text(text) except Exception as exc: # noqa: BLE001 self._log("warning", "telegram.fallback_failed", error=str(exc)) - message_id = message.get("message_id") - if isinstance(message_id, int) and not isinstance(message_id, bool): - try: - self.bot.edit_reply_markup(message_id) - except Exception as exc: # noqa: BLE001 - self._log("warning", "telegram.edit_failed", error=str(exc)) + if message_id is not None: + self._edit_markup(message_id, {"inline_keyboard": []}) + + def _handle_candidate_delete(self, callback_id, payload, message_id): + page_s, _, aid_s = payload.partition(":") + try: + page, aid = int(page_s), int(aid_s) + except (TypeError, ValueError): + self._answer(callback_id, "参数错误") + return + candidate = self.actions.delete_candidate(aid) + if candidate is None: + self._answer(callback_id, "未找到该候选") + else: + self._answer( + callback_id, f"✅ 已删除:{truncate_title(candidate.get('title'))}" + ) + self._log("info", "telegram.candidate_delete", aid=aid) + self._render_delete_keyboard(message_id, page) + + def _handle_candidate_page(self, callback_id, payload, message_id): + mode, _, page_s = payload.partition(":") + try: + page = int(page_s) + except (TypeError, ValueError): + self._answer(callback_id, "参数错误") + return + if mode == "del": + self._render_delete_keyboard(message_id, page) + self._answer(callback_id, f"第 {max(1, page)} 页") + + def _handle_candidate_clear(self, callback_id, payload, message_id): + if payload == "cancel": + if message_id is not None: + self._edit_markup(message_id, {"inline_keyboard": []}) + self._answer(callback_id, "已取消") + return + if payload not in ("all", "pending"): + self._answer(callback_id, "未知操作") + return + removed = self.actions.clear_candidates(pending_only=(payload == "pending")) + self._log("info", "telegram.candidate_clear", scope=payload, removed=removed) + if message_id is not None: + self._edit_markup(message_id, {"inline_keyboard": []}) + self._answer(callback_id, f"已清空 {removed} 条") + + def _render_delete_keyboard(self, message_id, page): + if message_id is None: + return + items = self.actions.pending_candidates() + self._edit_markup( + message_id, candidate_delete_keyboard(items, page, CANDIDATE_PAGE_SIZE) + ) + + def _edit_markup(self, message_id, markup) -> None: + try: + self.bot.edit_reply_markup(message_id, reply_markup=markup) + except Exception as exc: # noqa: BLE001 + self._log("warning", "telegram.edit_failed", error=str(exc)) + + # ------------------------------------------------------------ messages def handle_message(self, message: Mapping[str, Any]) -> None: chat_id = (message.get("chat") or {}).get("id") @@ -104,8 +188,8 @@ class TelegramPoller: if not isinstance(text, str) or not text.strip(): return text = text.strip() - if text.startswith("/start") or text.startswith("/help"): - self._reply(HELP_TEXT) + if text.startswith("/"): + self._handle_command(text) return try: result = self.actions.ingest_text(text) @@ -119,9 +203,44 @@ class TelegramPoller: ) self._reply(format_manual_reply(result)) - def _reply(self, text: str) -> None: + @staticmethod + def _parse_command(text: str) -> tuple[str, list[str]]: + parts = text.split() + token = parts[0][1:] # 去掉前导 "/" + command = token.split("@", 1)[0].lower() # 去掉 @BotName + return command, parts[1:] + + def _handle_command(self, text: str) -> None: + command, args = self._parse_command(text) + if command in ("start", "help"): + self._reply(HELP_TEXT) + elif command == "candidate": + page = int(args[0]) if args and args[0].isdigit() else 1 + self._reply(format_candidate_list( + self.actions.pending_candidates(), page, CANDIDATE_PAGE_SIZE, + )) + elif command == "candi_del": + items = self.actions.pending_candidates() + if not items: + self._reply("暂无待决策候选。") + return + self._reply( + CANDIDATE_DELETE_HEADER, + reply_markup=candidate_delete_keyboard( + items, 1, CANDIDATE_PAGE_SIZE + ), + ) + elif command == "candi_clear": + self._reply("确认清空候选库?", reply_markup=clear_candidates_keyboard()) + else: + self._reply(HELP_TEXT) + + def _reply(self, text: str, reply_markup=None) -> None: try: - self.bot.send_text(text) + if reply_markup is None: + self.bot.send_text(text) + else: + self.bot.send_text(text, reply_markup=reply_markup) except Exception as exc: # noqa: BLE001 self._log("warning", "telegram.reply_failed", error=str(exc)) diff --git a/tests/test_candidates.py b/tests/test_candidates.py index f5db665..f406510 100644 --- a/tests/test_candidates.py +++ b/tests/test_candidates.py @@ -180,11 +180,33 @@ def test_fav_action_state_machine(): db.close() +def test_list_pending_and_delete_all(): + with tempfile.TemporaryDirectory() as tmp: + db, repo = setup(tmp) + try: + candidate(repo, 1, score=9) + candidate(repo, 2, score=4) + candidate(repo, 3, score=1) + repo.mark_candidate_decision(3, "rejected") + + assert [c["aid"] for c in repo.list_pending_candidates()] == [1, 2] + assert repo.delete_all_candidates(pending_only=True) == 2 + assert repo.get_candidate(3) is not None + assert repo.list_pending_candidates() == [] + + candidate(repo, 4) + assert repo.delete_all_candidates() == 2 # aid 3 + aid 4 + assert repo.list_candidates() == [] + finally: + db.close() + + def main(): test_seen_and_known_aid() test_candidate_upsert_preserves_decision_and_notified() test_notify_and_reject_for_up() test_delete_expired_candidates_only_pending() + test_list_pending_and_delete_all() test_folder_for_up_uses_earliest_video() test_fav_action_state_machine() print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过") diff --git a/tests/test_library.py b/tests/test_library.py index ffd7fa4..6feab64 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -326,6 +326,33 @@ def test_ignore_and_blacklist(): db.close() +def test_candidate_admin_delete_and_clear(): + with tempfile.TemporaryDirectory() as tmp: + db, repo, service = setup(tmp, FakeReadClient()) + try: + add_candidate(repo, 1) + add_candidate(repo, 2) + add_candidate(repo, 3) + repo.mark_candidate_decision(3, "rejected") + + assert [c["aid"] for c in service.pending_candidates()] == [1, 2] + + deleted = service.delete_candidate(2) + assert deleted is not None and deleted["aid"] == 2 + assert repo.get_candidate(2) is None + assert service.delete_candidate(999) is None + + assert service.clear_candidates(pending_only=True) == 1 + assert repo.get_candidate(3) is not None # 已决策保留 + assert repo.get_candidate(1) is None + + add_candidate(repo, 4) + assert service.clear_candidates() == 2 # aid 3 + aid 4 + assert repo.list_candidates() == [] + finally: + db.close() + + def main(): test_ingest_candidate_added_and_snapshot() test_ingest_candidate_exists_and_missing() @@ -337,6 +364,7 @@ def main(): test_manual_ingest_truncation() test_manual_ingest_invalid_bvid_message() test_ignore_and_blacklist() + test_candidate_admin_delete_and_clear() print("OK: 入库、手动入库、忽略、拉黑测试全部通过") diff --git a/tests/test_telegram.py b/tests/test_telegram.py index c8a46f9..63896e7 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -11,11 +11,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.stdout.reconfigure(encoding="utf-8") from reaction_board.notify.telegram import ( # noqa: E402 + CANDIDATE_DELETE_HEADER, HELP_TEXT, TelegramClient, TelegramError, + candidate_delete_keyboard, + clear_candidates_keyboard, + format_candidate_list, format_duration, format_manual_reply, + truncate_title, ) from reaction_board.services.library import ( # noqa: E402 ActionResult, @@ -147,7 +152,9 @@ class FakeBot: self.fail_answer = fail_answer self.answers = [] self.sent = [] + self.markups = [] self.edits = [] + self.edit_markups = [] def get_updates(self, offset=None): return list(self.updates) @@ -157,17 +164,21 @@ class FakeBot: raise RuntimeError("HTTP Error 400: Bad Request") self.answers.append((callback_id, text)) - def send_text(self, text): + def send_text(self, text, reply_markup=None): self.sent.append(text) + self.markups.append(reply_markup) - def edit_reply_markup(self, message_id): + def edit_reply_markup(self, message_id, reply_markup=None): self.edits.append(message_id) + self.edit_markups.append(reply_markup) class FakeActions: def __init__(self): self.calls = [] self.manual = [] + self.candidates = [] + self.clears = [] def ingest_candidate(self, aid): self.calls.append(("add", aid)) @@ -190,6 +201,27 @@ class FakeActions: results=[IngestResult(True, 9, "added", title="咻咻满 reaction", message="已入库")], ) + def pending_candidates(self): + return list(self.candidates) + + def delete_candidate(self, aid): + self.calls.append(("cdel", aid)) + for index, candidate in enumerate(self.candidates): + if candidate["aid"] == aid: + return self.candidates.pop(index) + return None + + def clear_candidates(self, *, pending_only=False): + self.clears.append(pending_only) + total = len(self.candidates) + if pending_only: + self.candidates = [ + c for c in self.candidates if c.get("decision") is not None + ] + return total - len(self.candidates) + self.candidates = [] + return total + ADD_REPLY = "✅ 已入库《咻咻满 reaction》(aid 9)" @@ -266,6 +298,118 @@ def test_answer_failure_falls_back_to_text(): assert bot.edits == [10] +def candidates(n, *, start=1): + return [ + {"aid": 100 + i, "title": f"咻咻满 reaction {i}"} + for i in range(start, start + n) + ] + + +def test_truncate_and_keyboard_helpers(): + assert truncate_title("短标题") == "短标题" + long_title = "咻咻满" + "反" * 40 + out = truncate_title(long_title, 20) + assert len(out) == 20 and out.endswith("…") + assert truncate_title("") == "(无标题)" + + items = candidates(12) + text = format_candidate_list(items, 1) + assert "共 12 条 · 第 1/2 页" in text + assert "咻咻满 reaction 1" in text + assert "咻咻满 reaction 11" not in text + assert "第 2/2 页" in format_candidate_list(items, 2) + assert "第 2/2 页" in format_candidate_list(items, 99) # 页码越界夹取到末页 + assert format_candidate_list([], 1) == "暂无待决策候选。" + + keyboard = candidate_delete_keyboard(items, 1) + assert keyboard["inline_keyboard"][0][0]["callback_data"] == "cdel:1:101" + assert keyboard["inline_keyboard"][-1][0]["callback_data"] == "cpage:del:0" + clear_kb = clear_candidates_keyboard() + assert [b["callback_data"] for b in clear_kb["inline_keyboard"][0]] == [ + "cclr:all", "cclr:pending", "cclr:cancel", + ] + + +def test_candidate_list_command_pages(): + bot = FakeBot([ + message_update(1, "/candidate"), + message_update(2, "/candidate 2"), + message_update(3, "/candidate@re_xxm_bot"), + ]) + actions = FakeActions() + actions.candidates = candidates(12) + TelegramPoller(bot, actions).run_once(None) + assert "共 12 条 · 第 1/2 页" in bot.sent[0] + assert "第 2/2 页" in bot.sent[1] + assert "第 1/2 页" in bot.sent[2] # @BotName 后缀也能解析 + + +def test_candi_del_keyboard_and_rebuild(): + bot = FakeBot([ + message_update(1, "/candi_del"), + update(2, "cdel:1:101"), + update(3, "cpage:del:2"), + ]) + actions = FakeActions() + actions.candidates = candidates(12) + TelegramPoller(bot, actions).run_once(None) + + assert bot.sent[0] == CANDIDATE_DELETE_HEADER + keyboard = bot.markups[0] + assert keyboard["inline_keyboard"][0][0]["text"] == "咻咻满 reaction 1" + assert keyboard["inline_keyboard"][0][0]["callback_data"] == "cdel:1:101" + + assert ("cdel", 101) in actions.calls + assert bot.answers[0] == ("cb2", "✅ 已删除:咻咻满 reaction 1") + # 删除 101 后重建第一页键盘(新首条 102) + assert bot.edit_markups[0]["inline_keyboard"][0][0]["callback_data"] == "cdel:1:102" + + # 翻到第 2 页(剩余 11 条:102..112 → 第 2 页仅 112) + assert bot.answers[1] == ("cb3", "第 2 页") + assert bot.edit_markups[1]["inline_keyboard"][0][0]["callback_data"] == "cdel:2:112" + + +def test_candi_del_empty(): + bot = FakeBot([message_update(1, "/candi_del")]) + TelegramPoller(bot, FakeActions()).run_once(None) + assert bot.sent == ["暂无待决策候选。"] + assert bot.markups == [None] + + +def test_candi_clear_buttons(): + bot = FakeBot([ + message_update(1, "/candi_clear"), + update(2, "cclr:pending"), + update(3, "cclr:cancel"), + ]) + actions = FakeActions() + actions.candidates = candidates(3) + [ + {"aid": 999, "title": "已决策", "decision": "rejected"} + ] + TelegramPoller(bot, actions).run_once(None) + + keyboard = bot.markups[0] + assert [b["callback_data"] for b in keyboard["inline_keyboard"][0]] == [ + "cclr:all", "cclr:pending", "cclr:cancel", + ] + assert actions.clears == [True] # 只清待决策 + assert bot.answers[0] == ("cb2", "已清空 3 条") + assert bot.edit_markups[0] == {"inline_keyboard": []} + assert bot.answers[1] == ("cb3", "已取消") + + +def test_candidate_commands_unauthorized_and_unknown(): + bot = FakeBot([message_update(1, "/candidate", chat_id=999)]) + actions = FakeActions() + actions.candidates = candidates(3) + TelegramPoller(bot, actions).run_once(None) + assert bot.sent == [] + + bot2 = FakeBot([message_update(1, "/nope")]) + TelegramPoller(bot2, FakeActions()).run_once(None) + assert bot2.sent == [HELP_TEXT] + + class Recorder: def __init__(self): self.events = [] @@ -322,6 +466,12 @@ def main(): test_poller_dispatch_and_authorization() test_poller_manual_message() test_answer_failure_falls_back_to_text() + test_truncate_and_keyboard_helpers() + test_candidate_list_command_pages() + test_candi_del_keyboard_and_rebuild() + test_candi_del_empty() + test_candi_clear_buttons() + test_candidate_commands_unauthorized_and_unknown() test_poller_backoff_and_recovery() print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")