# 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)