feat(telegram): candidate list/delete/clear commands

私聊新增 /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 并刷看板缓存。
This commit is contained in:
kairee committed 2026-11-01 20:45:57 +08:00
1 parent c6790f0ba1
commit 4a9c37f2f3
9 files changed
+473 -23

No files matched your search

+5 -1
View File
@@ -20,7 +20,7 @@
| 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 | | 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 |
| 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 | | 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 |
| 搜索发现与候选池 | 可用(P3) | 每小时按 `discover` 配置搜索并分类打分后写入候选池。 | | 搜索发现与候选池 | 可用(P3) | 每小时按 `discover` 配置搜索并分类打分后写入候选池。 |
| Telegram 通知与入库 | 可用(P3) | 入库 / 忽略 / 拉黑按钮;私聊消息手动入库。 | | Telegram 通知与入库 | 可用(P3) | 入库 / 忽略 / 拉黑按钮;私聊消息手动入库;候选查看 / 删除 / 清空命令。 |
| 本地数据看板 | 可用(P2/P9) | 只读总览、视频榜、UP 主榜、内容效率、视频/UP 详情、粉丝曲线、数据说明、候选池和系统状态。 | | 本地数据看板 | 可用(P2/P9) | 只读总览、视频榜、UP 主榜、内容效率、视频/UP 详情、粉丝曲线、数据说明、候选池和系统状态。 |
| 访问统计(PV/UV) | 可用 | 前端埋点上报,按客户端 IP 与页面去重;先落内存、每分钟批量入库;系统页展示近 30 天。 | | 访问统计(PV/UV) | 可用 | 前端埋点上报,按客户端 IP 与页面去重;先落内存、每分钟批量入库;系统页展示近 30 天。 |
@@ -142,6 +142,10 @@ uv run python app.py run
- 每轮按 `discover.keywords` 搜索前 `discover.pages` 页,过滤已入库/已见/超时间窗/黑名单/标题不含要求词的结果; - 每轮按 `discover.keywords` 搜索前 `discover.pages` 页,过滤已入库/已见/超时间窗/黑名单/标题不含要求词的结果;
- 分数规则与 `classify` 配置一致;`score >= discover.candidate_min_score` 才写入候选池,`score >= notify.min_score` 才发送 Telegram; - 分数规则与 `classify` 配置一致;`score >= discover.candidate_min_score` 才写入候选池,`score >= notify.min_score` 才发送 Telegram;
- 通知按钮:**入库**、**忽略**、**拉黑**;回调只接受 `telegram.chat_id` 对应的私聊用户; - 通知按钮:**入库**、**忽略**、**拉黑**;回调只接受 `telegram.chat_id` 对应的私聊用户;
- 私聊命令(仅本人可用):
- `/candidate [页码]`:列出待决策候选标题(每页 10 条,用 `/candidate 2` 翻页);
- `/candi_del`:每页 10 个标题按钮,点击即删除该候选;删后原地重建本页键盘,可连续删;带上一页/下一页;
- `/candi_clear`:给出「全部 / 待决策 / 取消」按钮,清空整表或只清待决策;
- 「入库」把视频写入本地库并置为 `active`,**不写 B 站收藏夹**;同时用取到的 `view` 数据补一条当天快照; - 「入库」把视频写入本地库并置为 `active`,**不写 B 站收藏夹**;同时用取到的 `view` 数据补一条当天快照;
- 私聊文本消息会自动解析其中的视频链接 / BV 号 / av 号 / `b23.tv` 短链并逐条入库,回执汇总结果; - 私聊文本消息会自动解析其中的视频链接 / BV 号 / av 号 / `b23.tv` 短链并逐条入库,回执汇总结果;
- 忽略写 `candidate.decision='rejected'`;拉黑写 `up_blacklist` 并拒绝该 UP 的其他待决策候选; - 忽略写 `candidate.decision='rejected'`;拉黑写 `up_blacklist` 并拒绝该 UP 的其他待决策候选;
+6
View File
@@ -308,6 +308,12 @@ score >= 通知线 → 通知
- **「拉黑」**:把该视频的 **UP 拉入 `up_blacklist`**,同时把该 UP 的其他待决策候选一并标记为 `rejected`;**已入库/收藏夹里的该 UP 视频不受影响** - **「拉黑」**:把该视频的 **UP 拉入 `up_blacklist`**,同时把该 UP 的其他待决策候选一并标记为 `rejected`;**已入库/收藏夹里的该 UP 视频不受影响**
- 「打开视频」链接(`https://www.bilibili.com/video/<bvid>`) - 「打开视频」链接(`https://www.bilibili.com/video/<bvid>`)
**私聊管理命令**(仅 `telegram.chat_id` 本人可用):
- `/candidate [页码]`:纯文本列出待决策候选标题,每页 10 条;
- `/candi_del`:每页 10 个**标题按钮**(`cdel:<page>:<aid>`)+ 上一页/下一页;点击删除该候选后用 `editMessageReplyMarkup` **原地重建**本页键盘,可连续删;删空本页则回退上一页;
- `/candi_clear`:`[全部][待决策][取消]`(`cclr:all|pending|cancel`);「全部」清整张 `candidate` 表,「待决策」只清 `decision IS NULL`。
**黑名单语义(明确边界)**: **黑名单语义(明确边界)**:
1. 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知; 1. 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知;
+76 -1
View File
@@ -89,10 +89,85 @@ def format_manual_reply(manual_result: Any) -> str:
HELP_TEXT = ( HELP_TEXT = (
"发送 B 站视频链接、BV 号或 av 号即可手动入库并开始追踪。\n" "发送 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:<page>:<aid>``)+ 翻页按钮。"""
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: def format_candidate(candidate: Mapping[str, Any]) -> str:
title = html.escape(str(candidate.get("title") or "(无标题)")) title = html.escape(str(candidate.get("title") or "(无标题)"))
up_name = html.escape(str(candidate.get("up_name") or "未知UP")) up_name = html.escape(str(candidate.get("up_name") or "未知UP"))
+20
View File
@@ -919,6 +919,16 @@ class Repository:
return [dict(row) for row in rows] return [dict(row) for row in rows]
return self.db.read(run) 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( def mark_candidate_decision(
self, aid: int, decision: str, *, now: int | None = None self, aid: int, decision: str, *, now: int | None = None
) -> None: ) -> None:
@@ -944,6 +954,16 @@ class Repository:
"DELETE FROM candidate WHERE aid = ?", (int(aid),) "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( def delete_expired_candidates(
self, *, ttl_days: int, now: int | None = None self, *, ttl_days: int, now: int | None = None
) -> int: ) -> int:
+26
View File
@@ -302,6 +302,32 @@ class LibraryService:
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选", 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 # ------------------------------------------------------------ internals
def _result_from_outcome(self, outcome, *, title: str, up_name: str) -> IngestResult: def _result_from_outcome(self, outcome, *, title: str, up_name: str) -> IngestResult:
+138 -19
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Telegram getUpdates long polling 与回调/文本消息分发。 """Telegram getUpdates long polling 与回调/文本消息分发。
回调按钮处理候选「入库 / 忽略 / 拉黑」;私聊文本消息按视频链接、BV 号或 av 号 回调按钮处理候选「入库 / 忽略 / 拉黑」以及候选管理(查看 / 删除 / 清空);
手动入库。两类操作都只写本地库,不写 B 站收藏夹。 私聊文本消息按视频链接、BV 号或 av 号手动入库。所有操作都只写本地库,不写
B 站收藏夹。
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,7 +11,19 @@ from __future__ import annotations
import time import time
from typing import Any, Mapping 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: class TelegramPoller:
@@ -42,6 +55,8 @@ class TelegramPoller:
def _authorized(self, chat_id, from_id) -> bool: def _authorized(self, chat_id, from_id) -> bool:
return chat_id == self.bot.chat_id and from_id == self.bot.chat_id return chat_id == self.bot.chat_id and from_id == self.bot.chat_id
# ------------------------------------------------------------ callbacks
def handle_callback(self, callback: Mapping[str, Any]) -> None: def handle_callback(self, callback: Mapping[str, Any]) -> None:
callback_id = str(callback.get("id") or "") callback_id = str(callback.get("id") or "")
message = callback.get("message") or {} message = callback.get("message") or {}
@@ -55,9 +70,26 @@ class TelegramPoller:
self._answer(callback_id, "无权限") self._answer(callback_id, "无权限")
return 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: try:
aid = int(raw_aid) aid = int(payload)
except (TypeError, ValueError): except (TypeError, ValueError):
self._answer(callback_id, "未知操作") self._answer(callback_id, "未知操作")
return return
@@ -67,11 +99,8 @@ class TelegramPoller:
result = self.actions.ingest_candidate(aid) result = self.actions.ingest_candidate(aid)
elif action == "ign": elif action == "ign":
result = self.actions.ignore(aid) result = self.actions.ignore(aid)
elif action == "blk": else: # blk
result = self.actions.blacklist(aid) result = self.actions.blacklist(aid)
else:
self._answer(callback_id, "未知操作")
return
text = format_ingest_result(result) text = format_ingest_result(result)
self._log( self._log(
@@ -84,12 +113,67 @@ class TelegramPoller:
self.bot.send_text(text) self.bot.send_text(text)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._log("warning", "telegram.fallback_failed", error=str(exc)) self._log("warning", "telegram.fallback_failed", error=str(exc))
message_id = message.get("message_id") if message_id is not None:
if isinstance(message_id, int) and not isinstance(message_id, bool): self._edit_markup(message_id, {"inline_keyboard": []})
try:
self.bot.edit_reply_markup(message_id) def _handle_candidate_delete(self, callback_id, payload, message_id):
except Exception as exc: # noqa: BLE001 page_s, _, aid_s = payload.partition(":")
self._log("warning", "telegram.edit_failed", error=str(exc)) 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: def handle_message(self, message: Mapping[str, Any]) -> None:
chat_id = (message.get("chat") or {}).get("id") chat_id = (message.get("chat") or {}).get("id")
@@ -104,8 +188,8 @@ class TelegramPoller:
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
return return
text = text.strip() text = text.strip()
if text.startswith("/start") or text.startswith("/help"): if text.startswith("/"):
self._reply(HELP_TEXT) self._handle_command(text)
return return
try: try:
result = self.actions.ingest_text(text) result = self.actions.ingest_text(text)
@@ -119,9 +203,44 @@ class TelegramPoller:
) )
self._reply(format_manual_reply(result)) 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: 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 except Exception as exc: # noqa: BLE001
self._log("warning", "telegram.reply_failed", error=str(exc)) self._log("warning", "telegram.reply_failed", error=str(exc))
+22
View File
@@ -180,11 +180,33 @@ def test_fav_action_state_machine():
db.close() 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(): def main():
test_seen_and_known_aid() test_seen_and_known_aid()
test_candidate_upsert_preserves_decision_and_notified() test_candidate_upsert_preserves_decision_and_notified()
test_notify_and_reject_for_up() test_notify_and_reject_for_up()
test_delete_expired_candidates_only_pending() test_delete_expired_candidates_only_pending()
test_list_pending_and_delete_all()
test_folder_for_up_uses_earliest_video() test_folder_for_up_uses_earliest_video()
test_fav_action_state_machine() test_fav_action_state_machine()
print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过") print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过")
+28
View File
@@ -326,6 +326,33 @@ def test_ignore_and_blacklist():
db.close() 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(): def main():
test_ingest_candidate_added_and_snapshot() test_ingest_candidate_added_and_snapshot()
test_ingest_candidate_exists_and_missing() test_ingest_candidate_exists_and_missing()
@@ -337,6 +364,7 @@ def main():
test_manual_ingest_truncation() test_manual_ingest_truncation()
test_manual_ingest_invalid_bvid_message() test_manual_ingest_invalid_bvid_message()
test_ignore_and_blacklist() test_ignore_and_blacklist()
test_candidate_admin_delete_and_clear()
print("OK: 入库、手动入库、忽略、拉黑测试全部通过") print("OK: 入库、手动入库、忽略、拉黑测试全部通过")
+152 -2
View File
@@ -11,11 +11,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.notify.telegram import ( # noqa: E402 from reaction_board.notify.telegram import ( # noqa: E402
CANDIDATE_DELETE_HEADER,
HELP_TEXT, HELP_TEXT,
TelegramClient, TelegramClient,
TelegramError, TelegramError,
candidate_delete_keyboard,
clear_candidates_keyboard,
format_candidate_list,
format_duration, format_duration,
format_manual_reply, format_manual_reply,
truncate_title,
) )
from reaction_board.services.library import ( # noqa: E402 from reaction_board.services.library import ( # noqa: E402
ActionResult, ActionResult,
@@ -147,7 +152,9 @@ class FakeBot:
self.fail_answer = fail_answer self.fail_answer = fail_answer
self.answers = [] self.answers = []
self.sent = [] self.sent = []
self.markups = []
self.edits = [] self.edits = []
self.edit_markups = []
def get_updates(self, offset=None): def get_updates(self, offset=None):
return list(self.updates) return list(self.updates)
@@ -157,17 +164,21 @@ class FakeBot:
raise RuntimeError("HTTP Error 400: Bad Request") raise RuntimeError("HTTP Error 400: Bad Request")
self.answers.append((callback_id, text)) self.answers.append((callback_id, text))
def send_text(self, text): def send_text(self, text, reply_markup=None):
self.sent.append(text) 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.edits.append(message_id)
self.edit_markups.append(reply_markup)
class FakeActions: class FakeActions:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
self.manual = [] self.manual = []
self.candidates = []
self.clears = []
def ingest_candidate(self, aid): def ingest_candidate(self, aid):
self.calls.append(("add", aid)) self.calls.append(("add", aid))
@@ -190,6 +201,27 @@ class FakeActions:
results=[IngestResult(True, 9, "added", title="咻咻满 reaction", message="已入库")], 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)" ADD_REPLY = "✅ 已入库《咻咻满 reaction》(aid 9)"
@@ -266,6 +298,118 @@ def test_answer_failure_falls_back_to_text():
assert bot.edits == [10] 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: class Recorder:
def __init__(self): def __init__(self):
self.events = [] self.events = []
@@ -322,6 +466,12 @@ def main():
test_poller_dispatch_and_authorization() test_poller_dispatch_and_authorization()
test_poller_manual_message() test_poller_manual_message()
test_answer_failure_falls_back_to_text() 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() test_poller_backoff_and_recovery()
print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过") print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")