新增 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