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:
1 parent
c6790f0ba1
commit
4a9c37f2f3
9 files changed
+469
-19
No files matched your search
@@ -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 的其他待决策候选;
|
||||||
|
|||||||
@@ -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 新视频不进候选池、不发通知;
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,13 +113,68 @@ 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": []})
|
||||||
|
|
||||||
|
def _handle_candidate_delete(self, callback_id, payload, message_id):
|
||||||
|
page_s, _, aid_s = payload.partition(":")
|
||||||
try:
|
try:
|
||||||
self.bot.edit_reply_markup(message_id)
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
self._log("warning", "telegram.edit_failed", error=str(exc))
|
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")
|
||||||
from_id = (message.get("from") or {}).get("id")
|
from_id = (message.get("from") 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:
|
||||||
|
if reply_markup is None:
|
||||||
self.bot.send_text(text)
|
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))
|
||||||
|
|
||||||
|
|||||||
@@ -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 状态机测试全部通过")
|
||||||
|
|||||||
@@ -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
@@ -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 消息、分片、长轮询与回调授权测试全部通过")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user