feat: 添加 OneBot v11 QQ 机器人插件
功能: - WebSocket 正向/反向连接 OneBot 实现 - 接收私聊和群消息,转发给 AI 处理 - AI 回复自动发回 QQ - 支持 @机器人 / 前缀 / 全部消息 三种触发模式 - 群白名单、管理员配置 - 手动发消息命令 - 支持 go-cqhttp / Lagrange / LLOneBot / NapCat 等 文件: - onebot/onebot.py - 插件代码 - onebot/README.md - 使用教程 - plugin_registry.json - 注册表更新
This commit is contained in:
1 parent
040e90eb0d
commit
bdf1d1a29a
4 files changed
+726
-29
No files matched your search
@@ -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 <ws_url> 连接 OneBot (正向WebSocket)
|
||||
/onebot listen [port] 启动反向WebSocket监听 (默认8765)
|
||||
/onebot disconnect 断开连接
|
||||
/onebot status 查看连接状态
|
||||
/onebot config <key> <val> 配置:
|
||||
trigger_mode at|prefix|all 触发模式
|
||||
trigger_prefix <前缀> 前缀触发的前缀
|
||||
group_whitelist <群号> 添加群白名单
|
||||
admin <QQ号> 添加管理员
|
||||
self_id <QQ号> 设置机器人QQ号
|
||||
/onebot send <type> <id> <消息> 手动发消息
|
||||
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'<tool_call>[^<]*', '', 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 <key> <value>\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 <private|group> <ID> <消息>"
|
||||
|
||||
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"
|
||||
Reference in new issue
Block a user