新增 MaiBot 对话适配插件并补充 192.168.2.240 部署说明

This commit is contained in:
liuwei
2026-04-29 09:04:09 +08:00
parent ec29bc7551
commit d22e380c4e
5 changed files with 712 additions and 0 deletions

View 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` 再往“共享长连接 + 自动回复桥”方向升级。

View 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 直连链路。

View File

@@ -0,0 +1,6 @@
from .main import MaiBotAdapterPlugin
def get_plugin():
"""返回插件实例,供插件管理器按统一入口加载。"""
return MaiBotAdapterPlugin()

View 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

View 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}")