diff --git a/README.md b/README.md index 8072210..ab4da99 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,22 @@ ## 插件列表 -| 插件 | 说明 | 依赖 | -|------|------|------| -| [dglab_ws](./dglab_ws/) | DG-Lab 郊狼 3.0 WebSocket 控制 | websocket-client, qrcode(可选) | +| 插件码 | 插件 | 说明 | +|--------|------|------| +| `dglab` | [dglab_ws](./dglab_ws/) | DG-Lab 郊狼 3.0 WebSocket 控制 | +| `onebot` | [onebot](./onebot/) | OneBot v11 QQ 机器人 | ## 安装方式 -1. 将插件文件夹中的 `.py` 文件复制到 xiaoli-cli 的 `plugins/` 目录 -2. 在 `plugins_config.py` 中启用对应插件 +在 xiaoli-cli 中使用 `/plugin` 命令: -```bash -# 示例:安装 dglab_ws 插件 -cp dglab_ws/dglab_ws.py /path/to/xiaoli-cli/plugins/ +``` +/plugin install dglab 安装 DG-Lab 插件 +/plugin install onebot 安装 OneBot QQ 机器人插件 +/plugin list 查看所有可用插件 ``` -```python -# plugins_config.py -ENABLE_DGLAB_WS = True -``` +或手动安装:将插件文件夹中的 `.py` 文件复制到 xiaoli-cli 的 `plugins/` 目录。 ## 插件目录结构 @@ -35,34 +33,21 @@ ENABLE_DGLAB_WS = True ## 开发插件 -插件是一个独立 `.py` 文件,需包含 `Liugin` 类(兼容 `Plugin`): +插件是一个独立 `.py` 文件,需包含 `Liugin` 类: ```python class Liugin: def get_tool_info(self): - """返回插件元信息""" - return { - "name": "插件名", - "description": "一句话描述", - "keywords": ["关键词"], - "usage": "使用说明" - } + return {"name": "插件名", "description": "...", "keywords": [], "usage": "..."} def handle(self, args): - """处理命令,返回结果字符串""" return "结果" - def set_cli(self, cli): - """可选:注册CLI斜杠命令""" + def set_cli(self, cli): # 可选:注册CLI命令 pass - def get_mcp_definition(self): - """可选:MCP工具定义""" + def get_mcp_definition(self): # 可选:MCP支持 return {} - - def convert_mcp_args(self, arguments): - """可选:MCP参数转CLI参数""" - return "" ``` ## License diff --git a/onebot/README.md b/onebot/README.md new file mode 100644 index 0000000..77e408f --- /dev/null +++ b/onebot/README.md @@ -0,0 +1,159 @@ +# OneBot v11 QQ 机器人插件 + +> 让 xiaoli-cli 成为 QQ 机器人,通过 OneBot 协议连接 QQ 消息平台 + +## 什么是 OneBot + +[OneBot](https://github.com/botuniverse/onebot) 是一个聊天机器人应用标准,支持 QQ、微信等平台。常见的 OneBot 实现: + +| 实现 | 语言 | 说明 | +|------|------|------| +| [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) | Go | 最流行,功能全 | +| [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) | C# | 轻量,NTQQ 协议 | +| [LLOneBot](https://github.com/LLOneBot/LLOneBot) | TypeScript | QQNT 插件形式 | +| [NapCat](https://github.com/NapNeko/NapCatQQ) | TypeScript | 另一个 QQNT 方案 | + +## 前置准备 + +### 1. 安装依赖 + +```bash +pip install websocket-client +``` + +### 2. 安装 OneBot 实现 + +以 Lagrange 为例: + +```bash +# 下载 Lagrange +# https://github.com/LagrangeDev/Lagrange.Core/releases + +# 编辑配置 appsettings.json,设置正向 WebSocket +{ + "Implementations": [{ + "Type": "ForwardWebSocket", + "Host": "0.0.0.0", + "Port": 8080 + }] +} + +# 启动,扫码登录 +./Lagrange.Core +``` + +### 3. 安装插件 + +```bash +# 方式1: 通过插件码安装 +/plugin install onebot + +# 方式2: 手动复制 +cp onebot/onebot.py /path/to/xiaoli-cli/plugins/ +``` + +## 使用教程 + +### 方式一:正向 WebSocket(推荐) + +xiaoli-cli 主动连接 OneBot 服务: + +``` +# 连接 +/onebot connect ws://127.0.0.1:8080 + +# 查看状态 +/onebot status +``` + +### 方式二:反向 WebSocket + +xiaoli-cli 监听端口,等 OneBot 主动连接: + +``` +# 启动监听(默认 8765 端口) +/onebot listen 8765 +``` + +然后在 OneBot 配置中设置反向 WebSocket 地址为 `ws://你的IP:8765`。 + +### 配置触发方式 + +``` +# 只响应 @机器人 的消息(默认) +/onebot config trigger_mode at + +# 响应所有消息 +/onebot config trigger_mode all + +# 响应以特定前缀开头的消息 +/onebot config trigger_mode prefix +/onebot config trigger_prefix ! +``` + +### 群白名单 + +``` +# 只在指定群响应 +/onebot config group_whitelist 123456789 +/onebot config group_whitelist 987654321 +``` + +### 手动发消息 + +``` +# 发私聊 +/onebot send private 123456 你好 + +# 发群聊 +/onebot send group 123456789 大家好 +``` + +## 命令速查 + +| 命令 | 说明 | +|------|------| +| `/onebot connect ` | 正向 WebSocket 连接 | +| `/onebot listen [port]` | 反向 WebSocket 监听 | +| `/onebot disconnect` | 断开连接 | +| `/onebot status` | 查看状态 | +| `/onebot config ` | 配置参数 | +| `/onebot send ` | 手动发消息 | +| `/onebot help` | 显示帮助 | + +## 工作原理 + +``` +QQ 用户发消息 + ↓ +OneBot 实现 (Lagrange/go-cqhttp) + ↓ WebSocket 推送 +onebot 插件 (接收消息) + ↓ 注入上下文 +xiaoli-cli AI 引擎 (处理消息) + ↓ 生成回复 +onebot 插件 (发送回复) + ↓ OneBot API +QQ 用户收到回复 +``` + +## 常见问题 + +**Q: 连接不上怎么办?** +A: 检查 OneBot 实现是否启动,端口是否正确,防火墙是否放行。 + +**Q: 群里不回复?** +A: 检查 trigger_mode 设置,以及是否在群白名单中。 + +**Q: 支持图片/语音吗?** +A: 当前版本只处理文本消息。图片消息会提取文本部分处理。 + +**Q: 安全性如何?** +A: 建议设置群白名单,不要在公开群暴露。管理员功能可后续扩展。 + +## 参考 + +- [OneBot 标准文档](https://github.com/botuniverse/onebot) +- [OneBot v11 接口文档](https://github.com/botuniverse/onebot-11) +- [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) +- [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) diff --git a/onebot/onebot.py b/onebot/onebot.py new file mode 100644 index 0000000..a1b553e --- /dev/null +++ b/onebot/onebot.py @@ -0,0 +1,541 @@ +""" +OneBot v11 插件 — 让 xiaoli-cli 成为 QQ 机器人 +通过 WebSocket 连接 OneBot 实现(go-cqhttp / Lagrange / LLOneBot 等) + +功能: +- WebSocket 正向/反向连接 +- 接收私聊和群消息,转发给 AI 处理 +- AI 回复自动发回 QQ +- 支持 @机器人 触发 / 全部消息 / 前缀触发 +- 群消息可配置是否响应 +""" + +import json +import time +import threading +import re +import os + +try: + import websocket + HAS_WS = True +except ImportError: + HAS_WS = False + + +class Liugin: + """OneBot v11 QQ 机器人插件""" + + def __init__(self): + self.ws = None + self.connected = False + self.running = False + self.ws_url = "" + self.mode = "" # "forward" 正向 / "reverse" 反向 + + # 配置 + self.trigger_mode = "at" # at: 只响应@ / prefix: 前缀触发 / all: 全部消息 + self.trigger_prefix = "/" + self.group_whitelist = [] # 群白名单,空=全部响应 + self.admin_users = [] # 管理员 QQ 号 + self.listen_port = 8765 # 反向 WebSocket 监听端口 + self.self_id = "" # 机器人自身 QQ 号 + + # 消息队列 (用于 AI 回复) + self._pending_replies = {} # {message_id: {"context": ..., "reply": ...}} + self._msg_counter = 0 + + self.cli = None + self._listener_thread = None + + self.usage = """OneBot v11 QQ 机器人插件 使用说明: + +/onebot connect 连接 OneBot (正向WebSocket) +/onebot listen [port] 启动反向WebSocket监听 (默认8765) +/onebot disconnect 断开连接 +/onebot status 查看连接状态 +/onebot config 配置: + trigger_mode at|prefix|all 触发模式 + trigger_prefix <前缀> 前缀触发的前缀 + group_whitelist <群号> 添加群白名单 + admin 添加管理员 + self_id 设置机器人QQ号 +/onebot send <消息> 手动发消息 + type: private/group + id: QQ号/群号 +/onebot help 显示帮助 +""" + + def set_cli(self, cli): + self.cli = cli + if hasattr(cli, 'register_liugin_command'): + cli.register_liugin_command('onebot', self._cmd_handler) + + def _cmd_handler(self, args): + parts = args.strip().split(maxsplit=1) + if not parts: + return self.handle("help") + return self.handle(args) + + def get_tool_info(self): + return { + "name": "onebot", + "description": "OneBot v11 QQ 机器人插件,连接 go-cqhttp/Lagrange/LLOneBot 等,让 AI 接管 QQ 消息", + "keywords": [ + "onebot", "qq", "机器人", "bot", "go-cqhttp", + "lagrange", "消息", "群聊", "私聊", "cqhttp" + ], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "onebot", + "description": "OneBot v11 QQ bot plugin", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["connect", "listen", "disconnect", "status", "config", "send"] + }, + "args": {"type": "string"} + }, + "required": ["action"] + } + } + + def convert_mcp_args(self, arguments): + return f"{arguments.get('action', '')} {arguments.get('args', '')}" + + # ── WebSocket 连接 ── + + def _on_message(self, ws, raw): + try: + data = json.loads(raw) + except json.JSONDecodeError: + return + self._dispatch(data) + + def _on_error(self, ws, error): + print(f"\n[OneBot] ❌ WebSocket 错误: {error}") + + def _on_close(self, ws, code, reason): + self.connected = False + self.running = False + print(f"\n[OneBot] 🔌 连接断开 (code={code})") + + def _on_open(self, ws): + self.connected = True + self.running = True + print(f"\n[OneBot] ✅ 已连接到 OneBot 服务") + + def _connect_forward(self, url): + """正向 WebSocket 连接""" + if not HAS_WS: + return "❌ 需要 websocket-client: pip install websocket-client" + + self.ws_url = url + self.mode = "forward" + + try: + self.ws = websocket.WebSocketApp( + url, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + on_open=self._on_open + ) + self._listener_thread = threading.Thread(target=self.ws.run_forever, daemon=True) + self._listener_thread.start() + + for _ in range(30): + if self.connected: + break + time.sleep(0.1) + + if self.connected: + return f"✅ 已连接到 {url}" + return f"❌ 连接超时: {url}" + except Exception as e: + return f"❌ 连接失败: {e}" + + def _start_reverse_listener(self, port): + """反向 WebSocket 监听""" + try: + from http.server import HTTPServer, BaseHTTPRequestHandler + import hashlib + import struct + except ImportError: + return "❌ 缺少标准库组件" + + self.listen_port = port + self.mode = "reverse" + plugin_ref = self + + class ReverseWSHandler(BaseHTTPRequestHandler): + def do_GET(self): + # WebSocket 握手 + if self.headers.get('Upgrade', '').lower() != 'websocket': + self.send_error(400) + return + + key = self.headers.get('Sec-WebSocket-Key', '') + accept = hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-5AB5DC65C740").encode() + ).digest() + + self.send_response(101, 'Switching Protocols') + self.send_header('Upgrade', 'websocket') + self.send_header('Connection', 'Upgrade') + self.send_header('Sec-WebSocket-Accept', + __import__('base64').b64encode(accept).decode()) + self.end_headers() + + # 简化的 WebSocket 帧处理 + plugin_ref._handle_reverse_ws(self.rfile, self.wfile, self) + + def log_message(self, format, *args): + pass # 静默日志 + + class ReverseHTTPServer(HTTPServer): + allow_reuse_address = True + + def _run_server(): + try: + server = ReverseHTTPServer(('0.0.0.0', port), ReverseWSHandler) + self.connected = True + self.running = True + print(f"\n[OneBot] ✅ 反向 WebSocket 监听中: ws://0.0.0.0:{port}") + server.serve_forever() + except Exception as e: + print(f"\n[OneBot] ❌ 监听失败: {e}") + + self._listener_thread = threading.Thread(target=_run_server, daemon=True) + self._listener_thread.start() + return f"✅ 反向 WebSocket 已启动: ws://0.0.0.0:{port}" + + def _handle_reverse_ws(self, rfile, wfile, handler): + """处理反向 WebSocket 连接""" + self.connected = True + self.running = True + print(f"\n[OneBot] ✅ OneBot 已连接") + + try: + while self.running: + # 读取 WebSocket 帧 + header = rfile.read(2) + if len(header) < 2: + break + + opcode = header[0] & 0x0F + masked = bool(header[1] & 0x80) + length = header[1] & 0x7F + + if length == 126: + length = struct.unpack('>H', rfile.read(2))[0] + elif length == 127: + length = struct.unpack('>Q', rfile.read(8))[0] + + mask_key = rfile.read(4) if masked else b'' + payload = rfile.read(length) + + if masked: + payload = bytes( + b ^ mask_key[i % 4] + for i, b in enumerate(payload) + ) + + if opcode == 1: # Text + try: + data = json.loads(payload.decode('utf-8')) + self._dispatch(data) + except json.JSONDecodeError: + pass + elif opcode == 8: # Close + break + elif opcode == 9: # Ping + # 发 Pong + self._ws_send_pong(wfile, payload) + + except Exception as e: + print(f"\n[OneBot] 连接断开: {e}") + finally: + self.connected = False + + def _ws_send_pong(self, wfile, payload): + """发送 Pong 帧""" + try: + frame = bytes([0x8A, len(payload)]) + payload + wfile.write(frame) + wfile.flush() + except Exception: + pass + + # ── 消息分发 ── + + def _dispatch(self, data): + """分发 OneBot 事件""" + post_type = data.get("post_type", "") + + if post_type == "message": + self._handle_message(data) + elif post_type == "meta_event": + sub_type = data.get("meta_event_type", "") + if sub_type == "heartbeat": + pass + elif sub_type == "lifecycle": + if data.get("sub_type") == "connect": + self.self_id = str(data.get("self_id", "")) + print(f"\n[OneBot] 🤖 机器人已上线: {self.self_id}") + elif post_type == "request": + self._handle_request(data) + + def _handle_message(self, data): + """处理收到的消息""" + message_type = data.get("message_type", "") + raw_msg = data.get("raw_message", data.get("message", "")) + user_id = str(data.get("user_id", "")) + group_id = str(data.get("group_id", "")) + message_id = data.get("message_id", 0) + sender = data.get("sender", {}) + nickname = sender.get("nickname", user_id) + + # 群白名单检查 + if message_type == "group" and self.group_whitelist: + if group_id not in self.group_whitelist: + return + + # 解析纯文本 + text = self._extract_text(raw_msg) + + # 判断是否需要响应 + should_respond = False + + if message_type == "private": + should_respond = True + elif message_type == "group": + if self.trigger_mode == "all": + should_respond = True + elif self.trigger_mode == "at": + if f"[CQ:at,qq={self.self_id}]" in raw_msg: + should_respond = True + text = text.replace(f"@{self.self_id}", "").strip() + elif self.trigger_mode == "prefix": + if text.startswith(self.trigger_prefix): + should_respond = True + text = text[len(self.trigger_prefix):].strip() + + if not should_respond or not text: + return + + # 打印收到的消息 + if message_type == "group": + print(f"\n[OneBot] 💬 [{group_id}] {nickname}: {text[:80]}") + else: + print(f"\n[OneBot] 💬 {nickname}: {text[:80]}") + + # 交给 AI 处理 + self._process_with_ai(text, message_type, user_id, group_id, message_id, nickname) + + def _extract_text(self, raw_msg): + """从 CQ 码消息中提取纯文本""" + # 移除 CQ 码 + text = re.sub(r'\[CQ:[^\]]+\]', '', raw_msg) + return text.strip() + + def _handle_request(self, data): + """处理请求事件(好友申请、群邀请等)""" + request_type = data.get("request_type", "") + if request_type == "friend": + # 自动同意好友请求 + self._send_api("set_friend_add_request", { + "flag": data.get("flag", ""), + "approve": True + }) + print(f"\n[OneBot] ✅ 已自动同意好友请求: {data.get('user_id')}") + + def _process_with_ai(self, text, msg_type, user_id, group_id, msg_id, nickname): + """将消息交给 AI 处理并发回回复""" + if not self.cli: + return + + # 构建上下文 + if msg_type == "group": + context = f"[QQ群消息] 群{group_id} 用户{nickname}({user_id}): {text}" + else: + context = f"[QQ私聊] 用户{nickname}({user_id}): {text}" + + def _ai_worker(): + try: + # 调用 AI 处理 + response = self.cli._generate_response_with_animation(context) + if response: + # 清理回复 + reply = self._clean_reply(response) + if reply: + if msg_type == "group": + self._send_group_msg(group_id, reply, at_user=user_id) + else: + self._send_private_msg(user_id, reply) + except Exception as e: + print(f"\n[OneBot] ❌ AI 处理失败: {e}") + + threading.Thread(target=_ai_worker, daemon=True).start() + + def _clean_reply(self, response): + """清理 AI 回复,去掉 JSON 指令等""" + # 去掉工具调用 JSON + response = re.sub(r'\{"action":\s*"use_tool"[^}]*\}', '', response) + response = re.sub(r'\{"action":\s*"continue"[^}]*\}', '', response) + response = re.sub(r'[^<]*', '', response) + response = response.strip() + + # 限制长度 + if len(response) > 2000: + response = response[:1997] + "..." + + return response + + # ── OneBot API 调用 ── + + def _send_api(self, action, params=None): + """调用 OneBot API""" + if not self.connected or not self.ws: + return None + + self._msg_counter += 1 + payload = { + "action": action, + "params": params or {}, + "echo": self._msg_counter + } + + try: + if self.mode == "forward": + self.ws.send(json.dumps(payload)) + return self._msg_counter + except Exception as e: + print(f"\n[OneBot] ❌ API 调用失败: {e}") + return None + + def _send_private_msg(self, user_id, message): + """发送私聊消息""" + return self._send_api("send_private_msg", { + "user_id": int(user_id), + "message": message + }) + + def _send_group_msg(self, group_id, message, at_user=None): + """发送群消息""" + if at_user: + message = f"[CQ:at,qq={at_user}] {message}" + return self._send_api("send_group_msg", { + "group_id": int(group_id), + "message": message + }) + + # ── 命令入口 ── + + def handle(self, args): + parts = args.strip().split(maxsplit=1) + if not parts: + return self.usage + + action = parts[0].lower() + rest = parts[1] if len(parts) > 1 else "" + + if action == "help": + return self.usage + elif action == "connect": + return self._do_connect(rest.strip()) + elif action == "listen": + port = int(rest.strip()) if rest.strip().isdigit() else 8765 + return self._start_reverse_listener(port) + elif action == "disconnect": + return self._do_disconnect() + elif action == "status": + return self._do_status() + elif action == "config": + return self._do_config(rest) + elif action == "send": + return self._do_send(rest) + else: + return f"未知命令: {action}\n{self.usage}" + + def _do_connect(self, url): + if not url: + return "请提供 WebSocket 地址: /onebot connect ws://127.0.0.1:6700" + return self._connect_forward(url) + + def _do_disconnect(self): + self.running = False + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.connected = False + return "已断开连接" + + def _do_status(self): + lines = ["🤖 OneBot 状态:"] + lines.append(f" 连接: {'✅ 已连接' if self.connected else '❌ 未连接'}") + lines.append(f" 模式: {self.mode or '未设置'}") + if self.ws_url: + lines.append(f" 地址: {self.ws_url}") + lines.append(f" 端口: {self.listen_port}") + lines.append(f" 机器人QQ: {self.self_id or '未知'}") + lines.append(f" 触发模式: {self.trigger_mode}") + if self.trigger_mode == "prefix": + lines.append(f" 触发前缀: {self.trigger_prefix}") + if self.group_whitelist: + lines.append(f" 群白名单: {', '.join(self.group_whitelist)}") + if self.admin_users: + lines.append(f" 管理员: {', '.join(self.admin_users)}") + return "\n".join(lines) + + def _do_config(self, args): + parts = args.split(maxsplit=1) + if len(parts) < 2: + return "用法: /onebot config \n可用: trigger_mode, trigger_prefix, group_whitelist, admin, self_id" + + key, val = parts[0].lower(), parts[1].strip() + + if key == "trigger_mode": + if val not in ("at", "prefix", "all"): + return "触发模式: at / prefix / all" + self.trigger_mode = val + return f"✅ 触发模式: {val}" + elif key == "trigger_prefix": + self.trigger_prefix = val + return f"✅ 触发前缀: {val}" + elif key == "group_whitelist": + if val not in self.group_whitelist: + self.group_whitelist.append(val) + return f"✅ 群白名单: {self.group_whitelist}" + elif key == "admin": + if val not in self.admin_users: + self.admin_users.append(val) + return f"✅ 管理员: {self.admin_users}" + elif key == "self_id": + self.self_id = val + return f"✅ 机器人QQ: {val}" + else: + return f"未知配置: {key}" + + def _do_send(self, args): + parts = args.split(maxsplit=2) + if len(parts) < 3: + return "用法: /onebot send <消息>" + + msg_type, target_id, message = parts[0].lower(), parts[1], parts[2] + + if msg_type in ("private", "p", "私聊"): + self._send_private_msg(target_id, message) + return f"✅ 已发送私聊消息给 {target_id}" + elif msg_type in ("group", "g", "群"): + self._send_group_msg(target_id, message) + return f"✅ 已发送群消息到 {target_id}" + else: + return "类型: private / group" diff --git a/plugin_registry.json b/plugin_registry.json index 4991450..72606bb 100644 --- a/plugin_registry.json +++ b/plugin_registry.json @@ -13,6 +13,18 @@ "deps": ["websocket-client"], "optional_deps": ["qrcode"], "keywords": ["dg-lab", "dglab", "郊狼", "coyote", "websocket", "脉冲"] + }, + "onebot": { + "name": "OneBot v11 QQ 机器人", + "description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现", + "author": "xiaoli-cli-plugins", + "version": "1.0.0", + "file": "onebot.py", + "url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/onebot.py", + "docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/README.md", + "deps": ["websocket-client"], + "optional_deps": [], + "keywords": ["onebot", "qq", "机器人", "bot", "go-cqhttp", "lagrange", "群聊", "私聊"] } } }