私聊新增 /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 并刷看板缓存。
292 lines
11 KiB
Python
292 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Telegram getUpdates long polling 与回调/文本消息分发。
|
|
|
|
回调按钮处理候选「入库 / 忽略 / 拉黑」以及候选管理(查看 / 删除 / 清空);
|
|
私聊文本消息按视频链接、BV 号或 av 号手动入库。所有操作都只写本地库,不写
|
|
B 站收藏夹。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Mapping
|
|
|
|
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:
|
|
def __init__(self, bot, actions, *, logger=None, sleeper=time.sleep) -> None:
|
|
self.bot = bot
|
|
self.actions = actions
|
|
self.logger = logger
|
|
self._sleep = sleeper
|
|
|
|
def _log(self, level: str, event: str, **fields) -> None:
|
|
if self.logger is not None:
|
|
getattr(self.logger, level)(event, **fields)
|
|
|
|
def run_once(self, offset: int | None = None) -> int | None:
|
|
updates = self.bot.get_updates(offset)
|
|
for update in updates:
|
|
update_id = update.get("update_id")
|
|
if isinstance(update_id, int) and not isinstance(update_id, bool):
|
|
offset = max(offset or 0, update_id + 1)
|
|
callback = update.get("callback_query")
|
|
if isinstance(callback, Mapping):
|
|
self.handle_callback(callback)
|
|
continue
|
|
message = update.get("message")
|
|
if isinstance(message, Mapping):
|
|
self.handle_message(message)
|
|
return offset
|
|
|
|
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 {}
|
|
chat_id = (message.get("chat") or {}).get("id")
|
|
from_id = (callback.get("from") or {}).get("id")
|
|
if not self._authorized(chat_id, from_id):
|
|
self._log(
|
|
"warning", "telegram.unauthorized",
|
|
chat_id=chat_id, from_id=from_id,
|
|
)
|
|
self._answer(callback_id, "无权限")
|
|
return
|
|
|
|
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(payload)
|
|
except (TypeError, ValueError):
|
|
self._answer(callback_id, "未知操作")
|
|
return
|
|
|
|
if action in ("add", "fav"):
|
|
# fav 为旧按钮回调,兼容已发出的历史消息。
|
|
result = self.actions.ingest_candidate(aid)
|
|
elif action == "ign":
|
|
result = self.actions.ignore(aid)
|
|
else: # blk
|
|
result = self.actions.blacklist(aid)
|
|
|
|
text = format_ingest_result(result)
|
|
self._log(
|
|
"info", "telegram.action",
|
|
action=action, aid=aid, ok=result.ok, state=result.state,
|
|
)
|
|
if not self._answer(callback_id, text):
|
|
# 回调 ID 过期时 answerCallbackQuery 会 400;改用普通消息告知结果。
|
|
try:
|
|
self.bot.send_text(text)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._log("warning", "telegram.fallback_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")
|
|
from_id = (message.get("from") or {}).get("id")
|
|
if not self._authorized(chat_id, from_id):
|
|
self._log(
|
|
"warning", "telegram.unauthorized_message",
|
|
chat_id=chat_id, from_id=from_id,
|
|
)
|
|
return
|
|
text = message.get("text")
|
|
if not isinstance(text, str) or not text.strip():
|
|
return
|
|
text = text.strip()
|
|
if text.startswith("/"):
|
|
self._handle_command(text)
|
|
return
|
|
try:
|
|
result = self.actions.ingest_text(text)
|
|
except Exception as exc: # noqa: BLE001 单条消息失败不影响长轮询
|
|
self._log("error", "telegram.ingest_error", error=str(exc))
|
|
self._reply(f"入库失败:{exc}")
|
|
return
|
|
self._log(
|
|
"info", "telegram.manual_ingest",
|
|
refs=result.refs, added=result.added,
|
|
)
|
|
self._reply(format_manual_reply(result))
|
|
|
|
@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:
|
|
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))
|
|
|
|
def _answer(self, callback_id: str, text: str) -> bool:
|
|
try:
|
|
self.bot.answer_callback(callback_id, text)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 过期回调不应升级为错误
|
|
self._log("warning", "telegram.answer_failed", error=str(exc))
|
|
return False
|
|
|
|
def run_forever(
|
|
self,
|
|
stop_event=None,
|
|
*,
|
|
initial_delay: float = 3.0,
|
|
max_delay: float = 60.0,
|
|
max_iterations: int | None = None,
|
|
) -> None:
|
|
offset: int | None = None
|
|
failures = 0
|
|
delay = initial_delay
|
|
iterations = 0
|
|
while stop_event is None or not stop_event.is_set():
|
|
try:
|
|
offset = self.run_once(offset)
|
|
except Exception as exc: # noqa: BLE001 长轮询瞬时断连后退避重试
|
|
failures += 1
|
|
level = "error" if failures >= 5 else "warning"
|
|
self._log(
|
|
level, "telegram.poll_failed",
|
|
error=str(exc), failures=failures, retry_in=delay,
|
|
)
|
|
if stop_event is not None:
|
|
if stop_event.wait(delay):
|
|
break
|
|
else:
|
|
self._sleep(delay)
|
|
delay = min(delay * 2, max_delay)
|
|
else:
|
|
if failures:
|
|
self._log(
|
|
"info", "telegram.poll_recovered", failures=failures
|
|
)
|
|
failures = 0
|
|
delay = initial_delay
|
|
iterations += 1
|
|
if max_iterations is not None and iterations >= max_iterations:
|
|
break |