Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
377 lines
18 KiB
Python
377 lines
18 KiB
Python
# 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)
|