Files
shirobana-bot/bot-utility/dispatch.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

377 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)