Files
re-xxm/reaction_board/services/telegram_poller.py
T

173 lines
6.5 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 HELP_TEXT, format_ingest_result, format_manual_reply
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
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
action, _, raw_aid = str(callback.get("data") or "").partition(":")
try:
aid = int(raw_aid)
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)
elif action == "blk":
result = self.actions.blacklist(aid)
else:
self._answer(callback_id, "未知操作")
return
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))
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))
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("/start") or text.startswith("/help"):
self._reply(HELP_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))
def _reply(self, text: str) -> None:
try:
self.bot.send_text(text)
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