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:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
# dispatch.py — 共享指令分派(TG 与 QQ 两个通道共用同一套业务逻辑)
# 由 bridges/napcat.py(QQ)和 main.py(TG)在收到归一化消息后调用。
import asyncio
import re
import setu
import jm
import pixiv
import bilibili
import mail
import live_monitor
import ginkgostu
import identity
import identity_cmd
import for_admin_komari_agent
import for_admin_catch_log
from deepseek import deepseek_balance
from channel import send_message
import help_ui
import mode_commands
from agent.modes import ModeStore
HELP_TEXT = help_ui.command_index()
ADMIN_HELP_TEXT = (
"管理员指令\n"
"━━━━━━━━━━━━\n"
"服务缓存清理\n"
"• .jm del-cache 清理 JM 本地临时文件\n"
"• .pixiv del-cache 清理 pixiv 下载缓存\n"
"• .setu del-cache 清理 setu 下载缓存\n"
"━━━━━━━━━━━━\n"
"服务器管理\n"
"• .server help 显示服务器模块帮助\n"
"━━━━━━━━━━━━\n"
"DeepSeek 模块\n"
"• .deepseek help 显示 DeepSeek 余额监控帮助\n"
"━━━━━━━━━━━━\n"
"日志查看\n"
"• .log help 查看日志模块帮助\n"
"━━━━━━━━━━━━\n"
"仅对管理员开放"
)
# 需要个人身份(会写个人配置)的指令前缀:群聊里未绑定用户使用这些会被提示先绑定
IDENTITY_REQUIRED_PREFIXES = (".mail", ".ginkgo", ".me", ".我")
# 例外:只读帮助类子命令不涉及个人配置,群聊里未绑定也放行
READONLY_SUBCOMMANDS = ("help", "-h", "帮助", "rule", "规则")
def _is_readonly_help(text):
parts = text.split()
return len(parts) >= 2 and parts[1] in READONLY_SUBCOMMANDS
def _needs_identity(low):
"""是否命中「需要个人身份」的指令前缀(精确命中或前缀+空格)。
用 startswith 裸匹配会让 ".me" 误伤 ".message" 之类的普通文本。"""
return any(low == p or low.startswith(p + " ") for p in IDENTITY_REQUIRED_PREFIXES)
async def _handle_setu_param(channel, msg_type, target_id, param, user_id=None):
"""解析 .setu 参数并执行(群聊/私聊共用)
user_id: 发送者统一身份 id,仅 del-cache(管理员校验)需要"""
if param.lower() in ("help", "-h", "帮助"):
await send_message(channel, msg_type, target_id, setu.HELP_TEXT)
return
if param.lower() in ("del-cache", "delcache", "clearcache", "clear"):
asyncio.create_task(setu.handle_del_cache(channel, msg_type, target_id, user_id))
return
tags, r18_flag, ai_flag, error_msg = setu.parse_setu_command(f"来张涩图 {param}")
if error_msg:
await send_message(channel, msg_type, target_id, error_msg)
elif tags is None:
await send_message(channel, msg_type, target_id, "参数解析失败,请检查格式")
else:
asyncio.create_task(setu.handle_setu(tags, r18_flag, ai_flag, msg_type, target_id, channel))
async def _handle_chat_legacy(channel, msg_type, target_id, user_id, raw_text):
"""Keep the former chat bot's explicit commands on the merged entrypoint."""
low = raw_text.lower()
if not low.startswith((".wallet", ".compact", ".del", ".memory", ".chat")):
return False
from chat import engine
if low == ".wallet":
bal = await engine.get_wallet_balance()
text = "没有带钱包呢…拿到钱包了再告诉你!" if bal is None else f"白花的钱包里还有 {bal:.2f} 元呢"
await send_message(channel, msg_type, target_id, text)
return True
if low == ".chat" or low.startswith(".chat "):
if not channel.is_admin(user_id):
await send_message(channel, msg_type, target_id, "该指令仅管理员可用")
return True
arg = raw_text[5:].strip().lower()
if arg not in ("on", "off"):
await send_message(channel, msg_type, target_id, "用法: .chat on|off")
else:
engine.set_chat_enabled(arg == "on")
await send_message(channel, msg_type, target_id, f"聊天功能已{'开启' if arg == 'on' else '关闭'}")
return True
if msg_type != "private":
await send_message(channel, msg_type, target_id, "该指令仅限私聊使用")
return True
engine.set_delivery(user_id, channel, target_id)
if low == ".compact":
await send_message(channel, msg_type, target_id, "正在把我们的对话变成记忆,请稍候...")
result = await engine.compact_history(user_id)
await send_message(channel, msg_type, target_id, result)
return True
if low == ".del" or low.startswith(".del "):
arg = raw_text[4:].strip()
if not arg.isdigit() or int(arg) <= 0:
await send_message(channel, msg_type, target_id, "用法: /del n (n 为正整数)")
else:
task = engine._user_pending_tasks.pop(user_id, None)
if task:
task.cancel()
engine._user_pending_messages.pop(user_id, None)
engine._user_pending_images.pop(user_id, None)
result = engine.delete_last_history(user_id, int(arg))
await send_message(channel, msg_type, target_id, result)
return True
if low == ".memory" or low.startswith(".memory "):
arg = raw_text[7:].strip() if len(raw_text) > 7 else ""
await engine.handle_memory_command(channel, user_id, arg)
return True
return False
async def handle_message(channel, msg_type, user_id, target_id, raw_text, image_infos=None, is_mention=False):
"""统一消息入口。
channel: Channel 对象(发送与管理员判定都走它)
msg_type: "group" | "private"
user_id: 发送者 id(已归约为统一身份 id / uid,业务状态一律以它为 key)
target_id: 群 id(群聊)或用户 id(私聊)
raw_text: 已剥离 @bot 前缀的纯文本
image_infos: 图片信息列表 [{"url":..,"file":..,"file_size":..}]
is_mention: 群聊中是否 @bot / 回复 bot
"""
image_infos = image_infos or []
group_id = target_id if msg_type == "group" else None
# TG 斜杠指令 → QQ 点号指令归一化: /setu、/setu@botname xxx → .setu xxx
if raw_text and raw_text.startswith("/"):
m = re.match(r"^/([A-Za-z0-9_]+)(?:@\w+)?", raw_text)
if m:
raw_text = "." + m.group(1) + raw_text[m.end():]
# ---------- 统一身份:归约与准入 ----------
uid = identity.resolve(channel.name, user_id)
if uid is None and channel.name == "tg":
uid = identity.ensure_tg(user_id, getattr(channel, "tg_username", "") or "")
if uid is not None:
user_id = uid # 业务层一律看到统一身份 id
else:
# QQ 侧未绑定:绑定指令与帮助始终放行
low = raw_text.lower()
if low in (".bind", ".绑定") or low.startswith((".bind ", ".绑定 ")):
parts_b = raw_text.split(maxsplit=1)
await identity_cmd.handle_qq_bind(channel, msg_type, target_id, user_id,
parts_b[1].strip() if len(parts_b) > 1 else "")
return
if low == ".my" or low.startswith(".my "):
arg = raw_text[3:].strip() if len(raw_text) > 3 else ""
await identity_cmd.handle_my(channel, msg_type, target_id, user_id, None, arg)
return
if low == ".help" or low.startswith(".help "):
await send_message(channel, msg_type, target_id, help_ui.command_help())
return
if low == ".setting" or low.startswith(".setting "):
await mode_commands.handle_setting(channel, msg_type, None, target_id, raw_text)
return
if msg_type == "group":
# 群聊是独立会话,不要求绑定身份:只有会写个人配置的指令才提示绑定,
# 其余群指令照常放行(群态按群保存,不会产生 QQ 用户的独立配置)
if _needs_identity(low) and not _is_readonly_help(low):
await send_message(channel, msg_type, target_id, identity_cmd.GROUP_BIND_TEXT)
return
else:
# QQ 私聊未绑定:只有点号指令才提示绑定;普通文字、图片、表情等静默。
# TG 斜杠指令已在上方归一为点号,因此 /xxx 同样会命中这里。
if raw_text.startswith("."):
await send_message(channel, msg_type, target_id, identity_cmd.UNBOUND_TEXT)
return
# ---------- pixiv PID 监听(.pixiv 帮助后, 下一条纯数字消息直接取图)----------
if pixiv.try_consume_pid(channel, msg_type, target_id, user_id, raw_text):
return
# ---------- .my(统一身份与个人设置;兼容 .me / .bind / .unbind 别名)----------
_low = raw_text.lower()
if _low == ".my" or _low.startswith(".my "):
_arg = raw_text[3:].strip() if len(raw_text) > 3 else ""
await identity_cmd.handle_my(channel, msg_type, target_id, user_id, uid, _arg)
return
if _low in (".me", ".我"):
await identity_cmd.handle_my(channel, msg_type, target_id, user_id, uid, "account")
return
if _low == ".bind" or _low.startswith(".bind "):
_parts = raw_text.split(maxsplit=1)
_arg = _parts[1].strip() if len(_parts) > 1 else ""
await identity_cmd.handle_my(channel, msg_type, target_id, user_id, uid,
"account bind " + _arg)
return
if _low == ".unbind" or _low.startswith(".unbind "):
_parts = raw_text.split(maxsplit=1)
_arg = _parts[1].strip() if len(_parts) > 1 else ""
await identity_cmd.handle_my(channel, msg_type, target_id, user_id, uid,
"account unbind " + _arg)
return
# ---------- .setting(command/chat 回复模式)----------
if _low == ".setting" or _low.startswith(".setting "):
await mode_commands.handle_setting(channel, msg_type, user_id, target_id, raw_text)
return
# ---------- .help 全局帮助 ----------
if raw_text.lower() == ".help" or raw_text.lower().startswith(".help "):
arg = raw_text[5:].strip().lower() if len(raw_text) > 5 else ""
if arg == "admin":
if channel.is_admin(user_id):
await send_message(channel, msg_type, target_id, ADMIN_HELP_TEXT)
else:
await send_message(channel, msg_type, target_id, "该指令仅管理员可用")
else:
mode = ModeStore().get_mode(channel.name, user_id, group_id)
if mode == "chat":
content = await help_ui.chat_help(user_id, arg or None, channel.is_admin(user_id))
else:
content = help_ui.command_help(arg or None, channel.is_admin(user_id))
await send_message(channel, msg_type, target_id, content)
return
# ---------- .jm ----------
if raw_text.lower() == ".jm" or raw_text.lower().startswith(".jm "):
asyncio.create_task(jm.handle_jm(channel, msg_type, target_id, user_id, raw_text))
return
# ---------- .pixiv ----------
if raw_text.lower() == ".pixiv" or raw_text.lower().startswith(".pixiv "):
asyncio.create_task(pixiv.handle_pixiv(channel, msg_type, target_id, user_id, raw_text))
return
# ---------- .bilibili ----------
if raw_text.lower() == ".bilibili" or raw_text.lower().startswith(".bilibili "):
asyncio.create_task(bilibili.handle_bilibili(channel, msg_type, target_id, user_id, raw_text))
return
# ---------- .setu ----------
if raw_text.lower() == ".setu" or raw_text.lower().startswith(".setu "):
param = raw_text[5:].strip() if len(raw_text) > 5 else ""
await _handle_setu_param(channel, msg_type, target_id, param, user_id)
return
# ---------- .mail ----------
if raw_text.lower() == ".mail" or raw_text.lower().startswith(".mail "):
asyncio.create_task(mail.handle_mail(channel, msg_type, target_id, user_id, raw_text))
return
# ---------- .live ----------
if raw_text.lower() == ".live" or raw_text.startswith(".live "):
parts = raw_text.split(maxsplit=3)
subcmd = parts[1] if len(parts) >= 2 else ""
if not subcmd or subcmd.lower() in ("help", "-h", "帮助"):
await send_message(channel, msg_type, target_id, live_monitor.HELP_TEXT)
return
if subcmd in ("status", "状态", "list", "列表"):
await send_message(channel, msg_type, target_id,
live_monitor.status_text(msg_type, target_id, channel.name))
return
if subcmd not in ("follow", "unfollow"):
await send_message(channel, msg_type, target_id,
"用法: .live follow {房间号 0-100} {1/2/空}\n1=主站 2=副站 空=全部\n发送 .live help 查看帮助")
return
if len(parts) < 3:
await send_message(channel, msg_type, target_id, "用法: .live follow {房间号 0-100} {1/2/空}")
return
room = parts[2].strip()
try:
room_num = int(room)
if room_num < 0 or room_num > 100:
raise ValueError
except ValueError:
await send_message(channel, msg_type, target_id, "房间号必须为 0-100 的整数")
return
room = str(room_num)
site = parts[3].strip() if len(parts) >= 4 else ""
if site not in ("", "1", "2"):
await send_message(channel, msg_type, target_id, "站点参数只能是 1(主站) 2(副站) 或留空(全部)")
return
chat_type = "group" if msg_type == "group" else "private"
site_label = {"1": "主站", "2": "副站", "": "全部"}[site]
# 群聊是独立会话:关注记在群上(QQ 群推 QQ 群,TG 群推 TG 群);
# 私聊关注挂在统一身份上(记 uid),推送时按 TG + 已绑定 QQ 扇出镜像
if chat_type == "group":
follow_channel, follow_id = channel.name, target_id
else:
if not identity.push_targets(user_id):
await send_message(channel, msg_type, target_id,
"身份异常:找不到可用的推送目标,请联系管理员")
return
follow_channel, follow_id = "tg", user_id
if subcmd == "follow":
live_monitor.add_follow(room, chat_type, follow_id, site, follow_channel)
await send_message(channel, msg_type, target_id, f"已经添加对{site_label}直播间{room}的关注!")
else:
live_monitor.remove_follow(room, chat_type, follow_id, site, follow_channel)
await send_message(channel, msg_type, target_id, f"已经取消对{site_label}直播间{room}的关注!")
return
# ---------- .ginkgo ----------
if raw_text.lower() == ".ginkgo" or raw_text.lower().startswith(".ginkgo "):
asyncio.create_task(ginkgostu.handle_ginkgo(channel, msg_type, target_id, user_id, raw_text))
return
# ---------- .server(管理员)----------
if raw_text.lower() == ".server" or raw_text.lower().startswith(".server "):
if not channel.is_admin(user_id):
await send_message(channel, msg_type, target_id, "该指令仅管理员可用")
else:
await for_admin_komari_agent.handle_command(channel, msg_type, target_id, raw_text)
return
# ---------- .deepseek(管理员)----------
if raw_text.lower().startswith(".deepseek"):
if not channel.is_admin(user_id):
await send_message(channel, msg_type, target_id, "该指令仅管理员可用")
else:
await deepseek_balance.handle_command(channel, msg_type, target_id, raw_text)
return
# ---------- .log(管理员)----------
if raw_text.lower() == ".log" or raw_text.lower().startswith(".log "):
if not channel.is_admin(user_id):
await send_message(channel, msg_type, target_id, "该指令仅管理员可用")
else:
await for_admin_catch_log.handle_command(channel, msg_type, target_id, raw_text)
return
# ---------- 原 chat bot 的显式命令 ----------
if await _handle_chat_legacy(channel, msg_type, target_id, user_id, raw_text):
return
# ==================== 自然语言路由 ====================
# 保留旧快捷语,不让 Agent 截获既有显式行为。
if raw_text.startswith("来张涩图") or raw_text.startswith("test"):
if msg_type != "group" or is_mention:
if raw_text.startswith("test"):
await send_message(channel, msg_type, target_id, f"test: success,{msg_type}")
else:
param = raw_text[4:] if len(raw_text) > 4 else ""
await _handle_setu_param(channel, msg_type, target_id, param, user_id)
return
# 未识别的点号命令不能落入模型,避免把打字错误变成真实工具操作。
if raw_text.startswith("."):
await send_message(channel, msg_type, target_id, "没有这个指令呢。发送 .help 查看可用服务。")
return
mode = ModeStore().get_mode(channel.name, user_id, group_id)
if mode != "chat":
return
from chat import engine
engine.set_delivery(user_id, channel, target_id)
if msg_type == "private":
raw_text = mail.resolve_context_reference(user_id, raw_text)
await engine.handle_private_message(channel, user_id, raw_text, image_infos)
else:
# 群消息始终记录;engine 内部仍只在 @/回复本 bot 时生成回复。
await engine.handle_group_message(channel, group_id, user_id, raw_text,
image_infos, is_mention)