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
+473
-23
No files matched your search
@@ -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:<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:
|
||||
title = html.escape(str(candidate.get("title") or "(无标题)"))
|
||||
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 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in new issue
Block a user