feat(onebot): support main-port WebSocket transports and filter protocol events

This commit is contained in:
Hyu
2026-10-12 13:36:26 +08:00
parent 09a84f6642
commit 11ed8a2306
18 changed files with 752 additions and 136 deletions
+6 -5
View File
@@ -6,14 +6,15 @@ from __future__ import annotations
import argparse
import asyncio
import json
import os
import time
import websockets
async def run(port: int) -> None:
uri = f'ws://127.0.0.1:{port}/ws'
async def run(uri: str) -> None:
headers = {
'Authorization': 'Bearer ' + os.environ['ONEBOT_PROBE_TOKEN'],
'X-Self-ID': '900001',
'X-Client-Role': 'Universal',
'User-Agent': 'LangBot-E2E-OneBot/1.0',
@@ -27,7 +28,7 @@ async def run(port: int) -> None:
except OSError:
await asyncio.sleep(0.25)
if connection is None:
raise RuntimeError(f'OneBot reverse WebSocket did not open on port {port}')
raise RuntimeError('OneBot reverse WebSocket did not open')
actions: list[str] = []
event_deadline = time.monotonic() + 20
@@ -88,6 +89,6 @@ async def run(port: int) -> None:
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, required=True)
parser.add_argument('--url', required=True)
args = parser.parse_args()
asyncio.run(run(args.port))
asyncio.run(run(args.url))
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createServer } from "node:net";
import { randomBytes } from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
@@ -60,19 +60,6 @@ const result = {
evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"],
};
async function getFreePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await new Promise((resolve) => server.close(resolve));
if (!port) throw new Error("Could not allocate a temporary OneBot port.");
return port;
}
async function api(page, path, options = {}) {
return await page.evaluate(
async ({ baseUrl, path, options, token }) => {
@@ -96,7 +83,7 @@ async function api(page, path, options = {}) {
);
}
async function runProbe(port) {
async function runProbe(url, accessToken) {
const repo = await resolveLangBotRepo();
const python = join(repo, ".venv", "bin", "python");
const script = join(
@@ -107,8 +94,9 @@ async function runProbe(port) {
throw new Error(`LangBot virtualenv Python is missing: ${python}`);
}
return await new Promise((resolve, reject) => {
const child = spawn(python, [script, "--port", String(port)], {
const child = spawn(python, [script, "--url", url], {
cwd: repo,
env: { ...process.env, ONEBOT_PROBE_TOKEN: accessToken },
});
let stdout = "";
let stderr = "";
@@ -132,7 +120,7 @@ async function runProbe(port) {
try {
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
const port = await getFreePort();
const accessToken = randomBytes(32).toString("hex");
browser = await createBrowser(paths);
const { page } = browser;
@@ -190,9 +178,7 @@ try {
.getByText(/Configure Your Bot|配置机器人|ボットを設定/)
.first()
.waitFor({ timeout: 15_000 });
const portInput = page.getByRole("spinbutton").first();
await portInput.fill(String(port));
await portInput.press("Tab");
await page.getByLabel(/Access token|访问令牌|アクセストークン/i).fill(accessToken);
await page
.getByRole("button", {
name: /Save & Enable Bot|保存并启用|保存して有効化/,
@@ -270,7 +256,7 @@ try {
}
result.visible_signals.push("route-persisted");
result.runtime.onebot = await runProbe(port);
result.runtime.onebot = await runProbe(`${backendUrl.replace(/^http/, "ws").replace(/\/$/, "")}/bots/${botId}/onebot/v11/ws`, accessToken);
let routeStatus;
for (let attempt = 0; attempt < 30; attempt += 1) {
routeStatus = await api(
@@ -32,7 +32,7 @@ preconditions:
- "The LangBot virtualenv includes the websockets package used by the local OneBot probe."
steps:
- "Open Quick Start and choose Welcome new members with the OneBot v11 channel."
- "Create and enable a temporary OneBot Bot on an available local port."
- "Create and enable a temporary OneBot Bot using the main-port reverse WS URL and a generated access token."
- "Select Local Agent and confirm the editable welcome prompt is prefilled."
- "Choose QA Deterministic Runner and finish the wizard."
- "Connect a local OneBot reverse WebSocket and send a group_increase notice."
@@ -1,6 +1,7 @@
from __future__ import annotations
import quart
import quart_cors
import traceback
from .. import group
@@ -10,6 +11,35 @@ from .....utils import bounded_executor
@group.group_class('webhooks', '/bots')
class WebhookRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.quart_app.websocket(self.path + '/<bot_uuid>/onebot/v11/ws')
@quart_cors.cors_exempt
async def onebot_websocket(bot_uuid: str):
# Protocol clients omit Origin; bearer authentication protects this endpoint.
from .....platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
runtime = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
if runtime is None or not runtime.enable or not isinstance(runtime.adapter, AiocqhttpAdapter):
quart.abort(404)
adapter = runtime.adapter
socket = quart.websocket._get_current_object()
if adapter.config['connection_mode'] != 'reverse_ws':
quart.abort(409)
if not adapter.authenticate(socket.headers):
quart.abort(401)
if socket.headers.get('X-Client-Role', '').lower() != 'universal':
quart.abort(400)
self_id = socket.headers.get('X-Self-ID', '')
expected = str(adapter.config.get('self_id', '') or '')
if not self_id or (expected and self_id != expected):
quart.abort(400)
if adapter.bot.connection is not None:
quart.abort(409)
await socket.accept()
try:
await adapter.serve_connection(socket.send, socket.receive, socket.close, self_id)
except Exception:
await socket.close(1011)
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook(bot_uuid: str):
"""处理 bot webhook 回调(无子路径)"""
+2 -2
View File
@@ -52,13 +52,13 @@ class BotService:
return None
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
"""Whether the adapter manifest declares a generated webhook URL config item."""
"""Whether the manifest needs a generated public bot ingress URL."""
component = self._get_adapter_component(adapter_name)
if component is None:
return False
for config_item in component.spec.get('config', []):
if config_item.get('type') == 'webhook-url':
if config_item.get('type') == 'webhook-url' or config_item.get('name') == '__system.onebot_ws_url':
return True
return False
+2 -1
View File
@@ -2,7 +2,6 @@
OMNI_ADAPTER_NAMES = frozenset(
{
'aiocqhttp',
'telegram',
'discord',
'qqofficial',
@@ -20,6 +19,8 @@ OMNI_ADAPTER_NAMES = frozenset(
def canonical_adapter_name(name: str) -> str:
"""Accept previous built-in IDs without renaming legacy or custom adapters."""
if name in ('aiocqhttp-omni', 'aiocqhttp-eba'):
return 'onebot-v11'
if name.endswith('-eba') and name[:-4] in OMNI_ADAPTER_NAMES:
return f'{name[:-4]}-omni'
return name
@@ -2,6 +2,12 @@ from __future__ import annotations
import asyncio
import traceback
import hmac
from urllib.parse import urlsplit
from websockets.asyncio.client import connect
from langbot.pkg.core.task_boundary import create_detached_task
from langbot.pkg.platform.adapters.aiocqhttp.transport import OneBotClient, OneBotConnection
import typing
import aiocqhttp
@@ -20,7 +26,10 @@ from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedErro
class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
bot: typing.Any = pydantic.Field(exclude=True)
_runtime: typing.Any = pydantic.PrivateAttr(default=None)
_stopped: asyncio.Event = pydantic.PrivateAttr(default_factory=asyncio.Event)
_connection_task: typing.Any = pydantic.PrivateAttr(default=None)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
@@ -36,24 +45,64 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
run_config = dict(config)
config = dict(config)
config.setdefault('connection_mode', 'reverse_ws')
if not isinstance(config.get('access-token'), str) or not config['access-token'].strip():
raise ValueError('OneBot requires a non-empty access token')
if config['connection_mode'] not in ('reverse_ws', 'forward_ws'):
raise ValueError('Unsupported OneBot connection mode')
if config['connection_mode'] == 'forward_ws':
url = urlsplit(config.get('ws_url', ''))
if url.scheme not in ('ws', 'wss') or not url.hostname or url.username or url.password or url.fragment:
raise ValueError('OneBot ws_url must be a ws:// or wss:// endpoint without embedded credentials')
super().__init__(config=config, logger=logger, bot=OneBotClient(), bot_account_id='', listeners={})
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
def bind_runtime(self, runtime):
self._runtime = runtime
run_config['shutdown_trigger'] = shutdown_trigger_placeholder
access_token = run_config.pop('access-token', '') or None
bot = aiocqhttp.CQHttp(access_token=access_token)
async def _validate_connection(self):
if self._stopped.is_set() or self._runtime is None:
raise RuntimeError('OneBot runtime is inactive')
current = await self._runtime.ap.platform_mgr.resolve_public_bot(self._runtime.bot_entity.uuid)
if current is not self._runtime or not current.enable:
raise RuntimeError('OneBot runtime placement changed')
super().__init__(
config=run_config,
logger=logger,
bot=bot,
bot_account_id='',
listeners={},
def authenticate(self, headers):
token = str(self.config.get('access-token', ''))
supplied = headers.get('Authorization', '')
return bool(token) and hmac.compare_digest(supplied.encode(), ('Bearer ' + token).encode())
async def serve_connection(self, send, receive, close, self_id=''):
await self._validate_connection()
if self.bot.connection is not None:
raise RuntimeError('OneBot already has an active connection')
expected = str(self.config.get('self_id', '') or '')
if expected and self_id and expected != self_id:
raise ValueError('OneBot account mismatch')
connection = OneBotConnection(
send,
receive,
close,
self._receive_event,
self._validate_connection,
self_id=expected or self_id,
)
self._register_native_handlers()
self.bot.connection = connection
self.bot_account_id = expected or self_id
self._lookup.clear()
task = create_detached_task(connection.run(), workspace_uuid=self._runtime.workspace_uuid)
self._connection_task = task
try:
await task
finally:
await connection.close()
if self.bot.connection is connection:
self.bot.connection = None
self._connection_task = None
self._lookup.clear()
async def _receive_event(self, payload):
await self._handle_native_event(aiocqhttp.Event.from_payload(payload))
def get_supported_events(self) -> list[str]:
return [
@@ -121,33 +170,51 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
self.listeners.pop(event_type, None)
async def run_async(self):
await self.bot._server_app.run_task(**self.config)
if not self.config.get('access-token'):
raise ValueError('OneBot requires a non-empty access token')
if self.config['connection_mode'] == 'reverse_ws':
await self._stopped.wait()
return
delay = 1
while not self._stopped.is_set():
await self._validate_connection()
try:
async with connect(
self.config['ws_url'],
additional_headers={'Authorization': 'Bearer ' + self.config['access-token']},
open_timeout=10,
close_timeout=5,
ping_interval=20,
ping_timeout=20,
max_size=8 * 1024 * 1024,
proxy=None,
) as socket:
delay = 1
await self.serve_connection(socket.send, socket.recv, socket.close)
except asyncio.CancelledError:
raise
except Exception as exc:
await self.logger.warning(f'OneBot connection lost ({type(exc).__name__}); reconnecting')
try:
await asyncio.wait_for(self._stopped.wait(), timeout=delay)
except TimeoutError:
pass
delay = min(delay * 2, 30)
async def kill(self) -> bool:
self._stopped.set()
task = self._connection_task
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
self._lookup.clear()
return False
def _register_native_handlers(self):
@self.bot.on_message()
async def on_message(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_notice()
async def on_notice(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_request()
async def on_request(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_websocket_connection
async def on_websocket_connection(event: aiocqhttp.Event):
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
await self._dispatch_native_event(event)
return True
async def _handle_native_event(self, event: aiocqhttp.Event):
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
# Connection metadata belongs to the adapter, never business event routing.
if getattr(event, 'type', None) == 'meta_event':
return
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
return
try:
@@ -34,8 +34,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
if event_type == 'request':
return AiocqhttpEventConverter.request_to_eba(event)
if event_type == 'meta_event':
return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}')
return None
@staticmethod
@@ -90,7 +88,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
message_id=getattr(event, 'message_id', ''),
message_chain=message_chain,
sender=sender,
@@ -111,7 +109,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if notice_type in ('group_recall', 'friend_recall'):
return platform_events.MessageDeletedEvent(
type='message.deleted',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
message_id=getattr(event, 'message_id', ''),
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
chat_type=platform_entities.ChatType.GROUP
@@ -129,7 +127,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=group,
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
timestamp=float(getattr(event, 'time', 0) or 0),
@@ -137,7 +135,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
return platform_events.MemberJoinedEvent(
type='group.member_joined',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=group,
member=user,
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
@@ -151,7 +149,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=group,
operator=operator,
timestamp=float(getattr(event, 'time', 0) or 0),
@@ -159,7 +157,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
return platform_events.MemberLeftEvent(
type='group.member_left',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=group,
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
@@ -175,7 +173,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
kwargs: dict[str, typing.Any] = {
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
'adapter_name': 'aiocqhttp-omni',
'adapter_name': 'onebot-v11',
'group': group,
'operator': operator,
'timestamp': float(getattr(event, 'time', 0) or 0),
@@ -187,7 +185,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if duration > 0:
return platform_events.MemberBannedEvent(
type='group.member_banned',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=group,
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
operator=operator,
@@ -198,7 +196,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if notice_type == 'friend_add':
return platform_events.FriendAddedEvent(
type='friend.added',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
@@ -211,7 +209,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if request_type == 'friend':
return platform_events.FriendRequestReceivedEvent(
type='friend.request_received',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
request_id=getattr(event, 'flag', ''),
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
message=getattr(event, 'comment', None),
@@ -221,7 +219,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
group=AiocqhttpEventConverter.group_from_event(event),
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
request_id=getattr(event, 'flag', ''),
@@ -258,7 +256,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='aiocqhttp-omni',
adapter_name='onebot-v11',
action=action,
data={key: value for key, value in dict(event).items() if key not in {'message'}},
timestamp=float(getattr(event, 'time', 0) or 0),
@@ -2,7 +2,7 @@ apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: aiocqhttp-omni
name: onebot-v11
label:
en_US: OneBot v11
zh_Hans: OneBot v11
@@ -22,42 +22,59 @@ spec:
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
config:
- name: host
label:
en_US: Host
zh_Hans: 主机
zh_Hant: 主機
description:
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0
type: string
- name: connection_mode
label: {en_US: Connection mode, zh_Hans: 连接模式, zh_Hant: 連線模式, ja_JP: 接続方式}
type: select
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_Hans: 端口
zh_Hant: 連接埠
default: reverse_ws
options:
- name: reverse_ws
label: {en_US: 'Reverse WS — protocol endpoint connects to LangBot', zh_Hans: '反向 WS — 协议端连接 LangBot', zh_Hant: '反向 WS — 協定端連接 LangBot', ja_JP: '逆方向 WS — プロトコル側から LangBot に接続'}
- name: forward_ws
label: {en_US: 'Forward WS — LangBot connects to protocol endpoint', zh_Hans: '正向 WS — LangBot 连接协议端', zh_Hant: '正向 WS — LangBot 連接協定端', ja_JP: '順方向 WS — LangBot からプロトコル側に接続'}
description:
en_US: Reverse WebSocket listen port
zh_Hans: 反向 WebSocket 监听端口
zh_Hant: 反向 WebSocket 監聽連接埠
type: integer
required: true
default: 2280
- name: access-token
label:
en_US: Access Token
zh_Hans: 访问令牌
zh_Hant: 存取令牌
en_US: Reverse WS uses LangBot's main service port. Forward WS requires a protocol endpoint reachable from LangBot. HTTP is not supported.
zh_Hans: 反向 WS 由协议端连接 LangBot 主服务端口,无需额外监听端口;正向 WS 由 LangBot 连接可访问的协议端。不支持 HTTP。
zh_Hant: 反向 WS 由協定端連接 LangBot 主服務連接埠;正向 WS 由 LangBot 連接可存取的協定端。不支援 HTTP。
ja_JP: 逆方向 WS は LangBot のメインポートを使用。順方向 WS は LangBot から到達可能なプロトコル側に接続します。HTTP は非対応です。
- name: __system.onebot_ws_url
label: {en_US: Protocol endpoint connection URL, zh_Hans: 协议端连接地址, zh_Hant: 協定端連線位址, ja_JP: プロトコル側の接続先 URL}
description:
en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured
zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填
zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填
en_US: Save the bot, then copy this URL into the protocol endpoint's WebSocket client (Universal). Old host/port settings are ignored; update the protocol endpoint URL.
zh_Hans: 保存机器人后,将此地址填入协议端的 WebSocket 客户端(Universal)配置。旧 host/port 配置不再生效,请同步修改协议端地址。
zh_Hant: 儲存後將此位址填入協定端 WebSocket 用戶端(Universal)。舊 host/port 不再生效,請同步更新協定端位址。
ja_JP: 保存後、この URL をプロトコル側の WebSocket クライアント(Universal)に設定。旧 host/port は無効です。接続先を更新してください。
type: string
required: false
default: ""
default: ''
show_if: {field: connection_mode, operator: eq, value: reverse_ws}
- name: ws_url
label: {en_US: Protocol endpoint WebSocket URL, zh_Hans: 协议端 WebSocket 地址, zh_Hant: 協定端 WebSocket 位址, ja_JP: プロトコル側 WebSocket URL}
description:
en_US: LangBot connects to this ws:// or wss:// Universal endpoint. It must be reachable from LangBot.
zh_Hans: LangBot 将主动连接此 ws:// 或 wss:// 地址,使用协议端同时提供事件和 API 的 Universal 端点。请确保 LangBot 能访问它。
zh_Hant: LangBot 主動連接此 ws:// 或 wss:// Universal 位址,請確保可存取。
ja_JP: LangBot がこの ws:// または wss:// Universal URL に接続します。到達可能な URL を指定してください。
type: string
required: true
default: ''
show_if: {field: connection_mode, operator: eq, value: forward_ws}
- name: access-token
label: {en_US: Access token, zh_Hans: 访问令牌, zh_Hant: 存取權杖, ja_JP: アクセストークン}
description:
en_US: Required in both modes. Configure the same secret token on LangBot and the protocol endpoint. Sent using Authorization Bearer.
zh_Hans: 两种模式均必填。在 LangBot 与协议端设置相同的保密令牌,连接时使用 Authorization Bearer 验证。
zh_Hant: 兩種模式皆必填。在 LangBot 與協定端設定相同的保密權杖。
ja_JP: 両方式で必須。LangBot とプロトコル側に同じ秘密トークンを設定してください。
type: string
required: true
default: ''
- name: self_id
label: {en_US: Expected bot account (optional), zh_Hans: 预期机器人账号(可选), zh_Hant: 預期機器人帳號(選填), ja_JP: ボットアカウント(任意)}
description: {en_US: Reject connections or events from a different account., zh_Hans: 拒绝其他账号的连接或事件。, zh_Hant: 拒絕其他帳號的連線或事件。, ja_JP: 異なるアカウントの接続やイベントを拒否します。}
type: string
required: false
default: ''
supported_events:
- message.received
@@ -0,0 +1,125 @@
"""OneBot v11 duplex transport, independent of an HTTP server."""
from __future__ import annotations
import asyncio
import functools
import json
import uuid
from aiocqhttp.exceptions import ActionFailed, ApiNotAvailable
class OneBotConnection:
def __init__(self, send, receive, close, on_event, validate, *, self_id='', timeout=30):
self.send = send
self.receive = receive
self.close_socket = close
self.on_event = on_event
self.validate = validate
self.self_id = self_id
self.timeout = timeout
self.pending = {}
self.events = asyncio.Queue(maxsize=256)
self.closed = False
self.send_lock = asyncio.Lock()
async def call_action(self, action, **params):
await self.validate()
if self.closed:
raise ApiNotAvailable('OneBot connection closed')
if len(self.pending) >= 256:
raise ApiNotAvailable('Too many pending OneBot actions')
echo = uuid.uuid4().hex
future = asyncio.get_running_loop().create_future()
self.pending[echo] = future
try:
async with asyncio.timeout(self.timeout):
async with self.send_lock:
await self.validate()
await self.send(json.dumps({'action': action, 'params': params, 'echo': echo}))
result = await future
if result.get('status') not in ('ok', 'async') or result.get('retcode') not in (0, 1):
raise ActionFailed(result)
return result.get('data')
finally:
self.pending.pop(echo, None)
if not future.done():
future.cancel()
async def _read(self):
while True:
raw = await self.receive()
if len(raw) > 8 * 1024 * 1024:
raise ValueError('OneBot frame too large')
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError('OneBot frame must be an object')
await self.validate()
if 'post_type' in data:
account = str(data.get('self_id', ''))
if not account or (self.self_id and account != self.self_id):
raise ValueError('OneBot account mismatch')
self.self_id = account
# Never block responses behind an event that itself calls an API.
self.events.put_nowait(data)
else:
echo = data.get('echo')
future = self.pending.get(echo) if isinstance(echo, str) else None
if future is not None and not future.done():
future.set_result(data)
async def _consume(self):
while True:
data = await self.events.get()
await self.validate()
await self.on_event(data)
async def _watch_placement(self):
while True:
await asyncio.sleep(5)
await self.validate()
async def run(self):
tasks = [asyncio.create_task(coro()) for coro in (self._read, self._consume, self._watch_placement)]
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await self.close()
async def close(self):
if self.closed:
return
self.closed = True
for future in self.pending.values():
if not future.done():
future.set_exception(ApiNotAvailable('OneBot connection closed'))
self.pending.clear()
await self.close_socket()
class OneBotClient:
"""Keep the existing converter/API call surface without CQHttp's server."""
def __init__(self):
self.connection = None
async def call_action(self, action, **params):
connection = self.connection
if connection is None:
raise ApiNotAvailable('OneBot protocol endpoint is not connected')
return await connection.call_action(action, **params)
def __getattr__(self, action):
if action.startswith('_'):
raise AttributeError(action)
return functools.partial(self.call_action, action)
async def send(self, event, message, **kwargs):
params = {key: event[key] for key in ('message_type', 'user_id', 'group_id', 'discuss_id') if key in event}
return await self.call_action('send_msg', **(params | {'message': message} | kwargs))
+3
View File
@@ -2357,6 +2357,9 @@ class PlatformManager:
execution_context=execution_context,
)
if hasattr(adapter_inst, 'bind_runtime'):
adapter_inst.bind_runtime(runtime_bot)
await runtime_bot.initialize()
bot_key = self._runtime_bot_key(runtime_bot)
+13 -5
View File
@@ -7,6 +7,7 @@ import json
import time
from collections import Counter
from pathlib import Path
from types import SimpleNamespace
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
@@ -36,9 +37,8 @@ def dump_event(event: platform_events.Event) -> dict:
async def main():
parser = argparse.ArgumentParser(description='Live OneBot v11 / aiocqhttp EBA probe for Matcha or a real endpoint.')
parser.add_argument('--host', default='127.0.0.1')
parser.add_argument('--port', type=int, default=2280)
parser.add_argument('--access-token', default='')
parser.add_argument('--ws-url', required=True, help='Protocol endpoint Universal WebSocket URL')
parser.add_argument('--access-token', required=True)
parser.add_argument('--timeout', type=int, default=120)
parser.add_argument('--target-type', choices=['private', 'group'], default=None)
parser.add_argument('--target-id', default=None)
@@ -54,10 +54,18 @@ async def main():
out_fp = out_path.open('a', encoding='utf-8')
adapter = AiocqhttpAdapter(
{'host': args.host, 'port': args.port, 'access-token': args.access_token},
{'connection_mode': 'forward_ws', 'ws_url': args.ws_url, 'access-token': args.access_token},
ProbeLogger(),
)
# This standalone converter probe uses a single explicit local runtime.
runtime = SimpleNamespace(enable=True, workspace_uuid='probe', bot_entity=SimpleNamespace(uuid='probe'))
async def resolve_probe(_):
return runtime
runtime.ap = SimpleNamespace(platform_mgr=SimpleNamespace(resolve_public_bot=resolve_probe))
adapter.bind_runtime(runtime)
observed: list[platform_events.Event] = []
first_message = asyncio.Event()
@@ -79,7 +87,7 @@ async def main():
return None
task = asyncio.create_task(adapter.run_async())
print(f'Listening on ws://{args.host}:{args.port}/ws/ . Trigger events from Matcha now.')
print('Connecting to the protocol endpoint. Trigger events now.')
api_results: dict[str, str] = {}
try:
@@ -34,7 +34,7 @@ class DummyLogger(AbstractEventLogger):
def make_adapter() -> AiocqhttpAdapter:
return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': ''}, DummyLogger())
return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': 'test-secret'}, DummyLogger())
def manifest() -> dict:
@@ -48,13 +48,40 @@ def manifest() -> dict:
/ 'aiocqhttp'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
def onebot_event(payload: dict) -> aiocqhttp.Event:
return aiocqhttp.Event.from_payload(payload)
@pytest.mark.parametrize('meta_type', ['heartbeat', 'lifecycle', 'future_transport_metadata'])
async def test_protocol_metadata_never_reaches_business_listeners(meta_type):
adapter = make_adapter()
listener = AsyncMock()
adapter.register_listener(platform_events.EBAEvent, listener)
payload = {'post_type': 'meta_event', 'meta_event_type': meta_type, 'self_id': 42, 'time': 1700000000}
assert await AiocqhttpEventConverter.target2yiri(onebot_event(payload)) is None
await adapter._receive_event(payload)
listener.assert_not_awaited()
assert adapter.bot_account_id == '42'
# Genuine platform-specific user interactions still reach business routing.
await adapter._receive_event(
{
'post_type': 'notice',
'notice_type': 'notify',
'sub_type': 'poke',
'self_id': 42,
'time': 1700000001,
'user_id': 10001,
'target_id': 42,
}
)
listener.assert_awaited_once()
assert listener.await_args.args[0].type == 'platform.specific'
def test_aiocqhttp_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
@@ -255,7 +282,7 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages():
assert isinstance(private_event, platform_events.MessageReceivedEvent)
assert private_event.type == 'message.received'
assert private_event.adapter_name == 'aiocqhttp-omni'
assert private_event.adapter_name == 'onebot-v11'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 10001
assert private_event.sender.nickname == 'Alice'
@@ -243,7 +243,8 @@ async def test_reload_stops_and_drops_existing_platform_runtimes():
@pytest.mark.asyncio
@pytest.mark.parametrize(
('saved_adapter', 'registered_adapter'),
[('probe', 'probe')] + [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)],
[('probe', 'probe'), ('aiocqhttp-eba', 'onebot-v11'), ('aiocqhttp-omni', 'onebot-v11')]
+ [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)],
)
async def test_cloud_startup_reuses_validated_platform_binding(saved_adapter, registered_adapter):
class TenantUow:
@@ -13,13 +13,17 @@ from langbot.pkg.platform.botmgr import PlatformManager
@pytest.mark.asyncio
@pytest.mark.parametrize('name', sorted(OMNI_ADAPTER_NAMES))
async def test_saved_adapter_ids_are_normalized_across_bot_api(name):
saved = {'uuid': 'bot', 'adapter': f'{name}-eba', 'adapter_config': {}}
@pytest.mark.parametrize(
'saved_name,public_name',
[(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)]
+ [('aiocqhttp-eba', 'onebot-v11'), ('aiocqhttp-omni', 'onebot-v11'), ('onebot-v11', 'onebot-v11')],
)
async def test_saved_adapter_ids_are_normalized_across_bot_api(saved_name, public_name):
saved = {'uuid': 'bot', 'adapter': saved_name, 'adapter_config': {}}
component = SimpleNamespace(
metadata=SimpleNamespace(name=f'{name}-omni'),
metadata=SimpleNamespace(name=public_name),
spec={'config': [{'type': 'webhook-url'}]},
to_plain_dict=lambda: {'metadata': {'name': f'{name}-omni'}},
to_plain_dict=lambda: {'metadata': {'name': public_name}},
)
result = SimpleNamespace(all=lambda: [saved], first=lambda: saved)
application = SimpleNamespace(
@@ -30,18 +34,20 @@ async def test_saved_adapter_ids_are_normalized_across_bot_api(name):
),
)
service = BotService(application)
assert (await service.get_bots('workspace'))[0]['adapter'] == f'{name}-omni'
assert (await service.get_bot('workspace', 'bot'))['adapter'] == f'{name}-omni'
assert (await service.get_bots('workspace'))[0]['adapter'] == public_name
assert (await service.get_bot('workspace', 'bot'))['adapter'] == public_name
assert service._adapter_declares_webhook_url(saved['adapter'])
assert (await service._prepare_bot_data('workspace', saved, include_uuid=True))['adapter'] == f'{name}-omni'
assert saved['adapter'] == f'{name}-eba'
assert (await service._prepare_bot_data('workspace', saved, include_uuid=True))['adapter'] == public_name
assert saved['adapter'] == saved_name
manager = PlatformManager(application)
manager.adapter_components = [component]
assert manager.get_available_adapter_manifest_by_name(saved['adapter']) is component
assert manager.get_available_adapter_info_by_name(saved['adapter'])['metadata']['name'] == f'{name}-omni'
assert manager.get_available_adapter_info_by_name(saved['adapter'])['metadata']['name'] == public_name
@pytest.mark.parametrize('name', ['telegram', 'custom-eba', 'my-telegram-eba', 'telegram-omni', 'websocket'])
@pytest.mark.parametrize(
'name', ['aiocqhttp', 'onebot-v11', 'telegram', 'custom-eba', 'my-telegram-eba', 'telegram-omni', 'websocket']
)
def test_legacy_and_custom_adapter_ids_are_unchanged(name):
assert canonical_adapter_name(name) == name
@@ -49,14 +55,23 @@ def test_legacy_and_custom_adapter_ids_are_unchanged(name):
def test_discovered_manifests_expose_only_omni_names():
root = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/platform/adapters'
for name in OMNI_ADAPTER_NAMES:
metadata = yaml.safe_load((root / name / 'manifest.yaml').read_text())['metadata']
metadata = yaml.safe_load((root / name / 'manifest.yaml').read_text(encoding='utf-8'))['metadata']
assert metadata['name'] == f'{name}-omni'
@pytest.mark.asyncio
@pytest.mark.parametrize('disabled_name', ['telegram-eba', 'telegram-omni'])
async def test_old_disabled_adapter_config_still_hides_omni(disabled_name):
component = SimpleNamespace(metadata=SimpleNamespace(name='telegram-omni'))
@pytest.mark.parametrize(
'disabled_name,public_name',
[
('telegram-eba', 'telegram-omni'),
('telegram-omni', 'telegram-omni'),
('aiocqhttp-eba', 'onebot-v11'),
('aiocqhttp-omni', 'onebot-v11'),
('onebot-v11', 'onebot-v11'),
],
)
async def test_old_disabled_adapter_config_still_hides_omni(disabled_name, public_name):
component = SimpleNamespace(metadata=SimpleNamespace(name=public_name))
application = SimpleNamespace(
storage_mgr=SimpleNamespace(storage_provider=SimpleNamespace(delete_dir_recursive=AsyncMock())),
instance_config=SimpleNamespace(data={'system': {'disabled_adapters': [disabled_name]}}),
@@ -0,0 +1,329 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from quart import Quart
import quart_cors
from quart.testing.connections import WebsocketResponseError
from aiocqhttp.exceptions import ActionFailed, ApiNotAvailable
from websockets.asyncio.server import serve
from langbot.pkg.platform.adapters.aiocqhttp.transport import OneBotConnection
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
from tests.unit_tests.platform.test_aiocqhttp_eba_adapter import make_adapter
def bind(adapter):
runtime = SimpleNamespace(
adapter=adapter, enable=True, workspace_uuid='workspace-a', bot_entity=SimpleNamespace(uuid='bot-a')
)
ap = SimpleNamespace(platform_mgr=SimpleNamespace(resolve_public_bot=AsyncMock(return_value=runtime)))
runtime.ap = ap
adapter.bind_runtime(runtime)
adapter.config['access-token'] = 'secret'
return runtime, ap
async def test_echo_out_of_order_and_event_api_does_not_deadlock():
incoming, outgoing = asyncio.Queue(), asyncio.Queue()
handled = asyncio.Event()
async def event(data):
assert await connection.call_action('get_group_info', group_id=1) == {'name': 'group'}
handled.set()
connection = OneBotConnection(outgoing.put, incoming.get, AsyncMock(), event, AsyncMock())
task = asyncio.create_task(connection.run())
first = asyncio.create_task(connection.call_action('first'))
second = asyncio.create_task(connection.call_action('second'))
a, b = json.loads(await outgoing.get()), json.loads(await outgoing.get())
for request in (b, a):
await incoming.put(
json.dumps({'echo': request['echo'], 'status': 'ok', 'retcode': 0, 'data': request['action']})
)
assert await first == 'first'
assert await second == 'second'
await incoming.put(json.dumps({'post_type': 'message', 'self_id': 42}))
request = json.loads(await outgoing.get())
await incoming.put(json.dumps({'echo': request['echo'], 'status': 'ok', 'retcode': 0, 'data': {'name': 'group'}}))
await asyncio.wait_for(handled.wait(), 1)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert not connection.pending
async def test_timeout_disconnect_and_failure():
outgoing = asyncio.Queue()
connection = OneBotConnection(outgoing.put, AsyncMock(), AsyncMock(), AsyncMock(), AsyncMock(), timeout=0.01)
with pytest.raises(TimeoutError):
await connection.call_action('timeout')
assert not connection.pending
await outgoing.get()
connection.timeout = 2
action = asyncio.create_task(connection.call_action('failure'))
request = json.loads(await outgoing.get())
connection.pending[request['echo']].set_result({'status': 'failed', 'retcode': 100})
with pytest.raises(ActionFailed):
await action
action = asyncio.create_task(connection.call_action('disconnect'))
await outgoing.get()
await connection.close()
with pytest.raises(ApiNotAvailable):
await action
@pytest.mark.parametrize('payload', ['[]', 'broken', '{"post_type":"message","self_id":99}'])
async def test_invalid_frames_and_account_mismatch(payload):
incoming = asyncio.Queue()
await incoming.put(payload)
connection = OneBotConnection(AsyncMock(), incoming.get, AsyncMock(), AsyncMock(), AsyncMock(), self_id='42')
with pytest.raises(ValueError):
await connection.run()
assert connection.closed
async def test_main_port_reverse_ws_auth_and_placement():
adapter = make_adapter()
runtime, ap = bind(adapter)
app = Quart(__name__)
quart_cors.cors(app, allow_origin='*')
await WebhookRouterGroup(ap, app).initialize()
client = app.test_client()
headers = {'Authorization': 'Bearer secret', 'X-Self-ID': '42', 'X-Client-Role': 'Universal'}
for changes, status in [({'Authorization': 'Bearer wrong'}, 401), ({'X-Client-Role': 'Event'}, 400)]:
with pytest.raises(WebsocketResponseError) as error:
async with client.websocket('/bots/bot-a/onebot/v11/ws', headers=headers | changes):
await asyncio.sleep(0.01)
assert error.value.response.status_code == status
async with client.websocket('/bots/bot-a/onebot/v11/ws', headers=headers | {'X-Workspace-Id': 'attacker'}) as ws:
await ws.send(json.dumps({'post_type': 'meta_event', 'meta_event_type': 'heartbeat', 'self_id': 42}))
for _ in range(100):
if adapter.bot.connection:
break
await asyncio.sleep(0.001)
action = asyncio.create_task(adapter.bot.get_login_info())
request = json.loads(await ws.receive())
await ws.send(json.dumps({'echo': request['echo'], 'status': 'ok', 'retcode': 0, 'data': {'user_id': 42}}))
assert await asyncio.wait_for(action, 1) == {'user_id': 42}
ap.platform_mgr.resolve_public_bot.return_value = None
with pytest.raises(RuntimeError, match='placement'):
await adapter.bot.get_login_info()
assert runtime.workspace_uuid == 'workspace-a'
await adapter.kill()
async def test_forward_ws_round_trip_and_shutdown():
connected = asyncio.Event()
async def endpoint(socket):
assert socket.request.headers['Authorization'] == 'Bearer secret'
await socket.send(json.dumps({'post_type': 'meta_event', 'meta_event_type': 'heartbeat', 'self_id': 42}))
connected.set()
async for raw in socket:
request = json.loads(raw)
await socket.send(
json.dumps({'echo': request['echo'], 'status': 'ok', 'retcode': 0, 'data': {'message_id': 7}})
)
async with serve(endpoint, '127.0.0.1', 0) as server:
adapter = make_adapter()
bind(adapter)
adapter.config.update(
connection_mode='forward_ws', ws_url=f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}'
)
task = asyncio.create_task(adapter.run_async())
await asyncio.wait_for(connected.wait(), 2)
for _ in range(100):
if adapter.bot.connection:
break
await asyncio.sleep(0.001)
assert await adapter.bot.send_private_msg(user_id=1, message='hello') == {'message_id': 7}
await adapter.kill()
await asyncio.gather(task, return_exceptions=True)
assert adapter.bot.connection is None
async def test_idle_placement_revocation_closes_connection():
adapter = make_adapter()
_, ap = bind(adapter)
closed = asyncio.Event()
async def close():
closed.set()
incoming = asyncio.Queue()
task = asyncio.create_task(adapter.serve_connection(AsyncMock(), incoming.get, close, '42'))
for _ in range(100):
if adapter.bot.connection:
break
await asyncio.sleep(0.001)
with pytest.raises(RuntimeError, match='active connection'):
await adapter.serve_connection(AsyncMock(), incoming.get, close, '42')
ap.platform_mgr.resolve_public_bot.return_value = None
await asyncio.wait_for(closed.wait(), 7)
with pytest.raises(RuntimeError, match='placement'):
await task
assert adapter.bot.connection is None
async def test_forward_reconnects_after_endpoint_disconnect():
count = 0
reconnected = asyncio.Event()
async def endpoint(socket):
nonlocal count
count += 1
if count == 1:
await socket.close()
else:
reconnected.set()
await socket.wait_closed()
async with serve(endpoint, '127.0.0.1', 0) as server:
adapter = make_adapter()
bind(adapter)
adapter.config.update(
connection_mode='forward_ws', ws_url=f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}'
)
task = asyncio.create_task(adapter.run_async())
await asyncio.wait_for(reconnected.wait(), 4)
await adapter.kill()
await asyncio.gather(task, return_exceptions=True)
assert count == 2
def test_legacy_manifest_keeps_independent_adapter():
from langbot.pkg.discover.engine import ComponentDiscoveryEngine
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter as NewAdapter
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter as LegacyAdapter
engine = ComponentDiscoveryEngine(SimpleNamespace())
legacy = engine.load_component_manifest('pkg/platform/sources/aiocqhttp.yaml')
modern = engine.load_component_manifest('pkg/platform/adapters/aiocqhttp/manifest.yaml')
assert legacy.get_python_component_class() is LegacyAdapter
assert modern.get_python_component_class() is NewAdapter
assert modern.metadata.name == 'onebot-v11'
assert LegacyAdapter is not NewAdapter
fields = {item['name']: item for item in legacy.spec['config']}
assert fields['port']['default'] == 2280
assert fields['access-token']['required'] is False
assert 'connection_mode' not in fields
def test_configuration_rejects_invalid_credentials_and_url():
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
from tests.unit_tests.platform.test_aiocqhttp_eba_adapter import DummyLogger
with pytest.raises(ValueError, match='token'):
AiocqhttpAdapter({}, DummyLogger())
for url in ('https://example.com', 'ws://user:[email protected]', ''):
with pytest.raises(ValueError, match='ws_url'):
AiocqhttpAdapter({'access-token': 'secret', 'connection_mode': 'forward_ws', 'ws_url': url}, DummyLogger())
@pytest.mark.parametrize('mode', ['reverse_ws', 'forward_ws', 'legacy'])
async def test_message_event_to_reply_roundtrip(mode):
"""Exercise real converters and callbacks against a simulated protocol peer."""
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter as LegacyAdapter
from langbot_plugin.api.entities.builtin.platform import events, message
from tests.unit_tests.platform.test_aiocqhttp_eba_adapter import DummyLogger
adapter = LegacyAdapter({'access-token': 'secret'}, DummyLogger()) if mode == 'legacy' else make_adapter()
finished = asyncio.Event()
received = []
async def callback(event, source):
received.append(str(event.message_chain))
await source.send_message('person', '10001', message.MessageChain([message.Plain(text='roundtrip reply')]))
finished.set()
adapter.register_listener(events.FriendMessage, callback)
async def protocol_peer(send, receive):
await send(
json.dumps(
{
'time': 1700000000,
'self_id': 42,
'post_type': 'message',
'message_type': 'private',
'sub_type': 'friend',
'message_id': 9,
'user_id': 10001,
'message': [{'type': 'text', 'data': {'text': 'roundtrip incoming'}}],
'raw_message': 'roundtrip incoming',
'font': 0,
'sender': {'user_id': 10001, 'nickname': 'Protocol test'},
}
)
)
while not finished.is_set():
request = json.loads(await asyncio.wait_for(receive(), 3))
action = request['action']
if action == 'send_private_msg':
assert str(request['params']['user_id']) == '10001'
assert 'roundtrip reply' in json.dumps(request['params']['message'])
data = {'message_id': 10}
elif action == 'get_stranger_info':
data = {'user_id': 10001, 'nickname': 'Protocol test'}
else:
raise AssertionError(f'Unexpected action: {action}')
await send(json.dumps({'echo': request['echo'], 'status': 'ok', 'retcode': 0, 'data': data}))
if action == 'send_private_msg':
await asyncio.wait_for(finished.wait(), 3)
if mode == 'forward_ws':
bind(adapter)
async def endpoint(ws):
assert ws.request.headers['Authorization'] == 'Bearer secret'
await protocol_peer(ws.send, ws.recv)
async with serve(endpoint, '127.0.0.1', 0) as server:
adapter.config.update(connection_mode=mode, ws_url=f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}')
task = asyncio.create_task(adapter.run_async())
try:
await asyncio.wait_for(finished.wait(), 5)
finally:
await adapter.kill()
await asyncio.gather(task, return_exceptions=True)
else:
if mode == 'legacy':
app, path = adapter.bot._server_app, '/ws/'
else:
_, ap = bind(adapter)
app, path = Quart(__name__), '/bots/bot-a/onebot/v11/ws'
await WebhookRouterGroup(ap, app).initialize()
headers = {'Authorization': 'Bearer secret', 'X-Self-ID': '42', 'X-Client-Role': 'Universal'}
async with app.test_client().websocket(path, headers=headers) as ws:
await protocol_peer(ws.send, ws.receive)
await adapter.kill()
assert received == ['roundtrip incoming']
@pytest.mark.parametrize(
'name,path',
[
('aiocqhttp', 'pkg/platform/sources/aiocqhttp.yaml'),
('onebot-v11', 'pkg/platform/adapters/aiocqhttp/manifest.yaml'),
('aiocqhttp-omni', 'pkg/platform/adapters/aiocqhttp/manifest.yaml'),
('aiocqhttp-eba', 'pkg/platform/adapters/aiocqhttp/manifest.yaml'),
],
)
async def test_public_url_only_available_for_new_onebot_adapter(name, path):
from langbot.pkg.discover.engine import ComponentDiscoveryEngine
from langbot.pkg.api.http.service.bot import BotService
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'webhook_prefix': 'https://bot.example.com'}}),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
)
ap.discover = ComponentDiscoveryEngine(ap)
ap.discover.load_component_manifest(path)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'bot-a', 'adapter': name, 'adapter_config': {}})
result = await service.get_runtime_bot_info('workspace-a', 'bot-a')
expected = 'https://bot.example.com/bots/bot-a' if name != 'aiocqhttp' else None
assert result['adapter_runtime_values']['webhook_full_url'] == expected
@@ -288,6 +288,10 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
() => ({
bot_uuid: initBotId,
webhook_url: webhookUrl,
onebot_ws_url: webhookUrl
? webhookUrl.replace(/^http/, 'ws').replace(/\/$/, '') +
'/onebot/v11/ws'
: '',
extra_webhook_url: extraWebhookUrl,
adapter_config: { title: embedTitle },
outbound_ips: systemInfo.outbound_ips,
+4
View File
@@ -1823,6 +1823,10 @@ function StepBotConfig({
systemContext={{
is_wizard: true,
webhook_url: webhookUrl,
onebot_ws_url: webhookUrl
? webhookUrl.replace(/^http/, 'ws').replace(/\/$/, '') +
'/onebot/v11/ws'
: '',
extra_webhook_url: extraWebhookUrl,
outbound_ips: systemInfo.outbound_ips,
}}