# -*- 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