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,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)
|
||||
Reference in New Issue
Block a user