新增 MaiBot 对话适配插件并补充 192.168.2.240 部署说明
This commit is contained in:
180
plugins/maibot_adapter/DEPLOY_192.168.2.240.md
Normal file
180
plugins/maibot_adapter/DEPLOY_192.168.2.240.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 192.168.2.240 部署 MaiBot 清单
|
||||
|
||||
这份文档用于把 `MaiBot` 部署到 `192.168.2.240`,供 `plugins/maibot_adapter` 调用。
|
||||
|
||||
## 当前状态
|
||||
|
||||
我已经在 `abot` 里新增了 `maibot_adapter` 插件,但当前还没有这台服务器的 SSH 凭据。
|
||||
如果你把 SSH 用户名/密码,或者私钥登录方式给我,我就可以直接按这份清单远程执行。
|
||||
|
||||
## 目标
|
||||
|
||||
部署完成后,`abot` 将使用以下接口访问 MaiBot:
|
||||
|
||||
1. `POST /api/webui/auth/verify`
|
||||
2. `GET /api/webui/ws-token`
|
||||
3. `WS /ws?token=...`
|
||||
|
||||
因此最终需要保证:
|
||||
|
||||
1. `http://192.168.2.240:8001/api/webui/health` 可访问
|
||||
2. 你手里有一个可用的 `MaiBot WebUI token`
|
||||
3. `plugins/maibot_adapter/config.toml` 中填入同一个 `server_url` 与 `access_token`
|
||||
|
||||
## 推荐部署方式
|
||||
|
||||
当前优先建议“宿主机 Python 直跑”:
|
||||
|
||||
1. 部署简单,方便先打通插件联调
|
||||
2. 日志更容易直接看
|
||||
3. 后面稳定后再考虑改成 Docker Compose
|
||||
|
||||
## 服务器执行步骤
|
||||
|
||||
以下命令默认按 Linux 服务器写。
|
||||
|
||||
### 1. 安装基础依赖
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y git python3 python3-venv python3-pip
|
||||
```
|
||||
|
||||
### 2. 拉取 MaiBot
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
sudo git clone https://github.com/Mai-with-u/MaiBot.git
|
||||
sudo chown -R $USER:$USER /opt/MaiBot
|
||||
cd /opt/MaiBot
|
||||
```
|
||||
|
||||
### 3. 创建虚拟环境并安装依赖
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
如果 `pip install -e .` 遇到单个依赖下载慢,可以切换镜像源再执行。
|
||||
|
||||
### 4. 首次启动
|
||||
|
||||
```bash
|
||||
source /opt/MaiBot/.venv/bin/activate
|
||||
cd /opt/MaiBot
|
||||
python bot.py
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `bot.py` 是 MaiBot 主入口
|
||||
2. 首次启动时通常需要你按它自己的配置流程完成基础设置
|
||||
3. WebUI 默认监听端口通常是 `8001`
|
||||
|
||||
### 5. 验证健康状态
|
||||
|
||||
本机执行:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8001/api/webui/health
|
||||
```
|
||||
|
||||
局域网执行:
|
||||
|
||||
```bash
|
||||
curl http://192.168.2.240:8001/api/webui/health
|
||||
```
|
||||
|
||||
期待返回:
|
||||
|
||||
```json
|
||||
{"status":"healthy","service":"MaiBot WebUI"}
|
||||
```
|
||||
|
||||
### 6. 获取 WebUI token
|
||||
|
||||
这一步有两种方式:
|
||||
|
||||
1. 按 MaiBot 首次配置流程,在 WebUI 中设置 token
|
||||
2. 如果已经有现成 token,直接记下来给 `maibot_adapter` 使用
|
||||
|
||||
最终你需要把 token 填到:
|
||||
|
||||
`plugins/maibot_adapter/config.toml`
|
||||
|
||||
```toml
|
||||
server_url = "http://192.168.2.240:8001"
|
||||
access_token = "你的MaiBotToken"
|
||||
```
|
||||
|
||||
### 7. 建议做成 systemd 服务
|
||||
|
||||
创建 `/etc/systemd/system/maibot.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=MaiBot Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/MaiBot
|
||||
ExecStart=/opt/MaiBot/.venv/bin/python /opt/MaiBot/bot.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启用并启动:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable maibot
|
||||
sudo systemctl start maibot
|
||||
sudo systemctl status maibot
|
||||
```
|
||||
|
||||
看日志:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u maibot -f
|
||||
```
|
||||
|
||||
## 与 abot 对接
|
||||
|
||||
部署好后,回到 `abot`:
|
||||
|
||||
1. 编辑 `plugins/maibot_adapter/config.toml`
|
||||
2. 填入:
|
||||
- `server_url = "http://192.168.2.240:8001"`
|
||||
- `access_token = "你的MaiBotToken"`
|
||||
3. 重启 `abot` 或热加载插件
|
||||
|
||||
测试指令:
|
||||
|
||||
```text
|
||||
麦麦 你好
|
||||
```
|
||||
|
||||
或者在群里直接:
|
||||
|
||||
```text
|
||||
@机器人 你好
|
||||
```
|
||||
|
||||
## 备注
|
||||
|
||||
如果你后面希望:
|
||||
|
||||
1. `ai_auto_response` 也复用 MaiBot
|
||||
2. 做自动插话而不是只做命令对话
|
||||
3. 做会话长驻、减少每次重新认证和建连成本
|
||||
|
||||
下一步就可以把 `maibot_adapter` 再往“共享长连接 + 自动回复桥”方向升级。
|
||||
35
plugins/maibot_adapter/README.md
Normal file
35
plugins/maibot_adapter/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# MaiBot Adapter 插件
|
||||
|
||||
这个插件用于把外部独立部署的 `MaiBot WebUI` 对接到 `abot` 的插件体系里。
|
||||
|
||||
## 当前能力
|
||||
|
||||
1. 支持命令触发:
|
||||
- `麦麦 你好`
|
||||
- `maibot 你是谁`
|
||||
2. 支持群聊里 `@机器人` 后把文本转发给 MaiBot。
|
||||
3. 插件内部会自动执行:
|
||||
- `POST /api/webui/auth/verify`
|
||||
- `GET /api/webui/ws-token`
|
||||
- `WS /ws?token=...`
|
||||
4. 每次消息会走一个独立的 WebSocket 逻辑会话,并等待 `bot_message` 事件作为最终回复。
|
||||
|
||||
## 配置说明
|
||||
|
||||
见 `config.toml`:
|
||||
|
||||
1. `server_url`
|
||||
- MaiBot WebUI 地址,例如 `http://192.168.2.240:8001`
|
||||
2. `access_token`
|
||||
- MaiBot WebUI 登录 token
|
||||
3. `session_scope`
|
||||
- `room`:同群共享语境
|
||||
- `sender`:同群内每个用户独立语境
|
||||
|
||||
## 部署建议
|
||||
|
||||
如果你后面要让 `ai_auto_response` 也改走 MaiBot,建议顺序如下:
|
||||
|
||||
1. 先用这个插件验证 MaiBot 对话质量与延迟。
|
||||
2. 确认稳定后,再把 `ai_auto_response` 的“决策/生成”改成调用同一套桥接逻辑。
|
||||
3. 最后再决定是否完全下线 `ai_auto_response` 原本的 LLM 直连链路。
|
||||
6
plugins/maibot_adapter/__init__.py
Normal file
6
plugins/maibot_adapter/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .main import MaiBotAdapterPlugin
|
||||
|
||||
|
||||
def get_plugin():
|
||||
"""返回插件实例,供插件管理器按统一入口加载。"""
|
||||
return MaiBotAdapterPlugin()
|
||||
45
plugins/maibot_adapter/config.toml
Normal file
45
plugins/maibot_adapter/config.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[MaiBotAdapter]
|
||||
enable = true
|
||||
|
||||
# 命令触发词:
|
||||
# 1. 不需要前缀,直接使用 “麦麦 你好” 这种形式即可;
|
||||
# 2. 群聊里也支持 @机器人 后直接转发给 MaiBot;
|
||||
# 3. 如果你后面希望只保留自动插话入口,可以把命令缩减到一个内部调试词。
|
||||
commands = ["麦麦", "maibot", "mai"]
|
||||
command-tip = """
|
||||
🤖MaiBot 对话指令:
|
||||
麦麦 你好
|
||||
"""
|
||||
|
||||
# 是否允许群聊里通过 @机器人 触发。
|
||||
allow_group_at = true
|
||||
|
||||
# MaiBot WebUI 服务根地址:
|
||||
# 1. 不要带末尾斜杠;
|
||||
# 2. 插件会基于这个地址自动请求:
|
||||
# - /api/webui/auth/verify
|
||||
# - /api/webui/ws-token
|
||||
# - /ws
|
||||
server_url = "http://192.168.2.240:8001"
|
||||
|
||||
# MaiBot WebUI 的访问令牌:
|
||||
# 1. 这是你在 MaiBot WebUI 登录页里使用的 token;
|
||||
# 2. 插件会先调用 auth/verify 写入 Cookie,再换取一次性 ws-token;
|
||||
# 3. 建议部署完成后改成你自己的正式 token。
|
||||
access_token = ""
|
||||
|
||||
# WebSocket 聊天的超时配置(秒):
|
||||
# 1. connect_timeout 控制 HTTP/WS 建连超时;
|
||||
# 2. reply_timeout 控制发送问题后等待 MaiBot 回复的最长时间。
|
||||
connect_timeout = 15
|
||||
reply_timeout = 90
|
||||
|
||||
# 会话维度:
|
||||
# 1. room 表示同一个群共享一个 MaiBot 身份语境;
|
||||
# 2. sender 表示每个发言人各自独立会话;
|
||||
# 3. 当前默认 room,更适合群聊人格连续性。
|
||||
session_scope = "room"
|
||||
|
||||
# 是否校验 HTTPS 证书。
|
||||
# 如果你后面给 MaiBot 挂了自签名证书,可以临时改成 false。
|
||||
verify_ssl = true
|
||||
446
plugins/maibot_adapter/main.py
Normal file
446
plugins/maibot_adapter/main.py
Normal file
@@ -0,0 +1,446 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
from base.plugin_common.plugin_interface import PluginStatus
|
||||
from utils.decorator.plugin_decorators import plugin_stats_decorator
|
||||
from utils.decorator.rate_limit_decorator import group_feature_rate_limit
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||||
from wechat_ipad import WechatAPIClient
|
||||
|
||||
|
||||
class MaiBotAdapterPlugin(MessagePluginInterface):
|
||||
"""将外部部署的 MaiBot WebUI 聊天能力桥接到 abot 的消息插件。"""
|
||||
|
||||
FEATURE_KEY = "MAIBOT_CHAT"
|
||||
FEATURE_DESCRIPTION = "🤖 MaiBot 对话桥接 [麦麦, maibot, mai]"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "MaiBot对话"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "通过 MaiBot WebUI 的统一 WebSocket 协议接入外部 MaiBot 对话能力"
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
return "Codex"
|
||||
|
||||
@property
|
||||
def command_prefix(self) -> Optional[str]:
|
||||
"""命令插件沿用现有空前缀约定,直接匹配第一个词。"""
|
||||
return ""
|
||||
|
||||
@property
|
||||
def commands(self) -> List[str]:
|
||||
return self._commands
|
||||
|
||||
@property
|
||||
def feature_key(self) -> Optional[str]:
|
||||
return self.FEATURE_KEY
|
||||
|
||||
@property
|
||||
def feature_description(self) -> Optional[str]:
|
||||
return self.FEATURE_DESCRIPTION
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 注册权限特征,便于按群开关此插件。
|
||||
self.feature = self.register_feature()
|
||||
self._commands: List[str] = ["麦麦", "maibot", "mai"]
|
||||
self._enabled = True
|
||||
self._allow_group_at = True
|
||||
self._command_tip = "麦麦 你好"
|
||||
self._server_url = ""
|
||||
self._access_token = ""
|
||||
self._connect_timeout = 15
|
||||
self._reply_timeout = 90
|
||||
self._session_scope = "room"
|
||||
self._verify_ssl = True
|
||||
self._config_ready = False
|
||||
|
||||
def initialize(self, context: Dict[str, Any]) -> bool:
|
||||
"""初始化插件配置与上下文引用。"""
|
||||
self.LOG.debug(f"正在初始化 {self.name} 插件...")
|
||||
|
||||
maibot_config = self._config.get("MaiBotAdapter", {}) or {}
|
||||
self._commands = [str(item).strip() for item in maibot_config.get("commands", self._commands) if str(item).strip()]
|
||||
self._enabled = bool(maibot_config.get("enable", True))
|
||||
self._allow_group_at = bool(maibot_config.get("allow_group_at", True))
|
||||
self._command_tip = str(maibot_config.get("command-tip", self._command_tip)).strip()
|
||||
self._server_url = str(maibot_config.get("server_url", "") or "").rstrip("/")
|
||||
self._access_token = str(maibot_config.get("access_token", "") or "").strip()
|
||||
self._connect_timeout = max(5, int(maibot_config.get("connect_timeout", 15) or 15))
|
||||
self._reply_timeout = max(10, int(maibot_config.get("reply_timeout", 90) or 90))
|
||||
self._session_scope = str(maibot_config.get("session_scope", "room") or "room").strip().lower()
|
||||
self._verify_ssl = bool(maibot_config.get("verify_ssl", True))
|
||||
|
||||
# 这里不因为配置缺失而让插件初始化失败:
|
||||
# 1. 这样插件可以先被系统正常加载,后续热更新 TOML 即可生效;
|
||||
# 2. 真正处理消息时会再次检查配置完备性,并打印更清晰的日志。
|
||||
self._config_ready = bool(self._server_url and self._access_token)
|
||||
if not self._config_ready:
|
||||
self.LOG.warning(
|
||||
f"[{self.name}] 当前 server_url/access_token 未配置完整,插件会加载成功但不会实际处理消息"
|
||||
)
|
||||
|
||||
self.LOG.debug(
|
||||
f"[{self.name}] 初始化完成: commands={self._commands}, "
|
||||
f"allow_group_at={self._allow_group_at}, server_url={self._server_url}, "
|
||||
f"session_scope={self._session_scope}, verify_ssl={self._verify_ssl}"
|
||||
)
|
||||
return True
|
||||
|
||||
def start(self) -> bool:
|
||||
self.status = PluginStatus.RUNNING
|
||||
self.LOG.debug(f"[{self.name}] 插件已启动")
|
||||
return True
|
||||
|
||||
def stop(self) -> bool:
|
||||
self.status = PluginStatus.STOPPED
|
||||
self.LOG.info(f"[{self.name}] 插件已停止")
|
||||
return True
|
||||
|
||||
def can_process(self, message: Dict[str, Any]) -> bool:
|
||||
"""判断当前消息是否该由 MaiBot 对话插件接管。"""
|
||||
if not self._enabled:
|
||||
return False
|
||||
|
||||
if not self._config_ready:
|
||||
return False
|
||||
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
if not content:
|
||||
return False
|
||||
|
||||
first_token = content.split(" ", 1)[0]
|
||||
if first_token in self._commands:
|
||||
return True
|
||||
|
||||
if self._allow_group_at and bool(message.get("is_at", False)) and str(message.get("roomid", "") or "").strip():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@plugin_stats_decorator(plugin_name="MaiBot对话")
|
||||
@group_feature_rate_limit(max_per_minute=5, feature_key=FEATURE_KEY)
|
||||
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||
"""处理命令对话或群聊 @ 对话,并将问题转发到远端 MaiBot。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
sender = str(message.get("sender", "") or "").strip()
|
||||
roomid = str(message.get("roomid", "") or "").strip()
|
||||
target = roomid if roomid else sender
|
||||
gbm: GroupBotManager = message.get("gbm")
|
||||
bot: WechatAPIClient = message.get("bot")
|
||||
|
||||
self.LOG.info(
|
||||
f"[{self.name}] 收到消息: sender={sender}, roomid={roomid}, "
|
||||
f"is_at={message.get('is_at', False)}, content_preview={content[:120]}"
|
||||
)
|
||||
|
||||
if roomid and gbm and self.feature and gbm.get_group_permission(target, self.feature) == PermissionStatus.DISABLED:
|
||||
self.LOG.info(f"[{self.name}] 群 {target} 未启用功能权限,跳过处理")
|
||||
return False, "没有权限"
|
||||
|
||||
query = self._extract_query(message)
|
||||
if not query:
|
||||
if bot:
|
||||
await bot.send_text_message(target, self._command_tip, sender if roomid else "")
|
||||
return False, "没有提供问题内容"
|
||||
|
||||
session_key = self._build_session_key(roomid=roomid, sender=sender)
|
||||
self.LOG.info(
|
||||
f"[{self.name}] 准备请求 MaiBot: session_key={session_key}, "
|
||||
f"query_len={len(query)}, query_preview={query[:120]}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._query_maibot(session_key=session_key, query=query)
|
||||
response = self._normalize_response_text(response)
|
||||
if not response:
|
||||
self.LOG.warning(f"[{self.name}] MaiBot 返回空响应: session_key={session_key}")
|
||||
return False, "MaiBot 返回空响应"
|
||||
|
||||
if bot:
|
||||
await bot.send_text_message(target, response, sender if roomid else "")
|
||||
self.LOG.info(
|
||||
f"[{self.name}] MaiBot 回复成功: session_key={session_key}, "
|
||||
f"reply_len={len(response)}, reply_preview={response[:120]}"
|
||||
)
|
||||
return True, "发送成功"
|
||||
except Exception as exc:
|
||||
self.LOG.exception(f"[{self.name}] 请求 MaiBot 失败: {exc}")
|
||||
if bot:
|
||||
await bot.send_text_message(target, f"❌MaiBot 对话失败:{exc}", sender if roomid else "")
|
||||
return False, f"MaiBot 对话失败: {exc}"
|
||||
|
||||
def _extract_query(self, message: Dict[str, Any]) -> str:
|
||||
"""从命令消息或 @ 消息中提取真正发给 MaiBot 的文本。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
roomid = str(message.get("roomid", "") or "").strip()
|
||||
is_at = bool(message.get("is_at", False))
|
||||
first_token = content.split(" ", 1)[0] if content else ""
|
||||
|
||||
if first_token in self._commands:
|
||||
parts = content.split(" ", 1)
|
||||
return parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if is_at and roomid:
|
||||
# 兼容微信里常见的 “@机器人[空白]内容” 形式,去掉最前面的 @ 提及部分。
|
||||
return re.sub(r"^@.*?[\u2005|\s]+", "", content).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _build_session_key(self, roomid: str, sender: str) -> str:
|
||||
"""按配置生成 MaiBot 会话键,决定上下文是按群共享还是按人隔离。"""
|
||||
normalized_roomid = roomid or "private"
|
||||
normalized_sender = sender or "unknown"
|
||||
|
||||
if self._session_scope == "sender":
|
||||
return f"abot:{normalized_roomid}:{normalized_sender}"
|
||||
return f"abot:{normalized_roomid}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_response_text(response: str) -> str:
|
||||
"""简单清理回复文本,避免把 WS 结构层遗留空白直接发回群里。"""
|
||||
normalized_text = str(response or "").replace("\r\n", "\n").strip()
|
||||
normalized_text = re.sub(r"\n{3,}", "\n\n", normalized_text)
|
||||
return normalized_text
|
||||
|
||||
async def _query_maibot(self, session_key: str, query: str) -> str:
|
||||
"""执行一次完整的 MaiBot HTTP 认证 + WS 会话对话流程。"""
|
||||
client_timeout = aiohttp.ClientTimeout(total=self._reply_timeout + self._connect_timeout + 10)
|
||||
cookie_jar = aiohttp.CookieJar(unsafe=True)
|
||||
ssl_option = None if self._verify_ssl else False
|
||||
|
||||
async with aiohttp.ClientSession(timeout=client_timeout, cookie_jar=cookie_jar) as session:
|
||||
await self._login_webui(session=session, ssl_option=ssl_option)
|
||||
ws_token = await self._fetch_ws_token(session=session, ssl_option=ssl_option)
|
||||
ws_url = self._build_ws_url(ws_token)
|
||||
|
||||
self.LOG.info(f"[{self.name}] 正在连接 MaiBot WebSocket: ws_url={ws_url}")
|
||||
async with session.ws_connect(
|
||||
ws_url,
|
||||
ssl=ssl_option,
|
||||
heartbeat=30,
|
||||
receive_timeout=self._reply_timeout + 10,
|
||||
timeout=self._connect_timeout,
|
||||
) as websocket:
|
||||
client_session_id = f"{session_key}:{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# 这里显式打开逻辑聊天会话,确保后续回复都能按 session 维度关联回来。
|
||||
open_request_id = f"open_{uuid.uuid4().hex}"
|
||||
await websocket.send_json(
|
||||
{
|
||||
"op": "call",
|
||||
"id": open_request_id,
|
||||
"domain": "chat",
|
||||
"method": "session.open",
|
||||
"session": client_session_id,
|
||||
"data": {
|
||||
"restore": True,
|
||||
"user_id": session_key,
|
||||
"user_name": "ABotBridge",
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._wait_for_call_ok(
|
||||
websocket=websocket,
|
||||
request_id=open_request_id,
|
||||
expected_session=client_session_id,
|
||||
)
|
||||
|
||||
send_request_id = f"send_{uuid.uuid4().hex}"
|
||||
await websocket.send_json(
|
||||
{
|
||||
"op": "call",
|
||||
"id": send_request_id,
|
||||
"domain": "chat",
|
||||
"method": "message.send",
|
||||
"session": client_session_id,
|
||||
"data": {
|
||||
"content": query,
|
||||
"user_name": "ABotBridge",
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._wait_for_call_ok(
|
||||
websocket=websocket,
|
||||
request_id=send_request_id,
|
||||
expected_session=client_session_id,
|
||||
)
|
||||
|
||||
reply_text = await self._wait_for_bot_message(
|
||||
websocket=websocket,
|
||||
expected_session=client_session_id,
|
||||
)
|
||||
|
||||
# 关闭逻辑会话是“尽力而为”动作:
|
||||
# 1. 即使关闭失败,也不影响当前已经拿到的回复;
|
||||
# 2. 因为 WebSocket 断开后服务端也会清理连接,所以这里不把异常上抛。
|
||||
try:
|
||||
close_request_id = f"close_{uuid.uuid4().hex}"
|
||||
await websocket.send_json(
|
||||
{
|
||||
"op": "call",
|
||||
"id": close_request_id,
|
||||
"domain": "chat",
|
||||
"method": "session.close",
|
||||
"session": client_session_id,
|
||||
"data": {},
|
||||
}
|
||||
)
|
||||
except Exception as close_exc:
|
||||
self.LOG.warning(f"[{self.name}] 关闭 MaiBot 逻辑会话失败: {close_exc}")
|
||||
|
||||
return reply_text
|
||||
|
||||
async def _login_webui(self, session: aiohttp.ClientSession, ssl_option: Any) -> None:
|
||||
"""使用 MaiBot WebUI token 登录,以便后续换取一次性 ws-token。"""
|
||||
verify_url = f"{self._server_url}/api/webui/auth/verify"
|
||||
payload = {"token": self._access_token}
|
||||
self.LOG.info(f"[{self.name}] 正在校验 MaiBot token: verify_url={verify_url}")
|
||||
|
||||
async with session.post(verify_url, json=payload, ssl=ssl_option) as response:
|
||||
response_text = await response.text()
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"MaiBot token 校验失败,HTTP {response.status}: {response_text[:200]}")
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"MaiBot token 校验响应不是合法 JSON: {response_text[:200]}") from exc
|
||||
|
||||
if not bool(response_data.get("valid")):
|
||||
raise RuntimeError(f"MaiBot token 无效: {response_data.get('message') or response_text[:200]}")
|
||||
|
||||
async def _fetch_ws_token(self, session: aiohttp.ClientSession, ssl_option: Any) -> str:
|
||||
"""通过已登录的 Cookie 换取一次性 WebSocket 临时 token。"""
|
||||
token_url = f"{self._server_url}/api/webui/ws-token"
|
||||
self.LOG.info(f"[{self.name}] 正在申请 MaiBot ws-token: url={token_url}")
|
||||
|
||||
async with session.get(token_url, ssl=ssl_option) as response:
|
||||
response_text = await response.text()
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"MaiBot ws-token 获取失败,HTTP {response.status}: {response_text[:200]}")
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"MaiBot ws-token 响应不是合法 JSON: {response_text[:200]}") from exc
|
||||
|
||||
if not bool(response_data.get("success")):
|
||||
raise RuntimeError(f"MaiBot ws-token 获取失败: {response_data.get('message') or response_text[:200]}")
|
||||
|
||||
ws_token = str(response_data.get("token", "") or "").strip()
|
||||
if not ws_token:
|
||||
raise RuntimeError("MaiBot ws-token 为空")
|
||||
return ws_token
|
||||
|
||||
def _build_ws_url(self, ws_token: str) -> str:
|
||||
"""根据 server_url 自动转换成统一 WebSocket 地址。"""
|
||||
if self._server_url.startswith("https://"):
|
||||
base_ws_url = "wss://" + self._server_url[len("https://"):]
|
||||
elif self._server_url.startswith("http://"):
|
||||
base_ws_url = "ws://" + self._server_url[len("http://"):]
|
||||
else:
|
||||
raise RuntimeError(f"不支持的 MaiBot server_url: {self._server_url}")
|
||||
return f"{base_ws_url}/ws?token={ws_token}"
|
||||
|
||||
async def _wait_for_call_ok(
|
||||
self,
|
||||
websocket: aiohttp.ClientWebSocketResponse,
|
||||
request_id: str,
|
||||
expected_session: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""等待某次 WebSocket call 的确认响应。"""
|
||||
deadline = time.time() + self._reply_timeout
|
||||
while time.time() < deadline:
|
||||
message = await self._receive_ws_json(websocket)
|
||||
|
||||
# 统一 WebSocket 的准备事件不参与业务判断,直接忽略。
|
||||
if message.get("domain") == "system" and message.get("event") == "ready":
|
||||
continue
|
||||
|
||||
if message.get("op") != "response":
|
||||
continue
|
||||
|
||||
if str(message.get("id") or "") != request_id:
|
||||
continue
|
||||
|
||||
if not bool(message.get("ok")):
|
||||
error_info = message.get("error") or {}
|
||||
raise RuntimeError(f"MaiBot 调用失败: {error_info}")
|
||||
|
||||
data = message.get("data") or {}
|
||||
if expected_session and str(data.get("session") or expected_session) != expected_session:
|
||||
self.LOG.warning(
|
||||
f"[{self.name}] 收到 session 不匹配的响应: expected={expected_session}, actual={data.get('session')}"
|
||||
)
|
||||
return message
|
||||
|
||||
raise TimeoutError(f"等待 MaiBot 请求确认超时: request_id={request_id}")
|
||||
|
||||
async def _wait_for_bot_message(
|
||||
self,
|
||||
websocket: aiohttp.ClientWebSocketResponse,
|
||||
expected_session: str,
|
||||
) -> str:
|
||||
"""等待目标会话真正的机器人消息事件。"""
|
||||
deadline = time.time() + self._reply_timeout
|
||||
while time.time() < deadline:
|
||||
message = await self._receive_ws_json(websocket)
|
||||
|
||||
if message.get("op") != "event":
|
||||
continue
|
||||
|
||||
if str(message.get("domain") or "") != "chat":
|
||||
continue
|
||||
|
||||
if str(message.get("session") or "") != expected_session:
|
||||
continue
|
||||
|
||||
event_name = str(message.get("event") or "").strip()
|
||||
data = message.get("data") or {}
|
||||
data_type = str(data.get("type") or "").strip()
|
||||
|
||||
# typing / history / system / user_message 都属于过程事件,继续等待即可。
|
||||
if event_name == "bot_message" and data_type == "bot_message":
|
||||
reply_text = str(data.get("content", "") or "").strip()
|
||||
if reply_text:
|
||||
return reply_text
|
||||
|
||||
if event_name == "error" or data_type == "error":
|
||||
raise RuntimeError(str(data.get("content") or "MaiBot 返回错误事件"))
|
||||
|
||||
raise TimeoutError(f"等待 MaiBot 回复超时: session={expected_session}")
|
||||
|
||||
async def _receive_ws_json(self, websocket: aiohttp.ClientWebSocketResponse) -> Dict[str, Any]:
|
||||
"""从 WebSocket 中读取一条 JSON 消息,并统一处理异常场景。"""
|
||||
message = await websocket.receive()
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
payload = json.loads(message.data)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"MaiBot WebSocket 返回了非法 JSON: {message.data[:200]}") from exc
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
raise RuntimeError(f"MaiBot WebSocket 返回了非对象 JSON: {payload}")
|
||||
|
||||
if message.type == aiohttp.WSMsgType.CLOSED:
|
||||
raise RuntimeError("MaiBot WebSocket 已关闭")
|
||||
|
||||
if message.type == aiohttp.WSMsgType.ERROR:
|
||||
raise RuntimeError(f"MaiBot WebSocket 出错: {websocket.exception()}")
|
||||
|
||||
raise RuntimeError(f"MaiBot WebSocket 返回了不支持的消息类型: {message.type}")
|
||||
Reference in New Issue
Block a user