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

+138 -19
View File
@@ -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))