Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
# 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())
|
||||
Reference in New Issue
Block a user