# tg_watchdog.py — TG 长轮询自愈看门狗 # 背景: bj -> US 的 xray 隧道会僵死/重置(TCP 看似 ESTABLISHED,更新被静默吞掉, # aiogram 侧可能只留零星报错)。检测到隧道不健康、Telegram 侧更新积压、 # 或取更新失败风暴时,停止轮询并让进程退出,由 nssm 自动拉起(新连接)。 # # 日志: 同时写 stdout 与同目录 tg_watchdog.log(逐行 flush)。 # 强制退出走 os._exit,stdout 缓冲会丢,因此必须落文件。 import asyncio import logging import os import sys import time WATCHDOG_INTERVAL = 120 # 检查间隔(秒) WATCHDOG_LIMIT = 3 # 连续异常阈值(约 6 分钟) FETCH_FAIL_LIMIT = 5 # 单个检查窗口内取更新失败次数上限(风暴判定) API_TIMEOUT = 20 # 单次 API 检查超时(秒) GRACE_SECONDS = 25 # 优雅停止的等待时间,超时则强制退出 LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tg_watchdog.log") def _log(msg: str): """写入 stdout 与独立日志文件(即时 flush,强制退出也不丢)""" try: print(msg) except Exception: pass try: with open(LOG_FILE, "a", encoding="utf-8") as f: f.write("[" + time.strftime("%Y-%m-%d %H:%M:%S") + "] " + msg + "\n") f.flush() except Exception: pass class _FetchFailureCounter(logging.Handler): """统计 aiogram 的取更新失败次数(长轮询被重置的直接信号)""" def __init__(self): super().__init__(level=logging.WARNING) self.count = 0 def emit(self, record): try: if "Failed to fetch updates" in record.getMessage(): self.count += 1 except Exception: pass def install_fetch_counter(): """挂到 aiogram.dispatcher logger 上,返回计数器""" counter = _FetchFailureCounter() logging.getLogger("aiogram.dispatcher").addHandler(counter) return counter async def _notify_admins(text: str): """尽力通知各管理员统一身份(TG 可能已死,靠 QQ 侧镜像送达)。 按 uid 扇出(TG 私聊 + 已绑定 QQ 私聊);每个目标保留 8s 超时预算, 避免僵死连接阻塞看门狗的重建流程。 """ try: import identity from channel import push_to_user for uid in identity.admin_uids(): targets = identity.push_targets(uid) try: await asyncio.wait_for( push_to_user(uid, text, tag="TG 看门狗", targets=targets), timeout=8 * max(1, len(targets))) except Exception: pass except Exception: pass async def tg_watchdog(bot, dp, name: str = "utility"): """监控 TG 长轮询健康度;异常达阈值则重建连接(进程退出 -> nssm 拉起)""" counter = install_fetch_counter() _log(f"已启动: 服务={name}, 间隔 {WATCHDOG_INTERVAL}s, 连续异常阈值 {WATCHDOG_LIMIT} 次, " f"取更新失败阈值 {FETCH_FAIL_LIMIT} 次/窗口, API 超时 {API_TIMEOUT}s") fails = 0 backlog = 0 while True: await asyncio.sleep(WATCHDOG_INTERVAL) fetch_fails = counter.count counter.count = 0 if fetch_fails: _log(f"本窗口取更新失败 {fetch_fails} 次" + ("(达风暴阈值)" if fetch_fails >= FETCH_FAIL_LIMIT else "")) try: await asyncio.wait_for(bot.get_me(), timeout=API_TIMEOUT) fails = 0 except Exception as e: fails += 1 _log(f"getMe 失败 {fails}/{WATCHDOG_LIMIT}: {type(e).__name__}: {e}") if fails == 0: try: info = await asyncio.wait_for(bot.get_webhook_info(), timeout=API_TIMEOUT) pending = int(getattr(info, "pending_update_count", 0) or 0) if pending > 0: backlog += 1 _log(f"Telegram 侧积压 {pending} 条更新(检测 {backlog}/{WATCHDOG_LIMIT})") else: backlog = 0 except Exception as e: _log(f"getWebhookInfo 异常: {type(e).__name__}: {e}") if (fails >= WATCHDOG_LIMIT or backlog >= WATCHDOG_LIMIT or fetch_fails >= FETCH_FAIL_LIMIT): _log(f"TG 通道不健康(getMe 连续失败 {fails} 次 / 积压检测 {backlog} 次 / " f"取更新失败 {fetch_fails} 次),重建连接") try: sys.stdout.flush() except Exception: pass await _notify_admins(f"[{name}] TG 通道异常,正在自动重建连接…") try: dp.stop_polling() except Exception as e: _log(f"stop_polling 异常: {e}") await asyncio.sleep(GRACE_SECONDS) _log("优雅停止超时,强制退出以触发服务重启") try: sys.stdout.flush() sys.stderr.flush() except Exception: pass os._exit(1)