# main.py — TG 主入口 # 启动内容: # 1. 加载 settings.json(TG token / 代理 / 各通道管理员) # 2. 共享后台服务: live_monitor Flask(:1986) + 开播通知消费者、 # mail 服务(:9088 绑定确认 + 轮询线程 + 通知消费者)、 # deepseek 余额监控、komari 服务器监控、ginkgo 白果云通知轮询、 # 统一身份库 identity(bridges/accounts) # 3. NapCat QQ 桥 (bridges/napcat.py, OneBot 11 反向 WS :9089) # 4. Telegram bot(aiogram 长轮询,经本地 xray 代理出站) import asyncio import json import os import queue import sys import tempfile import threading import time BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # 本目录 (bot/utility) 自动在 sys.path; 父目录 bot/ 提供共享平台层 channel/tg_channel # chat/ 是独立的 chat bot(bot/chat/main.py 入口),本入口不引用 sys.path.insert(0, os.path.dirname(BASE_DIR)) from channel import (Channel, register_channel, get_channel, send_message, push_to_user, load_settings, admin_list) from tg_channel import TGChannel, TG_USER_OFFSET, TG_GROUP_OFFSET from dispatch import handle_message import tg_watchdog import live_monitor import mail import ginkgostu import identity import for_admin_komari_agent from deepseek import deepseek_balance from bridges.napcat import start_napcat_bridge import help_ui import notifications import notification_render import scheduler import notebook from agent.modes import ModeStore from aiogram import Bot, Dispatcher, types from aiogram.client.session.aiohttp import AiohttpSession # ---------------- 开播通知消费者(移植自旧 main.py,通道化) ---------------- async def check_live_notifications(): while True: try: while True: notif = live_monitor.notification_queue.get_nowait() room_id = notif["room_id"] site = notif["site"] chat_type = notif["chat_type"] uid = notif.get("uid") label = live_monitor.SITE_LABEL.get(site, "主站") url = f"https://live.rrrosmontis.icu/players/player.html?site={site}&room={room_id}" msg = f"注意到 {label} 的 {room_id} 房间似乎正在直播!\n{url}" chat_id = notif.get("chat_id") event = notifications.Event( kind="live", dedupe_key=f"live:{site}:{room_id}:{chat_type}:{uid or chat_id}:{int(notif.get('ts') or notif.get('time') or time.time())}", legacy_text=msg, uid=uid if chat_type == "private" else None, channel=None if chat_type == "private" else notif.get("channel", "qq"), msg_type=chat_type, target_id=None if chat_type == "private" else chat_id, payload={"room_id": room_id, "site": site, "label": label, "url": url}, ) notifications.center.publish(event) print(f"[live_monitor] 已入队 {label} 房间 {room_id} 开播通知") except queue.Empty: pass await asyncio.sleep(2) # ---------------- Telegram 消息处理 ---------------- def _unlink_temp_paths(paths): for path in paths: try: os.unlink(path) except OSError: pass def make_tg_handler(tg_channel: TGChannel): async def on_message(message: types.Message, bot: Bot): if not message.from_user or message.from_user.is_bot: return downloaded_paths = [] try: raw_uid = message.from_user.id ns_uid = TGChannel.ns_user(raw_uid) image_infos = [] # 群聊 mention 剥离在本地变量上进行(aiogram 3 的 Message 是 frozen model, # 不能就地改写 message.text) msg_text = message.text if message.chat.type == "private": msg_type = "private" target_id = ns_uid is_mention = False else: msg_type = "group" target_id = TGChannel.ns_group(message.chat.id) is_mention = False # 回复 bot 的消息 rt = message.reply_to_message if rt and rt.from_user and rt.from_user.id == bot.id: is_mention = True # @bot mention(实体偏移按 UTF-16 计,用 extract_from 取文本, # 不能用 str 下标切片——非 BMP 字符会错位) if msg_text: bot_uname = (tg_channel.tg_username or "").lower() for ent in (message.entities or []): if ent.type != "mention": continue frag = ent.extract_from(message.text) if frag and frag.lstrip("@").lower() == bot_uname: is_mention = True msg_text = msg_text.replace(frag, "", 1) # mention 剥离后重新取文本 text = (msg_text or message.caption or "").strip() # 图片(取最大张) if message.photo: ph = message.photo[-1] suffix = ".jpg" fd, path = tempfile.mkstemp(suffix=suffix) os.close(fd) try: await bot.download(ph, destination=path) downloaded_paths.append(path) image_infos.append({ "url": path, # TG 端已落盘,主模型直接读取 "file": path, "file_size": str(ph.file_size or os.path.getsize(path)), }) except Exception as e: print(f"[tg] 图片下载失败: {e}") try: os.unlink(path) except OSError: pass # 用户贴纸:保存原始文件;动态/视频贴纸用缩略图供主模型学习。 sticker = getattr(message, "sticker", None) if sticker: is_animated = bool(getattr(sticker, "is_animated", False)) is_video = bool(getattr(sticker, "is_video", False)) suffix = ".tgs" if is_animated else ".webm" if is_video else ".webp" paths = [] try: fd, original_path = tempfile.mkstemp(suffix=suffix) os.close(fd) paths.append(original_path) await bot.download(sticker, destination=original_path) downloaded_paths.append(original_path) preview_path = original_path if is_animated or is_video: thumbnail = getattr(sticker, "thumbnail", None) if not thumbnail: raise ValueError("动态贴纸缺少可用缩略图") fd, preview_path = tempfile.mkstemp(suffix=".jpg") os.close(fd) paths.append(preview_path) await bot.download(thumbnail, destination=preview_path) downloaded_paths.append(preview_path) sticker_count = sum(1 for info in image_infos if info.get("kind") == "sticker") image_infos.append({ "kind": "sticker", "source_id": f"s{sticker_count + 1}", "url": preview_path, "file": original_path, "original_path": original_path, "file_size": str(os.path.getsize(preview_path)), "file_unique_id": getattr(sticker, "file_unique_id", ""), "emoji": getattr(sticker, "emoji", "") or "", "set_name": getattr(sticker, "set_name", "") or "", "source_uid": ns_uid, "format": "animated" if is_animated else "video" if is_video else "static", }) except Exception as e: print(f"[tg] 贴纸下载失败: {e}") for path in paths: try: os.unlink(path) except OSError: pass if not text and not image_infos: return # TG 根 Help 在 command/chat 两种模式下都保留原生按钮; # 仅正文渲染方式不同,模块/QQ 仍走统一 dispatch。 if text.lower() in (".help", "/help") or text.lower().startswith("/help@"): uid = identity.resolve("tg", ns_uid) or identity.ensure_tg(ns_uid, tg_channel.tg_username) group_id = target_id if msg_type == "group" else None include_admin = tg_channel.is_admin(uid) mode = ModeStore().get_mode("tg", uid, group_id) if mode == "chat": content = await help_ui.chat_help(uid, None, include_admin) else: content = help_ui.command_index(include_admin) await bot.send_message( message.chat.id, content, reply_markup=_help_keyboard(include_admin), ) return print(f"[tg] {msg_type}/{target_id} uid={raw_uid} mention={is_mention} text={text[:60]!r} imgs={len(image_infos)}") await handle_message(tg_channel, msg_type, ns_uid, target_id, text, image_infos, is_mention) except Exception as e: import traceback print(f"[tg] 消息处理异常: {e}") traceback.print_exc() finally: if downloaded_paths: asyncio.get_running_loop().call_later(900, _unlink_temp_paths, tuple(downloaded_paths)) return on_message # ---------------- Telegram Help buttons ---------------- def _help_keyboard(include_admin: bool): from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup rows, row = [], [] for index, item in enumerate(help_ui.SERVICES, 1): if item.admin and not include_admin: continue row.append(InlineKeyboardButton(text=item.title, callback_data=f"help:{index}")) if len(row) == 3: rows.append(row) row = [] if row: rows.append(row) return InlineKeyboardMarkup(inline_keyboard=rows) def make_tg_help_callback(tg_channel: TGChannel): async def on_help(callback: types.CallbackQuery, bot: Bot): if not callback.from_user or not callback.message: return item = help_ui.service((callback.data or "").split(":", 1)[-1]) if item is None: await callback.answer("这个帮助入口已经失效") return ns_uid = TGChannel.ns_user(callback.from_user.id) uid = identity.resolve("tg", ns_uid) or identity.ensure_tg(ns_uid, tg_channel.tg_username) include_admin = tg_channel.is_admin(uid) if item.admin and not include_admin: await callback.answer("该功能仅管理员可用", show_alert=True) return await callback.answer() chat = callback.message.chat group_id = TGChannel.ns_group(chat.id) if chat.type != "private" else None mode = ModeStore().get_mode("tg", uid, group_id) if mode == "chat": content = await help_ui.chat_help(uid, item.key, include_admin) else: content = help_ui.command_help(item.key, include_admin) await bot.send_message( chat.id, content, reply_markup=_help_keyboard(include_admin), ) return on_help # ---------------- 主入口 ---------------- async def main(): settings = load_settings() tg_token = settings.get("tg_token", "") tg_proxy = settings.get("tg_proxy", "") # 0) 统一身份库(账号映射目录由入口注入,平台层不感知 utility 目录结构) identity.configure(os.path.join(BASE_DIR, "bridges", "accounts"), settings.get("admin", {}).get("tg")) from chat import engine engine.init_chat() notifications.center.configure(notification_render.render_event) notifications.center.register_acknowledger( "mail", lambda event: mail._advance_last_seen( int(event.uid), event.payload["addr"], int(event.payload["mid"]))) # 1) 共享后台服务 threading.Thread(target=live_monitor.run_flask, daemon=True).start() print("[live_monitor] Flask 服务器已启动在端口 1986") mail.start_services() print("[mail] 邮件服务已启动(确认 HTTP :9088 + 轮询线程)") # create_task 必须持有强引用:事件循环只持弱引用,无引用的任务可能被 GC 中途回收 bg_tasks = [ asyncio.create_task(check_live_notifications()), asyncio.create_task(mail.notification_loop()), asyncio.create_task(deepseek_balance.monitor_loop()), asyncio.create_task(for_admin_komari_agent.monitor_loop()), asyncio.create_task(ginkgostu.monitor_loop()), asyncio.create_task(notifications.center.worker()), asyncio.create_task(scheduler.scheduler.worker()), asyncio.create_task(notebook.notebook.cleanup_worker()), ] # 2) QQ 桥(NapCat 反向 WS :9089) bg_tasks.append(asyncio.create_task(start_napcat_bridge(settings.get("admin", {}).get("qq")))) # 3) Telegram bot if not tg_token: print("[tg] settings.json 缺少 tg_token,Telegram 通道不启动") await asyncio.Future() return # aiogram 3.31: 代理走 AiohttpSession(需 aiohttp-socks) bot = Bot(token=tg_token, session=AiohttpSession(proxy=tg_proxy or None)) tg_channel = TGChannel(bot, admin_ids=settings.get("admin", {}).get("tg")) for _attempt in range(30): try: tg_channel.tg_username = (await bot.get_me()).username or "" break except Exception as e: print(f"[tg] get_me 失败(第{_attempt+1}次): {e},5秒后重试") await asyncio.sleep(5) if _attempt == 29: raise register_channel(tg_channel) print(f"[tg] Telegram 通道已启动: @{tg_channel.tg_username}" + (f" (proxy={tg_proxy})" if tg_proxy else "")) # 注册 TG 斜杠命令菜单(.help 仍可用;管理员指令不放入菜单,直接输入即可) try: from aiogram.types import BotCommand await bot.set_my_commands([ BotCommand(command="help", description="查看服务与帮助"), BotCommand(command="my", description="统一身份与个人设置"), BotCommand(command="setting", description="切换 command/chat 模式"), ]) print("[tg] 斜杠命令菜单已注册") except Exception as e: print(f"[tg] set_my_commands 失败(不影响运行): {e}") dp = Dispatcher() dp.message.register(make_tg_handler(tg_channel)) dp.callback_query.register(make_tg_help_callback(tg_channel)) for _attempt in range(30): try: await bot.delete_webhook(drop_pending_updates=False) break except Exception as e: print(f"[tg] delete_webhook 失败(第{_attempt+1}次): {e},5秒后重试") await asyncio.sleep(5) if _attempt == 29: raise print("[tg] 开始长轮询...") bg_tasks.append(asyncio.create_task(tg_watchdog.tg_watchdog(bot, dp, name="utility"))) await dp.start_polling(bot, close_bot=True) if __name__ == "__main__": asyncio.run(main())