mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-25 06:54:19 +00:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d02e1a14a3 | |||
| cd6a39d3a2 | |||
| ed3598f8ac | |||
| d7b97741e3 | |||
| bf61547083 | |||
| 59b2a7cd51 | |||
| a43978ff24 | |||
| e3417dd20b | |||
| 8d9d22a873 | |||
| a332583750 | |||
| 55b098bf39 | |||
| 2982e7c553 | |||
| e1e14e9269 | |||
| 1c4713138f | |||
| dbb39663bc | |||
| 1c128a1524 | |||
| 8ad1203fd5 | |||
| 144bec371c | |||
| 74a18191dd | |||
| a15c98eb06 | |||
| cbe17cde6c | |||
| 876e8bf804 | |||
| b3848c9d05 | |||
| 85743cc75f | |||
| c689b10c0d | |||
| 812b1fff4c | |||
| 9daf22d661 | |||
| 42a2c70b14 | |||
| 64ed6d994b | |||
| 2ff854f79a | |||
| 52c096ea4c | |||
| eda80030b5 | |||
| dfbd176e42 | |||
| 6ddd24ae68 | |||
| a2cdbb621b | |||
| b92d54254b | |||
| 4ceb4dce0f | |||
| 7f174a19d3 | |||
| b0b9221495 | |||
| f4b3b87d7a | |||
| 638a322368 | |||
| 1f67ff2e8d | |||
| b68ff1956c | |||
| 7e5d74a1ad | |||
| 8a42fd8b21 | |||
| 4b9aa20985 | |||
| 7328881e6f | |||
| 197e117900 | |||
| 417b83d3aa | |||
| 950da65797 | |||
| 3ed35593e9 | |||
| 63bdee22b4 | |||
| c55db54fd2 | |||
| 57f2e85388 | |||
| 503d29ffed | |||
| 05f370ca49 | |||
| c7e8eb1214 | |||
| 5c182c0f29 | |||
| e4a471af18 | |||
| dfcf9d10e4 | |||
| eb475245ab | |||
| d1b7d56392 | |||
| 9f23f4c572 |
@@ -52,6 +52,15 @@ RUN apt-get update \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||
# Install Node.js LTS so the sandbox (nsjail/Docker box) can run npx-based
|
||||
# stdio MCP servers. node/npx land in /usr/bin, which is on the nsjail
|
||||
# read-only mount whitelist (_READONLY_SYSTEM_MOUNTS), so they are bound
|
||||
# into the sandbox chroot automatically. Without node, any npx-launched
|
||||
# MCP server exits with return_code=127 (command not found).
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh \
|
||||
&& bash /tmp/nodesource_setup.sh \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -f /tmp/nodesource_setup.sh \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& apt-get purge -y --auto-remove curl gnupg \
|
||||
|
||||
@@ -55,6 +55,12 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
|
||||
---
|
||||
|
||||
## 😎 Stay Updated
|
||||
|
||||
Click the Star and Watch buttons in the top-right corner of the repository to get the latest updates.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
### ☁️ LangBot Cloud (Recommended)
|
||||
@@ -74,7 +80,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### One-Click Cloud Deploy
|
||||
|
||||
+7
-1
@@ -55,6 +55,12 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
|
||||
---
|
||||
|
||||
## 😎 保持更新
|
||||
|
||||
点击[仓库首页](https://github.com/langbot-app/LangBot)右上角 Star 和 Watch 按钮,获取最新动态。
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### ☁️ LangBot Cloud(推荐)
|
||||
@@ -74,7 +80,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### 一键云部署
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
|
||||
---
|
||||
|
||||
## 😎 Manténgase Actualizado
|
||||
|
||||
Haga clic en los botones Star y Watch en la esquina superior derecha del repositorio para obtener las últimas actualizaciones.
|
||||
|
||||

|
||||
|
||||
## Inicio Rápido
|
||||
|
||||
### ☁️ LangBot Cloud (Recomendado)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### Despliegue en la Nube con un Clic
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
|
||||
---
|
||||
|
||||
## 😎 Restez à Jour
|
||||
|
||||
Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt pour obtenir les dernières mises à jour.
|
||||
|
||||

|
||||
|
||||
## Démarrage Rapide
|
||||
|
||||
### ☁️ LangBot Cloud (Recommandé)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### Déploiement Cloud en un Clic
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
|
||||
---
|
||||
|
||||
## 😎 最新情報を入手
|
||||
|
||||
リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。
|
||||
|
||||

|
||||
|
||||
## クイックスタート
|
||||
|
||||
### ☁️ LangBot Cloud(推奨)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### ワンクリッククラウドデプロイ
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
|
||||
---
|
||||
|
||||
## 😎 최신 정보 받기
|
||||
|
||||
리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요.
|
||||
|
||||

|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### ☁️ LangBot Cloud (추천)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### 원클릭 클라우드 배포
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot — это **платформа с открытым исходным к
|
||||
|
||||
---
|
||||
|
||||
## 😎 Оставайтесь в курсе
|
||||
|
||||
Нажмите кнопки Star и Watch в правом верхнем углу репозитория, чтобы получать последние обновления.
|
||||
|
||||

|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### ☁️ LangBot Cloud (Рекомендуется)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### Облачное развертывание одним кликом
|
||||
|
||||
+7
-1
@@ -56,6 +56,12 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
|
||||
---
|
||||
|
||||
## 😎 保持更新
|
||||
|
||||
點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。
|
||||
|
||||

|
||||
|
||||
## 快速開始
|
||||
|
||||
### ☁️ LangBot Cloud(推薦)
|
||||
@@ -75,7 +81,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### 一鍵雲端部署
|
||||
|
||||
+7
-1
@@ -54,6 +54,12 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
|
||||
---
|
||||
|
||||
## 😎 Cập nhật Mới nhất
|
||||
|
||||
Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu trữ để nhận các bản cập nhật mới nhất.
|
||||
|
||||

|
||||
|
||||
## Bắt đầu nhanh
|
||||
|
||||
### ☁️ LangBot Cloud (Khuyên dùng)
|
||||
@@ -73,7 +79,7 @@ uvx langbot
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
### Triển khai đám mây một cú nhấp
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
# HTTP Bot Adapter — Design Document
|
||||
|
||||
> Status: **Implemented** · Branch: `feat/http-bot-adapter` · Author: LangBot core
|
||||
>
|
||||
> A first-class, **standalone** message-platform adapter (`http_bot`) that lets
|
||||
> any external system (e.g. LangBot Space ticketing, an internal back-office, a
|
||||
> CRM, a custom web app) talk to a LangBot pipeline over plain HTTP — **inbound**
|
||||
> by POSTing messages in, **outbound** by receiving replies on a callback URL —
|
||||
> with full support for the pipeline's native N→1 aggregation and 1→M
|
||||
> multi-reply semantics, and **without** holding a long-lived WebSocket
|
||||
> connection.
|
||||
>
|
||||
> **Shipped in this branch:**
|
||||
> - `src/langbot/pkg/platform/sources/http_bot.yaml` — adapter manifest (auto-discovered)
|
||||
> - `src/langbot/pkg/platform/sources/http_bot.py` — `HttpBotAdapter`
|
||||
> - `src/langbot/pkg/platform/sources/http_bot_signing.py` — HMAC helpers
|
||||
> - `src/langbot/pkg/platform/sources/http_bot.svg` — icon
|
||||
> - `docs/platforms/http-bot.md` — integration guide
|
||||
> - `docs/http-bot-openapi.json` — machine-readable contract
|
||||
> - `examples/http-bot/` — Python + TypeScript reference clients
|
||||
>
|
||||
> **Final decisions (resolving the original open questions):**
|
||||
> 1. Callback URL is **config-only** — never accepted per-message (SSRF closed).
|
||||
> 2. **Session reset is provided** — `POST /bots/<uuid>/reset` keyed by `session_id`.
|
||||
> 3. Reference **clients are provided** — `examples/http-bot/client.py` + `client.ts`.
|
||||
> 4. **Sync convenience mode is included** — `POST /bots/<uuid>/sync` (opt-in, lossy).
|
||||
|
||||
---
|
||||
|
||||
## 1. Background & Motivation
|
||||
|
||||
### 1.1 The concrete need
|
||||
|
||||
LangBot Space wants to use a LangBot pipeline as the brain for **ticket
|
||||
handling**. The integration is **server-to-server**: Space's backend pushes a
|
||||
user's ticket messages into LangBot and renders LangBot's replies back into the
|
||||
ticket thread.
|
||||
|
||||
This interaction is **not** request/response shaped:
|
||||
|
||||
- **N → 1**: a user may fire several messages in a row ("the app crashed" …
|
||||
"when I click export" … "here's a screenshot"). The pipeline's
|
||||
**message aggregation** feature should debounce and merge these into one turn.
|
||||
- **1 → N**: a single turn may yield **multiple** outbound messages — a tool/
|
||||
function call narrating progress, a plugin emitting several cards, a streamed
|
||||
answer split into chunks.
|
||||
|
||||
### 1.2 Why the existing options don't fit
|
||||
|
||||
LangBot today exposes exactly one externally-reachable way to drive a pipeline
|
||||
that is **not** tied to a specific IM vendor: the **WebSocket** path
|
||||
(`/api/v1/pipelines/<uuid>/ws/connect` for dashboard debug, and
|
||||
`/api/v1/embed/<bot_uuid>/ws/connect` for the embeddable web widget).
|
||||
|
||||
For a server-to-server integration the WebSocket path has real friction:
|
||||
|
||||
| Problem | Detail |
|
||||
|---|---|
|
||||
| Long-lived connection | Caller must maintain a socket, heartbeats, and reconnect logic for what is fundamentally a fire-and-collect workload. |
|
||||
| Session identity | Inbound messages are keyed by the transient `connection_id` (`websocket_{connection_id}`); the caller **cannot supply a stable, business-meaningful session id** (e.g. a ticket number). Multi-ticket isolation is not expressible. |
|
||||
| Auth mismatch | The debug socket is gated by the **dashboard JWT** (must not be handed to an external service); the embed socket is gated by **Cloudflare Turnstile** (a *browser* human-check that a backend cannot satisfy). Neither is a server-to-server credential. |
|
||||
| In-memory, single-process state | Session history lives in process memory and is lost on restart. |
|
||||
|
||||
> **Key realisation.** The N→1 / 1→M behaviour the caller wants is **not**
|
||||
> provided by WebSocket — it is provided by the **pipeline** (aggregation +
|
||||
> the adapter being free to call `reply_message` any number of times). It is
|
||||
> therefore **transport-independent**. We can deliver the exact same semantics
|
||||
> over a far lighter HTTP transport.
|
||||
|
||||
### 1.3 Why a *new, standalone* adapter (not a refactor of an existing one)
|
||||
|
||||
The brief is explicit: **do not reuse / fork an existing vendor adapter.** The
|
||||
vendor adapters (`lark`, `wecom`, `qqofficial`, `slack`, …) carry vendor-specific
|
||||
signature schemes, payload shapes, and message-segment mappings. Bending one of
|
||||
them into a "generic" mode would couple a public integration surface to one
|
||||
vendor's quirks and make the developer experience worse for everyone.
|
||||
|
||||
Instead we ship `http_bot` as a clean, independent adapter whose **entire
|
||||
contract is LangBot's own** — documented, versioned, and designed front-to-back
|
||||
around *integrator* developer experience.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- **G1** A standalone `http_bot` adapter, selectable like any other platform
|
||||
adapter in the dashboard, with its own config schema and docs.
|
||||
- **G2** **Inbound**: external systems POST messages to a stable LangBot URL,
|
||||
carrying a **caller-defined `session_id`** that maps 1:1 to a LangBot session.
|
||||
- **G3** **Outbound**: LangBot delivers each reply by POSTing to a
|
||||
caller-configured **callback URL**; one turn may produce **many** callbacks.
|
||||
- **G4** Preserve pipeline-native **N→1 aggregation** and **1→M multi-reply**.
|
||||
- **G5** Server-to-server **auth**: shared-secret HMAC request signing both
|
||||
directions (no JWT, no Turnstile, no long-lived socket).
|
||||
- **G6** **Great DX**: copy-pasteable curl, a tiny reference client, an OpenAPI
|
||||
fragment, idempotency, clear error envelope, and a local echo-server recipe.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Not replacing or deprecating the WebSocket / embed widget path (that remains
|
||||
the right tool for *browser*, real-time, streaming chat UIs).
|
||||
- Not a synchronous "one request → one response" RPC (explicitly rejected: it
|
||||
cannot express 1→M; see §9 for the optional sync convenience mode).
|
||||
- No built-in message **persistence/replay** in v1 (callbacks are at-least-once
|
||||
best-effort; durability is the caller's responsibility — see §8).
|
||||
- No multi-tenant API-key management UI in v1 (one secret per bot; see §11).
|
||||
|
||||
---
|
||||
|
||||
## 3. How LangBot routes a message (the parts we plug into)
|
||||
|
||||
Understanding the existing flow is what makes this adapter cheap. A message
|
||||
flows through these stages (verified against current `master`):
|
||||
|
||||
```
|
||||
INBOUND OUTBOUND
|
||||
external POST ─┐ ┌─ reply_message()
|
||||
▼ │ reply_message_chunk()
|
||||
POST /bots/<bot_uuid> (unified webhook router, AuthType.NONE)
|
||||
│ webhooks.py → adapter.handle_unified_webhook(bot_uuid, path, request)
|
||||
▼ │
|
||||
HttpBotAdapter.handle_unified_webhook │ (called 0..N times
|
||||
• verify HMAC signature │ per turn by the
|
||||
• parse {session_id, message[]} │ pipeline / plugins)
|
||||
• build FriendMessage / GroupMessage │
|
||||
• fire registered listener ───────────────┐ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
botmgr.on_friend_message / on_group_message │
|
||||
• (optional) webhook_pusher fan-out │
|
||||
• msg_aggregator.add_message(...) ── N→1 debounce ──►│
|
||||
│ │
|
||||
▼ │
|
||||
query_pool → pipeline.run() ─── invokes adapter ─────┘
|
||||
reply methods 1..M times
|
||||
```
|
||||
|
||||
Two framework facts we rely on:
|
||||
|
||||
1. **N→1 aggregation is free.** `botmgr` hands every inbound event to
|
||||
`self.ap.msg_aggregator.add_message(...)`, which debounces per
|
||||
`session_id` and merges consecutive messages into one pipeline turn
|
||||
(`pkg/pipeline/aggregator.py`). The adapter does nothing special.
|
||||
|
||||
2. **1→M is free.** The pipeline (and any plugin in the chain) calls
|
||||
`adapter.reply_message()` / `reply_message_chunk()` **as many times as it
|
||||
wants** per turn. The adapter's only job is to deliver each call outward.
|
||||
For `http_bot` that means: **one outbound callback POST per call.**
|
||||
|
||||
3. **A unified inbound route already exists.** `WebhookRouterGroup`
|
||||
(`pkg/api/http/controller/groups/webhooks.py`) maps
|
||||
`POST /bots/<bot_uuid>[/<path>]` (auth `NONE`) to
|
||||
`adapter.handle_unified_webhook(bot_uuid, path, request)`. `http_bot`
|
||||
implements that method and is reachable **without registering any new
|
||||
route** — it does its own signature verification, exactly like the vendor
|
||||
webhook adapters do.
|
||||
|
||||
> Net new code is essentially: one `http_bot.py` adapter, one `http_bot.yaml`
|
||||
> schema, signing helpers, and docs. No router, aggregator, or pipeline changes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────┐ (1) inbound: POST signed message
|
||||
│ External system │ ──────────────────────────────────────────────► ┌──────────────────────┐
|
||||
│ (LangBot Space, │ POST /bots/<bot_uuid> │ LangBot │
|
||||
│ CRM, web app …) │ X-LB-Signature, X-LB-Timestamp │ │
|
||||
│ │ { session_id, message:[...] } │ HttpBotAdapter │
|
||||
│ - callback server │ ◄────────────────────────────────────────────── │ (platform/sources) │
|
||||
│ (receives │ (4) outbound: POST signed reply(s) │ │
|
||||
│ replies) │ POST <callback_url> │ pipeline + aggregator│
|
||||
└────────────────────┘ X-LB-Signature, X-LB-Timestamp └──────────────────────┘
|
||||
{ session_id, sequence, is_final,
|
||||
message:[...] } (sent 1..M times)
|
||||
```
|
||||
|
||||
- The adapter is **stateless across requests** at the HTTP layer; session
|
||||
continuity is carried by `session_id` and resolved by LangBot's normal
|
||||
session manager.
|
||||
- **Inbound** and **outbound** are **independent HTTP exchanges**. LangBot does
|
||||
not answer the inbound POST with the pipeline result; it `202 Accepts` it and
|
||||
later POSTs the reply(s) to the callback URL. This is what makes 1→M natural.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration Schema (`http_bot.yaml`)
|
||||
|
||||
Follows the existing `MessagePlatformAdapter` manifest convention (cf.
|
||||
`slack.yaml`). Fields:
|
||||
|
||||
| field | type | required | purpose |
|
||||
|---|---|---|---|
|
||||
| `inbound_secret` | string (secret) | yes | HMAC key the **caller** uses to sign inbound POSTs; LangBot verifies. |
|
||||
| `callback_url` | string (url) | no* | Where LangBot POSTs replies. *Optional if the caller supplies `callback_url` per-message (see §6.1); a static default lives here. |
|
||||
| `outbound_secret` | string (secret) | no | HMAC key LangBot uses to sign outbound callbacks; caller verifies. Defaults to `inbound_secret` if empty. |
|
||||
| `default_session_type` | enum `person`/`group` | no | Default when a message omits `session_type`. Default `person`. |
|
||||
| `signature_required` | bool | no | If `false`, skip inbound signature check (dev only; logs a warning). Default `true`. |
|
||||
| `callback_timeout` | int (seconds) | no | Per-callback HTTP timeout. Default `15`. |
|
||||
| `callback_max_retries` | int | no | Retries on 5xx/timeout with backoff. Default `3`. |
|
||||
| `webhook_url` | webhook-url (display) | — | Read-only field rendering the inbound URL `…/bots/<bot_uuid>` for copy-paste, like other webhook adapters. |
|
||||
|
||||
Manifest sketch (i18n labels elided for brevity):
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: http_bot
|
||||
label: { en_US: "HTTP Bot", zh_Hans: "HTTP 通用接入" }
|
||||
description:
|
||||
en_US: "Integrate any backend over plain HTTP. Push messages in, receive replies on a callback URL. Server-to-server, no long-lived connection."
|
||||
zh_Hans: "通过 HTTP 接入任意后端系统。推入消息、在回调地址接收回复。面向服务间集成,无需长连接。"
|
||||
icon: http_bot.svg
|
||||
spec:
|
||||
categories: [popular, global]
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
config:
|
||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||
- { name: callback_url, type: string, required: false, default: "" }
|
||||
- { name: outbound_secret, type: string, required: false, default: "" }
|
||||
- { name: default_session_type, type: select, required: false, default: "person",
|
||||
options: [person, group] }
|
||||
- { name: signature_required, type: boolean, required: false, default: true }
|
||||
- { name: callback_timeout, type: integer, required: false, default: 15 }
|
||||
- { name: callback_max_retries, type: integer, required: false, default: 3 }
|
||||
- { name: webhook_url, type: webhook-url, required: false, default: "" }
|
||||
execution:
|
||||
python:
|
||||
path: ./http_bot.py
|
||||
attr: HttpBotAdapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. The HTTP Contract (this is the DX surface)
|
||||
|
||||
### 6.1 Inbound — push a message into LangBot
|
||||
|
||||
```
|
||||
POST /bots/{bot_uuid}
|
||||
Content-Type: application/json
|
||||
X-LB-Timestamp: 1718000000
|
||||
X-LB-Signature: sha256=<hex hmac>
|
||||
X-LB-Idempotency-Key: <uuid> # optional, dedup window
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"session_id": "ticket-10293", // REQUIRED. Caller-defined. Maps 1:1 to a LangBot session.
|
||||
"session_type": "person", // optional, "person" | "group"; default from config
|
||||
"sender": { // optional metadata, surfaced to pipeline/plugins
|
||||
"id": "user-5567",
|
||||
"name": "Alice"
|
||||
},
|
||||
"message": [ // REQUIRED. A LangBot MessageChain (list of segments).
|
||||
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
|
||||
{ "type": "Image", "url": "https://.../screenshot.png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response (LangBot does **not** block on the pipeline):
|
||||
|
||||
```jsonc
|
||||
// 202 Accepted
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "accepted",
|
||||
"data": {
|
||||
"session_id": "ticket-10293",
|
||||
"accepted_message_id": "in_01H....", // server-assigned id for this inbound message
|
||||
"aggregating": true // true if buffered by the aggregator
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**N→1 in practice.** Fire three POSTs with the same `session_id` inside the
|
||||
aggregation window → the pipeline runs **once** with the three messages merged.
|
||||
No special flag needed; this is the aggregator's default behaviour when enabled
|
||||
on the pipeline.
|
||||
|
||||
### 6.2 Outbound — LangBot delivers replies to your callback
|
||||
|
||||
For each `reply_message` / `reply_message_chunk` the pipeline emits, LangBot
|
||||
POSTs to `callback_url`:
|
||||
|
||||
```
|
||||
POST {callback_url}
|
||||
Content-Type: application/json
|
||||
X-LB-Timestamp: 1718000001
|
||||
X-LB-Signature: sha256=<hex hmac over body>
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"session_id": "ticket-10293", // echoes the inbound session
|
||||
"reply_to": "in_01H....", // the inbound message id this answers
|
||||
"sequence": 1, // 1-based ordinal within this turn (for 1→M ordering)
|
||||
"is_final": false, // false for intermediate/streamed parts
|
||||
"stream": false, // true when this is a streamed chunk
|
||||
"message": [
|
||||
{ "type": "Plain", "text": "Looking into it — checking your export logs…" }
|
||||
],
|
||||
"timestamp": "2026-06-22T09:00:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
**1→M in practice.** A turn that fires a function call then a final answer
|
||||
produces e.g.:
|
||||
|
||||
```
|
||||
POST callback → { sequence: 1, is_final: false, message: ["Checking logs…"] }
|
||||
POST callback → { sequence: 2, is_final: false, message: ["Found 2 failed exports."] }
|
||||
POST callback → { sequence: 3, is_final: true, message: ["Fixed. Try again now."] }
|
||||
```
|
||||
|
||||
The caller stitches by `session_id` + `sequence`, and knows the turn is complete
|
||||
when `is_final: true` arrives.
|
||||
|
||||
Your callback endpoint should return `200` quickly. A non-2xx triggers retry
|
||||
with backoff (`callback_max_retries`).
|
||||
|
||||
### 6.3 Error envelope (inbound)
|
||||
|
||||
Consistent, machine-readable; never leak internals:
|
||||
|
||||
```jsonc
|
||||
{ "code": 40101, "msg": "invalid signature", "data": null }
|
||||
```
|
||||
|
||||
| HTTP | code | meaning |
|
||||
|---|---|---|
|
||||
| 202 | 0 | accepted |
|
||||
| 400 | 40001 | malformed body / missing `session_id` or `message` |
|
||||
| 401 | 40101 | bad/expired signature |
|
||||
| 403 | 40301 | bot disabled |
|
||||
| 404 | 40401 | bot_uuid not found / not an `http_bot` adapter |
|
||||
| 409 | 40901 | duplicate idempotency key (already accepted) |
|
||||
| 413 | 41301 | message too large |
|
||||
| 500 | 50001 | internal error |
|
||||
|
||||
---
|
||||
|
||||
## 7. Signing scheme (both directions)
|
||||
|
||||
Symmetric, dependency-free HMAC-SHA256 — trivial to implement in any language.
|
||||
|
||||
```
|
||||
signing_string = "{timestamp}.{raw_request_body}"
|
||||
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
|
||||
```
|
||||
|
||||
Verification rules:
|
||||
|
||||
- Reject if `|now - timestamp| > 300s` (replay window).
|
||||
- Constant-time compare (`hmac.compare_digest`).
|
||||
- Inbound verified with `inbound_secret`; outbound signed with
|
||||
`outbound_secret` (falls back to `inbound_secret`).
|
||||
- `signature_required: false` bypasses verification **and logs a warning** —
|
||||
intended only for local development behind a trusted network.
|
||||
|
||||
Reference (Python, ~6 lines):
|
||||
|
||||
```python
|
||||
import hmac, hashlib, time
|
||||
|
||||
def sign(secret: str, body: bytes, ts: int | None = None) -> tuple[str, str]:
|
||||
ts = ts or int(time.time())
|
||||
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
|
||||
return str(ts), "sha256=" + mac.hexdigest()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Delivery semantics & reliability
|
||||
|
||||
- **Inbound**: `202 Accepted` means *queued*, not *processed*. Use
|
||||
`X-LB-Idempotency-Key` to make client retries safe (dedup window, e.g. 10 min).
|
||||
- **Outbound**: **at-least-once**, best-effort. Retries on timeout/5xx with
|
||||
exponential backoff up to `callback_max_retries`. Callbacks for one
|
||||
`session_id` are delivered **in `sequence` order** (serialised per session);
|
||||
across sessions they may interleave.
|
||||
- **No persistence in v1**: if LangBot restarts mid-turn, in-flight callbacks
|
||||
may be lost. Durable replay is deferred (see §13). Callers needing exactly-once
|
||||
should dedup on `(session_id, reply_to, sequence)`.
|
||||
- **Backpressure**: the adapter must not block the pipeline on slow callbacks —
|
||||
outbound POSTs run on a per-session ordered queue with the configured timeout.
|
||||
|
||||
---
|
||||
|
||||
## 9. Optional: synchronous convenience mode (v1.1, behind a flag)
|
||||
|
||||
Some simple callers genuinely want "POST a message, get the reply in the HTTP
|
||||
response" and don't care about streaming/multi-part. We can offer an **opt-in**
|
||||
sync endpoint that internally waits for `is_final` and **collapses** all 1→M
|
||||
parts into one array:
|
||||
|
||||
```
|
||||
POST /bots/{bot_uuid}/sync → 200 { session_id, message: [ ...all parts concatenated... ] }
|
||||
```
|
||||
|
||||
Implemented by attaching a per-request future that resolves on the final reply,
|
||||
with a hard timeout. This is a **convenience wrapper** over the same machinery,
|
||||
explicitly documented as lossy for streaming/ordering. Not in v1 core.
|
||||
|
||||
---
|
||||
|
||||
## 10. Adapter implementation sketch (`platform/sources/http_bot.py`)
|
||||
|
||||
Implements `AbstractMessagePlatformAdapter`. Key methods:
|
||||
|
||||
```python
|
||||
class HttpBotAdapter(AbstractMessagePlatformAdapter):
|
||||
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
# --- inbound -------------------------------------------------------
|
||||
async def handle_unified_webhook(self, bot_uuid, path, request):
|
||||
body = await request.get_body()
|
||||
if self.config.get("signature_required", True):
|
||||
if not self._verify(request, body):
|
||||
return jsonify({"code": 40101, "msg": "invalid signature"}), 401
|
||||
data = json.loads(body)
|
||||
session_id = data["session_id"] # caller-defined identity
|
||||
session_type = data.get("session_type", self.config.get("default_session_type", "person"))
|
||||
chain = MessageChain.model_validate(data["message"])
|
||||
event = self._build_event(session_type, session_id, data.get("sender"), chain)
|
||||
# remember where to send replies for this session
|
||||
self._callback_for[session_id] = data.get("callback_url") or self.config.get("callback_url")
|
||||
# fire the registered listener → botmgr → msg_aggregator (N→1) → pipeline
|
||||
if type(event) in self.listeners:
|
||||
asyncio.create_task(self.listeners[type(event)](event, self))
|
||||
return jsonify({"code": 0, "msg": "accepted",
|
||||
"data": {"session_id": session_id, "accepted_message_id": event.message_id}}), 202
|
||||
|
||||
# --- outbound (called 1..M times per turn by the pipeline) ---------
|
||||
async def reply_message(self, message_source, message, quote_origin=False):
|
||||
return await self._post_callback(message_source, message, is_final=True, stream=False)
|
||||
|
||||
async def reply_message_chunk(self, message_source, bot_message, message,
|
||||
quote_origin=False, is_final=False):
|
||||
return await self._post_callback(message_source, message, is_final=is_final, stream=True)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
def register_listener(self, event_type, func): self.listeners[event_type] = func
|
||||
def unregister_listener(self, event_type, func): self.listeners.pop(event_type, None)
|
||||
async def run_async(self): pass # nothing to poll; purely webhook-driven
|
||||
async def kill(self): pass
|
||||
```
|
||||
|
||||
`_post_callback` resolves the session's callback URL, assigns the next
|
||||
`sequence`, signs the body, and enqueues an ordered, retrying POST.
|
||||
|
||||
Session→callback mapping is kept in a small in-memory dict keyed by
|
||||
`session_id` (acceptable for v1; a turn's callback URL is captured at inbound
|
||||
time so replies always have a destination even if config later changes).
|
||||
|
||||
---
|
||||
|
||||
## 11. Security considerations
|
||||
|
||||
- **Inbound route is `AuthType.NONE`** at the framework level (same as all
|
||||
webhook adapters) — the adapter **must** enforce HMAC itself. Default
|
||||
`signature_required: true`.
|
||||
- **Timestamp window** (±300s) + idempotency key blunt replay.
|
||||
- **SSRF on callback_url**: validate scheme (`https` in prod), and consider an
|
||||
allow-list / block of private CIDRs since LangBot initiates the POST. Document
|
||||
this; enforce in code where feasible.
|
||||
- **Secret storage**: secrets live in the bot's `adapter_config` like every
|
||||
other adapter credential; surfaced as `type: string`/secret in the dashboard.
|
||||
- **One secret per bot** in v1. Per-caller key rotation / multiple keys is a
|
||||
future enhancement (§13).
|
||||
|
||||
---
|
||||
|
||||
## 12. Developer Experience (explicit deliverables)
|
||||
|
||||
The whole point of a standalone adapter is that **integrating is pleasant**. v1
|
||||
ships:
|
||||
|
||||
1. **`docs/platforms/http-bot.md`** — task-oriented integration guide:
|
||||
create the bot → copy inbound URL → set secret → stand up a callback
|
||||
endpoint → send first message → handle 1→M.
|
||||
2. **Copy-paste curl** for the first message (with a working signing one-liner).
|
||||
3. **Reference clients** (≤50 LOC each) in `examples/http-bot/`:
|
||||
`client.py` (push + a Flask/Quart callback receiver) and `client.ts`.
|
||||
4. **OpenAPI fragment** `docs/http-bot-openapi.json` describing inbound +
|
||||
callback shapes, so integrators can codegen.
|
||||
5. **Local echo recipe**: a one-command callback server that prints every
|
||||
reply, so a developer sees N→1 and 1→M working in under five minutes.
|
||||
6. **Postman/Hoppscotch collection** (nice-to-have).
|
||||
|
||||
DX acceptance check: *a developer who has never seen LangBot can, from the docs
|
||||
alone, push a message and observe a multi-part reply on their callback within
|
||||
10 minutes.*
|
||||
|
||||
### Quickstart (curl)
|
||||
|
||||
```bash
|
||||
BOT=https://your-langbot/bots/2f1c....
|
||||
SECRET=supersecret
|
||||
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"hello"}]}'
|
||||
TS=$(date +%s)
|
||||
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
|
||||
curl -sS -X POST "$BOT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-LB-Timestamp: $TS" \
|
||||
-H "X-LB-Signature: $SIG" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Future work
|
||||
|
||||
- **Durable outbound queue** (persist + replay across restarts; exactly-once).
|
||||
- **Per-caller API keys** with rotation and scopes (multi-tenant Space usage).
|
||||
- **Sync convenience endpoint** (§9) once core is stable.
|
||||
- **Server-Sent Events outbound option** for callers that *do* want a stream but
|
||||
not a full duplex socket — single GET, server pushes chunks.
|
||||
- **Dashboard "test console"** for `http_bot` (send a message, watch callbacks)
|
||||
mirroring the existing WebSocket debug panel.
|
||||
|
||||
---
|
||||
|
||||
## 14. Rollout / task breakdown
|
||||
|
||||
| # | Task | Touches |
|
||||
|---|---|---|
|
||||
| 1 | `http_bot.yaml` manifest + icon | `platform/sources/` |
|
||||
| 2 | `HttpBotAdapter` (inbound verify, event build, outbound queue) | `platform/sources/http_bot.py` |
|
||||
| 3 | Signing helper module (shared) | `platform/sources/` or `utils/` |
|
||||
| 4 | i18n strings (en/zh/ja) | adapter yaml + web locale |
|
||||
| 5 | Integration docs `docs/platforms/http-bot.md` | `docs/` |
|
||||
| 6 | OpenAPI fragment + reference clients | `docs/`, `examples/http-bot/` |
|
||||
| 7 | Tests: signature verify, N→1 aggregation, 1→M ordering, retry | `tests/` |
|
||||
| 8 | (opt) SSRF guard for callback_url | adapter |
|
||||
|
||||
No changes required to: the unified webhook router, the aggregator, the query
|
||||
pool, or the pipeline. That is the design's main payoff.
|
||||
|
||||
---
|
||||
|
||||
## 15. Resolved decisions
|
||||
|
||||
1. **Callback URL trust** — **config-only.** The inbound message may not carry a
|
||||
`callback_url`; replies always go to the bot-config URL. Closes the SSRF
|
||||
vector where a leaked inbound secret could redirect replies.
|
||||
2. **Session lifecycle** — **`POST /bots/<uuid>/reset`** (body `{session_id,
|
||||
session_type?}`) drops the matching session from the session manager; the
|
||||
next message starts a fresh conversation. Implemented via sub-path routing in
|
||||
`handle_unified_webhook`.
|
||||
3. **Group semantics** — for `session_type: group`, `session_id` is the group/
|
||||
launcher id; `sender.id` (and optional `sender.group_name`) identify the
|
||||
member. A Space ticket maps to one `session_id`.
|
||||
4. **Backpressure** — bounded per-session outbound queue (maxlen 1000); on
|
||||
overflow the oldest reply is dropped and a warning logged, so a persistently
|
||||
down callback can never exhaust memory.
|
||||
|
||||
### Still open / deferred (see §13)
|
||||
|
||||
- Durable outbound queue (persist + replay across restarts).
|
||||
- Per-caller API keys with rotation/scopes for multi-tenant Space usage.
|
||||
- SSE outbound option and a dashboard test console.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
|
||||
|
||||
状态快照日期:2026-06-20。
|
||||
状态快照日期:2026-06-23。
|
||||
|
||||
## 实现状态
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| Result payload validation | Done | Wire 保持 `{type, data}`;Host 对投递/副作用类 payload 严格校验,tool-call telemetry 宽松,未知 type 忽略并 warning。 |
|
||||
| Old built-in runners | Done | 旧 `src/langbot/pkg/provider/runners/*` 与 `RequestRunner` 路径已从本分支删除。 |
|
||||
| Official runner manifests | Done | `local-agent`、ACP / Claude Code / Codex 外部 harness runner、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 |
|
||||
| Skill 链路 | Broken → Redesigning | 分支上 skill 激活链端到端悬空:`activate` 调用未定义的 `persist_activated_skill`(运行即 `AttributeError`)、`host.activated_skills` 只读不写、skill awareness 既未注入也未被 runner 消费。已拍板改为 **skill 全 tool 化**:发现走 `list_skills` / `langbot_list_assets` 加 skills 一类,`activate` / `register_skill` 走统一 tool 授权,`skill_authoring` capability 降级为便捷开关,host 直接写 `host.activated_skills`(last-write-wins)。 |
|
||||
| Skill 链路 | Unit-pass; WebUI smoke pending | 已按 **skill 全 tool 化** 收敛:发现走 `list_skills` / `langbot_list_assets` 和 skill resources;`activate` / `register_skill` 走统一 tool 授权;`skill_authoring` capability 降级为便捷开关。`activate` 现在会 best-effort 写入 conversation-scope `host.activated_skills`,后续 run 通过当前 pipeline-visible skill cache 恢复,语义为 last-write-wins。 |
|
||||
| Runtime Control Plane v2 foundation | Partial | Host-owned `AgentRun` / `AgentRunEvent` ledger、orchestrator 自动建账、result event persistence、run get/list/event page/cancel/append/finalize actions 已落地;`agent_run:admin` / `runtime:admin` 控制权限、最小 runtime register/heartbeat/list/reconcile 和 run claim/renew/release 原语已落地。完整 Agent Platform 产品形态、daemon supervisor、任务唤醒/长轮询/WebSocket、分布式 runtime 管控仍未完成。 |
|
||||
| Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 |
|
||||
| Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
|
||||
@@ -44,8 +44,8 @@
|
||||
|
||||
| 范围 | 状态 | 最近证据 |
|
||||
| --- | --- | --- |
|
||||
| LangBot Runtime Control Plane v2 foundation | Unit-pass; product E2E pending | 2026-06-16 `tests/unit_tests/agent/test_run_ledger_store.py`、`test_run_ledger_api_auth.py`、`test_orchestrator_integration.py` 通过,覆盖 ledger、admin permissions、runtime heartbeat、claim/reconcile、orchestrator 持久化和取消传播。 |
|
||||
| SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-16 SDK agent-runner 相关单测通过,覆盖 typed run ledger entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 |
|
||||
| LangBot Runtime Control Plane v2 foundation | Unit-pass; product E2E pending | 2026-06-23 `tests/unit_tests/agent`、`tests/unit_tests/plugin/test_handler_actions.py`、`tests/unit_tests/provider/test_skill_tools.py`、pipeline preproc/chat handler tests 和 Telegram EBA adapter tests 通过,覆盖 ledger、admin permissions、runtime heartbeat、claim/reconcile、orchestrator 持久化、取消传播、skill activation persistence 和插件化 runner pipeline path。 |
|
||||
| SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-23 SDK `tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/test_pull_api_handlers.py`、`tests/runtime/io/handlers/test_plugin_handler.py`、EBA event entities 和 message tests 通过,覆盖 typed entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 |
|
||||
|
||||
## 历史高价值记录
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Event Based Agents 架构设计总览
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 当前架构的局限性
|
||||
|
||||
LangBot 当前的平台适配器架构围绕**消息事件**单一场景设计:
|
||||
|
||||
- **事件层面**:只监听 `FriendMessage`(私聊消息)和 `GroupMessage`(群消息)两种事件
|
||||
- **API 层面**:只暴露 `send_message` 和 `reply_message` 两个平台 API
|
||||
- **处理层面**:所有消息统一进入 Pipeline 流水线处理,无法为不同事件类型配置不同处理逻辑
|
||||
- **适配器结构**:每个适配器是单个 Python 文件(200-800 行),随着功能增加难以维护
|
||||
|
||||
这导致以下问题:
|
||||
|
||||
1. **无法处理非消息事件**:新成员入群、好友请求、消息撤回、消息编辑等大部分平台都支持的事件被完全忽略
|
||||
2. **平台能力未充分利用**:编辑消息、撤回消息、获取群成员列表、管理群组等 API 无法使用
|
||||
3. **插件能力受限**:插件只能监听消息事件、只能发送/回复消息,无法实现更丰富的交互
|
||||
4. **处理逻辑不灵活**:所有消息走同一条 Pipeline,无法为入群欢迎、好友自动通过等场景配置独立的处理流程
|
||||
|
||||
### 设计目标
|
||||
|
||||
Event Based Agents(EBA)架构旨在将 LangBot 从"消息处理平台"升级为"事件驱动的智能代理平台":
|
||||
|
||||
- **丰富事件**:支持消息、群组、好友、Bot 状态等多种事件类型
|
||||
- **丰富 API**:支持消息编辑/撤回、群组管理、用户信息查询等通用 API,以及适配器特有 API 的透传调用
|
||||
- **灵活编排**:用户可在 WebUI 上为每个 Bot 的每种事件类型配置不同的处理器
|
||||
- **可扩展**:适配器可声明自己支持的事件和 API,平台特有能力通过标准机制暴露
|
||||
- **向后兼容**:现有插件无需修改即可在新架构下运行
|
||||
|
||||
## 2. 架构对比
|
||||
|
||||
### 现有架构
|
||||
|
||||
```
|
||||
消息平台 (Telegram/Discord/...)
|
||||
│
|
||||
▼
|
||||
平台适配器 (单文件, 只处理消息)
|
||||
│ FriendMessage / GroupMessage
|
||||
▼
|
||||
RuntimeBot (注册 on_friend_message / on_group_message 回调)
|
||||
│
|
||||
▼
|
||||
MessageAggregator (消息聚合)
|
||||
│
|
||||
▼
|
||||
QueryPool → Controller → Pipeline (固定阶段链)
|
||||
│ │
|
||||
│ ▼
|
||||
│ RequestRunner (local-agent / dify / n8n / ...)
|
||||
│
|
||||
▼
|
||||
adapter.reply_message() / adapter.send_message()
|
||||
```
|
||||
|
||||
关键代码路径:
|
||||
- 适配器基类:`langbot-plugin-sdk/.../abstract/platform/adapter.py` — `AbstractMessagePlatformAdapter`
|
||||
- 事件定义:`langbot-plugin-sdk/.../builtin/platform/events.py` — 仅 `FriendMessage` / `GroupMessage`
|
||||
- Bot 管理:`LangBot/src/langbot/pkg/platform/botmgr.py` — `RuntimeBot` 只注册两个消息回调
|
||||
- 流水线控制:`LangBot/src/langbot/pkg/pipeline/controller.py` — 从 QueryPool 消费并执行 Pipeline
|
||||
|
||||
### 新架构(Event Based Agents)
|
||||
|
||||
```
|
||||
消息平台 (Telegram/Discord/...)
|
||||
│
|
||||
▼
|
||||
平台适配器 (独立目录, 监听所有事件, 实现丰富 API)
|
||||
│ MessageReceived / MemberJoined / FriendRequest / ...
|
||||
▼
|
||||
EventBus (统一事件总线)
|
||||
│
|
||||
▼
|
||||
EventRouter (事件路由引擎, 读取 Bot 的 event_handlers 配置)
|
||||
│
|
||||
├─→ PipelineHandler — 现有流水线(完整 Stage 链)
|
||||
├─→ AgentHandler — 直接调用 RequestRunner(轻量 AI 处理)
|
||||
├─→ WebhookHandler — POST 到外部服务(Dify/n8n webhook 等)
|
||||
└─→ PluginHandler — 分发给插件 EventListener
|
||||
│
|
||||
▼
|
||||
统一平台 API
|
||||
send / reply / edit / delete / getGroupInfo / getUserInfo / callPlatformApi / ...
|
||||
```
|
||||
|
||||
## 3. 核心概念
|
||||
|
||||
### 3.1 统一事件体系
|
||||
|
||||
所有平台事件统一为命名空间式的事件类型:
|
||||
|
||||
| 命名空间 | 事件 | 说明 |
|
||||
|----------|------|------|
|
||||
| `message.*` | `message.received`, `message.edited`, `message.deleted`, `message.reaction` | 消息相关 |
|
||||
| `feedback.*` | `feedback.received` | 用户对 Bot 回复的点赞、点踩、取消反馈等评价事件 |
|
||||
| `group.*` | `group.member_joined`, `group.member_left`, `group.member_banned`, `group.info_updated` | 群组相关 |
|
||||
| `friend.*` | `friend.request_received`, `friend.added`, `friend.removed` | 好友相关 |
|
||||
| `bot.*` | `bot.invited_to_group`, `bot.removed_from_group`, `bot.muted`, `bot.unmuted` | Bot 状态 |
|
||||
| `platform.*` | `platform.{adapter}.{action}` | 适配器特有事件 |
|
||||
|
||||
详见 [01-event-system.md](./01-event-system.md)。
|
||||
|
||||
### 3.2 统一平台 API
|
||||
|
||||
扩展适配器基类,提供通用 API + 透传机制:
|
||||
|
||||
| 类别 | API | 必需/可选 |
|
||||
|------|-----|----------|
|
||||
| 消息 | `send_message`, `reply_message`, `edit_message`, `delete_message`, `forward_message` | send/reply 必需,其余可选 |
|
||||
| 群组 | `get_group_info`, `get_group_member_list`, `get_group_member_info`, `mute_member`, `kick_member` | 全部可选 |
|
||||
| 用户 | `get_user_info`, `get_friend_list` | 全部可选 |
|
||||
| 媒体 | `upload_file`, `get_file_url` | 全部可选 |
|
||||
| 透传 | `call_platform_api(action, params)` | 可选 |
|
||||
|
||||
详见 [02-platform-api.md](./02-platform-api.md)。
|
||||
|
||||
### 3.3 适配器新结构
|
||||
|
||||
每个适配器从单文件迁移到独立目录:
|
||||
|
||||
```
|
||||
pkg/platform/adapters/
|
||||
├── _base/ # 基类和通用定义
|
||||
│ ├── adapter.py
|
||||
│ ├── events.py
|
||||
│ ├── entities.py
|
||||
│ └── api.py
|
||||
├── telegram/
|
||||
│ ├── __init__.py
|
||||
│ ├── adapter.py # 主适配器类
|
||||
│ ├── event_converter.py # 事件转换(多种事件类型)
|
||||
│ ├── message_converter.py # 消息链转换
|
||||
│ ├── api_impl.py # 通用 API 实现
|
||||
│ ├── platform_api.py # 平台特有 API
|
||||
│ ├── types.py # 平台特有类型
|
||||
│ └── manifest.yaml
|
||||
├── discord/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
详见 [03-adapter-structure.md](./03-adapter-structure.md)。
|
||||
|
||||
### 3.4 事件处理器(Event Handler)
|
||||
|
||||
> **2026-06 方向修订**:四种处理器的分类法已演进为「事件 → Agent」统一编排——所有编排目标(流水线、RequestRunner、webhook、工作流)收编为 Agent 抽象,插件 EventListener 保留为观察者角色。详见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本节保留原设计供对照。
|
||||
|
||||
四种处理器类型,用户在 WebUI 的 Bot 管理页面配置:
|
||||
|
||||
| 类型 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **pipeline** | 现有流水线机制,完整的多 Stage 处理链(PreProcessor → MessageProcessor → PostProcessor 等) | 复杂消息处理,需要完整的预处理/后处理流程 |
|
||||
| **agent** | 直接调用 RequestRunner(local-agent / dify / n8n / coze / dashscope / langflow / tbox),从 Pipeline 中解耦 | 轻量级 AI 处理、直接对接外部 LLMOps 平台处理各类事件 |
|
||||
| **webhook** | 将事件 POST 到外部 URL,根据响应执行动作 | 对接自建服务、Dify/n8n 的 Webhook 触发器、自定义后端 |
|
||||
| **plugin** | 分发给插件 EventListener 处理 | 插件自定义逻辑 |
|
||||
|
||||
配置存储在 Bot 表的 `event_handlers` JSON 字段中,通过 WebUI 编排面板管理。
|
||||
|
||||
详见 [04-event-routing.md](./04-event-routing.md)。
|
||||
|
||||
### 3.5 插件 SDK 改造
|
||||
|
||||
- 新事件类型全部暴露给插件
|
||||
- 新 API 全部通过 `LangBotAPIProxy` 暴露
|
||||
- 兼容层保证现有插件零修改运行
|
||||
|
||||
详见 [05-plugin-sdk.md](./05-plugin-sdk.md)。
|
||||
|
||||
## 4. 关键设计决策
|
||||
|
||||
| # | 决策点 | 选择 | 理由 |
|
||||
|---|--------|------|------|
|
||||
| 1 | 事件处理器配置粒度 | 每个 Bot 独立配置 | Bot 是用户操作的核心单元,不同 Bot 可能对接不同业务场景 |
|
||||
| 2 | 适配器特有 API | 统一抽象 + `call_platform_api` 透传 | 通用 API 覆盖大部分场景,透传机制保证灵活性,避免每个适配器导出独立的类型化 API 包 |
|
||||
| 3 | 向后兼容策略 | 兼容层适配 | 保留旧事件类型和 API 作为新系统的 alias/wrapper,现有插件无需修改 |
|
||||
| 4 | 处理器配置存储 | Bot 表新增 `event_handlers` JSON 字段 | 简单直接,避免新增关联表;替代现有 `use_pipeline_uuid` |
|
||||
| 5 | Agent 处理器定位 | 从 Pipeline 中解耦 RequestRunner | 不是所有事件都需要完整 Pipeline Stage 链;Agent 处理器提供轻量级 AI 处理路径,支持所有现有 Runner |
|
||||
| 6 | 事件命名方式 | 命名空间式(`message.received`) | 清晰的分类层级,便于通配匹配(`message.*`),与 WebUI 配置天然对应 |
|
||||
|
||||
## 5. 文档索引
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [01-event-system.md](./01-event-system.md) | 统一事件体系:事件分类、定义、生命周期 |
|
||||
| [02-platform-api.md](./02-platform-api.md) | 统一平台 API:通用 API、透传 API、实体定义 |
|
||||
| [03-adapter-structure.md](./03-adapter-structure.md) | 适配器新结构:目录布局、基类、注册机制 |
|
||||
| [04-event-routing.md](./04-event-routing.md) | 事件路由与编排:路由引擎、处理器类型、WebUI 数据模型 |
|
||||
| [05-plugin-sdk.md](./05-plugin-sdk.md) | 插件 SDK 改造:新事件/API、兼容层 |
|
||||
| [06-migration-plan.md](./06-migration-plan.md) | 分阶段迁移计划 |
|
||||
| [07-agent-orchestration.md](./07-agent-orchestration.md) | **产品最终形态(2026-06 修订)**:Agent 统一编排、SDK Agent 组件契约、发布火车 |
|
||||
|
||||
## 6. 涉及的代码仓库
|
||||
|
||||
| 仓库 | 改动范围 |
|
||||
|------|----------|
|
||||
| **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 |
|
||||
| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot 实体扩展、数据库迁移、RequestRunner 解耦 |
|
||||
| **LangBot**(前端) | Bot 事件处理器编排面板 |
|
||||
| **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 |
|
||||
| **langbot-plugin-demo** | 示例更新(使用新事件和 API) |
|
||||
@@ -0,0 +1,561 @@
|
||||
# 统一事件体系
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- **命名空间分类**:事件类型采用 `{namespace}.{action}` 格式,如 `message.received`
|
||||
- **通用优先**:大部分平台都支持的事件抽象为通用事件,定义统一的字段格式
|
||||
- **平台特有事件标准化**:各适配器的独有事件通过 `PlatformSpecificEvent` 承载,保留原始数据
|
||||
- **向后兼容**:现有 `FriendMessage` / `GroupMessage` 通过兼容层映射到新的 `message.received` 事件
|
||||
|
||||
## 2. 事件基类层次
|
||||
|
||||
```
|
||||
Event (事件基类)
|
||||
├── MessageEvent (消息相关事件)
|
||||
│ ├── MessageReceivedEvent # message.received
|
||||
│ ├── MessageEditedEvent # message.edited
|
||||
│ ├── MessageDeletedEvent # message.deleted
|
||||
│ └── MessageReactionEvent # message.reaction
|
||||
├── FeedbackEvent (用户反馈事件)
|
||||
│ └── FeedbackReceivedEvent # feedback.received
|
||||
├── GroupEvent (群组相关事件)
|
||||
│ ├── MemberJoinedEvent # group.member_joined
|
||||
│ ├── MemberLeftEvent # group.member_left
|
||||
│ ├── MemberBannedEvent # group.member_banned
|
||||
│ ├── MemberUnbannedEvent # group.member_unbanned
|
||||
│ └── GroupInfoUpdatedEvent # group.info_updated
|
||||
├── FriendEvent (好友相关事件)
|
||||
│ ├── FriendRequestReceivedEvent # friend.request_received
|
||||
│ ├── FriendAddedEvent # friend.added
|
||||
│ └── FriendRemovedEvent # friend.removed
|
||||
├── BotEvent (Bot 状态事件)
|
||||
│ ├── BotInvitedToGroupEvent # bot.invited_to_group
|
||||
│ ├── BotRemovedFromGroupEvent # bot.removed_from_group
|
||||
│ ├── BotMutedEvent # bot.muted
|
||||
│ └── BotUnmutedEvent # bot.unmuted
|
||||
└── PlatformSpecificEvent # platform.{adapter}.{action}
|
||||
```
|
||||
|
||||
## 3. 通用事件定义
|
||||
|
||||
### 3.1 事件基类
|
||||
|
||||
```python
|
||||
class Event(pydantic.BaseModel):
|
||||
"""事件基类"""
|
||||
|
||||
type: str
|
||||
"""事件类型标识,如 'message.received'"""
|
||||
|
||||
timestamp: float
|
||||
"""事件发生的时间戳"""
|
||||
|
||||
bot_uuid: str
|
||||
"""接收到此事件的 Bot UUID"""
|
||||
|
||||
adapter_name: str
|
||||
"""产生此事件的适配器名称"""
|
||||
|
||||
source_platform_object: typing.Optional[typing.Any] = None
|
||||
"""原始平台事件对象,供适配器内部使用"""
|
||||
```
|
||||
|
||||
### 3.2 消息事件
|
||||
|
||||
#### MessageReceivedEvent (`message.received`)
|
||||
|
||||
收到新消息。这是最核心的事件,替代现有的 `FriendMessage` / `GroupMessage`。
|
||||
|
||||
```python
|
||||
class MessageReceivedEvent(Event):
|
||||
"""收到新消息"""
|
||||
|
||||
type: str = "message.received"
|
||||
|
||||
message_id: typing.Union[int, str]
|
||||
"""消息 ID"""
|
||||
|
||||
message_chain: MessageChain
|
||||
"""消息内容"""
|
||||
|
||||
sender: User
|
||||
"""发送者"""
|
||||
|
||||
chat_type: ChatType # "private" | "group"
|
||||
"""会话类型"""
|
||||
|
||||
chat_id: typing.Union[int, str]
|
||||
"""会话 ID(私聊为对方用户 ID,群聊为群 ID)"""
|
||||
|
||||
group: typing.Optional[Group] = None
|
||||
"""群信息(仅群聊时存在)"""
|
||||
```
|
||||
|
||||
与现有类型的映射关系:
|
||||
- `chat_type == "private"` → 等价于现有 `FriendMessage`
|
||||
- `chat_type == "group"` → 等价于现有 `GroupMessage`
|
||||
|
||||
`ChatType` 枚举:
|
||||
|
||||
```python
|
||||
class ChatType(str, Enum):
|
||||
PRIVATE = "private"
|
||||
GROUP = "group"
|
||||
```
|
||||
|
||||
#### MessageEditedEvent (`message.edited`)
|
||||
|
||||
消息被编辑。
|
||||
|
||||
```python
|
||||
class MessageEditedEvent(Event):
|
||||
"""消息被编辑"""
|
||||
|
||||
type: str = "message.edited"
|
||||
|
||||
message_id: typing.Union[int, str]
|
||||
"""被编辑的消息 ID"""
|
||||
|
||||
new_content: MessageChain
|
||||
"""编辑后的新内容"""
|
||||
|
||||
editor: User
|
||||
"""编辑者"""
|
||||
|
||||
chat_type: ChatType
|
||||
chat_id: typing.Union[int, str]
|
||||
group: typing.Optional[Group] = None
|
||||
```
|
||||
|
||||
#### MessageDeletedEvent (`message.deleted`)
|
||||
|
||||
消息被删除/撤回。
|
||||
|
||||
```python
|
||||
class MessageDeletedEvent(Event):
|
||||
"""消息被删除/撤回"""
|
||||
|
||||
type: str = "message.deleted"
|
||||
|
||||
message_id: typing.Union[int, str]
|
||||
"""被删除的消息 ID"""
|
||||
|
||||
operator: typing.Optional[User] = None
|
||||
"""操作者(可能是发送者自己撤回,也可能是管理员删除)"""
|
||||
|
||||
chat_type: ChatType
|
||||
chat_id: typing.Union[int, str]
|
||||
group: typing.Optional[Group] = None
|
||||
```
|
||||
|
||||
#### MessageReactionEvent (`message.reaction`)
|
||||
|
||||
消息收到表情回应。
|
||||
|
||||
```python
|
||||
class MessageReactionEvent(Event):
|
||||
"""消息收到表情回应"""
|
||||
|
||||
type: str = "message.reaction"
|
||||
|
||||
message_id: typing.Union[int, str]
|
||||
"""被回应的消息 ID"""
|
||||
|
||||
user: User
|
||||
"""回应者"""
|
||||
|
||||
reaction: str
|
||||
"""回应的表情标识(emoji 或平台特定表情 ID)"""
|
||||
|
||||
is_add: bool
|
||||
"""True 为添加回应,False 为移除回应"""
|
||||
|
||||
chat_type: ChatType
|
||||
chat_id: typing.Union[int, str]
|
||||
group: typing.Optional[Group] = None
|
||||
```
|
||||
|
||||
### 3.3 用户反馈事件
|
||||
|
||||
#### FeedbackReceivedEvent (`feedback.received`)
|
||||
|
||||
用户对 Bot 回复提交反馈。该事件用于承载平台提供的点赞、点踩、取消反馈以及点踩原因等评价信息;典型来源包括企业微信 AI Bot 的 `feedback_event`、飞书卡片按钮回调、Web Embed 的反馈入口等。
|
||||
|
||||
```python
|
||||
class FeedbackReceivedEvent(Event):
|
||||
"""收到用户反馈"""
|
||||
|
||||
type: str = "feedback.received"
|
||||
|
||||
feedback_id: str
|
||||
"""平台侧反馈 ID,用于幂等记录或取消反馈"""
|
||||
|
||||
feedback_type: int
|
||||
"""1 = like, 2 = dislike, 3 = cancel/remove feedback"""
|
||||
|
||||
feedback_content: typing.Optional[str] = None
|
||||
"""用户填写的自由文本反馈"""
|
||||
|
||||
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||
"""点踩时平台提供的预设不准确原因"""
|
||||
|
||||
user_id: typing.Optional[str] = None
|
||||
"""提交反馈的用户 ID"""
|
||||
|
||||
session_id: typing.Optional[str] = None
|
||||
"""会话 ID,例如 person_xxx 或 group_xxx"""
|
||||
|
||||
message_id: typing.Optional[str] = None
|
||||
"""被评价的 Bot 回复消息 ID"""
|
||||
|
||||
stream_id: typing.Optional[str] = None
|
||||
"""流式回复 ID,用于关联 streaming response"""
|
||||
```
|
||||
|
||||
设计约定:
|
||||
|
||||
- `feedback_id` 是幂等键;同一个 `feedback_id` 的后续事件应更新已有记录。
|
||||
- `feedback_type == 3` 表示用户取消/移除反馈,处理器可删除对应记录或标记为取消。
|
||||
- 如果平台只能给出原始回调 payload,差异字段保留在 `source_platform_object` 或 `PlatformSpecificEvent.data` 中;通用字段仍优先映射到 `FeedbackReceivedEvent`。
|
||||
- 该事件保留向后兼容映射:EBA 事件可转换为旧的 `FeedbackEvent`,字段语义保持一致。
|
||||
|
||||
### 3.4 群组事件
|
||||
|
||||
#### MemberJoinedEvent (`group.member_joined`)
|
||||
|
||||
新成员加入群组。
|
||||
|
||||
```python
|
||||
class MemberJoinedEvent(Event):
|
||||
"""新成员加入群组"""
|
||||
|
||||
type: str = "group.member_joined"
|
||||
|
||||
group: Group
|
||||
"""群组"""
|
||||
|
||||
member: User
|
||||
"""加入的成员"""
|
||||
|
||||
inviter: typing.Optional[User] = None
|
||||
"""邀请者(如有)"""
|
||||
|
||||
join_type: typing.Optional[str] = None
|
||||
"""加入方式:'invite' / 'request' / 'direct' / None"""
|
||||
```
|
||||
|
||||
#### MemberLeftEvent (`group.member_left`)
|
||||
|
||||
成员离开群组。
|
||||
|
||||
```python
|
||||
class MemberLeftEvent(Event):
|
||||
"""成员离开群组"""
|
||||
|
||||
type: str = "group.member_left"
|
||||
|
||||
group: Group
|
||||
member: User
|
||||
|
||||
is_kicked: bool = False
|
||||
"""是否被踢出"""
|
||||
|
||||
operator: typing.Optional[User] = None
|
||||
"""操作者(踢出时为管理员)"""
|
||||
```
|
||||
|
||||
#### MemberBannedEvent (`group.member_banned`)
|
||||
|
||||
成员被禁言。
|
||||
|
||||
```python
|
||||
class MemberBannedEvent(Event):
|
||||
"""成员被禁言"""
|
||||
|
||||
type: str = "group.member_banned"
|
||||
|
||||
group: Group
|
||||
member: User
|
||||
operator: typing.Optional[User] = None
|
||||
duration: typing.Optional[int] = None
|
||||
"""禁言时长(秒),None 表示永久"""
|
||||
```
|
||||
|
||||
#### MemberUnbannedEvent (`group.member_unbanned`)
|
||||
|
||||
成员被解除禁言。
|
||||
|
||||
```python
|
||||
class MemberUnbannedEvent(Event):
|
||||
"""成员被解除禁言"""
|
||||
|
||||
type: str = "group.member_unbanned"
|
||||
|
||||
group: Group
|
||||
member: User
|
||||
operator: typing.Optional[User] = None
|
||||
```
|
||||
|
||||
#### GroupInfoUpdatedEvent (`group.info_updated`)
|
||||
|
||||
群组信息被修改。
|
||||
|
||||
```python
|
||||
class GroupInfoUpdatedEvent(Event):
|
||||
"""群组信息被修改"""
|
||||
|
||||
type: str = "group.info_updated"
|
||||
|
||||
group: Group
|
||||
"""更新后的群组信息"""
|
||||
|
||||
operator: typing.Optional[User] = None
|
||||
"""操作者"""
|
||||
|
||||
changed_fields: list[str] = []
|
||||
"""发生变更的字段名列表,如 ['name', 'description']"""
|
||||
```
|
||||
|
||||
### 3.5 好友事件
|
||||
|
||||
#### FriendRequestReceivedEvent (`friend.request_received`)
|
||||
|
||||
收到好友请求。
|
||||
|
||||
```python
|
||||
class FriendRequestReceivedEvent(Event):
|
||||
"""收到好友请求"""
|
||||
|
||||
type: str = "friend.request_received"
|
||||
|
||||
request_id: typing.Union[int, str]
|
||||
"""请求 ID,用于后续 approve/reject 操作"""
|
||||
|
||||
user: User
|
||||
"""请求者"""
|
||||
|
||||
message: typing.Optional[str] = None
|
||||
"""验证消息"""
|
||||
```
|
||||
|
||||
#### FriendAddedEvent (`friend.added`)
|
||||
|
||||
成功添加好友。
|
||||
|
||||
```python
|
||||
class FriendAddedEvent(Event):
|
||||
"""成功添加好友"""
|
||||
|
||||
type: str = "friend.added"
|
||||
|
||||
user: User
|
||||
"""新好友"""
|
||||
```
|
||||
|
||||
#### FriendRemovedEvent (`friend.removed`)
|
||||
|
||||
好友被移除。
|
||||
|
||||
```python
|
||||
class FriendRemovedEvent(Event):
|
||||
"""好友被移除"""
|
||||
|
||||
type: str = "friend.removed"
|
||||
|
||||
user: User
|
||||
"""被移除的好友"""
|
||||
```
|
||||
|
||||
### 3.6 Bot 状态事件
|
||||
|
||||
#### BotInvitedToGroupEvent (`bot.invited_to_group`)
|
||||
|
||||
Bot 被邀请加入群组。
|
||||
|
||||
```python
|
||||
class BotInvitedToGroupEvent(Event):
|
||||
"""Bot 被邀请加入群组"""
|
||||
|
||||
type: str = "bot.invited_to_group"
|
||||
|
||||
group: Group
|
||||
inviter: typing.Optional[User] = None
|
||||
|
||||
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||
"""邀请请求 ID,某些平台需要 Bot 确认才加入"""
|
||||
```
|
||||
|
||||
#### BotRemovedFromGroupEvent (`bot.removed_from_group`)
|
||||
|
||||
Bot 被移出群组。
|
||||
|
||||
```python
|
||||
class BotRemovedFromGroupEvent(Event):
|
||||
"""Bot 被移出群组"""
|
||||
|
||||
type: str = "bot.removed_from_group"
|
||||
|
||||
group: Group
|
||||
operator: typing.Optional[User] = None
|
||||
```
|
||||
|
||||
#### BotMutedEvent / BotUnmutedEvent (`bot.muted` / `bot.unmuted`)
|
||||
|
||||
Bot 被禁言/解除禁言。
|
||||
|
||||
```python
|
||||
class BotMutedEvent(Event):
|
||||
"""Bot 被禁言"""
|
||||
|
||||
type: str = "bot.muted"
|
||||
|
||||
group: Group
|
||||
operator: typing.Optional[User] = None
|
||||
duration: typing.Optional[int] = None
|
||||
|
||||
|
||||
class BotUnmutedEvent(Event):
|
||||
"""Bot 被解除禁言"""
|
||||
|
||||
type: str = "bot.unmuted"
|
||||
|
||||
group: Group
|
||||
operator: typing.Optional[User] = None
|
||||
```
|
||||
|
||||
### 3.7 平台特有事件
|
||||
|
||||
对于无法抽象为通用事件的平台特有事件,使用统一的 `PlatformSpecificEvent` 承载:
|
||||
|
||||
```python
|
||||
class PlatformSpecificEvent(Event):
|
||||
"""平台特有事件
|
||||
|
||||
适配器无法映射到通用事件类型时,使用此类型承载。
|
||||
插件可以通过 adapter_name + action 来识别和处理。
|
||||
"""
|
||||
|
||||
type: str = "platform.specific"
|
||||
|
||||
action: str
|
||||
"""平台特有的事件动作标识,如 'channel_created', 'pin_message'"""
|
||||
|
||||
data: dict = {}
|
||||
"""事件数据,结构由具体适配器定义"""
|
||||
```
|
||||
|
||||
事件类型字符串格式为 `platform.{adapter_name}.{action}`,例如:
|
||||
- `platform.telegram.chat_member_updated` — Telegram 的群成员信息更新
|
||||
- `platform.discord.channel_created` — Discord 的频道创建
|
||||
- `platform.discord.voice_state_update` — Discord 的语音状态变更
|
||||
- `platform.slack.app_home_opened` — Slack 的 App Home 打开
|
||||
|
||||
## 4. 各平台事件支持矩阵
|
||||
|
||||
下表标注各通用事件在主要平台上的支持情况:
|
||||
|
||||
| 事件 | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||
|------|----------|---------|-----------|------|------|-------|------|------|------|
|
||||
| `message.received` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `message.edited` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||
| `message.deleted` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||
| `message.reaction` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||
| `feedback.received` | N | N | N | Y | N | N | Y | N | N |
|
||||
| `group.member_joined` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `group.member_left` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `group.member_banned` | Y | Y | Y | N | N | N | N | N | N |
|
||||
| `group.info_updated` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||
| `friend.request_received` | N | Y | Y | N | N | N | Y | Y | Y |
|
||||
| `friend.added` | N | Y | Y | N | N | N | Y | Y | N |
|
||||
| `bot.invited_to_group` | Y | Y | Y | Y | Y | Y | Y | N | Y |
|
||||
| `bot.removed_from_group` | Y | Y | Y | Y | N | N | Y | N | Y |
|
||||
| `bot.muted` | Y | N | Y | N | N | N | N | N | N |
|
||||
| `bot.unmuted` | Y | N | Y | N | N | N | N | N | N |
|
||||
| `platform.specific` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
|
||||
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准,实施时逐个确认。
|
||||
|
||||
## 5. 事件生命周期
|
||||
|
||||
```
|
||||
1. 平台 SDK 回调触发
|
||||
│
|
||||
2. 适配器 EventConverter.target2yiri(raw_event)
|
||||
│ 将平台原生事件转换为统一 Event 对象
|
||||
│ 无法映射的事件 → PlatformSpecificEvent
|
||||
│
|
||||
3. 适配器回调注册的 listener(event, adapter)
|
||||
│
|
||||
4. RuntimeBot 接收事件
|
||||
│
|
||||
5. EventBus 分发
|
||||
│
|
||||
6. EventRouter 查询 Bot 的 event_handlers 配置
|
||||
│ 匹配事件类型 → 找到对应的 Handler
|
||||
│ 支持通配符:'message.*' 匹配所有消息事件
|
||||
│ 未匹配到 → 走默认 Handler(plugin,保持向后兼容)
|
||||
│
|
||||
7. Handler 处理事件
|
||||
│ PipelineHandler → 进入 Pipeline 流水线
|
||||
│ AgentHandler → 调用 RequestRunner
|
||||
│ WebhookHandler → POST 到外部 URL
|
||||
│ PluginHandler → 分发给插件 EventListener
|
||||
│
|
||||
8. Handler 执行完毕,可能通过 API 执行响应动作
|
||||
(发消息、编辑消息、踢人、同意好友请求等)
|
||||
```
|
||||
|
||||
## 6. 与现有事件类型的兼容映射
|
||||
|
||||
为保证现有插件不受影响,建立以下映射关系:
|
||||
|
||||
| 新事件 | 条件 | 旧事件 |
|
||||
|--------|------|--------|
|
||||
| `MessageReceivedEvent` (chat_type=private) | — | `FriendMessage` |
|
||||
| `MessageReceivedEvent` (chat_type=group) | — | `GroupMessage` |
|
||||
|
||||
在插件 SDK 层面:
|
||||
|
||||
| 新事件 | 旧插件事件 |
|
||||
|--------|-----------|
|
||||
| `MessageReceivedEvent` (chat_type=private, 非命令) | `PersonNormalMessageReceived` |
|
||||
| `MessageReceivedEvent` (chat_type=group, 非命令) | `GroupNormalMessageReceived` |
|
||||
| `MessageReceivedEvent` (chat_type=private, 命令) | `PersonCommandSent` |
|
||||
| `MessageReceivedEvent` (chat_type=group, 命令) | `GroupCommandSent` |
|
||||
| `MessageReceivedEvent` (处理完毕后) | `NormalMessageResponded` |
|
||||
|
||||
兼容层在事件分发给插件 EventListener 时自动生成旧格式事件,确保监听旧事件类型的插件仍能正常工作。
|
||||
|
||||
## 7. 事件类型注册表
|
||||
|
||||
适配器在 manifest.yaml 中声明自己支持的事件类型:
|
||||
|
||||
```yaml
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: telegram
|
||||
spec:
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.edited
|
||||
- message.deleted
|
||||
- message.reaction
|
||||
- feedback.received
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- group.member_banned
|
||||
- group.info_updated
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- bot.muted
|
||||
- bot.unmuted
|
||||
- platform.specific
|
||||
platform_specific_events:
|
||||
- chat_member_updated
|
||||
- chat_join_request
|
||||
```
|
||||
|
||||
这份声明用于:
|
||||
1. WebUI 在配置事件处理器时,只显示当前 Bot 的适配器支持的事件类型
|
||||
2. EventRouter 在路由时校验事件类型有效性
|
||||
3. 文档自动生成
|
||||
@@ -0,0 +1,546 @@
|
||||
# 统一平台 API 与实体定义
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- **通用 API 抽象**:大部分平台都支持的操作(发消息、获取群信息等)定义为通用 API 方法
|
||||
- **required / optional 标记**:每个 API 标记为必需或可选,适配器未实现可选 API 时抛出 `NotSupportedError`
|
||||
- **透传机制**:适配器特有的操作通过 `call_platform_api(action, params)` 统一入口透传调用
|
||||
- **能力声明**:适配器在 manifest 中声明自己支持的 API 列表,供 WebUI 和插件查询
|
||||
- **实体统一**:通用实体(User、Group 等)在 SDK 层面统一定义,适配器负责转换
|
||||
|
||||
## 2. 通用实体定义
|
||||
|
||||
### 2.1 现有实体回顾
|
||||
|
||||
当前 SDK 已有以下实体(`langbot_plugin/api/entities/builtin/platform/entities.py`):
|
||||
|
||||
```python
|
||||
Entity(id)
|
||||
├── Friend(id, nickname, remark)
|
||||
├── Group(id, name, permission)
|
||||
└── GroupMember(id, member_name, permission, group, special_title)
|
||||
```
|
||||
|
||||
### 2.2 新实体设计
|
||||
|
||||
扩展实体体系,保持向后兼容:
|
||||
|
||||
```python
|
||||
class User(pydantic.BaseModel):
|
||||
"""用户实体(统一表示)"""
|
||||
|
||||
id: typing.Union[int, str]
|
||||
"""用户 ID"""
|
||||
|
||||
nickname: str = ""
|
||||
"""昵称"""
|
||||
|
||||
avatar_url: typing.Optional[str] = None
|
||||
"""头像 URL"""
|
||||
|
||||
is_bot: bool = False
|
||||
"""是否为 Bot"""
|
||||
|
||||
# 以下为可选的扩展信息,不同平台可能部分为空
|
||||
username: typing.Optional[str] = None
|
||||
"""用户名(如 Telegram 的 @username)"""
|
||||
|
||||
remark: typing.Optional[str] = None
|
||||
"""备注名"""
|
||||
|
||||
|
||||
class Group(pydantic.BaseModel):
|
||||
"""群组实体"""
|
||||
|
||||
id: typing.Union[int, str]
|
||||
"""群组 ID"""
|
||||
|
||||
name: str = ""
|
||||
"""群组名称"""
|
||||
|
||||
description: typing.Optional[str] = None
|
||||
"""群组描述"""
|
||||
|
||||
member_count: typing.Optional[int] = None
|
||||
"""成员数量"""
|
||||
|
||||
avatar_url: typing.Optional[str] = None
|
||||
"""群组头像 URL"""
|
||||
|
||||
owner_id: typing.Optional[typing.Union[int, str]] = None
|
||||
"""群主 ID"""
|
||||
|
||||
|
||||
class GroupMember(pydantic.BaseModel):
|
||||
"""群成员实体"""
|
||||
|
||||
user: User
|
||||
"""用户信息"""
|
||||
|
||||
group_id: typing.Union[int, str]
|
||||
"""所属群组 ID"""
|
||||
|
||||
role: MemberRole
|
||||
"""群内角色"""
|
||||
|
||||
display_name: typing.Optional[str] = None
|
||||
"""群内显示名"""
|
||||
|
||||
joined_at: typing.Optional[float] = None
|
||||
"""加入群组的时间戳"""
|
||||
|
||||
title: typing.Optional[str] = None
|
||||
"""群头衔/特殊称号"""
|
||||
|
||||
|
||||
class MemberRole(str, Enum):
|
||||
"""群成员角色"""
|
||||
OWNER = "owner"
|
||||
ADMIN = "admin"
|
||||
MEMBER = "member"
|
||||
```
|
||||
|
||||
### 2.3 与现有实体的兼容映射
|
||||
|
||||
| 新实体 | 旧实体 | 映射方式 |
|
||||
|--------|--------|----------|
|
||||
| `User` | `Friend` | `User(id=friend.id, nickname=friend.nickname, remark=friend.remark)` |
|
||||
| `Group` | `Group`(旧) | `Group(id=old.id, name=old.name)` + `permission` 字段弃用 |
|
||||
| `GroupMember` | `GroupMember`(旧) | `GroupMember(user=User(...), role=..., display_name=old.member_name)` |
|
||||
| `MemberRole` | `Permission` | `OWNER↔Owner`, `ADMIN↔Administrator`, `MEMBER↔Member` |
|
||||
|
||||
旧实体类保留,标记为 `@deprecated`,内部通过转换方法桥接到新实体。
|
||||
|
||||
## 3. 通用 API 定义
|
||||
|
||||
### 3.1 API 方法一览
|
||||
|
||||
#### 消息 API
|
||||
|
||||
| 方法 | 必需/可选 | 说明 |
|
||||
|------|----------|------|
|
||||
| `send_message(target_type, target_id, message)` | **必需** | 主动发送消息 |
|
||||
| `reply_message(event, message, quote_origin)` | **必需** | 回复一个消息事件 |
|
||||
| `edit_message(chat_type, chat_id, message_id, new_content)` | 可选 | 编辑已发送的消息 |
|
||||
| `delete_message(chat_type, chat_id, message_id)` | 可选 | 删除/撤回消息 |
|
||||
| `forward_message(from_chat, message_id, to_chat_type, to_chat_id)` | 可选 | 转发消息到另一个会话 |
|
||||
| `get_message(chat_type, chat_id, message_id)` | 可选 | 获取指定消息的内容 |
|
||||
|
||||
#### 群组 API
|
||||
|
||||
| 方法 | 必需/可选 | 说明 |
|
||||
|------|----------|------|
|
||||
| `get_group_info(group_id)` | 可选 | 获取群组信息 |
|
||||
| `get_group_list()` | 可选 | 获取 Bot 加入的群组列表 |
|
||||
| `get_group_member_list(group_id)` | 可选 | 获取群成员列表 |
|
||||
| `get_group_member_info(group_id, user_id)` | 可选 | 获取指定群成员信息 |
|
||||
| `set_group_name(group_id, name)` | 可选 | 修改群名称 |
|
||||
| `mute_member(group_id, user_id, duration)` | 可选 | 禁言群成员 |
|
||||
| `unmute_member(group_id, user_id)` | 可选 | 解除禁言 |
|
||||
| `kick_member(group_id, user_id)` | 可选 | 踢出群成员 |
|
||||
| `leave_group(group_id)` | 可选 | Bot 退出群组 |
|
||||
|
||||
#### 用户 API
|
||||
|
||||
| 方法 | 必需/可选 | 说明 |
|
||||
|------|----------|------|
|
||||
| `get_user_info(user_id)` | 可选 | 获取用户信息 |
|
||||
| `get_friend_list()` | 可选 | 获取好友列表 |
|
||||
| `approve_friend_request(request_id, approve, remark)` | 可选 | 处理好友请求 |
|
||||
| `approve_group_invite(request_id, approve)` | 可选 | 处理入群邀请 |
|
||||
|
||||
#### 媒体 API
|
||||
|
||||
| 方法 | 必需/可选 | 说明 |
|
||||
|------|----------|------|
|
||||
| `upload_file(file_data, filename)` | 可选 | 上传文件,返回可引用的文件 ID 或 URL |
|
||||
| `get_file_url(file_id)` | 可选 | 获取文件下载 URL |
|
||||
|
||||
#### 透传 API
|
||||
|
||||
| 方法 | 必需/可选 | 说明 |
|
||||
|------|----------|------|
|
||||
| `call_platform_api(action, params)` | 可选 | 调用适配器特有 API |
|
||||
|
||||
### 3.2 API 方法签名详解
|
||||
|
||||
```python
|
||||
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||
"""平台适配器基类(新版)"""
|
||||
|
||||
# ======== 必需方法 ========
|
||||
|
||||
@abc.abstractmethod
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str, # "private" | "group"
|
||||
target_id: typing.Union[int, str],
|
||||
message: MessageChain,
|
||||
) -> MessageResult:
|
||||
"""主动发送消息
|
||||
|
||||
Returns:
|
||||
MessageResult: 包含 message_id 等发送结果
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def reply_message(
|
||||
self,
|
||||
event: MessageReceivedEvent,
|
||||
message: MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> MessageResult:
|
||||
"""回复一个消息事件"""
|
||||
...
|
||||
|
||||
# ======== 可选消息方法 ========
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: MessageChain,
|
||||
) -> None:
|
||||
"""编辑已发送的消息"""
|
||||
raise NotSupportedError("edit_message")
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""删除/撤回消息"""
|
||||
raise NotSupportedError("delete_message")
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> MessageResult:
|
||||
"""转发消息"""
|
||||
raise NotSupportedError("forward_message")
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> MessageReceivedEvent:
|
||||
"""获取指定消息"""
|
||||
raise NotSupportedError("get_message")
|
||||
|
||||
# ======== 可选群组方法 ========
|
||||
|
||||
async def get_group_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> Group:
|
||||
"""获取群组信息"""
|
||||
raise NotSupportedError("get_group_info")
|
||||
|
||||
async def get_group_list(self) -> list[Group]:
|
||||
"""获取 Bot 加入的群组列表"""
|
||||
raise NotSupportedError("get_group_list")
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[GroupMember]:
|
||||
"""获取群成员列表"""
|
||||
raise NotSupportedError("get_group_member_list")
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> GroupMember:
|
||||
"""获取指定群成员信息"""
|
||||
raise NotSupportedError("get_group_member_info")
|
||||
|
||||
async def set_group_name(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
name: str,
|
||||
) -> None:
|
||||
"""修改群名称"""
|
||||
raise NotSupportedError("set_group_name")
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
"""禁言群成员,duration 为秒数,0 表示永久"""
|
||||
raise NotSupportedError("mute_member")
|
||||
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""解除禁言"""
|
||||
raise NotSupportedError("unmute_member")
|
||||
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""踢出群成员"""
|
||||
raise NotSupportedError("kick_member")
|
||||
|
||||
async def leave_group(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""Bot 退出群组"""
|
||||
raise NotSupportedError("leave_group")
|
||||
|
||||
# ======== 可选用户方法 ========
|
||||
|
||||
async def get_user_info(
|
||||
self,
|
||||
user_id: typing.Union[int, str],
|
||||
) -> User:
|
||||
"""获取用户信息"""
|
||||
raise NotSupportedError("get_user_info")
|
||||
|
||||
async def get_friend_list(self) -> list[User]:
|
||||
"""获取好友列表"""
|
||||
raise NotSupportedError("get_friend_list")
|
||||
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
remark: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
"""处理好友请求"""
|
||||
raise NotSupportedError("approve_friend_request")
|
||||
|
||||
async def approve_group_invite(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
) -> None:
|
||||
"""处理入群邀请"""
|
||||
raise NotSupportedError("approve_group_invite")
|
||||
|
||||
# ======== 可选媒体方法 ========
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
file_data: bytes,
|
||||
filename: str,
|
||||
) -> str:
|
||||
"""上传文件,返回文件 ID 或 URL"""
|
||||
raise NotSupportedError("upload_file")
|
||||
|
||||
async def get_file_url(
|
||||
self,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
"""获取文件下载 URL"""
|
||||
raise NotSupportedError("get_file_url")
|
||||
|
||||
# ======== 透传 API ========
|
||||
|
||||
async def call_platform_api(
|
||||
self,
|
||||
action: str,
|
||||
params: dict = {},
|
||||
) -> dict:
|
||||
"""调用适配器特有 API
|
||||
|
||||
Args:
|
||||
action: 平台特有的 API 动作标识
|
||||
params: 参数字典
|
||||
|
||||
Returns:
|
||||
dict: 返回结果
|
||||
|
||||
Examples:
|
||||
# Telegram: pin 消息
|
||||
await adapter.call_platform_api("pin_message", {
|
||||
"chat_id": 123456,
|
||||
"message_id": 789
|
||||
})
|
||||
|
||||
# Discord: 创建频道
|
||||
await adapter.call_platform_api("create_channel", {
|
||||
"guild_id": "...",
|
||||
"name": "new-channel",
|
||||
"type": "text"
|
||||
})
|
||||
"""
|
||||
raise NotSupportedError("call_platform_api")
|
||||
|
||||
# ======== 流式输出(保留现有机制) ========
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
event: MessageReceivedEvent,
|
||||
bot_message: dict,
|
||||
message: MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
"""流式回复消息"""
|
||||
raise NotSupportedError("reply_message_chunk")
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""是否支持流式输出"""
|
||||
return False
|
||||
|
||||
# ======== 生命周期方法(保留现有) ========
|
||||
|
||||
@abc.abstractmethod
|
||||
async def run_async(self):
|
||||
"""启动适配器"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def kill(self) -> bool:
|
||||
"""停止适配器"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def register_listener(self, event_type, callback):
|
||||
"""注册事件监听器"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def unregister_listener(self, event_type, callback):
|
||||
"""注销事件监听器"""
|
||||
...
|
||||
```
|
||||
|
||||
### 3.3 返回值类型
|
||||
|
||||
```python
|
||||
class MessageResult(pydantic.BaseModel):
|
||||
"""消息发送结果"""
|
||||
|
||||
message_id: typing.Optional[typing.Union[int, str]] = None
|
||||
"""发送成功后的消息 ID"""
|
||||
|
||||
raw: typing.Optional[dict] = None
|
||||
"""平台原始返回数据"""
|
||||
|
||||
|
||||
class NotSupportedError(Exception):
|
||||
"""适配器未实现此 API"""
|
||||
|
||||
def __init__(self, api_name: str):
|
||||
self.api_name = api_name
|
||||
super().__init__(f"API not supported by this adapter: {api_name}")
|
||||
```
|
||||
|
||||
## 4. API 能力声明
|
||||
|
||||
适配器在 manifest.yaml 中声明支持的 API:
|
||||
|
||||
```yaml
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: telegram
|
||||
spec:
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- edit_message
|
||||
- delete_message
|
||||
- get_group_info
|
||||
- get_group_member_list
|
||||
- get_user_info
|
||||
- upload_file
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
platform_specific_apis:
|
||||
- action: pin_message
|
||||
description: "Pin a message in a chat"
|
||||
params_schema:
|
||||
chat_id: { type: "string", required: true }
|
||||
message_id: { type: "string", required: true }
|
||||
- action: unpin_message
|
||||
description: "Unpin a message"
|
||||
params_schema:
|
||||
chat_id: { type: "string", required: true }
|
||||
message_id: { type: "string", required: true }
|
||||
```
|
||||
|
||||
用途:
|
||||
1. **WebUI**:在配置界面展示当前 Bot 可用的 API 能力
|
||||
2. **插件**:插件可查询某个 Bot 是否支持特定 API,据此决定行为
|
||||
3. **文档**:自动生成各适配器的 API 支持矩阵
|
||||
|
||||
## 5. 各平台 API 支持矩阵
|
||||
|
||||
| API | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||
|-----|----------|---------|-----------|------|------|-------|------|------|------|
|
||||
| `send_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `reply_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `edit_message` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||
| `delete_message` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||
| `forward_message` | Y | N | Y | Y | N | N | Y | N | N |
|
||||
| `get_group_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||
| `get_group_member_list` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||
| `get_user_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||
| `get_friend_list` | N | Y | Y | N | N | N | Y | N | N |
|
||||
| `mute_member` | Y | Y | Y | N | N | N | N | N | N |
|
||||
| `kick_member` | Y | Y | Y | N | N | N | N | N | Y |
|
||||
| `upload_file` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
| `call_platform_api` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||
|
||||
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准。
|
||||
|
||||
## 6. MessageChain 扩展
|
||||
|
||||
### 6.1 保留的通用组件
|
||||
|
||||
以下 MessageComponent 类型保持不变,继续作为通用消息元素:
|
||||
|
||||
- `Source` — 消息元信息
|
||||
- `Plain` — 纯文本
|
||||
- `Quote` — 引用回复
|
||||
- `At` / `AtAll` — @提及
|
||||
- `Image` — 图片
|
||||
- `Voice` — 语音
|
||||
- `File` — 文件
|
||||
- `Forward` — 合并转发
|
||||
- `Face` — 表情
|
||||
- `Unknown` — 未知类型
|
||||
|
||||
### 6.2 平台特有组件处理
|
||||
|
||||
当前 MessageChain 中存在大量微信特有的组件类型(`WeChatMiniPrograms`, `WeChatEmoji`, `WeChatLink` 等)。在新架构下:
|
||||
|
||||
- 这些类型**继续保留**在 SDK 中以保持兼容
|
||||
- 新增的平台特有消息组件统一使用 `PlatformComponent` 基类:
|
||||
|
||||
```python
|
||||
class PlatformComponent(MessageComponent):
|
||||
"""平台特有的消息组件"""
|
||||
|
||||
type: str = "Platform"
|
||||
|
||||
platform: str
|
||||
"""平台标识"""
|
||||
|
||||
component_type: str
|
||||
"""组件类型"""
|
||||
|
||||
data: dict = {}
|
||||
"""组件数据"""
|
||||
```
|
||||
|
||||
适配器在转换消息链时,对于无法映射到通用组件的平台特有内容,使用 `PlatformComponent` 承载。
|
||||
@@ -0,0 +1,483 @@
|
||||
# 适配器新目录结构
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
- **模块化**:每个适配器从单文件拆分到独立目录,各模块职责清晰
|
||||
- **可维护**:随着事件和 API 的增加,代码量会显著增长,目录结构有助于管理复杂度
|
||||
- **一致性**:所有适配器遵循相同的目录布局和文件命名约定
|
||||
- **兼容现有发现机制**:保持 YAML manifest + ComponentDiscoveryEngine 的注册体系
|
||||
|
||||
## 2. 新目录布局
|
||||
|
||||
### 2.1 整体结构
|
||||
|
||||
```
|
||||
pkg/platform/
|
||||
├── __init__.py
|
||||
├── botmgr.py # PlatformManager + RuntimeBot(重构)
|
||||
├── event_bus.py # EventBus(新增)
|
||||
├── event_router.py # EventRouter(新增)
|
||||
├── logger.py # EventLogger(保留)
|
||||
├── webhook_pusher.py # WebhookPusher(重构为 WebhookHandler)
|
||||
│
|
||||
├── adapters/ # 适配器(新目录)
|
||||
│ ├── __init__.py
|
||||
│ │
|
||||
│ ├── telegram/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── adapter.py # TelegramAdapter 主类
|
||||
│ │ ├── event_converter.py # 平台事件 → 统一事件
|
||||
│ │ ├── message_converter.py # MessageChain 互转
|
||||
│ │ ├── api_impl.py # 通用 API 实现
|
||||
│ │ ├── platform_api.py # call_platform_api 的动作映射
|
||||
│ │ ├── types.py # 平台特有类型定义
|
||||
│ │ └── manifest.yaml # 适配器清单
|
||||
│ │
|
||||
│ ├── discord/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── adapter.py
|
||||
│ │ ├── event_converter.py
|
||||
│ │ ├── message_converter.py
|
||||
│ │ ├── api_impl.py
|
||||
│ │ ├── platform_api.py
|
||||
│ │ ├── types.py
|
||||
│ │ ├── voice.py # Discord 语音连接管理(特有)
|
||||
│ │ └── manifest.yaml
|
||||
│ │
|
||||
│ ├── aiocqhttp/ # OneBot v11 (QQ)
|
||||
│ │ └── ...
|
||||
│ ├── qqofficial/
|
||||
│ │ └── ...
|
||||
│ ├── lark/ # 飞书
|
||||
│ │ └── ...
|
||||
│ ├── dingtalk/
|
||||
│ │ └── ...
|
||||
│ ├── slack/
|
||||
│ │ └── ...
|
||||
│ ├── wechatpad/
|
||||
│ │ └── ...
|
||||
│ ├── officialaccount/ # 微信公众号
|
||||
│ │ └── ...
|
||||
│ ├── wecom/ # 企业微信
|
||||
│ │ └── ...
|
||||
│ ├── wecombot/
|
||||
│ │ └── ...
|
||||
│ ├── wecomcs/
|
||||
│ │ └── ...
|
||||
│ ├── kook/
|
||||
│ │ └── ...
|
||||
│ ├── line/
|
||||
│ │ └── ...
|
||||
│ ├── satori/
|
||||
│ │ └── ...
|
||||
│ ├── websocket/ # 内置 WebSocket 适配器
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── adapter.py
|
||||
│ │ ├── manager.py # WebSocket 连接管理
|
||||
│ │ └── manifest.yaml
|
||||
│ │
|
||||
│ └── legacy/ # 旧版适配器(保留一段时间后移除)
|
||||
│ ├── gewechat/
|
||||
│ ├── nakuru/
|
||||
│ └── qqbotpy/
|
||||
│
|
||||
└── handlers/ # 事件处理器实现(新增)
|
||||
├── __init__.py
|
||||
├── base.py # AbstractEventHandler 基类
|
||||
├── pipeline_handler.py # PipelineHandler
|
||||
├── agent_handler.py # AgentHandler
|
||||
├── webhook_handler.py # WebhookHandler
|
||||
└── plugin_handler.py # PluginHandler
|
||||
```
|
||||
|
||||
### 2.2 适配器目录内各文件职责
|
||||
|
||||
以 Telegram 为例:
|
||||
|
||||
| 文件 | 职责 | 关键类/函数 |
|
||||
|------|------|------------|
|
||||
| `adapter.py` | 主入口,继承 `AbstractPlatformAdapter`,组装其他模块 | `TelegramAdapter` |
|
||||
| `event_converter.py` | 将 Telegram 原生事件转换为统一事件类型 | `TelegramEventConverter` — 支持 Message/Edit/Delete/Reaction/MemberJoin 等所有事件 |
|
||||
| `message_converter.py` | `MessageChain` 与 Telegram 消息格式互转 | `TelegramMessageConverter.yiri2target()` / `target2yiri()` |
|
||||
| `api_impl.py` | 实现通用 API 方法(edit_message, delete_message, get_group_info 等) | 各 API 方法的 Telegram 实现 |
|
||||
| `platform_api.py` | 实现 `call_platform_api` 的动作分发表 | `PLATFORM_API_MAP = {"pin_message": ..., "unpin_message": ...}` |
|
||||
| `types.py` | 平台特有的类型定义 | Telegram 特有的枚举、配置结构等 |
|
||||
| `manifest.yaml` | 适配器清单:名称、配置 schema、支持的事件和 API 列表 | — |
|
||||
|
||||
## 3. 新基类设计
|
||||
|
||||
### 3.1 AbstractPlatformAdapter
|
||||
|
||||
新基类继承自现有 `AbstractMessagePlatformAdapter` 并扩展,位于 `langbot-plugin-sdk` 中:
|
||||
|
||||
```python
|
||||
# langbot_plugin/api/definition/abstract/platform/adapter.py
|
||||
|
||||
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||
"""平台适配器基类(EBA 版本)
|
||||
|
||||
相比旧版 AbstractMessagePlatformAdapter:
|
||||
- 新增通用 API 方法(edit_message, delete_message, get_group_info 等)
|
||||
- 新增透传 API(call_platform_api)
|
||||
- 新增能力声明(get_supported_events, get_supported_apis)
|
||||
- 事件监听器支持所有事件类型,不仅限于消息事件
|
||||
"""
|
||||
|
||||
bot_account_id: str = ""
|
||||
config: dict
|
||||
logger: AbstractEventLogger = pydantic.Field(exclude=True)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
# ---- 能力声明 ----
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
"""返回此适配器支持的事件类型列表
|
||||
|
||||
默认实现从 manifest.yaml 读取。
|
||||
适配器也可以 override 此方法动态声明。
|
||||
"""
|
||||
return ["message.received"]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
"""返回此适配器支持的 API 列表
|
||||
|
||||
默认实现从 manifest.yaml 读取。
|
||||
"""
|
||||
return ["send_message", "reply_message"]
|
||||
|
||||
# ---- 必需方法(抽象) ----
|
||||
|
||||
@abc.abstractmethod
|
||||
async def send_message(self, target_type, target_id, message) -> MessageResult:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def reply_message(self, event, message, quote_origin=False) -> MessageResult:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def run_async(self):
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def kill(self) -> bool:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def register_listener(self, event_type, callback):
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def unregister_listener(self, event_type, callback):
|
||||
...
|
||||
|
||||
# ---- 可选方法(默认抛 NotSupportedError) ----
|
||||
# edit_message, delete_message, forward_message,
|
||||
# get_group_info, get_group_member_list, ...
|
||||
# call_platform_api, ...
|
||||
# (完整签名见 02-platform-api.md)
|
||||
|
||||
# ---- 流式输出(保留) ----
|
||||
|
||||
async def reply_message_chunk(self, event, bot_message, message,
|
||||
quote_origin=False, is_final=False):
|
||||
raise NotSupportedError("reply_message_chunk")
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return False
|
||||
|
||||
# ---- 消息卡片(保留) ----
|
||||
|
||||
async def create_message_card(self, message_id, event) -> bool:
|
||||
return False
|
||||
|
||||
async def is_muted(self, group_id) -> bool:
|
||||
return False
|
||||
```
|
||||
|
||||
### 3.2 AbstractMessagePlatformAdapter 兼容
|
||||
|
||||
旧的 `AbstractMessagePlatformAdapter` 保留为 `AbstractPlatformAdapter` 的类型别名:
|
||||
|
||||
```python
|
||||
# 向后兼容
|
||||
AbstractMessagePlatformAdapter = AbstractPlatformAdapter
|
||||
```
|
||||
|
||||
现有适配器代码中的 `AbstractMessagePlatformAdapter` 引用不需要立即修改。
|
||||
|
||||
### 3.3 EventConverter 新设计
|
||||
|
||||
现有 `AbstractEventConverter` 只有 `target2yiri` 和 `yiri2target` 两个静态方法,且只处理消息事件。
|
||||
|
||||
新设计支持多种事件类型:
|
||||
|
||||
```python
|
||||
class AbstractEventConverter:
|
||||
"""事件转换器基类(EBA 版本)
|
||||
|
||||
适配器需要实现此转换器,将平台原生事件转换为统一事件。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def target2yiri(raw_event: typing.Any) -> typing.Optional[Event]:
|
||||
"""将平台原生事件转换为统一事件
|
||||
|
||||
Args:
|
||||
raw_event: 平台 SDK 回调传入的原始事件对象
|
||||
|
||||
Returns:
|
||||
统一 Event 对象,如果无法转换或不需要处理则返回 None
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def yiri2target(event: Event) -> typing.Any:
|
||||
"""将统一事件转换为平台原生事件(一般不需要)"""
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
具体适配器的 EventConverter 实现会是一个分发式的结构:
|
||||
|
||||
```python
|
||||
class TelegramEventConverter(AbstractEventConverter):
|
||||
"""Telegram 事件转换器"""
|
||||
|
||||
@staticmethod
|
||||
def target2yiri(update: telegram.Update) -> typing.Optional[Event]:
|
||||
# 消息事件
|
||||
if update.message:
|
||||
return TelegramEventConverter._convert_message(update)
|
||||
# 消息编辑
|
||||
if update.edited_message:
|
||||
return TelegramEventConverter._convert_edited_message(update)
|
||||
# 成员变动
|
||||
if update.chat_member:
|
||||
return TelegramEventConverter._convert_chat_member(update)
|
||||
# 回调查询(按钮点击等)
|
||||
if update.callback_query:
|
||||
return TelegramEventConverter._convert_callback_query(update)
|
||||
# 其他 → PlatformSpecificEvent
|
||||
return TelegramEventConverter._convert_platform_specific(update)
|
||||
|
||||
@staticmethod
|
||||
def _convert_message(update) -> MessageReceivedEvent:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def _convert_edited_message(update) -> MessageEditedEvent:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def _convert_chat_member(update) -> typing.Union[
|
||||
MemberJoinedEvent, MemberLeftEvent, ...
|
||||
]:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def _convert_platform_specific(update) -> PlatformSpecificEvent:
|
||||
...
|
||||
```
|
||||
|
||||
## 4. Manifest 文件格式扩展
|
||||
|
||||
现有 manifest.yaml 只声明 `kind`, `metadata`, `spec.config`, `execution`。
|
||||
|
||||
新增 `spec.supported_events` 和 `spec.supported_apis`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: telegram
|
||||
label:
|
||||
en_US: Telegram
|
||||
zh_Hans: Telegram
|
||||
icon: telegram.svg
|
||||
description:
|
||||
en_US: Telegram Bot adapter
|
||||
zh_Hans: Telegram Bot 适配器
|
||||
|
||||
spec:
|
||||
config:
|
||||
# 现有配置 schema(保持不变)
|
||||
- key: token
|
||||
label: { en_US: "Bot Token", zh_Hans: "Bot Token" }
|
||||
type: string
|
||||
required: true
|
||||
sensitive: true
|
||||
# ...
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.edited
|
||||
- message.deleted
|
||||
- message.reaction
|
||||
- feedback.received
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- group.member_banned
|
||||
- group.info_updated
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- bot.muted
|
||||
- bot.unmuted
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- edit_message
|
||||
- delete_message
|
||||
- get_group_info
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- get_user_info
|
||||
- upload_file
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: pin_message
|
||||
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
|
||||
- action: unpin_message
|
||||
description: { en_US: "Unpin a message", zh_Hans: "取消置顶" }
|
||||
- action: get_chat_administrators
|
||||
description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: pkg/platform/adapters/telegram/adapter.py
|
||||
attr: TelegramAdapter
|
||||
```
|
||||
|
||||
## 5. 适配器注册与发现
|
||||
|
||||
### 5.1 Blueprint 更新
|
||||
|
||||
`templates/components.yaml` 中更新扫描路径:
|
||||
|
||||
```yaml
|
||||
kind: Blueprint
|
||||
spec:
|
||||
components:
|
||||
MessagePlatformAdapter:
|
||||
fromDirs:
|
||||
- path: pkg/platform/adapters/ # 新路径
|
||||
```
|
||||
|
||||
`ComponentDiscoveryEngine` 的递归扫描逻辑不变——它会扫描所有子目录中的 `.yaml` 文件。因此每个适配器目录下的 `manifest.yaml` 会被自动发现。
|
||||
|
||||
### 5.2 PlatformManager 适配
|
||||
|
||||
`PlatformManager.initialize()` 的核心逻辑基本不变:
|
||||
|
||||
```python
|
||||
async def initialize(self):
|
||||
# 1. 发现适配器组件(自动扫描新目录结构)
|
||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||
|
||||
# 2. 动态导入适配器类
|
||||
for component in self.adapter_components:
|
||||
self.adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||
|
||||
# 3. 从数据库加载 Bot 并实例化适配器(不变)
|
||||
await self.load_bots_from_db()
|
||||
```
|
||||
|
||||
变更点:
|
||||
- `execution.python.path` 从 `pkg/platform/sources/telegram.py` 变为 `pkg/platform/adapters/telegram/adapter.py`
|
||||
- `get_python_component_class()` 正常工作,因为它按路径动态导入
|
||||
|
||||
## 6. RuntimeBot 重构
|
||||
|
||||
### 6.1 现有问题
|
||||
|
||||
当前 `RuntimeBot.initialize()` 硬编码注册了两个回调:
|
||||
|
||||
```python
|
||||
# 现有代码
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
```
|
||||
|
||||
### 6.2 新设计
|
||||
|
||||
`RuntimeBot` 改为注册一个通用的事件回调:
|
||||
|
||||
```python
|
||||
class RuntimeBot:
|
||||
async def initialize(self):
|
||||
# 注册通用事件回调,接收所有事件类型
|
||||
self.adapter.register_listener(Event, self._on_event)
|
||||
|
||||
async def _on_event(
|
||||
self,
|
||||
event: Event,
|
||||
adapter: AbstractPlatformAdapter,
|
||||
):
|
||||
"""统一事件入口"""
|
||||
|
||||
# 1. 设置事件的 bot_uuid 和 adapter_name
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
event.adapter_name = self.bot_entity.adapter
|
||||
|
||||
# 2. 日志记录
|
||||
await self._log_event(event)
|
||||
|
||||
# 3. 提交给 EventBus
|
||||
await self.ap.event_bus.emit(event, adapter)
|
||||
```
|
||||
|
||||
适配器侧的 `register_listener` 实现也需调整:
|
||||
- 当 `event_type` 为 `Event`(基类)时,注册为"接收所有事件"的通配回调
|
||||
- 适配器在收到平台原生事件时,通过 `EventConverter.target2yiri()` 转换后,调用所有匹配的回调
|
||||
|
||||
## 7. 从现有单文件适配器迁移
|
||||
|
||||
### 7.1 迁移模式
|
||||
|
||||
以 Telegram 为例,从 `sources/telegram.py`(445 行)拆分:
|
||||
|
||||
| 原代码位置 | → 新文件 |
|
||||
|-----------|----------|
|
||||
| `TelegramMessageConverter` 类 | `telegram/message_converter.py` |
|
||||
| `TelegramEventConverter` 类 | `telegram/event_converter.py`(扩展,支持更多事件) |
|
||||
| `TelegramAdapter.__init__` / `run_async` / `kill` / `register_listener` | `telegram/adapter.py` |
|
||||
| `TelegramAdapter.send_message` / `reply_message` / `reply_message_chunk` | `telegram/adapter.py`(消息方法保留在主类)+ `telegram/api_impl.py`(新增 API) |
|
||||
| 新增代码 | `telegram/api_impl.py`(edit_message, delete_message, get_group_info 等) |
|
||||
| 新增代码 | `telegram/platform_api.py`(pin_message, unpin_message 等的映射) |
|
||||
| `telegram.yaml` | `telegram/manifest.yaml`(扩展 supported_events/apis) |
|
||||
|
||||
### 7.2 迁移顺序建议
|
||||
|
||||
1. **Telegram** — 功能最完整的适配器之一,适合作为模板
|
||||
2. **Discord** — 第二个迁移,验证模式的通用性
|
||||
3. **AioCQHTTP (OneBot)** — 国内最常用,确保兼容
|
||||
4. **其他适配器** — 按使用频率排序
|
||||
|
||||
### 7.3 渐进式迁移
|
||||
|
||||
不需要一次性迁移所有适配器。可以采用渐进策略:
|
||||
|
||||
1. 先在 `adapters/` 下建立新适配器
|
||||
2. `Blueprint` 同时扫描 `sources/` 和 `adapters/` 两个目录
|
||||
3. 旧适配器在 `sources/` 中继续工作
|
||||
4. 逐个迁移到新结构
|
||||
5. 全部迁移完成后移除 `sources/` 目录
|
||||
|
||||
```yaml
|
||||
# 过渡期的 Blueprint
|
||||
kind: Blueprint
|
||||
spec:
|
||||
components:
|
||||
MessagePlatformAdapter:
|
||||
fromDirs:
|
||||
- path: pkg/platform/sources/ # 旧路径(尚未迁移的适配器)
|
||||
- path: pkg/platform/adapters/ # 新路径(已迁移的适配器)
|
||||
```
|
||||
@@ -0,0 +1,745 @@
|
||||
# 事件路由与编排
|
||||
|
||||
> **2026-06 方向修订**:本文档的四种 handler_type(pipeline / agent / webhook / plugin)分类法已被「事件 → Agent」统一编排取代,收编映射与新数据模型见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本文档中的事件匹配规则(§4)、`use_pipeline_uuid` 迁移策略(§6)、WebUI 交互骨架(§7)与 webhook 请求/响应格式(§5.4)仍然有效,将在 Agent 模型下沿用。
|
||||
|
||||
## 1. 概述
|
||||
|
||||
事件路由是 EBA 架构的核心机制:事件从适配器产生后,经由 EventBus 进入 EventRouter,由 EventRouter 根据 Bot 的配置将事件分发到对应的处理器(Handler)。
|
||||
|
||||
**配置方式**:用户在 WebUI 的 Bot 管理页面通过可视化编排面板管理事件处理器配置,配置数据存储在数据库的 Bot 表 `event_handlers` JSON 字段中。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 Bot 实体扩展
|
||||
|
||||
在 `bots` 表新增 `event_handlers` 字段:
|
||||
|
||||
```python
|
||||
class Bot(Base):
|
||||
__tablename__ = "bots"
|
||||
|
||||
uuid: str # 主键
|
||||
name: str
|
||||
description: str
|
||||
adapter: str
|
||||
adapter_config: dict # JSON
|
||||
enable: bool
|
||||
|
||||
# 新增
|
||||
event_handlers: list # JSON — 事件处理器配置列表
|
||||
|
||||
# 保留(过渡期后弃用)
|
||||
use_pipeline_name: str # deprecated
|
||||
use_pipeline_uuid: str # deprecated
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### 2.2 EventHandler 配置结构
|
||||
|
||||
`event_handlers` 字段存储一个 JSON 数组,每个元素定义一条事件路由规则:
|
||||
|
||||
```python
|
||||
class EventHandlerConfig(pydantic.BaseModel):
|
||||
"""单条事件处理器配置"""
|
||||
|
||||
event_type: str
|
||||
"""匹配的事件类型
|
||||
|
||||
支持精确匹配和通配符:
|
||||
- "message.received" — 精确匹配
|
||||
- "message.*" — 匹配 message 命名空间下所有事件
|
||||
- "group.*" — 匹配 group 命名空间下所有事件
|
||||
- "*" — 匹配所有事件(兜底)
|
||||
"""
|
||||
|
||||
handler_type: str
|
||||
"""处理器类型: "pipeline" | "agent" | "webhook" | "plugin" """
|
||||
|
||||
handler_config: dict = {}
|
||||
"""处理器的具体配置,结构取决于 handler_type"""
|
||||
|
||||
enabled: bool = True
|
||||
"""是否启用此规则"""
|
||||
|
||||
priority: int = 0
|
||||
"""优先级,数字越大越先匹配(同一事件类型有多条规则时)"""
|
||||
|
||||
description: str = ""
|
||||
"""规则描述(供 WebUI 显示)"""
|
||||
```
|
||||
|
||||
### 2.3 各 Handler 类型的 handler_config 结构
|
||||
|
||||
#### pipeline
|
||||
|
||||
```json
|
||||
{
|
||||
"handler_type": "pipeline",
|
||||
"handler_config": {
|
||||
"pipeline_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将事件作为消息事件传入现有 Pipeline 流水线。仅适用于 `message.received` 事件。
|
||||
|
||||
#### agent
|
||||
|
||||
```json
|
||||
{
|
||||
"handler_type": "agent",
|
||||
"handler_config": {
|
||||
"runner": "local-agent",
|
||||
"runner_config": {
|
||||
"model_uuid": "...",
|
||||
"prompt": "你是一个群组助理,请处理以下事件:{event_summary}",
|
||||
"tools_enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"handler_type": "agent",
|
||||
"handler_config": {
|
||||
"runner": "dify-service-api",
|
||||
"runner_config": {
|
||||
"base_url": "https://api.dify.ai/v1",
|
||||
"api_key": "...",
|
||||
"app_type": "agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
直接调用 RequestRunner 处理事件。可用的 runner 包括:
|
||||
- `local-agent` — 内置 LLM Agent
|
||||
- `dify-service-api` — Dify 平台
|
||||
- `n8n-service-api` — n8n 工作流
|
||||
- `coze-api` — Coze (扣子)
|
||||
- `dashscope-app-api` — 阿里百炼
|
||||
- `langflow-api` — Langflow
|
||||
- `tbox-app-api` — 蚂蚁 Tbox
|
||||
|
||||
Agent 处理器不经过 Pipeline 的多 Stage 流程,而是直接构建上下文并调用 Runner。适用于所有事件类型。
|
||||
|
||||
**Agent Handler 与 Pipeline 的关系**:
|
||||
- Pipeline 是完整的多 Stage 处理链(PreProcessor → MessageProcessor(内含Runner) → PostProcessor → ...),适合复杂消息处理
|
||||
- Agent Handler 是轻量级的,直接调用 Runner,跳过 PreProcessor/PostProcessor 等阶段
|
||||
- Pipeline 内部的 AI Stage 仍然使用 Runner,所以 Runner 本身被两种 Handler 共享
|
||||
- 用户可以根据场景选择:消息处理用 Pipeline(更多控制),其他事件用 Agent(更直接)
|
||||
|
||||
#### webhook
|
||||
|
||||
```json
|
||||
{
|
||||
"handler_type": "webhook",
|
||||
"handler_config": {
|
||||
"url": "https://example.com/webhook/langbot-events",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxx"
|
||||
},
|
||||
"timeout": 30,
|
||||
"retry_count": 3,
|
||||
"retry_interval": 5,
|
||||
"response_actions": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将事件序列化为 JSON POST 到外部 URL。支持的特性:
|
||||
- **认证**:通过 headers 配置(Bearer Token、API Key 等)
|
||||
- **重试**:配置重试次数和间隔
|
||||
- **响应动作**:如果 `response_actions` 为 true,解析响应 JSON 中的 `actions` 字段并执行(如发送消息、同意好友请求等)
|
||||
|
||||
Webhook 请求体格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": {
|
||||
"type": "group.member_joined",
|
||||
"timestamp": 1700000000.0,
|
||||
"bot_uuid": "...",
|
||||
"adapter_name": "telegram",
|
||||
"group": { "id": "...", "name": "..." },
|
||||
"member": { "id": "...", "nickname": "..." }
|
||||
},
|
||||
"bot": {
|
||||
"uuid": "...",
|
||||
"name": "...",
|
||||
"adapter": "telegram"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应体格式(当 `response_actions` 为 true 时):
|
||||
|
||||
```json
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "send_message",
|
||||
"params": {
|
||||
"target_type": "group",
|
||||
"target_id": "123456",
|
||||
"message": [{ "type": "Plain", "text": "欢迎新成员!" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "call_platform_api",
|
||||
"params": {
|
||||
"action": "pin_message",
|
||||
"params": { "chat_id": "123456", "message_id": "789" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### plugin
|
||||
|
||||
```json
|
||||
{
|
||||
"handler_type": "plugin",
|
||||
"handler_config": {
|
||||
"plugin_filter": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将事件分发给插件的 EventListener 处理。
|
||||
|
||||
- `plugin_filter`:可选的插件名过滤列表,为空表示分发给所有插件
|
||||
- 沿用现有的插件事件分发机制(按优先级遍历插件,支持 `prevent_postorder`)
|
||||
|
||||
### 2.4 完整配置示例
|
||||
|
||||
一个 Bot 的 `event_handlers` 配置示例:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"event_type": "message.received",
|
||||
"handler_type": "pipeline",
|
||||
"handler_config": {
|
||||
"pipeline_uuid": "default-pipeline-uuid"
|
||||
},
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"description": "消息事件使用默认流水线处理"
|
||||
},
|
||||
{
|
||||
"event_type": "group.member_joined",
|
||||
"handler_type": "agent",
|
||||
"handler_config": {
|
||||
"runner": "local-agent",
|
||||
"runner_config": {
|
||||
"model_uuid": "gpt-4o-mini",
|
||||
"prompt": "有新成员 {member_name} 加入了群组 {group_name},请生成一条欢迎消息。"
|
||||
}
|
||||
},
|
||||
"enabled": true,
|
||||
"priority": 0,
|
||||
"description": "新成员入群时用 AI 生成欢迎消息"
|
||||
},
|
||||
{
|
||||
"event_type": "friend.request_received",
|
||||
"handler_type": "webhook",
|
||||
"handler_config": {
|
||||
"url": "https://my-server.com/api/friend-request",
|
||||
"response_actions": true
|
||||
},
|
||||
"enabled": true,
|
||||
"priority": 0,
|
||||
"description": "好友请求转发到自建服务处理"
|
||||
},
|
||||
{
|
||||
"event_type": "*",
|
||||
"handler_type": "plugin",
|
||||
"handler_config": {},
|
||||
"enabled": true,
|
||||
"priority": -100,
|
||||
"description": "所有事件兜底发给插件处理"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 3. EventBus 设计
|
||||
|
||||
EventBus 是事件的中转站,接收来自各个 RuntimeBot 的事件,交由 EventRouter 处理。
|
||||
|
||||
```python
|
||||
class EventBus:
|
||||
"""事件总线"""
|
||||
|
||||
def __init__(self, ap: Application):
|
||||
self.ap = ap
|
||||
self.event_router = EventRouter(ap)
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
event: Event,
|
||||
adapter: AbstractPlatformAdapter,
|
||||
):
|
||||
"""接收并分发事件
|
||||
|
||||
Args:
|
||||
event: 统一事件对象
|
||||
adapter: 产生此事件的适配器实例
|
||||
"""
|
||||
# 1. 全局事件日志
|
||||
self.ap.logger.debug(
|
||||
f"EventBus: {event.type} from bot {event.bot_uuid}"
|
||||
)
|
||||
|
||||
# 2. 交由 EventRouter 路由处理
|
||||
await self.event_router.route(event, adapter)
|
||||
```
|
||||
|
||||
## 4. EventRouter 设计
|
||||
|
||||
EventRouter 是事件路由引擎,根据 Bot 的 `event_handlers` 配置决定事件的处理方式。
|
||||
|
||||
```python
|
||||
class EventRouter:
|
||||
"""事件路由引擎"""
|
||||
|
||||
def __init__(self, ap: Application):
|
||||
self.ap = ap
|
||||
self.handlers: dict[str, AbstractEventHandler] = {
|
||||
"pipeline": PipelineHandler(ap),
|
||||
"agent": AgentHandler(ap),
|
||||
"webhook": WebhookHandler(ap),
|
||||
"plugin": PluginHandler(ap),
|
||||
}
|
||||
|
||||
async def route(
|
||||
self,
|
||||
event: Event,
|
||||
adapter: AbstractPlatformAdapter,
|
||||
):
|
||||
"""路由事件到对应处理器"""
|
||||
|
||||
# 1. 获取 Bot 配置
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||
if not bot:
|
||||
return
|
||||
|
||||
# 2. 获取事件处理器配置
|
||||
event_handlers = bot.bot_entity.event_handlers or []
|
||||
|
||||
# 3. 匹配规则(按 priority 降序排列)
|
||||
matched_handlers = self._match_handlers(event.type, event_handlers)
|
||||
|
||||
if not matched_handlers:
|
||||
# 未匹配到任何规则 → 默认交给插件处理(向后兼容)
|
||||
await self.handlers["plugin"].handle(event, adapter, {})
|
||||
return
|
||||
|
||||
# 4. 执行第一个匹配的 Handler
|
||||
# (未来可扩展为多个 Handler 串行/并行执行)
|
||||
handler_config = matched_handlers[0]
|
||||
handler = self.handlers.get(handler_config.handler_type)
|
||||
|
||||
if handler:
|
||||
await handler.handle(event, adapter, handler_config.handler_config)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f"Unknown handler type: {handler_config.handler_type}"
|
||||
)
|
||||
|
||||
def _match_handlers(
|
||||
self,
|
||||
event_type: str,
|
||||
handlers: list[EventHandlerConfig],
|
||||
) -> list[EventHandlerConfig]:
|
||||
"""匹配事件类型到处理器配置
|
||||
|
||||
匹配规则:
|
||||
1. 精确匹配:event_type == handler.event_type
|
||||
2. 命名空间通配:handler.event_type 为 "message.*" 时匹配所有 "message.xxx"
|
||||
3. 全局通配:handler.event_type 为 "*" 时匹配所有事件
|
||||
4. 按 priority 降序排列
|
||||
5. 只返回 enabled=True 的规则
|
||||
"""
|
||||
matched = []
|
||||
for handler in handlers:
|
||||
if not handler.enabled:
|
||||
continue
|
||||
if self._event_type_matches(event_type, handler.event_type):
|
||||
matched.append(handler)
|
||||
|
||||
matched.sort(key=lambda h: h.priority, reverse=True)
|
||||
return matched
|
||||
|
||||
@staticmethod
|
||||
def _event_type_matches(event_type: str, pattern: str) -> bool:
|
||||
"""判断事件类型是否匹配模式"""
|
||||
if pattern == "*":
|
||||
return True
|
||||
if pattern == event_type:
|
||||
return True
|
||||
if pattern.endswith(".*"):
|
||||
namespace = pattern[:-2]
|
||||
return event_type.startswith(namespace + ".")
|
||||
return False
|
||||
```
|
||||
|
||||
## 5. 事件处理器(Handler)实现
|
||||
|
||||
### 5.1 Handler 基类
|
||||
|
||||
```python
|
||||
class AbstractEventHandler(abc.ABC):
|
||||
"""事件处理器基类"""
|
||||
|
||||
def __init__(self, ap: Application):
|
||||
self.ap = ap
|
||||
|
||||
@abc.abstractmethod
|
||||
async def handle(
|
||||
self,
|
||||
event: Event,
|
||||
adapter: AbstractPlatformAdapter,
|
||||
config: dict,
|
||||
) -> None:
|
||||
"""处理事件
|
||||
|
||||
Args:
|
||||
event: 统一事件对象
|
||||
adapter: 适配器实例(用于调用平台 API 发送响应)
|
||||
config: handler_config 配置
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### 5.2 PipelineHandler
|
||||
|
||||
将消息事件注入现有 Pipeline 流水线处理。
|
||||
|
||||
```python
|
||||
class PipelineHandler(AbstractEventHandler):
|
||||
"""Pipeline 处理器 — 将事件送入现有 Pipeline 流水线"""
|
||||
|
||||
async def handle(self, event, adapter, config):
|
||||
pipeline_uuid = config.get("pipeline_uuid")
|
||||
|
||||
if not isinstance(event, MessageReceivedEvent):
|
||||
self.ap.logger.warning(
|
||||
f"PipelineHandler only supports MessageReceivedEvent, "
|
||||
f"got {event.type}"
|
||||
)
|
||||
return
|
||||
|
||||
# 将 MessageReceivedEvent 转换为现有的 Query 并投入 QueryPool
|
||||
# 复用现有的 MessageAggregator + QueryPool + Pipeline 机制
|
||||
launcher_type = (
|
||||
LauncherTypes.PERSON
|
||||
if event.chat_type == ChatType.PRIVATE
|
||||
else LauncherTypes.GROUP
|
||||
)
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=event.bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=event.chat_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event.to_legacy_event(), # 转换为 FriendMessage/GroupMessage
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 AgentHandler
|
||||
|
||||
直接调用 RequestRunner 处理事件,不经过 Pipeline Stage 链。
|
||||
|
||||
```python
|
||||
class AgentHandler(AbstractEventHandler):
|
||||
"""Agent 处理器 — 直接调用 RequestRunner 处理事件"""
|
||||
|
||||
async def handle(self, event, adapter, config):
|
||||
runner_name = config.get("runner", "local-agent")
|
||||
runner_config = config.get("runner_config", {})
|
||||
|
||||
# 1. 查找 Runner 类
|
||||
runner_cls = None
|
||||
for r in preregistered_runners:
|
||||
if r.name == runner_name:
|
||||
runner_cls = r
|
||||
break
|
||||
|
||||
if not runner_cls:
|
||||
self.ap.logger.error(f"Runner not found: {runner_name}")
|
||||
return
|
||||
|
||||
# 2. 构建事件上下文(将事件信息整理为 Runner 可处理的格式)
|
||||
event_context = self._build_event_context(event, runner_config)
|
||||
|
||||
# 3. 实例化并调用 Runner
|
||||
runner = runner_cls(self.ap, self._build_runner_pipeline_config(config))
|
||||
|
||||
response_messages = []
|
||||
async for result in runner.run(event_context):
|
||||
response_messages.append(result)
|
||||
|
||||
# 4. 发送响应(如果 Runner 产生了回复)
|
||||
if response_messages and isinstance(event, MessageReceivedEvent):
|
||||
# 将 Runner 输出转换为 MessageChain 并回复
|
||||
reply_chain = self._build_reply_chain(response_messages)
|
||||
await adapter.reply_message(event, reply_chain)
|
||||
|
||||
def _build_event_context(self, event, runner_config):
|
||||
"""将事件构建为 Runner 可处理的上下文
|
||||
|
||||
对于消息事件,直接使用消息内容。
|
||||
对于其他事件,根据 runner_config 中的 prompt 模板生成描述文本。
|
||||
"""
|
||||
...
|
||||
|
||||
def _build_runner_pipeline_config(self, config):
|
||||
"""将 handler_config 转换为 Runner 需要的 pipeline_config 格式"""
|
||||
...
|
||||
```
|
||||
|
||||
### 5.4 WebhookHandler
|
||||
|
||||
将事件 POST 到外部 URL。
|
||||
|
||||
```python
|
||||
class WebhookHandler(AbstractEventHandler):
|
||||
"""Webhook 处理器 — 将事件 POST 到外部 URL"""
|
||||
|
||||
async def handle(self, event, adapter, config):
|
||||
url = config.get("url")
|
||||
method = config.get("method", "POST")
|
||||
headers = config.get("headers", {})
|
||||
timeout = config.get("timeout", 30)
|
||||
retry_count = config.get("retry_count", 3)
|
||||
response_actions = config.get("response_actions", False)
|
||||
|
||||
# 1. 构建请求体
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||
payload = {
|
||||
"event": event.model_dump(),
|
||||
"bot": {
|
||||
"uuid": bot.bot_entity.uuid,
|
||||
"name": bot.bot_entity.name,
|
||||
"adapter": bot.bot_entity.adapter,
|
||||
}
|
||||
}
|
||||
|
||||
# 2. 发送请求(带重试)
|
||||
response = await self._send_with_retry(
|
||||
url, method, headers, payload, timeout, retry_count
|
||||
)
|
||||
|
||||
# 3. 处理响应动作
|
||||
if response_actions and response:
|
||||
await self._execute_response_actions(
|
||||
response, adapter, event
|
||||
)
|
||||
|
||||
async def _execute_response_actions(self, response, adapter, event):
|
||||
"""执行响应中的动作列表"""
|
||||
actions = response.get("actions", [])
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
params = action.get("params", {})
|
||||
|
||||
if action_type == "send_message":
|
||||
chain = MessageChain.model_validate(params.get("message", []))
|
||||
await adapter.send_message(
|
||||
params["target_type"],
|
||||
params["target_id"],
|
||||
chain,
|
||||
)
|
||||
elif action_type == "reply":
|
||||
chain = MessageChain.model_validate(params.get("message", []))
|
||||
await adapter.reply_message(event, chain)
|
||||
elif action_type == "call_platform_api":
|
||||
await adapter.call_platform_api(
|
||||
params["action"],
|
||||
params.get("params", {}),
|
||||
)
|
||||
elif action_type == "approve_friend_request":
|
||||
await adapter.approve_friend_request(
|
||||
params["request_id"],
|
||||
params.get("approve", True),
|
||||
)
|
||||
# ... 更多动作类型
|
||||
```
|
||||
|
||||
### 5.5 PluginHandler
|
||||
|
||||
将事件分发给插件的 EventListener。
|
||||
|
||||
```python
|
||||
class PluginHandler(AbstractEventHandler):
|
||||
"""Plugin 处理器 — 分发给插件 EventListener"""
|
||||
|
||||
async def handle(self, event, adapter, config):
|
||||
plugin_filter = config.get("plugin_filter", [])
|
||||
|
||||
# 复用现有的插件事件分发机制
|
||||
# 通过 plugin_connector 将事件发送给 Plugin Runtime
|
||||
await self.ap.plugin_connector.emit_event(
|
||||
event=event,
|
||||
adapter=adapter,
|
||||
plugin_filter=plugin_filter,
|
||||
)
|
||||
```
|
||||
|
||||
## 6. use_pipeline_uuid 迁移
|
||||
|
||||
### 6.1 自动迁移
|
||||
|
||||
数据库迁移脚本将现有的 `use_pipeline_uuid` 自动转换为 `event_handlers`:
|
||||
|
||||
```python
|
||||
# 迁移逻辑
|
||||
for bot in all_bots:
|
||||
if bot.use_pipeline_uuid and not bot.event_handlers:
|
||||
bot.event_handlers = [
|
||||
{
|
||||
"event_type": "message.received",
|
||||
"handler_type": "pipeline",
|
||||
"handler_config": {
|
||||
"pipeline_uuid": bot.use_pipeline_uuid
|
||||
},
|
||||
"enabled": True,
|
||||
"priority": 10,
|
||||
"description": "Auto-migrated from use_pipeline_uuid"
|
||||
},
|
||||
{
|
||||
"event_type": "*",
|
||||
"handler_type": "plugin",
|
||||
"handler_config": {},
|
||||
"enabled": True,
|
||||
"priority": -100,
|
||||
"description": "Default plugin handler"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 6.2 过渡期兼容
|
||||
|
||||
在过渡期内,如果 `event_handlers` 为空且 `use_pipeline_uuid` 非空,EventRouter 自动回退到旧行为:
|
||||
|
||||
```python
|
||||
# EventRouter.route() 中的兼容逻辑
|
||||
if not event_handlers and bot.bot_entity.use_pipeline_uuid:
|
||||
# 回退:消息事件走 Pipeline,其他事件走 Plugin
|
||||
if isinstance(event, MessageReceivedEvent):
|
||||
await self.handlers["pipeline"].handle(
|
||||
event, adapter,
|
||||
{"pipeline_uuid": bot.bot_entity.use_pipeline_uuid}
|
||||
)
|
||||
else:
|
||||
await self.handlers["plugin"].handle(event, adapter, {})
|
||||
return
|
||||
```
|
||||
|
||||
## 7. WebUI 编排面板数据模型
|
||||
|
||||
### 7.1 交互设计概要
|
||||
|
||||
在 WebUI 的 Bot 管理页面,新增"事件处理器"标签页(或区域),呈现为一个**规则列表**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 事件处理器 [+ 添加规则] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ 规则 1 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||
│ │ 事件类型: [message.received ▾] │ │
|
||||
│ │ 处理器: [Pipeline ▾] │ │
|
||||
│ │ Pipeline: [默认流水线 ▾] │ │
|
||||
│ │ 优先级: [10] │ │
|
||||
│ │ 描述: 消息事件使用默认流水线处理 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ 规则 2 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||
│ │ 事件类型: [group.member_joined ▾] │ │
|
||||
│ │ 处理器: [Agent ▾] │ │
|
||||
│ │ Runner: [local-agent ▾] │ │
|
||||
│ │ 模型: [gpt-4o-mini ▾] │ │
|
||||
│ │ Prompt: [有新成员加入...] │ │
|
||||
│ │ 优先级: [0] │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ 规则 3 (兜底) ──────────────────────────── [启用] [删除] ─┐ │
|
||||
│ │ 事件类型: [* ▾] │ │
|
||||
│ │ 处理器: [Plugin ▾] │ │
|
||||
│ │ 优先级: [-100] │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 前端数据结构
|
||||
|
||||
```typescript
|
||||
interface EventHandlerRule {
|
||||
event_type: string; // 下拉选择,选项从适配器 manifest 的 supported_events 获取
|
||||
handler_type: string; // "pipeline" | "agent" | "webhook" | "plugin"
|
||||
handler_config: Record<string, any>; // 根据 handler_type 动态渲染不同的配置表单
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Bot 编辑接口扩展
|
||||
interface BotConfig {
|
||||
uuid: string;
|
||||
name: string;
|
||||
adapter: string;
|
||||
adapter_config: Record<string, any>;
|
||||
enable: boolean;
|
||||
event_handlers: EventHandlerRule[]; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 事件类型下拉选项
|
||||
|
||||
从 Bot 关联的适配器 manifest 中获取 `supported_events`,加上通配符选项:
|
||||
|
||||
```
|
||||
- message.received
|
||||
- message.edited
|
||||
- message.deleted
|
||||
- message.reaction
|
||||
- feedback.received
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- group.member_banned
|
||||
- group.info_updated
|
||||
- friend.request_received
|
||||
- friend.added
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- bot.muted
|
||||
- bot.unmuted
|
||||
- platform.specific
|
||||
─────────────────
|
||||
- message.* (所有消息事件)
|
||||
- feedback.* (所有反馈事件)
|
||||
- group.* (所有群组事件)
|
||||
- friend.* (所有好友事件)
|
||||
- bot.* (所有 Bot 事件)
|
||||
- * (所有事件)
|
||||
```
|
||||
|
||||
### 7.4 HTTP API
|
||||
|
||||
```
|
||||
GET /api/v1/bots/{uuid}/event-handlers 获取 Bot 的事件处理器配置
|
||||
PUT /api/v1/bots/{uuid}/event-handlers 更新 Bot 的事件处理器配置
|
||||
GET /api/v1/adapters/{name}/supported-events 获取适配器支持的事件类型
|
||||
GET /api/v1/adapters/{name}/supported-apis 获取适配器支持的 API
|
||||
```
|
||||
@@ -0,0 +1,738 @@
|
||||
# 插件 SDK 改造
|
||||
|
||||
## 1. 概述
|
||||
|
||||
插件 SDK 需要配合 EBA 架构进行以下改造:
|
||||
|
||||
1. **新事件类型**:将所有通用事件暴露给插件
|
||||
2. **新 API**:将新增的平台 API 通过 `LangBotAPIProxy` 暴露给插件
|
||||
3. **兼容层**:保证现有插件零修改运行
|
||||
4. **通信协议扩展**:新增 action 枚举支持新 API
|
||||
|
||||
## 2. 新事件类型暴露
|
||||
|
||||
### 2.1 插件事件模型扩展
|
||||
|
||||
当前插件 SDK 的事件模型(`api/entities/events.py`)只有消息相关事件。需要新增所有通用事件的插件级包装:
|
||||
|
||||
```python
|
||||
# api/entities/events.py — 新增事件
|
||||
|
||||
# ---- 消息事件(扩展) ----
|
||||
|
||||
class MessageEditedReceived(BaseEventModel):
|
||||
"""消息被编辑事件"""
|
||||
launcher_type: str
|
||||
launcher_id: typing.Union[int, str]
|
||||
message_id: typing.Union[int, str]
|
||||
editor_id: typing.Union[int, str]
|
||||
new_content: MessageChain
|
||||
chat_type: str # "private" | "group"
|
||||
|
||||
class MessageDeletedReceived(BaseEventModel):
|
||||
"""消息被删除/撤回事件"""
|
||||
launcher_type: str
|
||||
launcher_id: typing.Union[int, str]
|
||||
message_id: typing.Union[int, str]
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
chat_type: str
|
||||
|
||||
class MessageReactionReceived(BaseEventModel):
|
||||
"""消息表情回应事件"""
|
||||
launcher_type: str
|
||||
launcher_id: typing.Union[int, str]
|
||||
message_id: typing.Union[int, str]
|
||||
user_id: typing.Union[int, str]
|
||||
reaction: str
|
||||
is_add: bool
|
||||
|
||||
# ---- 用户反馈事件 ----
|
||||
|
||||
class FeedbackReceived(BaseEventModel):
|
||||
"""用户对 Bot 回复提交反馈"""
|
||||
feedback_id: str
|
||||
feedback_type: int # 1=like, 2=dislike, 3=cancel/remove feedback
|
||||
feedback_content: typing.Optional[str] = None
|
||||
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||
user_id: typing.Optional[str] = None
|
||||
session_id: typing.Optional[str] = None
|
||||
message_id: typing.Optional[str] = None
|
||||
stream_id: typing.Optional[str] = None
|
||||
|
||||
# ---- 群组事件 ----
|
||||
|
||||
class GroupMemberJoined(BaseEventModel):
|
||||
"""新成员加入群组"""
|
||||
group_id: typing.Union[int, str]
|
||||
group_name: str
|
||||
member_id: typing.Union[int, str]
|
||||
member_name: str
|
||||
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||
join_type: typing.Optional[str] = None
|
||||
|
||||
class GroupMemberLeft(BaseEventModel):
|
||||
"""成员离开群组"""
|
||||
group_id: typing.Union[int, str]
|
||||
group_name: str
|
||||
member_id: typing.Union[int, str]
|
||||
member_name: str
|
||||
is_kicked: bool = False
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
|
||||
class GroupMemberBanned(BaseEventModel):
|
||||
"""成员被禁言"""
|
||||
group_id: typing.Union[int, str]
|
||||
member_id: typing.Union[int, str]
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
duration: typing.Optional[int] = None
|
||||
|
||||
class GroupMemberUnbanned(BaseEventModel):
|
||||
"""成员被解除禁言"""
|
||||
group_id: typing.Union[int, str]
|
||||
member_id: typing.Union[int, str]
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
|
||||
class GroupInfoUpdated(BaseEventModel):
|
||||
"""群组信息被修改"""
|
||||
group_id: typing.Union[int, str]
|
||||
group_name: str
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
changed_fields: list[str] = []
|
||||
|
||||
# ---- 好友事件 ----
|
||||
|
||||
class FriendRequestReceived(BaseEventModel):
|
||||
"""收到好友请求"""
|
||||
request_id: typing.Union[int, str]
|
||||
user_id: typing.Union[int, str]
|
||||
user_name: str
|
||||
message: typing.Optional[str] = None
|
||||
|
||||
class FriendAdded(BaseEventModel):
|
||||
"""成功添加好友"""
|
||||
user_id: typing.Union[int, str]
|
||||
user_name: str
|
||||
|
||||
class FriendRemoved(BaseEventModel):
|
||||
"""好友被移除"""
|
||||
user_id: typing.Union[int, str]
|
||||
user_name: str
|
||||
|
||||
# ---- Bot 状态事件 ----
|
||||
|
||||
class BotInvitedToGroup(BaseEventModel):
|
||||
"""Bot 被邀请加入群组"""
|
||||
group_id: typing.Union[int, str]
|
||||
group_name: str
|
||||
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||
|
||||
class BotRemovedFromGroup(BaseEventModel):
|
||||
"""Bot 被移出群组"""
|
||||
group_id: typing.Union[int, str]
|
||||
group_name: str
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
|
||||
class BotMuted(BaseEventModel):
|
||||
"""Bot 被禁言"""
|
||||
group_id: typing.Union[int, str]
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
duration: typing.Optional[int] = None
|
||||
|
||||
class BotUnmuted(BaseEventModel):
|
||||
"""Bot 被解除禁言"""
|
||||
group_id: typing.Union[int, str]
|
||||
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||
|
||||
# ---- 平台特有事件 ----
|
||||
|
||||
class PlatformSpecificEventReceived(BaseEventModel):
|
||||
"""平台特有事件"""
|
||||
adapter_name: str
|
||||
action: str
|
||||
data: dict = {}
|
||||
```
|
||||
|
||||
### 2.2 EventListener 注册方式
|
||||
|
||||
插件的 EventListener 继续使用 `@self.handler(EventType)` 装饰器注册,只是可以注册的事件类型大幅增加:
|
||||
|
||||
```python
|
||||
class MyEventListener(EventListener):
|
||||
def __init__(self, host):
|
||||
super().__init__(host)
|
||||
|
||||
# 现有方式(继续工作)
|
||||
@self.handler(PersonNormalMessageReceived)
|
||||
async def on_person_message(ctx: EventContext):
|
||||
...
|
||||
|
||||
# 新事件类型
|
||||
@self.handler(GroupMemberJoined)
|
||||
async def on_member_joined(ctx: EventContext):
|
||||
group_name = ctx.event.group_name
|
||||
member_name = ctx.event.member_name
|
||||
await ctx.reply(MessageChain([
|
||||
Plain(f"欢迎 {member_name} 加入 {group_name}!")
|
||||
]))
|
||||
|
||||
@self.handler(FriendRequestReceived)
|
||||
async def on_friend_request(ctx: EventContext):
|
||||
# 自动通过好友请求
|
||||
await ctx.approve_friend_request(
|
||||
ctx.event.request_id, approve=True
|
||||
)
|
||||
|
||||
@self.handler(FeedbackReceived)
|
||||
async def on_feedback(ctx: EventContext):
|
||||
if ctx.event.feedback_type == 2:
|
||||
await self.log_warning(
|
||||
f"用户点踩了回复: {ctx.event.feedback_content or ''}"
|
||||
)
|
||||
|
||||
@self.handler(PlatformSpecificEventReceived)
|
||||
async def on_platform_event(ctx: EventContext):
|
||||
if ctx.event.adapter_name == "telegram" and ctx.event.action == "chat_join_request":
|
||||
...
|
||||
```
|
||||
|
||||
## 3. 新 API 暴露
|
||||
|
||||
### 3.1 LangBotAPIProxy 扩展
|
||||
|
||||
在 `LangBotAPIProxy` 中新增以下方法,插件通过 `self.xxx()` 调用(在 BasePlugin 中继承):
|
||||
|
||||
```python
|
||||
class LangBotAPIProxy:
|
||||
# ---- 现有方法(保留) ----
|
||||
# get_langbot_version, get_bots, get_bot_info,
|
||||
# send_message, invoke_llm, get/set/delete_plugin_storage, ...
|
||||
|
||||
# ---- 新增消息 API ----
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: MessageChain,
|
||||
) -> None:
|
||||
"""编辑已发送的消息"""
|
||||
...
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""删除/撤回消息"""
|
||||
...
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> dict:
|
||||
"""转发消息"""
|
||||
...
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> dict:
|
||||
"""获取指定消息"""
|
||||
...
|
||||
|
||||
# ---- 新增群组 API ----
|
||||
|
||||
async def get_group_info(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> dict:
|
||||
"""获取群组信息"""
|
||||
...
|
||||
|
||||
async def get_group_list(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
) -> list[dict]:
|
||||
"""获取 Bot 加入的群组列表"""
|
||||
...
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[dict]:
|
||||
"""获取群成员列表"""
|
||||
...
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> dict:
|
||||
"""获取指定群成员信息"""
|
||||
...
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
"""禁言群成员"""
|
||||
...
|
||||
|
||||
async def unmute_member(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""解除禁言"""
|
||||
...
|
||||
|
||||
async def kick_member(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""踢出群成员"""
|
||||
...
|
||||
|
||||
# ---- 新增用户 API ----
|
||||
|
||||
async def get_user_info(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
user_id: typing.Union[int, str],
|
||||
) -> dict:
|
||||
"""获取用户信息"""
|
||||
...
|
||||
|
||||
async def get_friend_list(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
) -> list[dict]:
|
||||
"""获取好友列表"""
|
||||
...
|
||||
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
remark: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
"""处理好友请求"""
|
||||
...
|
||||
|
||||
async def approve_group_invite(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
) -> None:
|
||||
"""处理入群邀请"""
|
||||
...
|
||||
|
||||
# ---- 新增透传 API ----
|
||||
|
||||
async def call_platform_api(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
action: str,
|
||||
params: dict = {},
|
||||
) -> dict:
|
||||
"""调用适配器特有 API
|
||||
|
||||
Examples:
|
||||
# Telegram: pin 消息
|
||||
result = await self.call_platform_api(
|
||||
bot_uuid, "pin_message",
|
||||
{"chat_id": 123456, "message_id": 789}
|
||||
)
|
||||
|
||||
# Discord: 创建频道
|
||||
result = await self.call_platform_api(
|
||||
bot_uuid, "create_channel",
|
||||
{"guild_id": "...", "name": "new-channel"}
|
||||
)
|
||||
"""
|
||||
...
|
||||
|
||||
# ---- 新增能力查询 API ----
|
||||
|
||||
async def get_supported_events(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
) -> list[str]:
|
||||
"""获取指定 Bot 的适配器支持的事件类型"""
|
||||
...
|
||||
|
||||
async def get_supported_apis(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
) -> list[str]:
|
||||
"""获取指定 Bot 的适配器支持的 API"""
|
||||
...
|
||||
```
|
||||
|
||||
### 3.2 QueryBasedAPIProxy 扩展
|
||||
|
||||
在事件处理上下文中(EventContext),通过 `QueryBasedAPIProxy` 新增便捷方法:
|
||||
|
||||
```python
|
||||
class QueryBasedAPIProxy:
|
||||
# ---- 现有方法(保留) ----
|
||||
# reply, get_bot_uuid, set_query_var, get_query_var,
|
||||
# create_new_conversation, ...
|
||||
|
||||
# ---- 新增便捷方法 ----
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: MessageChain,
|
||||
) -> None:
|
||||
"""在当前会话中编辑消息(自动使用当前 bot_uuid 和 chat 信息)"""
|
||||
...
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
"""在当前会话中删除消息"""
|
||||
...
|
||||
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
remark: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
"""处理好友请求(上下文中自动获取 bot_uuid)"""
|
||||
...
|
||||
|
||||
async def approve_group_invite(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
) -> None:
|
||||
"""处理入群邀请"""
|
||||
...
|
||||
|
||||
async def get_group_info(self) -> dict:
|
||||
"""获取当前群组信息(仅群聊事件中可用)"""
|
||||
...
|
||||
|
||||
async def get_group_member_list(self) -> list[dict]:
|
||||
"""获取当前群组成员列表(仅群聊事件中可用)"""
|
||||
...
|
||||
|
||||
async def call_platform_api(
|
||||
self,
|
||||
action: str,
|
||||
params: dict = {},
|
||||
) -> dict:
|
||||
"""调用平台特有 API(自动使用当前 bot_uuid)"""
|
||||
...
|
||||
```
|
||||
|
||||
## 4. 兼容层设计
|
||||
|
||||
### 4.1 事件兼容层
|
||||
|
||||
当 PluginHandler 将新的 `MessageReceivedEvent` 分发给插件时,需要同时生成旧格式事件:
|
||||
|
||||
```python
|
||||
class PluginEventCompatLayer:
|
||||
"""插件事件兼容层
|
||||
|
||||
将新的统一事件转换为旧的插件事件格式,
|
||||
确保监听旧事件类型的插件仍能正常工作。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def convert_to_legacy_events(
|
||||
event: Event,
|
||||
) -> list[BaseEventModel]:
|
||||
"""将统一事件转换为旧插件事件列表
|
||||
|
||||
一个统一事件可能生成多个旧插件事件。
|
||||
例如 MessageReceivedEvent 会同时生成:
|
||||
- PersonMessageReceived / GroupMessageReceived(总是生成)
|
||||
- PersonNormalMessageReceived / GroupNormalMessageReceived(非命令时)
|
||||
- PersonCommandSent / GroupCommandSent(命令时)
|
||||
"""
|
||||
legacy_events = []
|
||||
|
||||
if isinstance(event, MessageReceivedEvent):
|
||||
if event.chat_type == ChatType.PRIVATE:
|
||||
legacy_events.append(
|
||||
PersonMessageReceived(
|
||||
launcher_type="person",
|
||||
launcher_id=event.chat_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event.to_legacy_friend_message(),
|
||||
message_chain=event.message_chain,
|
||||
)
|
||||
)
|
||||
# 命令检测后还会生成 PersonNormalMessageReceived
|
||||
# 或 PersonCommandSent,在 Pipeline 阶段处理
|
||||
elif event.chat_type == ChatType.GROUP:
|
||||
legacy_events.append(
|
||||
GroupMessageReceived(
|
||||
launcher_type="group",
|
||||
launcher_id=event.chat_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event.to_legacy_group_message(),
|
||||
message_chain=event.message_chain,
|
||||
)
|
||||
)
|
||||
|
||||
# 新事件类型没有旧的对应物,不生成兼容事件
|
||||
# 只有监听了新事件类型的插件才会收到
|
||||
|
||||
return legacy_events
|
||||
```
|
||||
|
||||
### 4.2 分发流程
|
||||
|
||||
```
|
||||
统一事件 (MessageReceivedEvent)
|
||||
│
|
||||
├─→ 转换为旧格式 (PersonMessageReceived / GroupMessageReceived)
|
||||
│ └─→ 分发给监听旧事件类型的插件 EventListener
|
||||
│
|
||||
└─→ 直接分发为新格式 (MessageReceivedEvent → 对应的插件事件)
|
||||
└─→ 分发给监听新事件类型的插件 EventListener
|
||||
```
|
||||
|
||||
插件 Runtime 在分发事件时检查每个 EventListener 注册的事件类型:
|
||||
- 如果注册的是旧类型(`PersonMessageReceived` 等),发送兼容层生成的旧格式事件
|
||||
- 如果注册的是新类型(`GroupMemberJoined` 等),发送新格式事件
|
||||
- 两者可以共存,同一个插件可以同时监听新旧类型
|
||||
|
||||
### 4.3 API 兼容层
|
||||
|
||||
现有插件使用的 API 不受影响:
|
||||
|
||||
| 现有 API | 新架构行为 |
|
||||
|---------|----------|
|
||||
| `self.send_message(bot_uuid, target_type, target_id, message_chain)` | 不变,直接调用适配器的 `send_message` |
|
||||
| `ctx.reply(message_chain, quote_origin)` | 不变,在 MessageReceivedEvent 上下文中调用适配器的 `reply_message` |
|
||||
| `self.get_bots()` | 不变 |
|
||||
| `self.get_bot_info(bot_uuid)` | 不变 |
|
||||
|
||||
新 API 只是额外新增的方法,不影响现有方法。
|
||||
|
||||
## 5. 通信协议扩展
|
||||
|
||||
### 5.1 新增 Action 枚举
|
||||
|
||||
在 `entities/io/actions/enums.py` 中新增 action:
|
||||
|
||||
```python
|
||||
class PluginToRuntimeAction(str, Enum):
|
||||
# ---- 现有 actions(保留) ----
|
||||
REGISTER_PLUGIN = "register_plugin"
|
||||
REPLY = "reply"
|
||||
SEND_MESSAGE = "send_message"
|
||||
# ...
|
||||
|
||||
# ---- 新增消息 API ----
|
||||
EDIT_MESSAGE = "edit_message"
|
||||
DELETE_MESSAGE = "delete_message"
|
||||
FORWARD_MESSAGE = "forward_message"
|
||||
GET_MESSAGE = "get_message"
|
||||
|
||||
# ---- 新增群组 API ----
|
||||
GET_GROUP_INFO = "get_group_info"
|
||||
GET_GROUP_LIST = "get_group_list"
|
||||
GET_GROUP_MEMBER_LIST = "get_group_member_list"
|
||||
GET_GROUP_MEMBER_INFO = "get_group_member_info"
|
||||
MUTE_MEMBER = "mute_member"
|
||||
UNMUTE_MEMBER = "unmute_member"
|
||||
KICK_MEMBER = "kick_member"
|
||||
|
||||
# ---- 新增用户 API ----
|
||||
GET_USER_INFO = "get_user_info"
|
||||
GET_FRIEND_LIST = "get_friend_list"
|
||||
APPROVE_FRIEND_REQUEST = "approve_friend_request"
|
||||
APPROVE_GROUP_INVITE = "approve_group_invite"
|
||||
|
||||
# ---- 新增透传 API ----
|
||||
CALL_PLATFORM_API = "call_platform_api"
|
||||
|
||||
# ---- 新增能力查询 ----
|
||||
GET_SUPPORTED_EVENTS = "get_supported_events"
|
||||
GET_SUPPORTED_APIS = "get_supported_apis"
|
||||
|
||||
|
||||
class RuntimeToPluginAction(str, Enum):
|
||||
# ---- 现有 actions(保留) ----
|
||||
EMIT_EVENT = "emit_event"
|
||||
# ...
|
||||
# EMIT_EVENT 的 data 结构扩展以支持新事件类型
|
||||
```
|
||||
|
||||
### 5.2 新增 Action 的请求/响应格式
|
||||
|
||||
以 `EDIT_MESSAGE` 为例:
|
||||
|
||||
```json
|
||||
// 请求 (Plugin → Runtime)
|
||||
{
|
||||
"action": "edit_message",
|
||||
"seq_id": 12345,
|
||||
"data": {
|
||||
"bot_uuid": "...",
|
||||
"chat_type": "group",
|
||||
"chat_id": "123456",
|
||||
"message_id": "789",
|
||||
"new_content": [
|
||||
{ "type": "Plain", "text": "edited message" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 响应 (Runtime → Plugin)
|
||||
{
|
||||
"seq_id": 12345,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
以 `GET_GROUP_MEMBER_LIST` 为例:
|
||||
|
||||
```json
|
||||
// 请求
|
||||
{
|
||||
"action": "get_group_member_list",
|
||||
"seq_id": 12346,
|
||||
"data": {
|
||||
"bot_uuid": "...",
|
||||
"group_id": "123456"
|
||||
}
|
||||
}
|
||||
|
||||
// 响应
|
||||
{
|
||||
"seq_id": 12346,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"members": [
|
||||
{
|
||||
"user": { "id": "111", "nickname": "Alice" },
|
||||
"group_id": "123456",
|
||||
"role": "admin",
|
||||
"display_name": "管理员Alice"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以 `CALL_PLATFORM_API` 为例:
|
||||
|
||||
```json
|
||||
// 请求
|
||||
{
|
||||
"action": "call_platform_api",
|
||||
"seq_id": 12347,
|
||||
"data": {
|
||||
"bot_uuid": "...",
|
||||
"action": "pin_message",
|
||||
"params": {
|
||||
"chat_id": "123456",
|
||||
"message_id": "789"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应
|
||||
{
|
||||
"seq_id": 12347,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"result": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 LangBot 侧 Handler 实现
|
||||
|
||||
在 `ControlConnectionHandler`(LangBot → Runtime 侧)和 `PluginConnectionHandler`(Runtime → Plugin 侧)中新增对应的 action 处理逻辑:
|
||||
|
||||
```python
|
||||
# PluginConnectionHandler 中新增
|
||||
async def _handle_edit_message(self, data):
|
||||
bot_uuid = data["bot_uuid"]
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
await bot.adapter.edit_message(
|
||||
chat_type=data["chat_type"],
|
||||
chat_id=data["chat_id"],
|
||||
message_id=data["message_id"],
|
||||
new_content=MessageChain.model_validate(data["new_content"]),
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _handle_call_platform_api(self, data):
|
||||
bot_uuid = data["bot_uuid"]
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
result = await bot.adapter.call_platform_api(
|
||||
action=data["action"],
|
||||
params=data.get("params", {}),
|
||||
)
|
||||
return {"result": result}
|
||||
```
|
||||
|
||||
## 6. 插件开发者迁移指南
|
||||
|
||||
### 6.1 无需迁移(零修改运行)
|
||||
|
||||
以下场景的现有插件**不需要任何修改**:
|
||||
|
||||
- 使用 `PersonNormalMessageReceived` / `GroupNormalMessageReceived` 监听消息
|
||||
- 使用 `PersonCommandSent` / `GroupCommandSent` 处理命令
|
||||
- 使用 `ctx.reply()` 回复消息
|
||||
- 使用 `self.send_message()` 主动发消息
|
||||
- 使用 LLM / 存储 / RAG 等现有 API
|
||||
|
||||
### 6.2 推荐迁移(获得新能力)
|
||||
|
||||
如果插件希望利用新功能,可以:
|
||||
|
||||
1. **监听新事件类型**:在 EventListener 中注册新事件类型的 handler
|
||||
2. **使用新 API**:调用 `self.edit_message()`, `self.get_group_info()` 等
|
||||
3. **使用透传 API**:调用 `self.call_platform_api()` 使用平台特有功能
|
||||
|
||||
### 6.3 SDK 版本号
|
||||
|
||||
新功能通过提升 SDK minor 版本发布:
|
||||
|
||||
- 现有版本:`langbot-plugin-sdk >= x.y.z`
|
||||
- 新版本:`langbot-plugin-sdk >= x.(y+1).0`
|
||||
|
||||
插件的 `manifest.yaml` 中的 `min_sdk_version` 决定是否能使用新 API。使用旧 SDK 版本的插件在新 LangBot 上正常运行(兼容层保证),只是无法调用新 API。
|
||||
@@ -0,0 +1,431 @@
|
||||
# 分阶段迁移计划
|
||||
|
||||
> **2026-06 方向修订**:Phase 3 的「四种 Handler 框架」与 Phase 5 的编排面板形态,按 [07-agent-orchestration.md](./07-agent-orchestration.md) 调整为「事件 → Agent」统一编排(EventRouter + Agent 实体 + 绑定模型 + SDK Agent 组件契约)。阶段划分、依赖关系与验收标准仍然适用,按 Agent 模型重新解读即可;发布节奏见 07 §5「发布火车」。
|
||||
|
||||
## 1. 概述
|
||||
|
||||
EBA 架构涉及 langbot-plugin-sdk、LangBot 后端、LangBot 前端、文档和示例插件等多个仓库的改动。为降低风险、保证系统稳定性,采用分阶段渐进式迁移策略。
|
||||
|
||||
### 1.1 阶段总览
|
||||
|
||||
| 阶段 | 名称 | 范围 | 依赖 |
|
||||
|------|------|------|------|
|
||||
| Phase 1 | SDK 实体层 | langbot-plugin-sdk | 无 |
|
||||
| Phase 2 | 适配器重构 | LangBot 后端 | Phase 1 |
|
||||
| Phase 3 | 核心系统 | LangBot 后端 | Phase 2 |
|
||||
| Phase 4 | 插件 SDK 集成 | langbot-plugin-sdk + LangBot | Phase 3 |
|
||||
| Phase 5 | WebUI 编排面板 | LangBot 前端 | Phase 3 |
|
||||
| Phase 6 | 文档与示例 | langbot-wiki + langbot-plugin-demo | Phase 4, 5 |
|
||||
|
||||
### 1.2 核心原则
|
||||
|
||||
- **每个阶段结束后系统可运行**:任何阶段完成后,现有功能不受影响
|
||||
- **向后兼容贯穿全程**:旧接口在整个迁移期间保持可用
|
||||
- **先 SDK 后实现**:先定义好接口和模型,再做具体实现
|
||||
- **先核心适配器后边缘**:优先迁移用户量大的适配器
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1:SDK 实体层
|
||||
|
||||
**目标**:在 langbot-plugin-sdk 中定义新的事件体系、通用实体、API 接口和适配器基类。
|
||||
|
||||
**仓库**:`langbot-plugin-sdk`
|
||||
|
||||
### 2.1 任务清单
|
||||
|
||||
| # | 任务 | 文件/模块 | 说明 |
|
||||
|---|------|----------|------|
|
||||
| 1.1 | 定义通用事件基类层次 | `api/entities/builtin/platform/events.py` | 新增 `MessageReceivedEvent`, `MessageEditedEvent`, `GroupMemberJoinedEvent` 等,保留现有 `FriendMessage`/`GroupMessage` |
|
||||
| 1.2 | 定义平台特有事件基类 | `api/entities/builtin/platform/events.py` | 新增 `PlatformSpecificEvent` |
|
||||
| 1.3 | 扩展通用实体 | `api/entities/builtin/platform/entities.py` | 新增 `User`(统一 Friend/GroupMember 的基础)、`Channel` 等,保留现有实体 |
|
||||
| 1.4 | 清理消息组件 | `api/entities/builtin/platform/message.py` | 将 `WeChatMiniPrograms` 等 WeChat 特有组件标记为 platform-specific,不再作为通用组件 |
|
||||
| 1.5 | 定义新适配器基类 | `api/definition/abstract/platform/adapter.py` | 新增 `AbstractPlatformAdapter`(继承现有 `AbstractMessagePlatformAdapter` 并扩展通用 API 方法),保留旧基类 |
|
||||
| 1.6 | 定义 API 能力声明 | `api/definition/abstract/platform/capabilities.py`(新文件) | `AdapterCapabilities` 数据类,声明适配器支持的事件和 API |
|
||||
| 1.7 | 定义 `NotSupportedError` | `api/entities/builtin/platform/errors.py`(新文件) | 可选 API 未实现时抛出的异常 |
|
||||
|
||||
### 2.2 关键设计约束
|
||||
|
||||
- 所有新增定义以**新增文件或新增类**的方式引入,**不修改**现有类的字段和方法签名
|
||||
- 现有 `AbstractMessagePlatformAdapter` 保留不动,新基类 `AbstractPlatformAdapter` 继承它
|
||||
- 新事件类与旧事件类并存,通过 `event_type` 字段(命名空间字符串)区分
|
||||
|
||||
### 2.3 验收标准
|
||||
|
||||
- [ ] 所有新增类可正常 import 且通过类型检查
|
||||
- [ ] 现有 `FriendMessage`, `GroupMessage`, `AbstractMessagePlatformAdapter` 等类行为不变
|
||||
- [ ] 新增单元测试覆盖事件序列化/反序列化、实体构造
|
||||
- [ ] SDK 版本号 minor bump(如 `0.x.0` → `0.x+1.0`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2:适配器重构
|
||||
|
||||
**目标**:将现有单文件适配器迁移到独立目录结构,实现新事件监听和通用 API。
|
||||
|
||||
**仓库**:`LangBot`(后端)
|
||||
|
||||
### 3.1 适配器迁移优先级
|
||||
|
||||
根据用户量和代表性,建议按以下顺序迁移:
|
||||
|
||||
| 优先级 | 适配器 | 理由 |
|
||||
|--------|--------|------|
|
||||
| P0 | **Telegram** | 用户量大,API 最完善,适合作为参考实现 |
|
||||
| P0 | **Discord** | 国际用户主要平台,事件类型丰富 |
|
||||
| P1 | **aiocqhttp**(OneBot v11) | 国内 QQ 用户主要适配器 |
|
||||
| P1 | **Satori** | 通用协议适配器,覆盖多个平台 |
|
||||
| P2 | **Lark** / **DingTalk** / **Slack** | 企业平台,用户量中等 |
|
||||
| P2 | **qqofficial** / **WeChat 系列** | 国内用户 |
|
||||
| P3 | **Kook** / **LINE** / **WeCom 系列** | 用户量较小 |
|
||||
| P3 | **WebSocket** | 内置适配器,相对简单 |
|
||||
| P4 | **legacy/*** | 遗留适配器,按需决定是否迁移或废弃 |
|
||||
|
||||
### 3.2 单个适配器迁移步骤(以 Telegram 为例)
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 2.1 | 创建目录结构 | `pkg/platform/adapters/telegram/` 下创建 `__init__.py`, `adapter.py`, `event_converter.py`, `message_converter.py`, `api_impl.py`, `types.py`, `manifest.yaml` |
|
||||
| 2.2 | 迁移消息转换器 | 将 `TelegramMessageConverter` 从 `sources/telegram.py` 搬到 `adapters/telegram/message_converter.py`,逻辑不变 |
|
||||
| 2.3 | 重写事件转换器 | 新的 `TelegramEventConverter` 支持将 Telegram Update 转换为所有通用事件类型(不只是消息),不支持的事件转为 `PlatformSpecificEvent` |
|
||||
| 2.4 | 实现通用 API | 在 `api_impl.py` 中实现 `edit_message`, `delete_message`, `get_group_info` 等 Telegram 支持的通用 API |
|
||||
| 2.5 | 实现透传 API | 在 `adapter.py` 中实现 `call_platform_api`,将 action 映射到 Telegram Bot API 调用 |
|
||||
| 2.6 | 声明能力 | 在 `manifest.yaml` 或适配器类中声明支持的事件和 API 列表 |
|
||||
| 2.7 | 新建 Adapter 主类 | `TelegramAdapter` 继承 `AbstractPlatformAdapter`(新基类),委托各模块实现 |
|
||||
| 2.8 | 更新 manifest.yaml | 更新 `execution.python.path` 指向新位置 |
|
||||
| 2.9 | 验证 | 确保新适配器通过现有消息收发流程的测试 |
|
||||
|
||||
### 3.3 基础设施任务
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 2.A | 创建 `adapters/_base/` | 将 SDK 中新基类的运行时辅助代码放在此处(如事件分发辅助函数) |
|
||||
| 2.B | 更新 ComponentDiscovery | 使 `discover_blueprint` 支持扫描 `adapters/` 子目录中的 YAML |
|
||||
| 2.C | 更新 `templates/components.yaml` | 将 `fromDirs` 从 `pkg/platform/sources/` 改为 `pkg/platform/adapters/`(过渡期两个都扫描) |
|
||||
| 2.D | 保留旧 sources/ | 过渡期不删除旧文件,通过 manifest 的 `deprecated: true` 标记 |
|
||||
|
||||
### 3.4 验收标准
|
||||
|
||||
- [ ] 已迁移的适配器在新目录结构下正常启动和收发消息
|
||||
- [ ] 新事件(如 `message.edited`)在支持的平台上正确触发
|
||||
- [ ] 通用 API(如 `edit_message`)在支持的平台上正确执行
|
||||
- [ ] 未迁移的适配器(仍在 `sources/`)继续正常工作
|
||||
- [ ] ComponentDiscovery 同时扫描新旧目录
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3:核心系统
|
||||
|
||||
**目标**:实现 EventBus、EventRouter 和事件处理器框架,将事件从适配器分发到不同的处理器。
|
||||
|
||||
**仓库**:`LangBot`(后端)
|
||||
|
||||
### 4.1 任务清单
|
||||
|
||||
| # | 任务 | 文件/模块 | 说明 |
|
||||
|---|------|----------|------|
|
||||
| 3.1 | 实现 EventBus | `pkg/platform/event_bus.py`(新文件) | 事件总线:接收适配器事件,进行日志记录,分发给 EventRouter |
|
||||
| 3.2 | 实现 EventRouter | `pkg/platform/event_router.py`(新文件) | 事件路由引擎:读取 Bot 的 `event_handlers` 配置,匹配事件类型,分发到对应 Handler |
|
||||
| 3.3 | 实现 PipelineHandler | `pkg/platform/handlers/pipeline_handler.py` | 将 `message.received` 事件转为现有 Query,进入 Pipeline 流水线 |
|
||||
| 3.4 | 实现 AgentHandler | `pkg/platform/handlers/agent_handler.py` | 直接调用 RequestRunner 处理事件,不经过 Pipeline 多 Stage 流程 |
|
||||
| 3.5 | 实现 WebhookHandler | `pkg/platform/handlers/webhook_handler.py` | 将事件 POST 到外部 URL,解析响应执行动作(重构现有 WebhookPusher) |
|
||||
| 3.6 | 实现 PluginHandler | `pkg/platform/handlers/plugin_handler.py` | 将事件分发给插件 EventListener(复用现有 plugin_connector 机制) |
|
||||
| 3.7 | Bot 实体扩展 | `pkg/entity/persistence/bot.py` | 新增 `event_handlers` JSON 字段 |
|
||||
| 3.8 | 数据库迁移 | `pkg/persistence/migrations/` | 新增迁移脚本:添加 `event_handlers` 列,将现有 `use_pipeline_uuid` 数据迁移为 `event_handlers` 格式 |
|
||||
| 3.9 | 重构 RuntimeBot | `pkg/platform/botmgr.py` | 将 `initialize()` 中硬编码的 `on_friend_message`/`on_group_message` 回调替换为通过 EventBus 分发所有事件 |
|
||||
| 3.10 | 重构 MessageAggregator | `pkg/pipeline/aggregator.py` | 从 RuntimeBot 解耦,作为 PipelineHandler 的内部机制(只对 `message.received` 事件生效) |
|
||||
| 3.11 | Agent Handler 中 RequestRunner 解耦 | `pkg/provider/runner.py` + handlers | RequestRunner 需要能独立于 Pipeline Stage 运行,为 Agent Handler 提供轻量调用路径 |
|
||||
| 3.12 | HTTP API 扩展 | `pkg/api/http/controller/` | 新增/更新 Bot API 端点以支持 `event_handlers` 的 CRUD |
|
||||
|
||||
### 4.2 数据迁移策略
|
||||
|
||||
现有 Bot 表有 `use_pipeline_uuid` 字段,需要自动迁移为 `event_handlers`:
|
||||
|
||||
```python
|
||||
# 迁移逻辑伪代码
|
||||
for bot in all_bots:
|
||||
if bot.use_pipeline_uuid:
|
||||
bot.event_handlers = [
|
||||
{
|
||||
"event_type": "message.received",
|
||||
"handler_type": "pipeline",
|
||||
"handler_config": {
|
||||
"pipeline_uuid": bot.use_pipeline_uuid
|
||||
}
|
||||
}
|
||||
]
|
||||
else:
|
||||
bot.event_handlers = []
|
||||
```
|
||||
|
||||
### 4.3 RuntimeBot 重构要点
|
||||
|
||||
当前 `RuntimeBot.initialize()` 硬编码注册两个回调:
|
||||
|
||||
```python
|
||||
# 现有代码 (botmgr.py)
|
||||
self.adapter.register_listener(FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(GroupMessage, on_group_message)
|
||||
```
|
||||
|
||||
重构后改为注册通用事件回调:
|
||||
|
||||
```python
|
||||
# 新代码
|
||||
async def on_event(event: Event, adapter: AbstractPlatformAdapter):
|
||||
await self.event_bus.emit(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
event=event,
|
||||
adapter=adapter,
|
||||
)
|
||||
|
||||
# 注册所有事件类型的统一回调
|
||||
self.adapter.register_listener(Event, on_event)
|
||||
```
|
||||
|
||||
EventBus 接收事件后,调用 EventRouter 按配置分发。
|
||||
|
||||
### 4.4 事件处理器执行流程
|
||||
|
||||
```
|
||||
EventBus.emit(bot_uuid, event, adapter)
|
||||
│
|
||||
▼
|
||||
EventRouter.route(bot_uuid, event)
|
||||
│ 查询 bot.event_handlers 配置
|
||||
│ 匹配 event_type(精确匹配 > 通配符 *)
|
||||
▼
|
||||
匹配到的 Handler(s)
|
||||
│
|
||||
├── PipelineHandler.handle(event, adapter)
|
||||
│ │ 仅支持 message.received
|
||||
│ │ 构造 Query → MessageAggregator → QueryPool → Pipeline
|
||||
│ └── 沿用现有完整流水线机制
|
||||
│
|
||||
├── AgentHandler.handle(event, adapter)
|
||||
│ │ 根据 handler_config 选择 RequestRunner
|
||||
│ │ 直接调用 runner.run() 处理事件
|
||||
│ └── 将结果通过 adapter API 回复
|
||||
│
|
||||
├── WebhookHandler.handle(event, adapter)
|
||||
│ │ 序列化事件为 JSON
|
||||
│ │ POST 到 handler_config.url
|
||||
│ └── 解析响应,执行动作(回复消息、调用 API 等)
|
||||
│
|
||||
└── PluginHandler.handle(event, adapter)
|
||||
│ 通过 plugin_connector 分发给插件
|
||||
└── 插件 EventListener 处理
|
||||
```
|
||||
|
||||
### 4.5 验收标准
|
||||
|
||||
- [ ] `message.received` 事件通过 PipelineHandler 正确进入现有 Pipeline(与旧行为一致)
|
||||
- [ ] 新增事件(如 `group.member_joined`)能通过 PluginHandler 分发给插件
|
||||
- [ ] AgentHandler 能直接调用 RequestRunner(至少 `local-agent`)处理事件并回复
|
||||
- [ ] WebhookHandler 能将事件 POST 到外部 URL
|
||||
- [ ] 数据库迁移正确执行,`use_pipeline_uuid` 数据迁移到 `event_handlers`
|
||||
- [ ] 现有 Bot 在不修改配置的情况下行为不变(自动迁移保证)
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 4:插件 SDK 集成
|
||||
|
||||
**目标**:将新事件和 API 通过插件 SDK 暴露给插件开发者,同时实现兼容层。
|
||||
|
||||
**仓库**:`langbot-plugin-sdk` + `LangBot`
|
||||
|
||||
### 5.1 任务清单
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 4.1 | 新增插件事件包装 | 在 `api/entities/events.py` 中为每个通用事件新增插件级事件类(如 `MessageEditedReceived`, `MemberJoinedReceived`) |
|
||||
| 4.2 | 兼容层实现 | `PersonMessageReceived` / `GroupMessageReceived` 由新的 `MessageReceivedEvent` 自动生成,旧事件作为新事件的 alias |
|
||||
| 4.3 | 新 API 暴露 | 在 `LangBotAPIProxy` 中新增方法:`edit_message`, `delete_message`, `get_group_info`, `get_user_info`, `call_platform_api` 等 |
|
||||
| 4.4 | 通信协议扩展 | 在 `entities/io/actions/enums.py` 中新增 action 枚举(如 `EDIT_MESSAGE`, `DELETE_MESSAGE`, `GET_GROUP_INFO`, `CALL_PLATFORM_API`) |
|
||||
| 4.5 | Runtime Handler 扩展 | 在 PluginConnectionHandler / ControlConnectionHandler 中添加新 action 的处理逻辑 |
|
||||
| 4.6 | EventListener 扩展 | 确保 `@handler()` 装饰器支持注册新事件类型 |
|
||||
| 4.7 | QueryBasedAPI 扩展 | 在 `QueryBasedAPIProxy` 中新增事件上下文相关的 API(如 `get_event_source_adapter`) |
|
||||
|
||||
### 5.2 兼容层详细设计
|
||||
|
||||
```
|
||||
新事件系统 旧事件系统(兼容层)
|
||||
───────────── ─────────────────
|
||||
MessageReceivedEvent ┌→ PersonMessageReceived (chat_type == "private")
|
||||
(chat_type: "private"|"group") ┤
|
||||
└→ GroupMessageReceived (chat_type == "group")
|
||||
```
|
||||
|
||||
**实现方式**:在 RuntimeEventDispatcher 中,当分发 `MessageReceivedEvent` 给插件时,同时生成对应的旧事件类实例。插件可以用新事件类或旧事件类注册 handler,都能收到。
|
||||
|
||||
### 5.3 验收标准
|
||||
|
||||
- [ ] 现有插件(使用旧事件和 API)无需修改即可运行
|
||||
- [ ] 新插件可以使用新事件类型(如 `MemberJoinedReceived`)注册 handler
|
||||
- [ ] 新 API(如 `edit_message`)可通过 `self.edit_message()` 或 `event_context.edit_message()` 调用
|
||||
- [ ] 透传 API `call_platform_api` 可正常调用适配器特有功能
|
||||
- [ ] 所有新 action 的通信协议正确工作(stdio / WebSocket)
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 5:WebUI 编排面板
|
||||
|
||||
**目标**:在 WebUI 的 Bot 管理页面实现事件处理器的可视化编排。
|
||||
|
||||
**仓库**:`LangBot`(前端 `web/`)
|
||||
|
||||
### 6.1 任务清单
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 5.1 | Bot 编辑页面扩展 | 在 Bot 编辑页面新增「事件处理」面板 |
|
||||
| 5.2 | 事件处理器列表组件 | 可视化展示当前 Bot 的 `event_handlers` 列表,支持增删改排序 |
|
||||
| 5.3 | 事件类型选择器 | 下拉选择事件类型(命名空间分组展示),支持通配符 `*` |
|
||||
| 5.4 | Handler 类型选择与配置 | 选择 handler 类型后展示对应的配置表单(Pipeline 选择器、Runner 选择器、Webhook URL 等) |
|
||||
| 5.5 | Pipeline Handler 配置 | 复用现有的 Pipeline 选择 UI(从现有 `use_pipeline_uuid` 选择器迁移) |
|
||||
| 5.6 | Agent Handler 配置 | Runner 选择器(local-agent / dify / n8n / coze 等)+ Runner 参数配置表单 |
|
||||
| 5.7 | Webhook Handler 配置 | URL 输入、认证方式选择、Header 配置 |
|
||||
| 5.8 | Plugin Handler 配置 | 通常无需额外配置,分发给所有匹配的插件 EventListener |
|
||||
| 5.9 | HTTP API 对接 | 前端调用后端 API 保存/读取 `event_handlers` 配置 |
|
||||
| 5.10 | 迁移提示 | 对于从旧版本升级的用户,如果检测到 `use_pipeline_uuid` 已自动迁移,展示提示说明 |
|
||||
|
||||
### 6.2 UI 交互设计概要
|
||||
|
||||
```
|
||||
┌─ Bot 编辑页面 ─────────────────────────────────────┐
|
||||
│ │
|
||||
│ 基本信息 │ 适配器配置 │ ★ 事件处理 │ │
|
||||
│ │
|
||||
│ ┌─ 事件处理器列表 ────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ① message.received → Pipeline: "主流水线" │ │
|
||||
│ │ [编辑] [删除] │ │
|
||||
│ │ │ │
|
||||
│ │ ② group.member_joined → Agent: local-agent │ │
|
||||
│ │ [编辑] [删除] │ │
|
||||
│ │ │ │
|
||||
│ │ ③ * (默认) → Plugin │ │
|
||||
│ │ [编辑] [删除] │ │
|
||||
│ │ │ │
|
||||
│ │ [+ 添加事件处理器] │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [保存] [取消] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 验收标准
|
||||
|
||||
- [ ] 用户可以在 WebUI 上为 Bot 添加/编辑/删除事件处理器
|
||||
- [ ] 四种 Handler 类型均有对应的配置表单
|
||||
- [ ] 配置保存后正确写入数据库 `event_handlers` 字段
|
||||
- [ ] 旧版本升级后,自动迁移的配置在 UI 上正确展示
|
||||
- [ ] Pipeline Handler 的行为与旧的 `use_pipeline_uuid` 完全一致
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 6:文档与示例
|
||||
|
||||
**目标**:更新所有面向开发者的文档和示例。
|
||||
|
||||
**仓库**:`langbot-wiki`, `langbot-plugin-demo`
|
||||
|
||||
### 7.1 任务清单
|
||||
|
||||
| # | 任务 | 仓库 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 6.1 | EBA 架构概览文档 | langbot-wiki | 面向用户的新架构说明 |
|
||||
| 6.2 | 适配器开发指南更新 | langbot-wiki | 如何开发一个新的适配器(新目录结构、新基类、事件转换等) |
|
||||
| 6.3 | 插件开发指南更新 | langbot-wiki | 新事件类型、新 API 的使用说明 |
|
||||
| 6.4 | 插件迁移指南 | langbot-wiki | 现有插件如何迁移到新事件/API(如果需要使用新能力) |
|
||||
| 6.5 | 事件处理器配置指南 | langbot-wiki | WebUI 上如何配置事件处理器 |
|
||||
| 6.6 | 示例插件更新 | langbot-plugin-demo | HelloPlugin 增加新事件监听示例、新 API 调用示例 |
|
||||
| 6.7 | 新示例插件 | langbot-plugin-demo | 新建一个示例展示非消息事件处理(如入群欢迎) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险评估与缓解
|
||||
|
||||
### 8.1 技术风险
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| 适配器迁移中断现有功能 | 高 | 中 | 新旧目录并存,ComponentDiscovery 同时扫描两个目录,逐个适配器迁移验证 |
|
||||
| 事件模型不兼容导致插件崩溃 | 高 | 低 | 兼容层保证旧事件类型继续工作,新增类不修改旧类 |
|
||||
| 数据库迁移失败 | 高 | 低 | 迁移脚本做前置校验,`use_pipeline_uuid` 在过渡期保留不删除 |
|
||||
| RequestRunner 解耦破坏 Pipeline | 高 | 中 | Agent Handler 调用 Runner 的路径独立于 Pipeline,不修改现有 Pipeline Stage 中的 Runner 调用逻辑 |
|
||||
| 性能回退(EventBus 额外开销) | 中 | 低 | EventBus 在进程内同步分发,无额外序列化/网络开销 |
|
||||
| 各平台事件差异大难以统一 | 中 | 中 | 通用事件只抽象最大公约数字段,差异部分保留在 `source_platform_object`;不支持的事件走 `PlatformSpecificEvent` |
|
||||
|
||||
### 8.2 兼容性风险
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|----------|
|
||||
| 现有插件使用旧事件类 | 兼容层自动将新事件转为旧事件分发,两种事件类都能注册 handler |
|
||||
| 现有插件调用 `reply()` / `send_message()` | 这两个 API 保持不变,只是底层实现可能微调 |
|
||||
| 第三方基于 `AbstractMessagePlatformAdapter` 开发的适配器 | 旧基类保留,新基类继承旧基类,第三方适配器无需立即迁移 |
|
||||
| 用户自定义 Pipeline 配置 | Pipeline 机制完整保留,PipelineHandler 只是入口变了(从 RuntimeBot 硬编码变为 EventRouter 配置) |
|
||||
|
||||
### 8.3 回滚策略
|
||||
|
||||
每个 Phase 独立可回滚:
|
||||
|
||||
- **Phase 1**(SDK 新增类):删除新增文件,回退 SDK 版本号
|
||||
- **Phase 2**(适配器目录):恢复 `components.yaml` 的 `fromDirs` 指向旧目录,旧 sources/ 未删除
|
||||
- **Phase 3**(核心系统):回退数据库迁移,恢复 RuntimeBot 旧的硬编码回调
|
||||
- **Phase 4**(插件集成):回退 SDK 版本,插件使用旧版 SDK
|
||||
- **Phase 5**(WebUI):前端回退,Bot 编辑页面隐藏事件处理面板
|
||||
|
||||
---
|
||||
|
||||
## 9. 里程碑与时间线建议
|
||||
|
||||
| 里程碑 | 阶段 | 预期产出 |
|
||||
|--------|------|----------|
|
||||
| M1 | Phase 1 完成 | SDK 新版本发布,包含新事件/实体/基类定义 |
|
||||
| M2 | Phase 2 首批适配器(Telegram + Discord) | 两个参考实现,验证目录结构和事件/API 体系 |
|
||||
| M3 | Phase 3 核心系统 | EventBus + EventRouter + 四种 Handler 可用 |
|
||||
| M4 | Phase 2 剩余适配器 | 所有活跃适配器迁移完成 |
|
||||
| M5 | Phase 4 插件集成 | 新 SDK 发布,插件可使用新事件和 API |
|
||||
| M6 | Phase 5 WebUI | 事件处理器编排面板上线 |
|
||||
| M7 | Phase 6 文档 | 开发者文档和示例更新完毕 |
|
||||
|
||||
建议 M1-M3 作为第一个大版本发布(如 v5.0),M4-M7 在后续小版本迭代中完成。
|
||||
|
||||
---
|
||||
|
||||
## 10. 开发指引
|
||||
|
||||
### 10.1 分支策略
|
||||
|
||||
建议在主仓库创建 `feature/eba` 长期特性分支,各 Phase 在子分支上开发后合入特性分支:
|
||||
|
||||
```
|
||||
main
|
||||
└── feature/eba
|
||||
├── feature/eba-sdk-entities (Phase 1)
|
||||
├── feature/eba-adapter-telegram (Phase 2)
|
||||
├── feature/eba-adapter-discord (Phase 2)
|
||||
├── feature/eba-core-system (Phase 3)
|
||||
├── feature/eba-plugin-sdk (Phase 4)
|
||||
└── feature/eba-webui (Phase 5)
|
||||
```
|
||||
|
||||
### 10.2 测试策略
|
||||
|
||||
| 层次 | 测试内容 | 工具 |
|
||||
|------|----------|------|
|
||||
| 单元测试 | 事件序列化/反序列化、实体构造、API 调用 mock | pytest |
|
||||
| 集成测试 | EventBus → EventRouter → Handler 全链路 | pytest + asyncio |
|
||||
| 适配器测试 | 各适配器的事件转换、消息转换、API 调用 | pytest + mock SDK |
|
||||
| 端到端测试 | 从模拟平台事件到完整处理流程 | staging 环境 |
|
||||
| 插件兼容性测试 | 旧插件在新系统下的行为 | langbot-plugin-demo |
|
||||
|
||||
### 10.3 代码审查关注点
|
||||
|
||||
- 新增代码是否影响现有行为
|
||||
- 兼容层是否正确映射所有旧事件/API 场景
|
||||
- 数据库迁移是否可逆
|
||||
- 新 API 的错误处理(`NotSupportedError`)是否一致
|
||||
- 事件模型的序列化在 stdio/WebSocket 通信中是否正确
|
||||
@@ -0,0 +1,187 @@
|
||||
# Agent 统一编排(产品最终形态)
|
||||
|
||||
> **状态**:方向修订稿(2026-06-12),供「适配器改造 / Agent 插件化 / 工作流引擎」三条工作线评审。
|
||||
>
|
||||
> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一收编为 Agent 抽象**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。
|
||||
|
||||
## 1. 产品最终形态
|
||||
|
||||
**适配器接收各种事件 → 用户编排处理逻辑 → Agent 统一抽象**,实现从 0 代码到低代码再到全代码的全层面支持:
|
||||
|
||||
```
|
||||
消息平台 (Telegram / Discord / 企微 / ...)
|
||||
│ 各类平台事件
|
||||
▼
|
||||
平台适配器(EBA 新结构,已迁移 12 个)
|
||||
│ EBAEvent (message.* / group.* / friend.* / bot.* / feedback.* / platform.*)
|
||||
▼
|
||||
EventRouter(事件 → Agent 绑定)
|
||||
├─→ 选中的 Agent(响应者,单一仲裁)
|
||||
│ ├─ 内置:pipeline-wrapper(旧流水线收编)/ local-agent
|
||||
│ ├─ 插件:SDK Agent 组件(全代码)
|
||||
│ ├─ 低代码:工作流定义的 Agent(内部工作流引擎)
|
||||
│ └─ 外部:dify / n8n / coze / dashscope / webhook(RequestRunner 体系收编)
|
||||
│
|
||||
└─→ 插件 EventListener(观察者,N 个广播,可 prevent_default)
|
||||
```
|
||||
|
||||
| 编写方式 | Agent 形态 | 代码化程度 |
|
||||
|----------|-----------|-----------|
|
||||
| WebUI 配置模型 + 提示词 + 工具 | 内置 local-agent | 0 代码 |
|
||||
| 沿用现有流水线 | pipeline-wrapper 内置 Agent | 0 代码(兼容) |
|
||||
| 市场安装 | Agent 插件(市场分发) | 0 代码(使用者视角) |
|
||||
| 可视化工作流 | 工作流引擎定义的 Agent | 低代码 |
|
||||
| 对接外部平台 | dify / n8n / coze / webhook 外部 Agent | 集成 |
|
||||
| SDK 编写 | Agent 插件组件 | 全代码 |
|
||||
|
||||
### 1.1 三条并行工作线与汇合点
|
||||
|
||||
| 工作线 | 范围 | 在本架构中的位置 |
|
||||
|--------|------|------------------|
|
||||
| 适配器改造(refactor/eba,本分支) | 事件体系、适配器结构、平台 API、EventRouter | 事件的**生产侧** + 路由层 |
|
||||
| Agent 插件化 | Agent 抽象、Agent 组件类型、市场分发 | 事件的**消费侧**统一抽象 |
|
||||
| 工作流引擎 | 内部低代码工作流 | Agent 的一种**编写方式** |
|
||||
|
||||
**汇合点是 SDK 的 Agent 组件契约(§4)与 event→agent 绑定模型(§3)**。这两个接口冻结后,三条线可彼此 mock 独立推进。契约由本分支(EBA)牵头起草,三线评审后在 langbot-plugin-sdk 落地(发布通道:0.5.0aX pre-release 已打通)。
|
||||
|
||||
## 2. 从四种 Handler 到 Agent 统一抽象
|
||||
|
||||
### 2.1 演进理由
|
||||
|
||||
04 文档中的 pipeline / agent / webhook / plugin 四种 handler_type,本质上都是"对事件作出响应的逻辑",差别只在编写和部署方式。为四种类型分别设计配置表单、执行语义和扩展机制,等于把同一个概念做四遍。统一为 Agent 后:
|
||||
|
||||
- **产品**:用户只学一个概念——"给 Bot 的事件绑 Agent";
|
||||
- **工程**:路由层退化为很薄的 event → agent 分发,所有扩展集中到 Agent 抽象;
|
||||
- **生态**:Agent 成为市场上可分发、可复用的一等公民。
|
||||
|
||||
### 2.2 收编映射
|
||||
|
||||
| 原 handler_type(04 文档) | 收编后 |
|
||||
|---------------------------|--------|
|
||||
| `pipeline` | 内置 `pipeline-wrapper` Agent:实例配置为 `pipeline_uuid`,进程内直接复用 MessageAggregator → QueryPool → Pipeline 机制 |
|
||||
| `agent`(RequestRunner) | 现有 Runner 体系(local-agent / dify / n8n / coze / dashscope / langflow / tbox)整体收编为内置 Agent 家族——Runner 本来就是"Agent 抽象"的前身 |
|
||||
| `webhook` | 外部 Agent 的一种:事件 POST 出去、响应解析为动作(保留 04 §5.4 的请求/响应格式) |
|
||||
| `plugin`(EventListener 分发) | **不收编**——角色不同,见 §2.3 |
|
||||
|
||||
### 2.3 响应者与观察者的角色切分
|
||||
|
||||
事件的消费方有两种角色,不应混为一谈:
|
||||
|
||||
- **响应者(Agent)**:路由选中**一个**,负责对事件作出回应(回复消息、执行动作)。多条绑定匹配同一事件时按 priority 仲裁,只取最高者。
|
||||
- **观察者(插件 EventListener)**:**广播**给所有注册插件,做旁路逻辑(日志、审计、风控、统计)。沿用现有机制不变,包括 `prevent_default()`——观察者可拦截本次事件,使 Agent 不被调用(与现有"插件拦截流水线"行为完全兼容)。
|
||||
|
||||
执行顺序:事件到达 → 先广播观察者(按插件优先级)→ 若未被 prevent_default → 分发给选中的 Agent。
|
||||
|
||||
## 3. 数据模型:event → agent 绑定
|
||||
|
||||
### 3.1 Agent 实体化(推荐)
|
||||
|
||||
Agent 作为一等实体(独立表),用户先创建/安装 Agent,再在 Bot 上把事件绑定到 Agent。好处:跨 Bot 复用、市场分发、独立的配置页面。
|
||||
|
||||
```python
|
||||
class Agent(Base):
|
||||
"""Agent 实例:一个具体配置过的、可被事件绑定的响应者"""
|
||||
uuid: str # 主键
|
||||
name: str
|
||||
kind: str # "builtin" | "plugin"
|
||||
component_ref: str # 内置: "pipeline-wrapper" / "local-agent" / "dify" / "webhook" / ...
|
||||
# 插件: "<plugin_author>/<plugin_name>/<agent_component_name>"
|
||||
config: dict # JSON — 实例配置(pipeline_uuid / 模型与提示词 / 外部平台凭据 / 工作流 id ...)
|
||||
# 多租户预留:归属主体字段(tenant/workspace),首版可空
|
||||
```
|
||||
|
||||
Bot 上的绑定配置(替代 04 §2.2 的 EventHandlerConfig,沿用其匹配语义):
|
||||
|
||||
```python
|
||||
class EventBinding(pydantic.BaseModel):
|
||||
event_type: str # 精确 / "message.*" / "*",匹配规则同 04 §4
|
||||
agent_uuid: str # 绑定的 Agent 实例
|
||||
enabled: bool = True
|
||||
priority: int = 0 # 多条匹配时取最高者(单一仲裁)
|
||||
description: str = ''
|
||||
```
|
||||
|
||||
`use_pipeline_uuid` 自动迁移:为每个被引用的 pipeline 生成一个 `pipeline-wrapper` Agent 实例,并写入 `{"event_type": "message.received", "agent_uuid": <wrapper>}` 绑定。观察者广播不需要配置(始终发生),04 中"兜底 plugin 规则"不再需要。
|
||||
|
||||
## 4. SDK Agent 组件契约(草案)
|
||||
|
||||
Agent 成为插件系统的第七种组件(现有:Command / Tool / EventListener / KnowledgeEngine / Parser / Page)。
|
||||
|
||||
### 4.1 Manifest
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Agent
|
||||
metadata:
|
||||
name: group-assistant
|
||||
label: { en_US: Group Assistant, zh_Hans: 群助理 }
|
||||
spec:
|
||||
handled_events: # 声明可处理的事件类型;绑定 UI 据此过滤
|
||||
- message.received
|
||||
- group.member_joined
|
||||
config: # 实例化配置 schema,复用现有组件配置体系
|
||||
- name: model
|
||||
type: llm-model-selector
|
||||
- name: persona
|
||||
type: prompt-editor
|
||||
execution:
|
||||
python: { path: agent.py, attr: GroupAssistant }
|
||||
```
|
||||
|
||||
### 4.2 运行时接口
|
||||
|
||||
```python
|
||||
class Agent(BaseComponent):
|
||||
async def handle(self, ctx: AgentContext) -> typing.AsyncGenerator[AgentChunk, None]:
|
||||
"""处理一次事件,流式产出回复与动作。每次事件调用一次。"""
|
||||
...
|
||||
|
||||
class AgentContext:
|
||||
event: EBAEvent # 触发事件(统一事件体系)
|
||||
bot: BotHandle # 来源 Bot 信息
|
||||
session: SessionHandle # 会话句柄:历史消息、会话变量(LangBot 侧管理,Agent 保持无状态)
|
||||
config: dict # 该 Agent 实例的配置
|
||||
|
||||
# 能力面(经 runtime RPC 回 LangBot 执行):
|
||||
async def reply(self, chain: MessageChain, quote: bool = False): ...
|
||||
async def send_message(self, target_type: str, target_id: str, chain: MessageChain): ...
|
||||
async def call_platform_api(self, action: str, params: dict) -> dict: ...
|
||||
async def invoke_llm(self, model_uuid: str, messages: list, funcs: list = None) -> dict: ...
|
||||
# + 工具调用 / KB 检索 / 插件存储(沿用 LangBotAPIProxy 既有方法)
|
||||
|
||||
class AgentChunk:
|
||||
delta_message: MessageChain | None = None # 增量回复(流式)
|
||||
actions: list[dict] | None = None # 平台动作(同 webhook response_actions 格式)
|
||||
final: bool = False
|
||||
```
|
||||
|
||||
**流式**:复用 SDK 通信协议既有的 `chunk_status: continue/end` 机制,`handle()` 的每次 yield 对应一个 chunk。
|
||||
**内置与插件同构**:内置 Agent(pipeline-wrapper、local-agent、各外部平台)在 LangBot 进程内实现同一接口注册,不过 RPC;插件 Agent 经 plugin runtime 分发。对路由层二者不可区分。
|
||||
|
||||
### 4.3 执行语义与可靠性
|
||||
|
||||
| 关注点 | 约定 |
|
||||
|--------|------|
|
||||
| 仲裁 | 单响应者:priority 最高的匹配绑定生效,其余忽略 |
|
||||
| 性能 | 内置 Agent 进程内零额外开销;插件 Agent 每事件过一次 RPC 边界,消息场景需设延迟预算(评审项:目标 P95 附加延迟) |
|
||||
| 会话状态 | 归 LangBot 侧(SessionHandle),插件 Agent 原则上无状态,崩溃重启不丢会话 |
|
||||
| 降级 | Agent 调用失败/超时:可配置 fallback(回错误提示,或指定备用 Agent);pipeline-wrapper 作为进程内兜底与性能对照组 |
|
||||
| 多租户预留 | AgentContext / SessionHandle / 存储接口显式携带归属主体标识,禁止新增全局单例状态——为后续轻量 SaaS 多租户铺路 |
|
||||
|
||||
## 5. 发布火车
|
||||
|
||||
| 版本 | 内容 | 备注 |
|
||||
|------|------|------|
|
||||
| 4.11(可选) | 现状成果:12 个 EBA 适配器、插件全事件订阅、`call_platform_api` | 对用户不可见的管道工程 + 插件新能力,不动产品概念 |
|
||||
| **5.0** | 产品形态首发:EventRouter + event→agent 绑定 + WebUI 编排 + 数据迁移 + 内置 Agent(pipeline-wrapper、local-agent、外部平台家族)+ SDK Agent 组件契约(可标 experimental) | 资格线不依赖其他两线交付;配 SDK 0.5.0 正式版;走 beta 周期;deprecation(旧 sources 适配器、legacy/*、use_pipeline_uuid)集中在此窗口处理 |
|
||||
| 5.x | 工作流 Agent(工作流引擎线挂入)、Agent 市场生态、剩余适配器(satori 等)、Agent 插件化收尾 | 验证开放注册机制 |
|
||||
| 多租户 | 独立评估:仅数据隔离 → 5.x 部署选项;伴随权限/计费/产品定位变化 → 6.0 | 前置条件是 §4.3 的归属主体预留已落实 |
|
||||
|
||||
## 6. 开放问题(评审清单)
|
||||
|
||||
1. **webhook 的最终定位**:作为外部 Agent(响应者,现方案)之外,是否还需要"纯通知观察者"形态(现 WebhookPusher 的角色)?
|
||||
2. **多 Agent 协作**:单一仲裁之外,是否需要"串联/并联多个 Agent"的场景?(建议 5.0 不做,留给工作流引擎表达)
|
||||
3. **工作流引擎的宿主**:核心内置,还是自身也作为一个插件交付(解释工作流定义的 Agent 插件)?
|
||||
4. **插件 Agent 的延迟预算**:消息主链路过 RPC 的 P95 目标值与压测方案。
|
||||
5. **Pipeline 的长期命运**:pipeline-wrapper 兼容期多长,Stage 体系是否在 6.0 退役或被工作流引擎吸收。
|
||||
6. **SDK 1.0 时机**:Agent 契约稳定后是否随 LangBot 5.x 给插件生态一个 API 稳定承诺。
|
||||
@@ -0,0 +1,180 @@
|
||||
# Agent 页面与事件编排产品设计
|
||||
|
||||
> 状态:实施稿(2026-06-23)
|
||||
>
|
||||
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态应保留两种同级处理单元:**Agent 编排**与**Pipeline**。Agent 页面是统一入口,但不是把 Pipeline 消除。
|
||||
|
||||
## 1. 产品边界
|
||||
|
||||
LangBot 的处理逻辑分成两种同级形态:
|
||||
|
||||
| 形态 | 定位 | 可处理事件 | 典型用户 |
|
||||
| --- | --- | --- | --- |
|
||||
| Agent 编排 | 面向 EBA 的事件优先处理单元,承载 AgentRunner / 外部 runner / 后续工作流 | `message.*`、`group.*`、`friend.*`、`bot.*`、`feedback.*`、`platform.*` 等 | 希望按事件类型配置不同智能处理逻辑的用户 |
|
||||
| Pipeline | 现有无代码消息流水线与向后兼容形态 | 仅 `message.*`,首版等价于 `message.received` | 已有 Pipeline 用户、只需要消息处理的用户 |
|
||||
|
||||
Agent 页面负责统一管理这两种处理单元:
|
||||
|
||||
- 创建时选择 **Agent 编排** 或 **Pipeline**;
|
||||
- 列表中清晰标注类型与事件能力;
|
||||
- Pipeline 的编辑、调试、监控继续复用现有能力;
|
||||
- Agent 编排保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
### 2.1 Agent 页面
|
||||
|
||||
路径:`/home/agents`
|
||||
|
||||
职责:
|
||||
|
||||
1. 展示所有可被事件绑定的处理单元,包括 Agent 编排与 Pipeline。
|
||||
2. 创建时先选择类型:
|
||||
- Agent 编排:创建一条 Agent 配置对象,默认支持所有 EBA 事件;
|
||||
- Pipeline:创建现有 legacy pipeline,只能处理消息事件。
|
||||
3. 编辑时按类型进入不同表单:
|
||||
- Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring;
|
||||
- Agent 编排:配置基础信息、runner、runner config 和事件能力。
|
||||
|
||||
`/home/pipelines` 保留为兼容路由,但新导航入口使用 `/home/agents`。
|
||||
|
||||
### 2.2 Bot 的事件编排
|
||||
|
||||
Bot 上维护“事件 -> 处理单元”的绑定规则:
|
||||
|
||||
```text
|
||||
Bot
|
||||
└─ EventBinding[]
|
||||
├─ event_pattern: message.received / group.member_joined / group.* / *
|
||||
├─ target_type: agent / pipeline / discard
|
||||
├─ target_uuid: Agent UUID 或 Pipeline UUID
|
||||
├─ filters: 事件字段过滤条件
|
||||
├─ priority: 数字越大越优先
|
||||
└─ enabled
|
||||
```
|
||||
|
||||
Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,目标选择器不展示 Pipeline。
|
||||
|
||||
## 3. 持久化模型
|
||||
|
||||
### 3.1 Agent 编排实例
|
||||
|
||||
新增 `agents` 表,只保存 Agent 编排形态。Pipeline 继续保存在 `legacy_pipelines`。
|
||||
|
||||
```python
|
||||
class Agent(Base):
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
emoji: str
|
||||
kind: str # 首版固定为 "agent"
|
||||
component_ref: str # runner id / workflow id / future external ref
|
||||
config: dict # runner 与 runner_config
|
||||
enabled: bool
|
||||
supported_event_patterns: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
Agent 聚合 API 把 `agents` 与 `legacy_pipelines` 投影成同一个前端列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"name": "...",
|
||||
"kind": "agent | pipeline",
|
||||
"capability": {
|
||||
"supported_event_patterns": ["*"],
|
||||
"message_only": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline 投影时固定:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "pipeline",
|
||||
"capability": {
|
||||
"supported_event_patterns": ["message.*"],
|
||||
"message_only": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Bot 事件绑定
|
||||
|
||||
Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"event_pattern": "group.member_joined",
|
||||
"target_type": "agent",
|
||||
"target_uuid": "...",
|
||||
"filters": [],
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"description": "Welcome new group members"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 4. 匹配规则
|
||||
|
||||
事件模式支持三层:
|
||||
|
||||
1. 精确匹配:`group.member_joined`
|
||||
2. 命名空间通配:`group.*`
|
||||
3. 全局通配:`*`
|
||||
|
||||
优先级:
|
||||
|
||||
1. `enabled = true`
|
||||
2. event pattern 命中
|
||||
3. filters 全部命中
|
||||
4. `priority` 数值高者优先
|
||||
5. 同优先级按列表顺序
|
||||
|
||||
## 5. 兼容策略
|
||||
|
||||
1. 现有 `legacy_pipelines` 不迁移、不改语义。
|
||||
2. 现有 Bot 的 `use_pipeline_uuid` 仍作为消息事件默认 Pipeline。
|
||||
3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。
|
||||
4. 新增 `event_bindings` 是 EBA 事件编排配置,允许 Pipeline 目标但只限 `message.*`。
|
||||
5. `/api/v1/pipelines` 继续存在;新增 `/api/v1/agents` 作为聚合入口。
|
||||
|
||||
## 6. 分阶段落地
|
||||
|
||||
### P0:产品入口统一
|
||||
|
||||
- 新增 `/home/agents`。
|
||||
- 侧边栏显示“Agent”,但列表包含 Agent 编排与 Pipeline。
|
||||
- 创建时选择 Agent 编排或 Pipeline。
|
||||
- `/home/pipelines` 保留兼容。
|
||||
|
||||
### P1:配置模型落地
|
||||
|
||||
- 新增 `agents` 表与 `/api/v1/agents`。
|
||||
- Agent 编排可保存 runner 与 runner_config。
|
||||
- Pipeline 继续使用原 Pipeline 表单与 API。
|
||||
|
||||
### P2:事件编排配置面
|
||||
|
||||
- Bot 表单新增事件编排编辑器。
|
||||
- 读取 adapter manifest 的 `supported_events` 生成事件选项。
|
||||
- 根据事件类型过滤可选目标:Pipeline 仅在 `message.*` 可选。
|
||||
|
||||
### P3:EventRouter 执行接入
|
||||
|
||||
- EBA 事件先广播插件 observer。
|
||||
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排。
|
||||
- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径。
|
||||
- 非消息事件只调用 Agent 编排,不调用 Pipeline;AgentRunner 输出有平台 reply target 时会投递回平台。
|
||||
|
||||
## 7. 不做的事
|
||||
|
||||
- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。
|
||||
- 不把非消息事件伪装成用户文本塞入 Pipeline。
|
||||
- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。
|
||||
@@ -0,0 +1,41 @@
|
||||
# EBA Adapter Migration Records
|
||||
|
||||
This directory records adapter-level migration details for the Event-Based Agents architecture. Each adapter document should be kept close to the implementation and must answer four questions:
|
||||
|
||||
1. What changed in the adapter structure.
|
||||
2. Which configuration fields are required.
|
||||
3. Which events and APIs are supported.
|
||||
4. What has been verified end to end.
|
||||
|
||||
## Adapter Documents
|
||||
|
||||
General acceptance checklist: [EBA Adapter Acceptance Checklist](./acceptance-checklist.md)
|
||||
|
||||
Current acceptance report: [EBA Adapter Acceptance Report](./acceptance-report.md)
|
||||
|
||||
| Adapter | Status | Document |
|
||||
|---------|--------|----------|
|
||||
| Telegram | Migrated; partial plugin E2E, real UI inbound image/file verified | [Telegram](./telegram.md) |
|
||||
| Discord | Migrated; partial plugin E2E, media-inbound gaps remain | [Discord](./discord.md) |
|
||||
| OneBot v11 / aiocqhttp | Migrated; Matcha UI plus protocol-level multi-component coverage | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
|
||||
| DingTalk | Migrated; partial plugin E2E, real UI inbound image/file verified; group gap remains | [DingTalk](./dingtalk.md) |
|
||||
| Lark / Feishu | Migrated; partial live text E2E, media-inbound gap remains | [Lark / Feishu](./lark.md) |
|
||||
| WeCom | Migrated; private text plugin E2E verified, media/group gaps remain | [WeCom](./wecom.md) |
|
||||
| WeComBot | Migrated; private text and outbound/API plugin E2E verified, feedback/group gaps remain | [WeComBot](./wecombot.md) |
|
||||
| Official Account | Migrated; private text plugin E2E verified, proactive outbound not supported | [Official Account](./officialaccount.md) |
|
||||
| QQ Official API | Migrated; WebSocket inbound reached LangBot, model config blocked reply | [QQ Official API](./qqofficial.md) |
|
||||
| Slack | Migrated; private text and outbound/API plugin E2E verified | [Slack](./slack.md) |
|
||||
| WeCom Customer Service | Migrated; customer-side UI text plugin E2E verified, inbound media and platform-API live coverage pending | [WeCom Customer Service](./wecomcs.md) |
|
||||
| Kook | Migrated; unit/mocked converter and API coverage only, live acceptance pending | [Kook](./kook.md) |
|
||||
|
||||
## Documentation Checklist
|
||||
|
||||
When migrating a new adapter, add one document here with:
|
||||
|
||||
- Configuration table matching the adapter manifest.
|
||||
- Supported event list.
|
||||
- Supported common API list.
|
||||
- Supported `call_platform_api` action list.
|
||||
- Known unsupported APIs and the reason.
|
||||
- Live test notes, including platform, channel type, destructive operations, and residual risks.
|
||||
- A clear distinction between real UI inbound media, protocol-level injected inbound media, and bot outbound media.
|
||||
@@ -0,0 +1,208 @@
|
||||
# EBA Adapter Acceptance Checklist
|
||||
|
||||
This checklist is the architecture-level acceptance standard for every Event-Based Agents platform adapter. It is not platform-specific. Adapter migration is not complete until the adapter has a written result against this checklist.
|
||||
|
||||
## Evidence Levels
|
||||
|
||||
Use these evidence levels consistently in adapter records:
|
||||
|
||||
| Level | Meaning | Can Mark Complete |
|
||||
|-------|---------|-------------------|
|
||||
| `plugin-e2e-ui` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real platform/simulator UI action. | Yes |
|
||||
| `plugin-e2e-protocol` | Real SDK plugin running through standalone runtime, LangBot core, and the migrated adapter from a protocol-boundary event injection, such as a OneBot reverse WebSocket event. | Partial; must not be claimed as UI coverage |
|
||||
| `plugin-e2e-outbound` | Real SDK plugin calls an API and the bot output is visible in the real platform/simulator UI. | Yes for send/API coverage only |
|
||||
| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only |
|
||||
| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only |
|
||||
| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported |
|
||||
| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No |
|
||||
|
||||
The primary acceptance path must be `plugin-e2e-ui` for inbound UI-triggered behavior and `plugin-e2e-outbound` for bot send/API behavior. `adapter-live`, `plugin-e2e-protocol`, and `unit` tests are useful, but they must be labelled precisely.
|
||||
|
||||
## Required Architecture Path
|
||||
|
||||
Every adapter must prove this full path:
|
||||
|
||||
```text
|
||||
Real platform / simulator UI
|
||||
-> platform SDK native event
|
||||
-> adapter event converter
|
||||
-> unified EBA event/entity/message types
|
||||
-> LangBot core event dispatch
|
||||
-> standalone SDK runtime
|
||||
-> real test plugin listener
|
||||
-> plugin calls platform APIs through SDK
|
||||
-> LangBot core API dispatch
|
||||
-> adapter API implementation
|
||||
-> real platform / simulator UI
|
||||
```
|
||||
|
||||
The test plugin must record JSONL evidence containing:
|
||||
|
||||
- event class and `event.type`
|
||||
- `bot_uuid` and `adapter_name` as received by the plugin
|
||||
- adapter name
|
||||
- chat type and chat ID
|
||||
- sender/user/group IDs with secrets redacted
|
||||
- message component list for received messages
|
||||
- API action name, input summary, result or error
|
||||
- raw unsupported/blocked reason when an item is skipped
|
||||
|
||||
## Required Message Receive Tests
|
||||
|
||||
For every adapter, inbound message conversion must be tested through `plugin-e2e-ui` for each component the platform can receive. If a protocol-level injection is used, label it `plugin-e2e-protocol`; it proves the adapter/core/plugin path, but it does not prove that the user-facing platform UI can send that component. If the platform UI/simulator cannot create a component, record it as `blocked` with the endpoint limitation.
|
||||
|
||||
| Component | Required Receive Assertion |
|
||||
|-----------|----------------------------|
|
||||
| `Source` | Message ID and timestamp are present and stable enough for reply/get/delete APIs. |
|
||||
| `Plain` | Text is preserved exactly, including spaces and multi-line content. |
|
||||
| `At` | Mentioned user ID is converted to common `At.target`. |
|
||||
| `AtAll` | Broadcast mention is converted to common `AtAll`, if platform supports it. |
|
||||
| `Image` | Image ID, URL, path, or base64 is represented without leaking platform-native segment shape. |
|
||||
| `Voice` | Voice/audio component is represented as `Voice` when the platform exposes it. |
|
||||
| `File` | File name, ID/URL, and size are represented as `File` when available. |
|
||||
| `Quote` | Reply/quote source ID and origin content are represented when the platform exposes it. |
|
||||
| `Face` | Native emoji/sticker/dice/rps-like components are represented as `Face` or documented as platform-specific. |
|
||||
| `Forward` | Merged/forwarded messages are represented as `Forward` when the platform exposes structured content. |
|
||||
| `Unknown` | Unsupported native segments become `Unknown` or `PlatformSpecificEvent` data, not crashes. |
|
||||
| Mixed chain | A message containing multiple component types preserves order. |
|
||||
|
||||
The plugin must subscribe to `MessageReceivedEvent` and assert that `message_chain` contains common `langbot_plugin.api.entities.builtin.platform.message` components, not platform-native SDK objects.
|
||||
|
||||
## Required Message Send Tests
|
||||
|
||||
For every adapter, outbound message conversion must be tested through `plugin-e2e-outbound` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
|
||||
|
||||
| Component | Required Send Assertion |
|
||||
|-----------|-------------------------|
|
||||
| `Plain` | Text appears exactly on the platform. |
|
||||
| `At` | User mention renders as a mention or platform equivalent. |
|
||||
| `AtAll` | Broadcast mention renders or is explicitly unsupported. |
|
||||
| `Image` | URL, path, or base64 image sends and renders/downloads correctly. |
|
||||
| `Voice` | Voice/audio sends when supported. |
|
||||
| `File` | File sends with name and content/link when supported. |
|
||||
| `Quote` | Quoted reply points to the original message when supported. |
|
||||
| `Face` | Native emoji/sticker/dice/rps sends or is explicitly unsupported. |
|
||||
| `Forward` | Forward/merged-forward sends when supported; otherwise fallback behavior is documented. |
|
||||
| Mixed chain | A mixed chain preserves component order as closely as the platform allows. |
|
||||
|
||||
If a platform supports a component only in one direction, the adapter record must say so explicitly.
|
||||
|
||||
## Required Event Tests
|
||||
|
||||
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e-ui`, `plugin-e2e-protocol`, `not-supported`, or `blocked`.
|
||||
|
||||
| Event | Required Assertion |
|
||||
|-------|--------------------|
|
||||
| `message.received` | Real message reaches plugin as `MessageReceivedEvent`. |
|
||||
| `message.edited` | Edited message reaches plugin with message ID and new content, if declared. |
|
||||
| `message.deleted` | Deleted/recalled message reaches plugin with message ID and operator when available, if declared. |
|
||||
| `message.reaction` | Reaction add/remove reaches plugin with message ID, user, reaction, and direction, if declared. |
|
||||
| `feedback.received` | Feedback payload reaches plugin with feedback type and message/session IDs, if declared. |
|
||||
| `group.member_joined` | Join event reaches plugin with group and member. |
|
||||
| `group.member_left` | Leave/kick event reaches plugin with group, member, and kick flag. |
|
||||
| `group.member_banned` | Mute/ban event reaches plugin with group, member, operator, and duration. |
|
||||
| `group.info_updated` | Group metadata update reaches plugin with changed fields, if declared. |
|
||||
| `friend.request_received` | Friend request reaches plugin with request ID and message. |
|
||||
| `friend.added` | Friend-added event reaches plugin. |
|
||||
| `friend.removed` | Friend-removed event reaches plugin, if declared. |
|
||||
| `bot.invited_to_group` | Bot invite/join request reaches plugin with group and inviter/request ID. |
|
||||
| `bot.removed_from_group` | Bot removal reaches plugin with group and operator when available. |
|
||||
| `bot.muted` | Bot mute reaches plugin with duration. |
|
||||
| `bot.unmuted` | Bot unmute reaches plugin. |
|
||||
| `platform.specific` | At least one unmapped native event is delivered as structured platform-specific data, if declared. |
|
||||
|
||||
Do not declare an event in the manifest unless there is an implementation path and an acceptance entry.
|
||||
|
||||
## Required Common API Tests
|
||||
|
||||
The plugin must call every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. Each call must be recorded with input summary and result.
|
||||
|
||||
| API | Required Assertion |
|
||||
|-----|--------------------|
|
||||
| `send_message` | Plugin sends to private and group/channel targets where supported. |
|
||||
| `reply_message` | Plugin replies to the triggering message, with quoted mode tested when supported. |
|
||||
| `edit_message` | Plugin edits a bot-sent message, if declared. |
|
||||
| `delete_message` | Plugin deletes/recalls a bot-sent message, if declared and permissions allow. |
|
||||
| `forward_message` | Plugin forwards or emulates forwarding a real message, if declared. |
|
||||
| `get_message` | Plugin retrieves a real message and receives common `MessageReceivedEvent` shape. |
|
||||
| `get_group_info` | Plugin receives `UserGroup` with ID/name/count where available. |
|
||||
| `get_group_list` | Plugin receives joined groups/channels list where supported. |
|
||||
| `get_group_member_list` | Plugin receives list of `UserGroupMember` where supported. |
|
||||
| `get_group_member_info` | Plugin receives one member with role/display name where available. |
|
||||
| `set_group_name` | Plugin changes and restores a disposable group name, if declared. |
|
||||
| `mute_member` | Plugin mutes a disposable target, if declared. |
|
||||
| `unmute_member` | Plugin unmutes the same target, if declared. |
|
||||
| `kick_member` | Plugin kicks a disposable target only in destructive test mode, if declared. |
|
||||
| `leave_group` | Plugin leaves only in destructive test mode and only at the end, if declared. |
|
||||
| `get_user_info` | Plugin receives common `User` shape. |
|
||||
| `get_friend_list` | Plugin receives friend/contact list where supported. |
|
||||
| `approve_friend_request` | Plugin accepts/rejects a disposable friend request, if declared. |
|
||||
| `approve_group_invite` | Plugin accepts/rejects a disposable group invite, if declared. |
|
||||
| `upload_file` | Plugin uploads a real small file, if declared. |
|
||||
| `get_file_url` | Plugin resolves a real file ID to a URL, if declared. |
|
||||
| `call_platform_api` | Plugin calls every declared platform-specific action with safe parameters. |
|
||||
|
||||
Destructive APIs must be opt-in and documented with the exact target used.
|
||||
|
||||
The SDK must expose a plugin-side platform API escape hatch for adapter-specific actions. The acceptance plugin should call it from the same EBA event handler that received the real platform event, so the evidence proves both directions of the path:
|
||||
|
||||
```text
|
||||
plugin -> SDK call_platform_api -> LangBot core -> adapter call_platform_api -> platform SDK/API
|
||||
```
|
||||
|
||||
The result must be serialized into JSON-safe values before it is returned to the plugin runtime.
|
||||
|
||||
## Platform-Specific API Tests
|
||||
|
||||
Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry:
|
||||
|
||||
- `plugin-e2e-ui` or `plugin-e2e-outbound`: called by the plugin against the live/simulator endpoint.
|
||||
- `plugin-e2e-protocol`: called by the plugin after a protocol-boundary injected event; useful for endpoint-specific simulators but must be labelled.
|
||||
- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not.
|
||||
- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable.
|
||||
|
||||
Do not leave a platform-specific API in the manifest without a corresponding test record.
|
||||
|
||||
## Required Compatibility Tests
|
||||
|
||||
Each migrated adapter must also prove:
|
||||
|
||||
- Manifest supported events match `adapter.get_supported_events()`.
|
||||
- Manifest supported APIs match `adapter.get_supported_apis()`.
|
||||
- Manifest platform-specific actions match `PLATFORM_API_MAP`.
|
||||
- Legacy `FriendMessage` / `GroupMessage` listeners still work when the core registers them.
|
||||
- EBA listener dispatch prefers the most specific event class, then `EBAEvent`, then base `Event`.
|
||||
- Self-message filtering prevents bot echo loops without dropping edit/delete/moderation events needed for API tests.
|
||||
- `source_platform_object` is present for reply/debug but not required by plugins for common behavior.
|
||||
|
||||
## Required Documentation Per Adapter
|
||||
|
||||
Each adapter document must include:
|
||||
|
||||
- adapter directory and manifest name
|
||||
- config table
|
||||
- supported event table with evidence level per event
|
||||
- supported common API table with evidence level per API
|
||||
- platform-specific API table with evidence level per action
|
||||
- receive component table with evidence level per component
|
||||
- send component table with evidence level per component
|
||||
- exact test date
|
||||
- exact platform endpoint or simulator used
|
||||
- standalone runtime command
|
||||
- plugin path/name used for testing
|
||||
- evidence JSONL path
|
||||
- destructive operations performed or explicitly skipped
|
||||
- blocked items and reasons
|
||||
|
||||
## Acceptance Rule
|
||||
|
||||
An adapter can be marked migrated only when:
|
||||
|
||||
1. All declared events have `plugin-e2e-ui`, justified `plugin-e2e-protocol`, or `not-supported` evidence.
|
||||
2. All declared APIs have `plugin-e2e-outbound` or `not-supported` evidence.
|
||||
3. All platform-supported receive components have `plugin-e2e-ui` evidence; protocol-only receive coverage keeps the status partial.
|
||||
4. All platform-supported send components have `plugin-e2e-outbound` evidence.
|
||||
5. Unit tests cover conversion and API-shape boundaries.
|
||||
6. The adapter document lists every blocked or skipped item honestly.
|
||||
|
||||
If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial.
|
||||
@@ -0,0 +1,171 @@
|
||||
# EBA Adapter Acceptance Report
|
||||
|
||||
Date: May 10, 2026
|
||||
|
||||
Scope:
|
||||
|
||||
- `telegram-eba`
|
||||
- `discord-eba`
|
||||
- `aiocqhttp-eba`
|
||||
- `dingtalk-eba`
|
||||
- `lark-eba`
|
||||
- `wecom-eba`
|
||||
- `wecombot-eba`
|
||||
- `wecomcs-eba`
|
||||
- `officialaccount-eba`
|
||||
- `qqofficial-eba`
|
||||
- `slack-eba`
|
||||
|
||||
This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict:
|
||||
|
||||
- `plugin-e2e-ui`: real platform or simulator UI event reached LangBot, standalone runtime, and `EBAEventProbe`.
|
||||
- `plugin-e2e-protocol`: real adapter endpoint event reached LangBot, standalone runtime, and `EBAEventProbe`, but the event was injected at the platform protocol boundary rather than sent through the UI.
|
||||
- `plugin-e2e-outbound`: the plugin called SDK APIs and the resulting bot message was visible on the platform.
|
||||
- `unit`: mocked converter/API coverage only.
|
||||
- `blocked`: not completed, either because the platform/simulator/client could not trigger it or because a safe disposable fixture was unavailable.
|
||||
- `not-supported`: the platform has no equivalent capability.
|
||||
|
||||
## Summary
|
||||
|
||||
| Adapter | Status | Honest acceptance summary |
|
||||
|---------|--------|---------------------------|
|
||||
| Telegram | Partial EBA acceptance | Real Telegram UI covered private text, group mention text, bot invite, inbound private image/file, outbound component sweep, safe SDK APIs, and safe Telegram platform APIs. Real UI inbound voice/quote was not completed in the latest plugin run. |
|
||||
| Discord | Partial EBA acceptance | Real Discord UI covered group text, outbound image/file/quote/mention components, safe SDK APIs, and safe Discord platform APIs. Real UI inbound attachment/image/file/reply/mention was not completed. A later UI retry was blocked because the Discord client kept the send button disabled. |
|
||||
| OneBot v11 / aiocqhttp | Partial EBA acceptance | Matcha UI covered real group text and outbound supported components/APIs. Multi-component inbound `Source/Plain/At/Face/Image/Voice/File/Quote` was verified through the real OneBot reverse WebSocket adapter endpoint, but not through Matcha UI upload/send. Matcha blocks file-send and merged-forward APIs. |
|
||||
| DingTalk | Partial EBA acceptance | Real DingTalk UI covered private text, emoji-as-text inbound, private inbound image/file, outbound image/file/quote/mention fallback components, safe SDK APIs, and safe DingTalk platform APIs. Real UI inbound voice/quote and group trigger were not completed. |
|
||||
| Lark / Feishu | Partial EBA acceptance | EBA adapter structure, self-built/store app config, WebSocket/Webhook mode handling, converters, common APIs, platform APIs, and unit tests are in place. One real LangBot organization WebSocket private text event reached `EBAEventProbe`; outbound component sweep was visible in Feishu. Latest real UI image/file sends did not reach local plugin evidence, so media receive remains blocked. |
|
||||
| WeCom | Partial EBA acceptance | Regular WeCom application-message adapter is split into the EBA directory with manifest, converters, API mixin, platform API map, and unit tests. Private text reached `EBAEventProbe` through standalone runtime and the real WeCom client; safe plugin APIs passed. Real inbound media and broader event coverage remain pending. |
|
||||
| WeComBot | Partial EBA acceptance | WeCom AI Bot is split into the EBA directory with WebSocket long connection mode and optional webhook mode, EBA message/feedback/platform-specific conversion, cache-backed common APIs, platform API map, unit tests, and a direct live probe. Private text, outbound component sweep, safe common APIs, and all declared WeComBot platform APIs reached `EBAEventProbe`; group, real inbound media, and feedback callback evidence remain pending. |
|
||||
| WeCom Customer Service | Partial EBA acceptance | WeCom Customer Service is split into the EBA directory with manifest, converters, API mixin, platform API map, unit tests, docs, and a direct live probe scaffold. Real WeChat customer-side UI text reached `EBAEventProbe`; plugin outbound text/image and safe cache-backed common APIs passed. Inbound media and platform-specific API live coverage remain pending; later fallback text sends were blocked by WeCom `95001 send msg count limit`. |
|
||||
| Official Account | Partial EBA acceptance | WeChat Official Account is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, and a direct live probe scaffold. Real WeChat Official Account UI private text reached `EBAEventProbe`; safe cache-backed common APIs and declared platform APIs passed. Proactive outbound `send_message` is not supported because replies must be tied to inbound webhook windows; inbound image/voice live UI evidence remains pending. |
|
||||
| QQ Official API | Partial EBA acceptance | QQ Official API is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; reply/outbound evidence is blocked by the test model provider returning `model_not_found` for `deepseek-v3`. |
|
||||
| Slack | Partial EBA acceptance | Slack is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. Real Slack private text reached `EBAEventProbe`; safe common APIs, outbound component fallback sweep, and declared Slack platform APIs passed. Channel mention and real inbound media evidence remain pending. |
|
||||
|
||||
Telegram and DingTalk now have real user-side UI image/file upload evidence in plugin JSONL. Discord and aiocqhttp do not yet have real UI inbound image/file evidence.
|
||||
|
||||
## Evidence Files
|
||||
|
||||
| Adapter | Endpoint | Evidence |
|
||||
|---------|----------|----------|
|
||||
| Telegram private | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-rerun.jsonl` |
|
||||
| Telegram private media | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-media-ui.jsonl` |
|
||||
| Telegram group | Telegram Lite, `Rock'sBotGroup` | `data/temp/telegram-plugin-e2e-group.jsonl` |
|
||||
| Discord | Discord client, LangBot server, `#debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` |
|
||||
| aiocqhttp UI | local Matcha, group `test group` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||
| aiocqhttp protocol | OneBot reverse WebSocket endpoint `127.0.0.1:2280/ws` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||
| DingTalk | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` |
|
||||
| DingTalk private media | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-media-ui.jsonl` |
|
||||
| Lark / Feishu unit | local mocked Feishu SDK/client paths | `tests/unit_tests/platform/test_lark_eba_adapter.py` |
|
||||
| Lark / Feishu partial live | Feishu Mac, LangBot organization `LangBotDev` private chat | `data/temp/lark-plugin-e2e-ws.jsonl` |
|
||||
| WeCom Customer Service | WeChat customer-side UI, `客服消息 -> 浪波智能客服` on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` |
|
||||
| Official Account | WeChat desktop client, subscribed Official Account on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` |
|
||||
| QQ Official API unit | local mocked QQ Official client paths | `tests/unit_tests/platform/test_qqofficial_eba_adapter.py` |
|
||||
| Slack unit | local mocked Slack client paths | `tests/unit_tests/platform/test_slack_eba_adapter.py` |
|
||||
| Slack private | Slack workspace private DM on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl` |
|
||||
|
||||
All plugin runs used SDK standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the real plugin at `langbot-plugin-demo/EBAEventProbe`.
|
||||
|
||||
## Unified Shape Verification
|
||||
|
||||
All four adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event.
|
||||
|
||||
| Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||
|-------------|----------|---------|-----------|----------|---------------|
|
||||
| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending |
|
||||
| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-eba` in current unit/code; older live text evidence recorded `lark` before the naming fix |
|
||||
| common `MessageChain` delivered | `Plain`, group `At + Plain`, private `Image`, private `File` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain`, private `Source + Image`, private `Source + File` | live private `Source + Plain`; unit `Source + Plain + At/Image/File`; latest live image/file blocked |
|
||||
| common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed | live private user; unit private/group |
|
||||
| raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` |
|
||||
|
||||
## Message Receive Components
|
||||
|
||||
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||
|-----------|----------|---------|-----------|----------|---------------|
|
||||
| `Source` | design gap: event has message id but chain omits `Source` | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||
| `Plain` | plugin-e2e-ui private/group | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||
| `At` | plugin-e2e-ui group mention | unit; real UI mention not completed in latest run | plugin-e2e-protocol; unit | unit; group trigger not completed | unit; group trigger not completed |
|
||||
| `AtAll` | not-supported | unit only | unit only | unit/send fallback only | unit only |
|
||||
| `Image` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI image sent but not observed in plugin evidence |
|
||||
| `Voice` | converter/unit; real UI inbound not completed | not-supported as native voice; audio is attachment/file | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed | unit; real UI inbound not completed |
|
||||
| `File` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI file sent but not observed in plugin evidence |
|
||||
| `Quote` | converter/unit; real UI reply not completed | unit; real UI reply not completed | plugin-e2e-protocol | converter/unit; real UI quote not completed | unit/API-backed quote lookup; real UI quote not completed |
|
||||
| `Face` | not-supported as common `Face` | not-supported as common `Face` | plugin-e2e-protocol | UI emoji becomes `Plain` (`[smile]` text), not `Face` | not-supported as common `Face` |
|
||||
| `Forward` | not-supported inbound | not-supported inbound | unit; Matcha forward UI/action blocked | not-supported inbound | not-supported inbound |
|
||||
| Mixed chain | group `At + Plain`; media tested as separate messages | not completed inbound | plugin-e2e-protocol | media tested as separate messages; mixed inbound not completed | unit only |
|
||||
|
||||
## Message Send Components
|
||||
|
||||
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||
|-----------|----------|---------|-----------|----------|---------------|
|
||||
| `Plain` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||
| `At` | plugin-e2e-outbound equivalent | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback/equivalent | plugin-e2e-outbound |
|
||||
| `AtAll` | plugin-e2e-outbound fallback | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | unit; group live not completed |
|
||||
| `Image` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||
| `Voice` | not-supported in current send converter | not-supported as native voice | converter path; not completed against Matcha UI | fallback as file/text depending DingTalk media support | converter path; live not completed |
|
||||
| `File` | plugin-e2e-outbound | plugin-e2e-outbound | blocked by Matcha endpoint error | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||
| `Quote` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | plugin-e2e-outbound fallback |
|
||||
| `Face` | not-supported | not-supported | plugin-e2e-outbound attempted in mixed chain | fallback text | not-supported |
|
||||
| `Forward` | flattened fallback | flattened fallback | blocked by Matcha unsupported action | flattened fallback | plugin-e2e-outbound flattened fallback |
|
||||
| Mixed chain | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound except blocked file/forward | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||
|
||||
## Event Acceptance
|
||||
|
||||
| Event category | Telegram | Discord | aiocqhttp | DingTalk |
|
||||
|----------------|----------|---------|-----------|----------|
|
||||
| `message.received` | plugin-e2e-ui | plugin-e2e-ui | plugin-e2e-ui and plugin-e2e-protocol | plugin-e2e-ui private |
|
||||
| `message.edited` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||
| `message.deleted` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||
| `message.reaction` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | not-supported in standard OneBot message path | not declared |
|
||||
| member join/left/ban | implemented/unit or blocked without disposable users | blocked without disposable users | unit; Matcha fixture unavailable | not declared |
|
||||
| bot invited/removed | invite plugin-e2e-ui for Telegram; removal blocked | invite historical/plugin-series; removal blocked | unit; Matcha fixture unavailable | not declared |
|
||||
| requests/friend events | not applicable | not applicable | unit; Matcha fixture unavailable | not declared |
|
||||
| `platform.specific` | implemented; not latest plugin-e2e | not latest plugin-e2e | adapter lifecycle observed; plugin focus was message path | declared for fallback; not reproduced in UI run |
|
||||
|
||||
## Common API Acceptance
|
||||
|
||||
| API area | Telegram | Discord | aiocqhttp | DingTalk |
|
||||
|----------|----------|---------|-----------|----------|
|
||||
| send/reply | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound, with Matcha file/forward gaps | plugin-e2e-outbound |
|
||||
| edit/delete | historical/direct or unit; destructive/current UI not repeated | historical/direct; destructive/current UI not repeated | unit/destructive blocked | not declared or blocked |
|
||||
| message lookup | not-supported | not-supported | plugin-e2e | inbound cache-backed where available; limited live coverage |
|
||||
| group info/member info | plugin-e2e safe subset | plugin-e2e safe subset | plugin-e2e safe subset | private path only; group not completed |
|
||||
| user/friend info | plugin-e2e where platform allows | plugin-e2e where platform allows | plugin-e2e | plugin-e2e private user |
|
||||
| moderation/leave | blocked without disposable safe targets | blocked without disposable safe targets | blocked without disposable safe targets | blocked/not declared |
|
||||
| `get_file_url` | implemented; latest inbound `File` carried downloadable file data in plugin evidence | URL passthrough for attachments; inbound attachment not completed | not portable/endpoint-dependent | implemented through DingTalk media API; latest inbound `File` carried a platform file URL |
|
||||
| `call_platform_api` | plugin-e2e safe actions | plugin-e2e safe actions | plugin-e2e safe actions, Matcha gaps documented | plugin-e2e safe `check_access_token` |
|
||||
|
||||
## Platform-Specific API Acceptance
|
||||
|
||||
| Adapter | plugin-e2e verified | Blocked or not reproduced |
|
||||
|---------|---------------------|---------------------------|
|
||||
| Telegram | safe chat/admin/member count/chat-action actions | mutating actions and callback-only actions were not repeated |
|
||||
| Discord | safe channel/guild/role/typing actions | mutating pin/reaction/invite actions were not repeated in the latest plugin run; inbound attachment paths not completed |
|
||||
| aiocqhttp | safe OneBot actions such as status/version/can-send checks | `get_group_honor_info` unsupported by Matcha; admin/card/title/ban/record/file/forward require better endpoint fixtures |
|
||||
| DingTalk | `check_access_token`; real inbound file produced a file URL in the common `File` component | separate media-download replay APIs and group actions need a working follow-up fixture |
|
||||
|
||||
## SDK API Acceptance
|
||||
|
||||
`EBAEventProbe` exercised the standalone runtime path for:
|
||||
|
||||
- bot discovery and bot info lookup
|
||||
- send message
|
||||
- component sweep where enabled
|
||||
- platform API sweep where enabled
|
||||
- plugin storage
|
||||
- workspace storage
|
||||
- plugin/command/tool/knowledge-base list APIs
|
||||
|
||||
The probe logs set `ok=true` when the sweep completed with only expected unsupported/blocked items. Individual call details are stored in the JSONL evidence files.
|
||||
|
||||
## Residual Risks And Required Follow-Up
|
||||
|
||||
- Discord still requires real UI inbound image/file upload evidence before it can be called media-complete.
|
||||
- aiocqhttp has rich inbound component evidence only at the OneBot reverse WebSocket boundary; Matcha UI did not provide image/file upload coverage.
|
||||
- DingTalk group trigger remains unclosed; current evidence is private chat only.
|
||||
- Lark / Feishu requires a clean follow-up live pass: the latest LangBot organization WebSocket run connected, but UI-sent text/image/file after the loop-scheduling fix did not append plugin events.
|
||||
- Discord UI retry on May 10, 2026 was blocked by the client keeping the send button disabled even after text was entered.
|
||||
- Destructive moderation and leave APIs are intentionally blocked until disposable users/groups are available.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The EBA conversion path is implemented and partially proven for the migrated adapters. Telegram and DingTalk now have real UI private-chat image/file inbound evidence. Discord, aiocqhttp, and Lark / Feishu still have explicit UI-level media gaps, so the overall adapter set remains partial acceptance rather than production-complete media acceptance.
|
||||
@@ -0,0 +1,162 @@
|
||||
# OneBot v11 / aiocqhttp EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
OneBot v11 has been migrated to the EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/aiocqhttp/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
├── types.py
|
||||
└── onebot.svg
|
||||
```
|
||||
|
||||
The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `host` | Yes | `0.0.0.0` | Host for the reverse WebSocket server that the OneBot endpoint connects to. |
|
||||
| `port` | Yes | `2280` | Reverse WebSocket listen port. |
|
||||
| `access-token` | No | `""` | OneBot access token, if the endpoint is configured to use one. |
|
||||
|
||||
## Events
|
||||
|
||||
The adapter declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `message.deleted`
|
||||
- `group.member_joined`
|
||||
- `group.member_left`
|
||||
- `group.member_banned`
|
||||
- `friend.request_received`
|
||||
- `friend.added`
|
||||
- `bot.invited_to_group`
|
||||
- `bot.removed_from_group`
|
||||
- `bot.muted`
|
||||
- `bot.unmuted`
|
||||
- `platform.specific`
|
||||
|
||||
`platform.specific` is used for OneBot notice/request/meta events that do not yet have a common EBA event type, such as group admin changes, group file uploads, pokes, honor changes, and group join requests from non-bot users.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Supported | Supports private and group text, mentions, images, voice, files, faces, and flattened forwards. Group merged forwards are sent through OneBot forward APIs when possible. |
|
||||
| `reply_message` | Supported | Uses the original OneBot event and can prepend a reply segment. |
|
||||
| `edit_message` | Not supported | OneBot v11 has no standard message edit action. |
|
||||
| `delete_message` | Supported | Uses `delete_msg`; permission depends on endpoint and group role. |
|
||||
| `forward_message` | Supported | Emulates forward by fetching the source message with `get_msg` and sending its content to the target chat. |
|
||||
| `get_message` | Supported | Uses `get_msg` and converts the response into `MessageReceivedEvent`. |
|
||||
| `get_group_info` | Supported | Uses `get_group_info`. |
|
||||
| `get_group_list` | Supported | Uses `get_group_list`. |
|
||||
| `get_group_member_list` | Supported | Uses `get_group_member_list`. |
|
||||
| `get_group_member_info` | Supported | Uses `get_group_member_info`. |
|
||||
| `set_group_name` | Supported | Uses `set_group_name`; may be unsupported by mock endpoints. |
|
||||
| `get_user_info` | Supported | Uses `get_stranger_info`. |
|
||||
| `get_friend_list` | Supported | Uses `get_friend_list`. |
|
||||
| `approve_friend_request` | Supported | Uses `set_friend_add_request`. |
|
||||
| `approve_group_invite` | Supported | Uses `set_group_add_request` with `sub_type=invite`. |
|
||||
| `upload_file` | Not supported | OneBot v11 has endpoint-specific file upload extensions but no portable standalone upload action. |
|
||||
| `get_file_url` | Not supported | OneBot v11 file URL resolution is endpoint-specific. Use `call_platform_api("get_image")`, `get_record`, or endpoint extensions when available. |
|
||||
| `mute_member` | Supported | Uses `set_group_ban`. |
|
||||
| `unmute_member` | Supported | Uses `set_group_ban` with duration `0`. |
|
||||
| `kick_member` | Supported | Destructive; test only with disposable members. |
|
||||
| `leave_group` | Supported | Destructive; should run last in live tests. |
|
||||
| `call_platform_api` | Supported | See below. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `get_login_info`
|
||||
- `get_status`
|
||||
- `get_version_info`
|
||||
- `get_group_honor_info`
|
||||
- `set_group_card`
|
||||
- `set_group_special_title`
|
||||
- `set_group_admin`
|
||||
- `set_group_whole_ban`
|
||||
- `send_group_forward_msg`
|
||||
- `get_forward_msg`
|
||||
- `get_record`
|
||||
- `get_image`
|
||||
- `can_send_image`
|
||||
- `can_send_record`
|
||||
|
||||
## Message Conversion Notes
|
||||
|
||||
Incoming OneBot segments are converted into common `MessageChain` components before LangBot core/plugin dispatch:
|
||||
|
||||
- `text` -> `Plain`
|
||||
- `at` -> `At` / `AtAll`
|
||||
- `image` -> `Image` or `Face` for OneBot emoji-package images
|
||||
- `record` -> `Voice`
|
||||
- `file` -> `File`
|
||||
- `reply` -> `Quote`
|
||||
- `face`, `rps`, `dice` -> `Face`
|
||||
- unsupported segments -> `Unknown`
|
||||
|
||||
Outgoing `MessageChain` components are converted back into `aiocqhttp.Message` segments. Base64 media strings are normalized to OneBot `base64://...` format.
|
||||
|
||||
## Live Test Record
|
||||
|
||||
The direct live probe is:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/Users/qinjunyan/code/projects/langbot/langbot-plugin-sdk/src \
|
||||
uv run python tests/e2e/live_aiocqhttp_eba_probe.py --host 127.0.0.1 --port 2280
|
||||
```
|
||||
|
||||
It starts the reverse WebSocket adapter directly, records observed EBA events to `data/temp/aiocqhttp_eba_live_probe.jsonl`, waits for a real Matcha or OneBot message, then tries reply/send/get/delete/group/user/platform API calls as far as the endpoint supports them.
|
||||
|
||||
Verified on May 10, 2026 with local Matcha connected to `ws://127.0.0.1:2280/ws`:
|
||||
|
||||
- Real inbound group message converted to `MessageReceivedEvent`.
|
||||
- Real lifecycle connection converted to `PlatformSpecificEvent`.
|
||||
- Real reply API succeeded and rendered a quoted bot reply in Matcha.
|
||||
- Real proactive send API succeeded and rendered a bot group message in Matcha.
|
||||
- Real outgoing component sweep succeeded for text, `At`, `AtAll`, `Face`, and base64 `Image`.
|
||||
- Real `get_message`, `get_group_info`, `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record` calls succeeded against Matcha.
|
||||
- Unit conversion and API-shape tests passed for `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `rps`, `dice`, `Forward`, `Unknown`, private/group message events, delete notices, group join/leave/ban notices, bot mute notices, friend requests, group invites, friend added notices, dispatch specificity, send, reply, delete, forward, get message, group APIs, user APIs, request approval APIs, moderation APIs, leave group, unsupported file APIs, and all declared `call_platform_api` actions.
|
||||
|
||||
Skipped or residual live-test items:
|
||||
|
||||
- `edit_message`: not implemented because OneBot v11 has no standard edit action.
|
||||
- `upload_file` and `get_file_url`: not implemented as common APIs because portable OneBot v11 file upload/download URL semantics are endpoint-specific.
|
||||
- `kick_member` and `leave_group`: destructive; run only with explicit `--destructive` and disposable Matcha/OneBot state.
|
||||
- `group.info_updated`, message reactions, and message edits are not declared because OneBot v11 does not provide standard equivalents for them.
|
||||
- Matcha returned `ActionFailed` for outgoing `File` segment rendering and did not support merged-forward actions in this run. The adapter keeps the conversion/API implementations because they are valid OneBot/NapCat-style capabilities, but the Matcha live probe records them as skipped.
|
||||
- Matcha returned an empty `get_group_member_list` for the test group, so `get_group_member_info`, mute/unmute, kick, and leave were covered by unit/API-shape tests only in this run.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot `--standalone-runtime`, local Matcha, and group `测试群`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl`
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A real Matcha group message reached the plugin as `MessageReceived` with `bot_uuid=eba-aiocqhttp-matcha`, `adapter_name=aiocqhttp`, common `Source`/`Plain` message components, common sender, and common group identifiers.
|
||||
- A protocol-level OneBot reverse WebSocket event reached the plugin as `MessageReceived` with a mixed common chain: `Source`, `Plain`, `At`, `Face`, `Image`, `Voice`, `File`, `Quote`, and trailing `Plain`. This proves the real adapter + LangBot + standalone runtime + plugin path for mixed inbound OneBot payloads, but it was not sent through Matcha UI.
|
||||
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||
- Outbound component sweep succeeded for plain text plus `At`/`Face`, `AtAll`, base64 `Image`, and quoted reply.
|
||||
- Common APIs succeeded through the plugin path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, `get_group_member_list`, and `get_group_member_info`.
|
||||
- Safe OneBot platform APIs succeeded through `call_platform_api`: `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record`.
|
||||
|
||||
Documented Matcha limits in this E2E run:
|
||||
|
||||
- Matcha UI did not provide a completed image/file upload/send path for inbound media. The rich inbound media evidence is `plugin-e2e-protocol`, not UI-level media upload evidence.
|
||||
- Outbound `File` failed in Matcha even after the adapter emitted an official `file` segment shape.
|
||||
- Outbound `Forward` failed because Matcha returned unsupported action for merged-forward.
|
||||
- `get_group_honor_info` failed because Matcha returned unsupported action.
|
||||
- Destructive/admin APIs such as mute, unmute, kick, leave, group rename, card/title/admin/whole-ban changes, and request approvals were not run without disposable fixtures.
|
||||
@@ -0,0 +1,114 @@
|
||||
# DingTalk EBA Adapter Migration Record
|
||||
|
||||
Status: migrated with partial plugin E2E evidence.
|
||||
|
||||
Adapter directory: `src/langbot/pkg/platform/adapters/dingtalk/`
|
||||
|
||||
## What Changed
|
||||
|
||||
The DingTalk adapter now has an Event-Based Agents adapter package with:
|
||||
|
||||
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs.
|
||||
- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch.
|
||||
- `event_converter.py` for native DingTalk events to common EBA events.
|
||||
- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
|
||||
- `api_impl.py` for common EBA API implementations.
|
||||
- `platform_api.py` for DingTalk-specific `call_platform_api` actions.
|
||||
|
||||
The legacy DingTalk HTTP client now returns successful JSON response bodies from proactive send methods and raises with response details on non-200 responses.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `client-id` | yes | DingTalk robot/client identifier. |
|
||||
| `client-secret` | yes | DingTalk client secret. |
|
||||
| `robot-code` | yes | Robot code used for send APIs. |
|
||||
| `robot-name` | no | Used for bot mention/self filtering and display. |
|
||||
| `encrypt-key` | no | DingTalk callback encryption key when configured. |
|
||||
| `verification-token` | no | DingTalk callback verification token when configured. |
|
||||
|
||||
## Supported Events
|
||||
|
||||
| Event | Support | Evidence |
|
||||
|-------|---------|----------|
|
||||
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. |
|
||||
| `platform.specific` | implemented | Not reproduced in the latest UI run. |
|
||||
|
||||
## Receive Components
|
||||
|
||||
| Component | Support | Evidence |
|
||||
|-----------|---------|----------|
|
||||
| `Source` | supported | `plugin-e2e-ui` private message. |
|
||||
| `Plain` | supported | `plugin-e2e-ui` private text. DingTalk emoji currently arrives as plain text such as `[smile]`. |
|
||||
| `At` | converter path | Group trigger was not completed in the latest run. |
|
||||
| `AtAll` | fallback/send-side only | Not completed inbound. |
|
||||
| `Image` | supported | Real DingTalk Mac private-chat image upload reached the plugin as common `Image`. |
|
||||
| `Voice` | converter path | Real UI inbound voice was not completed. |
|
||||
| `File` | supported | Real DingTalk Mac private-chat file upload reached the plugin as common `File`. |
|
||||
| `Quote` | converter path | Real UI inbound quote was not completed. |
|
||||
| `Face` | not native common mapping | DingTalk emoji was observed as `Plain`, not `Face`. |
|
||||
| `Forward` | not-supported inbound | DingTalk does not expose a portable structured forward event in this adapter. |
|
||||
|
||||
## Send Components
|
||||
|
||||
| Component | Support | Evidence |
|
||||
|-----------|---------|----------|
|
||||
| `Plain` | supported | `plugin-e2e-outbound`. |
|
||||
| `At` | supported or text fallback | `plugin-e2e-outbound`. |
|
||||
| `AtAll` | fallback | `plugin-e2e-outbound`. |
|
||||
| `Image` | supported | `plugin-e2e-outbound`. |
|
||||
| `File` | supported | `plugin-e2e-outbound`. |
|
||||
| `Quote` | fallback | `plugin-e2e-outbound`. |
|
||||
| `Face` | fallback | `plugin-e2e-outbound` as text fallback. |
|
||||
| `Forward` | flattened fallback | `plugin-e2e-outbound`. |
|
||||
| `Voice` | fallback/endpoint-dependent | Not separately verified as a native DingTalk voice send. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Support | Notes |
|
||||
|-----|---------|-------|
|
||||
| `send_message` | supported | Verified through `EBAEventProbe`. |
|
||||
| `reply_message` | supported | Verified through quoted/fallback send path. |
|
||||
| `get_message` | cache-backed | Requires the message to have been observed by this adapter process. |
|
||||
| `get_group_info` | cache-backed/API-backed where available | Group path not completed in latest UI run. |
|
||||
| `get_group_list` | supported where DingTalk API allows | Limited live coverage. |
|
||||
| `get_group_member_info` | supported where DingTalk API allows | Limited live coverage. |
|
||||
| `get_user_info` | supported | Private sender path verified. |
|
||||
| `get_friend_list` | limited | DingTalk does not expose a portable friend-list equivalent. |
|
||||
| `get_file_url` | supported with media/file identifiers | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||
| `call_platform_api` | supported | Safe action `check_access_token` verified. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
| Action | Support | Evidence |
|
||||
|--------|---------|----------|
|
||||
| `check_access_token` | supported | `plugin-e2e`. |
|
||||
| `refresh_access_token` | supported | Implemented; not separately reproduced in the latest plugin run. |
|
||||
| `get_file_url` | supported | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||
| `get_audio_base64` | supported | Needs real inbound audio/media ID. |
|
||||
| `download_image_base64` | supported | Real inbound image reached the plugin as `Image`; separate image-download API replay was not completed. |
|
||||
|
||||
## End-to-End Evidence
|
||||
|
||||
Evidence files:
|
||||
|
||||
- Text/API/component JSONL: `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl`
|
||||
- Real UI inbound media JSONL: `data/temp/dingtalk-plugin-e2e-media-ui.jsonl`
|
||||
|
||||
Verified:
|
||||
|
||||
- DingTalk Mac private chat in the `LangBot Team` organization produced `MessageReceived` through LangBot standalone runtime and `EBAEventProbe`.
|
||||
- The common chain was `Source + Plain` for normal text.
|
||||
- DingTalk emoji was received as `Source + Plain`, not common `Face`.
|
||||
- Real DingTalk Mac private-chat image upload was received as `Source + Image`.
|
||||
- Real DingTalk Mac private-chat file upload was received as `Source + File`.
|
||||
- The plugin sent outbound text, mention/fallback, image, quote/fallback, file, and forward/fallback messages visible in DingTalk.
|
||||
- The plugin called safe SDK and DingTalk platform APIs.
|
||||
|
||||
Not completed:
|
||||
|
||||
- Real UI inbound voice.
|
||||
- Real UI inbound quote.
|
||||
- Group trigger with a real robot mention.
|
||||
- Destructive or organization-mutating APIs.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Discord EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
Discord has been migrated from the legacy source adapter:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/sources/discord.py
|
||||
src/langbot/pkg/platform/sources/discord.yaml
|
||||
```
|
||||
|
||||
EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/discord/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
├── types.py
|
||||
└── voice.py
|
||||
```
|
||||
|
||||
The adapter is registered as `discord-eba`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `client_id` | Yes | `""` | Discord application client ID. |
|
||||
| `token` | Yes | `""` | Discord bot token. |
|
||||
|
||||
The bot needs gateway permissions and intents for the target test server. Message Content intent is required for message bodies, Server Members intent is required for member APIs/events, and reaction events require the Reactions intent and channel permissions.
|
||||
|
||||
## Events
|
||||
|
||||
Discord declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `message.edited`
|
||||
- `message.deleted`
|
||||
- `message.reaction`
|
||||
- `group.member_joined`
|
||||
- `group.member_left`
|
||||
- `group.member_banned`
|
||||
- `bot.invited_to_group`
|
||||
- `bot.removed_from_group`
|
||||
- `platform.specific`
|
||||
|
||||
Discord-specific events that do not map cleanly to common events should be surfaced as `platform.specific`.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|-----------------|-------|
|
||||
| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. |
|
||||
| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. |
|
||||
| `edit_message` | Supported | Bot can edit its own messages. File edits are implemented by clearing old attachments and sending replacement files when needed. |
|
||||
| `delete_message` | Supported | Requires message management permissions for non-bot messages. |
|
||||
| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. |
|
||||
| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. |
|
||||
| `get_group_member_list` | Supported | Requires member cache or the Server Members intent/fetch permission. |
|
||||
| `get_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. |
|
||||
| `get_user_info` | Supported | Uses Discord user fetch/cache. |
|
||||
| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. |
|
||||
| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. |
|
||||
| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. |
|
||||
| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. |
|
||||
| `kick_member` | Supported | Destructive; test only with a disposable account/bot. |
|
||||
| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. |
|
||||
| `call_platform_api` | Supported | Discord-specific actions live here. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `get_channel`
|
||||
- `get_guild`
|
||||
- `get_guild_channels`
|
||||
- `get_guild_roles`
|
||||
- `create_invite`
|
||||
- `pin_message`
|
||||
- `unpin_message`
|
||||
- `add_reaction`
|
||||
- `remove_reaction`
|
||||
- `typing`
|
||||
|
||||
Voice helpers are intentionally kept Discord-specific:
|
||||
|
||||
- `join_voice_channel`
|
||||
- `leave_voice_channel`
|
||||
- `get_voice_connection_status`
|
||||
- `list_active_voice_connections`
|
||||
- `get_voice_channel_info`
|
||||
|
||||
## Live Test Record
|
||||
|
||||
The live probe is:
|
||||
|
||||
```bash
|
||||
uv run python tests/e2e/live_discord_eba_probe.py --help
|
||||
```
|
||||
|
||||
Verified on May 7, 2026 with a newly created Discord application/bot named `LangBot EBA Test 0507`, the LangBot Discord server, and the `#🐞-debugging` channel:
|
||||
|
||||
- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`.
|
||||
- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`.
|
||||
- Plugin runtime API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage APIs, workspace storage APIs, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||
- Direct live adapter probe observed `message.received`, `message.edited`, `message.deleted`, and `bot.removed_from_group`.
|
||||
- Message APIs verified: send, reply, edit, delete, forward, text/image/file mixed message chains.
|
||||
- User and guild APIs verified: `get_user_info`, `get_group_info`, `get_group_member_list`, `get_group_member_info`.
|
||||
- Platform-specific APIs verified: `get_channel`, `get_guild`, `get_guild_channels`, `get_guild_roles`, `create_invite`, `typing`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction`.
|
||||
- Unsupported API behavior verified: `upload_file` raises `NotSupportedError`.
|
||||
- Destructive API verified at the end: `leave_group`, which emitted `bot.removed_from_group`.
|
||||
|
||||
Not verified in the shared LangBot server live run: `mute_member`, `unmute_member`, and `kick_member`, because the run did not use a disposable target member. They are implemented through Discord timeout/kick APIs and should only be exercised against a disposable account or bot.
|
||||
|
||||
The test fixed one real test-fixture issue: `EBAEventProbe` previously assumed `get_bots()` returned UUID strings. The current standalone runtime returns bot dictionaries, so the probe now selects an enabled bot dictionary and passes its `uuid` to `get_bot_info` and `send_message`. The probe also now subscribes to `MessageDeleted`.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Verified again on May 10, 2026 with SDK standalone runtime, LangBot `--standalone-runtime`, Discord web client, the LangBot server, and `#🐞-debugging`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- Main plugin JSONL: `data/temp/discord-plugin-e2e-20260510-final.jsonl`
|
||||
- LangBot runtime log: `data/temp/discord-langbot-e2e-20260510-rerun.log`
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A newly invited Discord bot connected to the LangBot server and received a real web-client message in `#🐞-debugging`.
|
||||
- `MessageReceived` reached the plugin with `bot_uuid=eba-discord-live`, `adapter_name=discord`, common `Source`/`Plain` message components, common `User`, and common `UserGroup` for the guild.
|
||||
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||
- Outbound component sweep succeeded: plain text plus user mention, `AtAll`/`@everyone`, base64 image, quoted reply, file attachment, and flattened forward fallback.
|
||||
- Common APIs succeeded: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||
- Discord platform APIs succeeded through `call_platform_api`: `get_channel`, `typing`, `get_guild`, `get_guild_channels`, and `get_guild_roles`.
|
||||
|
||||
Documented limits in this E2E run:
|
||||
|
||||
- Real Discord UI inbound attachment/image/file, reply/quote, and fresh mention-chain messages were not completed in the plugin E2E evidence. Outbound image/file attachments from the bot do not prove inbound attachment conversion.
|
||||
- A later May 10 UI retry could write text into the Discord message box, but the client kept the send button disabled and did not send the message, so it produced no new plugin evidence.
|
||||
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Discord adapter.
|
||||
- Destructive moderation and guild-leave APIs were not repeated against the shared LangBot server.
|
||||
- Native Discord voice is not represented as common `Voice`; audio-like payloads are treated as file attachments.
|
||||
- `create_invite`, pin/unpin, and reaction mutation were covered by prior direct live probes but were not repeated by the final plugin run to avoid extra shared-server side effects.
|
||||
@@ -0,0 +1,108 @@
|
||||
# KOOK EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
KOOK has been migrated to the EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/kook/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `kook-eba`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `token` | Yes | `""` | KOOK bot token. |
|
||||
| `enable-stream-reply` | Yes | `false` | Reserved for shared platform configuration compatibility. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Evidence | Notes |
|
||||
|-------|----------|-------|
|
||||
| `message.received` | `plugin-e2e-ui` | Real KOOK UI channel message reached `EBAEventProbe` as `MessageReceivedEvent`. |
|
||||
| `platform.specific` | `plugin-e2e-ui` | KOOK gateway event without a common EBA mapping reached `EBAEventProbe` as `PlatformSpecificEventReceived`. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Evidence | Notes |
|
||||
|-----|----------|-------|
|
||||
| `send_message` | `plugin-e2e-outbound` | Probe plugin sent channel messages through SDK `send_message`; KOOK returned message IDs. |
|
||||
| `reply_message` | `unit` | Supports `reply_msg_id` and optional quoted replies when the source message ID is available. |
|
||||
| `get_message` | `plugin-e2e-outbound` | Probe plugin fetched the cached triggering message. |
|
||||
| `get_group_info` | `plugin-e2e-outbound` | Probe plugin received cached KOOK channel info. |
|
||||
| `get_group_list` | `plugin-e2e-outbound` | Probe plugin received cached channel/group entities observed by the adapter. |
|
||||
| `get_group_member_info` | `plugin-e2e-outbound` | Probe plugin received cached sender info as a group member. |
|
||||
| `get_user_info` | `plugin-e2e-outbound` | Probe plugin received cached sender user info. |
|
||||
| `get_friend_list` | `plugin-e2e-outbound` | Probe plugin received cached users. |
|
||||
| `upload_file` | `unit` | Uses KOOK `asset/create` and returns URL/ID. |
|
||||
| `get_file_url` | `unit` | KOOK media IDs are URL-like in the adapter path; returns the ID unchanged. |
|
||||
| `delete_message` | `unit` | Calls KOOK delete endpoints. Live permission verification is still required. |
|
||||
| `forward_message` | `plugin-e2e-outbound` | Probe plugin sent flattened forward content through SDK `send_message`. |
|
||||
| `call_platform_api` | `plugin-e2e-outbound` | Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
| Action | Evidence | Notes |
|
||||
|--------|----------|-------|
|
||||
| `get_current_user` | `plugin-e2e-outbound` | Probe plugin called `user/me`. |
|
||||
| `get_user` | `plugin-e2e-outbound` | Probe plugin called `user/view` for the triggering sender. |
|
||||
| `get_channel` | `plugin-e2e-outbound` | Probe plugin called `channel/view` for the triggering channel. |
|
||||
| `get_guild` | `plugin-e2e-outbound` | Probe plugin called `guild/view`; gateway URLs redact token query values. |
|
||||
| `get_gateway` | `plugin-e2e-outbound` | Probe plugin called `gateway/index`; returned token query values are redacted. |
|
||||
| `send_direct_message` | `unit` | Calls `direct-message/create`. |
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Receive Evidence | Send Evidence | Notes |
|
||||
|-----------|------------------|---------------|-------|
|
||||
| `Source` | `plugin-e2e-ui` | N/A | KOOK message ID and timestamp are preserved. |
|
||||
| `Plain` | `plugin-e2e-ui` | `plugin-e2e-outbound` | Text and KMarkdown are represented as plain common text. |
|
||||
| `At` | `plugin-e2e-ui` | `plugin-e2e-outbound` | KOOK `(met)<id>(met)` mentions map to common `At`. |
|
||||
| `AtAll` | `unit` | `plugin-e2e-outbound` | KOOK `(met)all(met)` maps to common `AtAll`; real inbound UI AtAll was not tested. |
|
||||
| `Image` | `unit` | `unit` | URL/image ID based path only; live rendering still needs verification. |
|
||||
| `Voice` | `unit` | `unit` | URL based path only; live rendering still needs verification. |
|
||||
| `File` | `unit` | `unit` | URL based path only; upload API is exposed separately. |
|
||||
| `Forward` | `unit` | `unit` | Outbound forwards are flattened; inbound structured forwards are not exposed by current legacy implementation. |
|
||||
| `Unknown` | `unit` | N/A | Unsupported KOOK message types become `Unknown` or `PlatformSpecificEvent`. |
|
||||
|
||||
## Acceptance Record
|
||||
|
||||
Test date: June 4, 2026.
|
||||
|
||||
Plugin E2E verified on June 4, 2026 with `EBAEventProbe`, SDK standalone runtime, KOOK WebSocket adapter, and a real KOOK channel UI message.
|
||||
|
||||
Evidence:
|
||||
|
||||
- JSONL: `data/temp/kook_eba_plugin_probe.jsonl`
|
||||
- Plugin log: `data/logs/eba-probe-kook.log`
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A real KOOK UI channel message reached the plugin as `MessageReceived` with `bot_uuid=7ab5b065-6e4e-4def-95f0-3c265366e26f`, `adapter_name=kook`, common sender/group/chat fields, and common `MessageChain` components.
|
||||
- KOOK gateway-specific event reached the plugin as `PlatformSpecificEventReceived`.
|
||||
- Probe plugin called SDK `send_message`; KOOK returned message IDs for text, At, AtAll, image URL/base64 fallback path, quote fallback, file fallback, and flattened forward cases.
|
||||
- Probe plugin called common API methods through the SDK path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, and `get_group_member_info`.
|
||||
- Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`: `get_current_user`, `get_user`, `get_channel`, `get_gateway`, and `get_guild`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit_tests/platform/test_kook_eba_adapter.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Blocked or partial items:
|
||||
|
||||
- `plugin-e2e-ui` inbound coverage for image, file, voice, AtAll, quote, and forward.
|
||||
- `plugin-e2e-outbound` visual verification in KOOK UI for image/file/voice rendering. KOOK returned message IDs, but UI inspection was not performed in this run.
|
||||
- `reply_message` and `delete_message` live permission verification.
|
||||
- Destructive or permission-sensitive APIs were not declared beyond delete; KOOK mute/kick/leave remain explicit `NotSupportedError` paths until a safe fixture is available.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Lark / Feishu EBA Adapter Migration Record
|
||||
|
||||
Status: migrated with unit coverage and partial live plugin E2E. WebSocket text reached the standalone runtime once in the LangBot organization test app, but the latest real UI image/file inbound attempts did not reach the local adapter log, so media receive is not release-complete yet.
|
||||
|
||||
Adapter directory: `src/langbot/pkg/platform/adapters/lark/`
|
||||
|
||||
## What Changed
|
||||
|
||||
The Lark/Feishu adapter now has an Event-Based Agents adapter package with:
|
||||
|
||||
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, platform-specific APIs, app type, and communication mode.
|
||||
- `adapter.py` for self-built/store app token handling, WebSocket long connection startup, Webhook callback handling, card feedback, streaming-card replies, and EBA dispatch.
|
||||
- `event_converter.py` for native Feishu events to common EBA events.
|
||||
- `message_converter.py` for Feishu text/post/image/file/audio payloads to/from common `MessageChain` components.
|
||||
- `api_impl.py` for common EBA API implementations.
|
||||
- `platform_api.py` for Feishu-specific `call_platform_api` actions.
|
||||
|
||||
The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-eba`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `app_id` | yes | Feishu/Lark application App ID. |
|
||||
| `app_secret` | yes | Feishu/Lark application App Secret. |
|
||||
| `bot_name` | yes | Must match the bot name so group mentions can be recognized. |
|
||||
| `enable-webhook` | yes | `false` uses WebSocket long connection; `true` uses Request URL/Webhook callbacks. |
|
||||
| `webhook_url` | no | Generated callback URL for Webhook mode. |
|
||||
| `encrypt-key` | no | Webhook decrypt key when event encryption is enabled. |
|
||||
| `enable-stream-reply` | yes | Enables streaming replies through an updating Feishu card. |
|
||||
| `app_type` | no | `self` for self-built apps; `isv` for store apps. |
|
||||
| `bot_added_welcome` | no | Optional group welcome message sent after bot-added events. |
|
||||
|
||||
## Application And Communication Modes
|
||||
|
||||
| Mode | Support | Implementation |
|
||||
|------|---------|----------------|
|
||||
| Self-built application | implemented | Uses standard app credentials and tenant token behavior from the Feishu SDK client. |
|
||||
| Store application | implemented | Builds an ISV client, requests app tickets, and resolves app/tenant access tokens with per-tenant caching. |
|
||||
| WebSocket long connection | implemented | Registers `im.message.receive_v1` and card-action callbacks through `lark_oapi.ws.Client`. |
|
||||
| Webhook Request URL | implemented | Handles URL verification, encrypted payloads, message events, app-ticket events, bot-added events, and card-action feedback. |
|
||||
|
||||
## Supported Events
|
||||
|
||||
| Event | Support | Evidence |
|
||||
|-------|---------|----------|
|
||||
| `message.received` | implemented | Unit coverage for private and group native events to common EBA events. |
|
||||
| `bot.invited_to_group` | implemented | Webhook bot-added event maps to common bot invite event and optional welcome send. |
|
||||
| `platform.specific` | implemented | Unknown callback events are preserved as `platform.specific`. |
|
||||
| `FeedbackEvent` | compatibility event | Card button feedback is still dispatched through the existing SDK `FeedbackEvent` type. |
|
||||
|
||||
## Receive Components
|
||||
|
||||
| Component | Support | Evidence |
|
||||
|-----------|---------|----------|
|
||||
| `Source` | supported | Unit coverage; live private text evidence. |
|
||||
| `Plain` | supported | Text and post payloads convert to common text; live private text evidence. |
|
||||
| `At` | supported | Feishu mentions map to common `At` with user ID and display name. |
|
||||
| `AtAll` | supported | `user_id=all` maps to common `AtAll`. |
|
||||
| `Image` | supported | Image payloads download through message resource API and map to common `Image`; real UI image send attempted, but not observed in local plugin evidence yet. |
|
||||
| `Voice` | supported | Audio payloads download through message resource API and map to common `Voice`. |
|
||||
| `File` | supported | File payloads download through message resource API and map to common `File`; real UI file send attempted, but not observed in local plugin evidence yet. |
|
||||
| `Quote` | supported | Parent/thread reply lookup maps quoted content into common `Quote`. |
|
||||
| `Face` | not native common mapping | Feishu emoji/stickers are not exposed as a portable common `Face` component here. |
|
||||
| `Forward` | not-supported inbound | Feishu does not expose a portable structured forward event in this adapter. |
|
||||
|
||||
## Send Components
|
||||
|
||||
| Component | Support | Evidence |
|
||||
|-----------|---------|----------|
|
||||
| `Plain` | supported | Unit coverage; sends Feishu `text`. |
|
||||
| `At` | supported | Unit coverage; sends Feishu `post` at element. |
|
||||
| `AtAll` | supported | Unit coverage; sends Feishu `post` at-all element. |
|
||||
| `Image` | supported | Uploads image resource and sends Feishu `image`. |
|
||||
| `Voice` | supported | Uploads OPUS/audio resource and sends Feishu `audio`. |
|
||||
| `File` | supported | Uploads file resource and sends Feishu `file`. |
|
||||
| `Quote` | supported/fallback | Sends quote marker plus origin content. |
|
||||
| `Face` | not-supported | No portable send mapping. |
|
||||
| `Forward` | flattened fallback | Flattens forward nodes into text/media messages. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Support | Notes |
|
||||
|-----|---------|-------|
|
||||
| `send_message` | supported | Supports private/open_id and group/chat_id targets; live plugin outbound component sweep produced visible Feishu messages. |
|
||||
| `reply_message` | supported | Replies to the source Feishu message; fixed to recover the native Feishu message ID from legacy-wrapped source events. |
|
||||
| `get_message` | cache-backed/API-backed | Returns cached inbound event where possible and converts uncached Feishu message API items into common `MessageReceivedEvent`. |
|
||||
| `get_group_info` | supported | Uses cached group or Feishu chat metadata. |
|
||||
| `get_group_member_info` | limited | Uses cached user data when available. |
|
||||
| `get_user_info` | limited | Uses cached user data when available. |
|
||||
| `get_file_url` | limited | Returns `file://` paths from downloaded inbound resources; remote Feishu resource download uses platform-specific API params. |
|
||||
| `call_platform_api` | supported | See below. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
| Action | Support | Evidence |
|
||||
|--------|---------|----------|
|
||||
| `check_tenant_access_token` | supported | Unit coverage. |
|
||||
| `refresh_app_access_token` | supported | Store-app token path implemented. |
|
||||
| `refresh_tenant_access_token` | supported | Store-app tenant token path implemented. |
|
||||
| `get_chat` | supported | Feishu chat metadata API wrapper. |
|
||||
| `get_message` | supported | Feishu message API wrapper with JSON-safe return values for plugin calls. |
|
||||
| `get_message_resource` | supported | Feishu message resource download wrapper. |
|
||||
|
||||
## End-to-End Evidence
|
||||
|
||||
Current code-level evidence:
|
||||
|
||||
- `tests/unit_tests/platform/test_lark_eba_adapter.py`
|
||||
- `PYTHONPATH=../langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_lark_eba_adapter.py -q`
|
||||
|
||||
Live evidence collected on May 11, 2026:
|
||||
|
||||
- Standalone runtime: `uv run lbp rt --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check`
|
||||
- LangBot: `uv run main.py --standalone-runtime --debug`
|
||||
- Plugin: `LangBot__EBAEventProbe`
|
||||
- Feishu org/app: LangBot organization, `LangBotDev` private chat.
|
||||
- Observed plugin JSONL: one private `MessageReceived` event with `Source + Plain`; plugin API probe then exercised bot discovery, bot info, `send_message`, outbound component sweep, storage/list APIs, and safe platform API calls.
|
||||
- Real UI sends attempted after the fixes: private text, local file, and image/video image upload. These appeared in the Feishu client but did not append new `EBAEventProbe` records in the local JSONL during this run.
|
||||
- Fixes from live testing: reply path now extracts the native Feishu `message_id` from legacy-wrapped source events; WebSocket callbacks are scheduled onto the adapter event loop instead of assuming the SDK callback has a running asyncio loop; platform API results are converted to JSON-safe values.
|
||||
|
||||
Live E2E items still required before marking release-complete:
|
||||
|
||||
- WebSocket self-built app in LangBot organization: repeat private text after callback-loop fix, plus private image/file/audio and group mention message received by `EBAEventProbe`.
|
||||
- Webhook self-built app in LangBot organization: URL verification plus text/image/file message received by `EBAEventProbe`.
|
||||
- Store app token path: at least token acquisition/tenant-token safe API through `call_platform_api`; full message E2E if a LangBot organization store-app fixture is available.
|
||||
- Outbound component sweep: text, mention, at-all, image, file, voice where Feishu accepts the fixture, quote/fallback, and forward/fallback.
|
||||
- Safe platform API sweep: token check, chat metadata, message lookup, and message resource download using real inbound IDs.
|
||||
|
||||
## Known Limits
|
||||
|
||||
- Store-app live E2E requires a real ISV app ticket/tenant installation fixture.
|
||||
- Current LangBot organization WebSocket run connected successfully but did not deliver the latest UI-sent image/file attempts to local plugin evidence; this blocks release-complete media acceptance.
|
||||
- Feishu native emoji/sticker semantics are not represented as common `Face`.
|
||||
- Destructive org or chat mutations are not declared in this adapter.
|
||||
@@ -0,0 +1,101 @@
|
||||
# OfficialAccount EBA Adapter
|
||||
|
||||
Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/`
|
||||
|
||||
Manifest name: `officialaccount-eba`
|
||||
|
||||
Status: partial migration. Unit/API-shape coverage is present, and private text `plugin-e2e-ui` plus safe API evidence has been verified against the `dev.rockchin.top` Official Account fixture. Proactive outbound `send_message` remains not supported by this adapter because WeChat Official Account replies must be tied to inbound webhook windows.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `webhook_url` | no | Generated by LangBot and copied into the Official Account callback settings. |
|
||||
| `token` | yes | WeChat callback token. |
|
||||
| `EncodingAESKey` | yes | WeChat message encryption key. |
|
||||
| `AppID` | yes | Official Account app ID. |
|
||||
| `AppSecret` | yes | Official Account app secret. |
|
||||
| `Mode` | yes | `drop` waits for an in-callback reply; `passive` returns the loading text first and queues the answer for the user's next message. |
|
||||
| `LoadingMessage` | no | Only used by `passive` mode. |
|
||||
| `api_base_url` | no | Optional API base URL for proxy deployments. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. |
|
||||
| `platform.specific` | unit | Subscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `reply_message` | unit | Queues/passively returns text through the inbound webhook source event. |
|
||||
| `get_message` | plugin-e2e-ui, unit | Cached inbound message retrieved by `EBAEventProbe` platform API sweep. |
|
||||
| `get_user_info` | plugin-e2e-ui, unit | Cached inbound sender retrieved by `EBAEventProbe` platform API sweep. |
|
||||
| `get_friend_list` | plugin-e2e-ui, unit | Cached inbound sender list retrieved by `EBAEventProbe` platform API sweep. |
|
||||
| `call_platform_api` | plugin-e2e-ui, unit | Safe diagnostic actions verified through `get_mode` and `get_cached_response_status`. |
|
||||
| `send_message` | not-supported | Official Account customer-service proactive messaging is not implemented by the existing SDK adapter; only webhook reply is supported here. |
|
||||
|
||||
## Platform APIs
|
||||
|
||||
| Action | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `get_mode` | plugin-e2e-ui, unit | Returned `{"mode": "drop", "longer_response": false}` in live probe. |
|
||||
| `get_cached_response_status` | plugin-e2e-ui, unit | Returned `{"pending": false}` in live probe. |
|
||||
|
||||
## Components
|
||||
|
||||
| Receive Component | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Source` | plugin-e2e-ui, unit | Uses `MsgId` and `CreateTime`; live UI text message included `Source`. |
|
||||
| `Plain` | plugin-e2e-ui, unit | Live UI text message mapped to `Plain`. |
|
||||
| `Image` | unit | `PicUrl` and `MediaId` map to common `Image`. |
|
||||
| `Voice` | unit | `MediaId` maps to common `Voice`. |
|
||||
| `Unknown` | unit | Unsupported message/event types do not crash. |
|
||||
| `At`, `AtAll`, `File`, `Quote`, `Face`, `Forward`, mixed chain | not-supported | WeChat Official Account inbound webhook payloads used by the current SDK do not expose these as common structured components. |
|
||||
|
||||
| Send Component | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Plain` | unit | Sent as webhook reply text. |
|
||||
| `Image`, `Voice`, `File`, `Quote`, `At`, `AtAll`, `Face`, `Forward`, mixed chain | not-supported | Existing SDK reply path is text XML only; non-text components degrade to readable placeholders in tests and are not declared as supported outbound components. |
|
||||
|
||||
## Verification Record
|
||||
|
||||
Test date: 2026-05-28
|
||||
|
||||
Endpoint/simulator: `dev.rockchin.top` with WeChat desktop client and a real subscribed Official Account conversation. The running EBA test stack used SDK standalone runtime ports `5400/5401`, LangBot from `/home/wgc/LangBotxg/LangBotEbaTest`, and `EBAEventProbe`.
|
||||
|
||||
Verified UI message: `EBA officialaccount single probe 2026-05-28 16:53`
|
||||
|
||||
Observed event/API evidence:
|
||||
|
||||
- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-eba`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`.
|
||||
- Common safe APIs through probe platform sweep: `get_message`, `get_user_info`, `get_friend_list`.
|
||||
- Platform APIs through `call_platform_api`: `get_mode`, `get_cached_response_status`.
|
||||
- `send_message` and outbound component sweep returned explicit `NotSupportedError: send_message:official_account_requires_inbound_webhook_reply`, as expected for this adapter.
|
||||
|
||||
Standalone runtime command:
|
||||
|
||||
```bash
|
||||
cd langbot-plugin-sdk
|
||||
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||
```
|
||||
|
||||
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||
|
||||
Adapter live probe:
|
||||
|
||||
```bash
|
||||
uv run python -m py_compile tests/e2e/live_officialaccount_eba_probe.py
|
||||
OFFICIALACCOUNT_TOKEN=... OFFICIALACCOUNT_ENCODING_AES_KEY=... OFFICIALACCOUNT_APP_SECRET=... OFFICIALACCOUNT_APP_ID=... uv run python tests/e2e/live_officialaccount_eba_probe.py
|
||||
```
|
||||
|
||||
Evidence JSONL path: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` for plugin E2E, or `data/temp/officialaccount_eba_probe.jsonl` for direct adapter live probe.
|
||||
|
||||
Destructive operations: none.
|
||||
|
||||
Blocked items:
|
||||
|
||||
- `plugin-e2e-outbound`: proactive `send_message` is not supported for this adapter; Official Account responses must be produced through the inbound webhook reply window.
|
||||
- Inbound image and voice live UI evidence remains pending; webhook conversion is covered by unit tests.
|
||||
@@ -0,0 +1,114 @@
|
||||
# QQOfficial EBA Adapter
|
||||
|
||||
Adapter directory: `src/langbot/pkg/platform/adapters/qqofficial/`
|
||||
|
||||
Manifest name: `qqofficial-eba`
|
||||
|
||||
Status: partial migration. The EBA adapter structure, manifest, converters, cache-backed safe APIs, platform API map, unit tests, and direct live probe scaffold are in place. A real QQ Official WebSocket bot on `dev.rockchin.top` received an inbound user message and drove LangBot into the normal pipeline path; the response path was blocked by the test environment model service returning `model_not_found` for `deepseek-v3`.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `appid` | yes | QQ Official app ID. |
|
||||
| `secret` | yes | QQ Official app secret. |
|
||||
| `token` | yes | QQ Official callback token. |
|
||||
| `enable-webhook` | yes | Uses LangBot unified webhook when true; otherwise uses the QQ WebSocket gateway. |
|
||||
| `enable-stream-reply` | yes | Enables C2C streaming replies when supported by the QQ Official endpoint. |
|
||||
| `webhook_url` | no | Generated by LangBot and copied into the QQ Official callback settings in webhook mode. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. |
|
||||
| `platform.specific` | unit, blocked | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `send_message` | unit, blocked | Sends private C2C, group, and text-only channel messages through the existing QQ Official client. Live outbound UI verification is pending because the test pipeline failed before producing a bot response. |
|
||||
| `reply_message` | unit, blocked | Replies using the source `QQOfficialEvent` message ID when available. Live reply was blocked by the test environment model service returning `model_not_found`. |
|
||||
| `get_message` | unit | Returns cached inbound `MessageReceivedEvent`. |
|
||||
| `get_user_info` | unit | Returns cached inbound sender. |
|
||||
| `get_friend_list` | unit | Returns cached private senders. |
|
||||
| `get_group_info` | unit | Returns cached group/channel metadata from inbound events. |
|
||||
| `get_group_member_info` | unit | Returns cached group sender as a common member. |
|
||||
| `get_group_member_list` | unit | Returns cached group members observed by the adapter. |
|
||||
| `call_platform_api` | unit, blocked | Safe diagnostic actions are implemented; live calls are pending credentials. |
|
||||
|
||||
## Platform APIs
|
||||
|
||||
| Action | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `check_access_token` | unit, blocked | Calls the existing client token check. |
|
||||
| `refresh_access_token` | unit, blocked | Forces token refresh. |
|
||||
| `get_gateway_url` | unit, blocked | Fetches the WebSocket gateway URL. |
|
||||
| `get_mode` | unit | Returns webhook and stream-reply mode. |
|
||||
|
||||
## Components
|
||||
|
||||
| Receive Component | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Source` | unit | Uses QQ message/event IDs and timestamp. |
|
||||
| `Plain` | unit | Preserves text content. |
|
||||
| `At` | unit | Group and channel mention events insert an adapter bot mention marker. |
|
||||
| `Image` | unit | QQ image attachment URL is converted to common `Image`; falls back to URL if download fails. |
|
||||
| `Unknown` | unit | Unsupported/empty native payloads become `Unknown`. |
|
||||
| `Voice`, `File`, `Quote`, `Face`, `Forward`, mixed chain | blocked | Current native parser only exposes text and image attachments; live endpoint behavior still needs verification. |
|
||||
|
||||
| Send Component | Evidence | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Plain` | unit, blocked | Sends through private, group, or channel text APIs. |
|
||||
| `At`, `AtAll` | unit, blocked | Converted to readable mention text. |
|
||||
| `Image` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||
| `Voice` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||
| `File` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||
| `Quote`, `Forward`, mixed chain | unit, blocked | Flattened to ordered send payloads where possible. |
|
||||
| `Face` | not-supported | No common QQ Official face mapping is implemented. |
|
||||
|
||||
## Verification Record
|
||||
|
||||
Test date: 2026-06-02
|
||||
|
||||
Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-eba`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`.
|
||||
|
||||
Observed evidence:
|
||||
|
||||
- The QQ Official WebSocket bot was enabled with `enable-webhook=false`.
|
||||
- A real user message reached LangBot and entered the standard pipeline path.
|
||||
- The response path stopped at the model layer with `model_not_found` for `deepseek-v3`; this is a model/provider configuration issue, not an adapter conversion failure.
|
||||
- `qq-webhook.langbot.dev` was temporarily routed through Caddy to `127.0.0.1:5301` for webhook checks, but the observed EBA bot used WebSocket mode.
|
||||
|
||||
Standalone runtime command:
|
||||
|
||||
```bash
|
||||
cd langbot-plugin-sdk
|
||||
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||
```
|
||||
|
||||
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||
|
||||
Adapter live probe:
|
||||
|
||||
```bash
|
||||
uv run python -m py_compile tests/e2e/live_qqofficial_eba_probe.py
|
||||
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py
|
||||
```
|
||||
|
||||
Webhook-mode probe:
|
||||
|
||||
```bash
|
||||
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py --webhook --host 0.0.0.0 --port 5312
|
||||
```
|
||||
|
||||
Evidence JSONL path: `data/temp/qqofficial_eba_probe.jsonl` for direct adapter live probe; plugin E2E evidence should use `data/temp/qqofficial_eba_plugin_probe.jsonl`.
|
||||
|
||||
Destructive operations: none implemented.
|
||||
|
||||
Blocked items:
|
||||
|
||||
- `plugin-e2e-ui`: standalone probe plugin JSONL evidence is still pending; the observed live run reached LangBot core/pipeline but was not recorded by the EBA probe plugin.
|
||||
- `plugin-e2e-outbound`: waiting for visible QQ client verification of plugin `send_message`/`reply_message` output after a working model/provider is configured.
|
||||
- Inbound non-text media and platform lifecycle events require endpoint evidence before they can be marked complete.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Slack EBA Adapter
|
||||
|
||||
## Structure
|
||||
|
||||
Slack is migrated into `src/langbot/pkg/platform/adapters/slack/` with the standard EBA adapter layout:
|
||||
|
||||
- `adapter.py` owns lifecycle, listener dispatch, unified webhook handling, outbound send/reply, and event caches.
|
||||
- `event_converter.py` maps Slack `im` and `app_mention` channel events to `message.received`.
|
||||
- `message_converter.py` maps common `MessageChain` components to Slack text fallback and maps inbound Slack text/image payloads back to EBA components.
|
||||
- `api_impl.py` provides cache-backed common read APIs.
|
||||
- `platform_api.py` declares safe Slack-specific API actions.
|
||||
- `manifest.yaml` declares `slack-eba`.
|
||||
|
||||
The legacy `src/langbot/pkg/platform/sources/slack.py` adapter is kept unchanged.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `webhook_url` | No | Generated by LangBot. Paste it into Slack Event Subscriptions. |
|
||||
| `bot_token` | Yes | Slack bot token, usually `xoxb-...`. |
|
||||
| `signing_secret` | Yes | Slack app signing secret. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Notes |
|
||||
|-------|-------|
|
||||
| `message.received` | Emitted for private `im` messages and channel `app_mention` events. Channel messages are mapped to group chats. |
|
||||
| `platform.specific` | Reserved for Slack event types that are not converted into common message events. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
Required:
|
||||
|
||||
- `send_message`
|
||||
- `reply_message`
|
||||
|
||||
Optional:
|
||||
|
||||
- `get_message`
|
||||
- `get_user_info`
|
||||
- `get_friend_list`
|
||||
- `get_group_info`
|
||||
- `get_group_list`
|
||||
- `get_group_member_list`
|
||||
- `get_group_member_info`
|
||||
- `call_platform_api`
|
||||
|
||||
Cache-backed APIs are only available after the relevant inbound event has been observed.
|
||||
|
||||
## Platform APIs
|
||||
|
||||
| Action | Notes |
|
||||
|--------|-------|
|
||||
| `get_mode` | Returns webhook mode and configured bot account id. |
|
||||
| `auth_test` | Calls Slack `auth.test` with the configured bot token. |
|
||||
|
||||
## Known Limits
|
||||
|
||||
- Slack file/image outbound is currently represented as text fallback because the existing Slack SDK wrapper only exposes `chat_postMessage`.
|
||||
- Inbound channel coverage follows the legacy adapter behavior: only `app_mention` events are treated as group messages.
|
||||
- Real live testing requires a public callback URL configured in Slack Event Subscriptions.
|
||||
|
||||
## Verification
|
||||
|
||||
Local mocked unit coverage validates manifest parity, event conversion, legacy listener compatibility, cache-backed APIs, send/reply routing, and declared platform APIs.
|
||||
|
||||
Plugin E2E evidence was captured on June 2, 2026 against `dev.rockchin.top` with Slack private DM input and `EBAEventProbe` through the standalone runtime.
|
||||
|
||||
Evidence file: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl`.
|
||||
|
||||
Observed:
|
||||
|
||||
- Real Slack private text produced `MessageReceived` with `adapter_name=slack-eba`, `Source + Plain`, private chat type, and filled `bot_uuid`.
|
||||
- Safe common APIs passed: `get_message`, `get_user_info`, `get_friend_list`.
|
||||
- Outbound component fallback sweep passed through `send_message`: plain/at/face, image, quote, file, and forward.
|
||||
- Declared Slack platform APIs passed: `get_mode`, `auth_test`.
|
||||
|
||||
Still pending:
|
||||
|
||||
- Channel `app_mention` plugin E2E.
|
||||
- Real inbound Slack file/image UI evidence.
|
||||
|
||||
Live probe scaffold: `tests/e2e/live_slack_eba_probe.py`.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Telegram EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
Telegram has been migrated to the EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/telegram/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `telegram-eba`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `token` | Yes | `""` | Telegram Bot API token from BotFather. |
|
||||
| `markdown_card` | No | `true` | Whether to render Markdown card style replies. |
|
||||
| `enable-stream-reply` | Yes | `false` | Whether to use Telegram streaming reply mode. |
|
||||
|
||||
## Events
|
||||
|
||||
Telegram declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `message.edited`
|
||||
- `message.reaction`
|
||||
- `group.member_joined`
|
||||
- `group.member_left`
|
||||
- `group.member_banned`
|
||||
- `bot.invited_to_group`
|
||||
- `bot.removed_from_group`
|
||||
- `bot.muted`
|
||||
- `bot.unmuted`
|
||||
- `platform.specific`
|
||||
|
||||
`platform.specific` is currently used for Telegram-only callback and chat-member update payloads that do not yet have a more specific common event type.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Supported | Supports text, image, file, and mixed message chains. |
|
||||
| `reply_message` | Supported | Supports quoted replies through the original message event. |
|
||||
| `edit_message` | Supported | Uses Telegram message editing APIs. |
|
||||
| `delete_message` | Supported | Deletes messages where bot permissions allow it. |
|
||||
| `forward_message` | Supported | Forwards a message between Telegram chats. |
|
||||
| `get_group_info` | Supported | Uses Telegram chat metadata. |
|
||||
| `get_group_member_list` | Supported | Telegram only exposes administrators through the Bot API; this returns the available member set. |
|
||||
| `get_group_member_info` | Supported | Maps Telegram member status to EBA member roles. |
|
||||
| `get_user_info` | Supported | Uses Telegram `get_chat` for user chat metadata. |
|
||||
| `upload_file` | Not supported | Telegram has no standalone upload endpoint; files are uploaded as part of messages. The adapter raises `NotSupportedError`. |
|
||||
| `get_file_url` | Supported | Returns the Bot API file URL. Test output redacts the bot token. |
|
||||
| `mute_member` | Supported | Requires a supergroup and bot moderation permission. |
|
||||
| `unmute_member` | Supported | Uses current `telegram.ChatPermissions` fields. |
|
||||
| `kick_member` | Supported | Destructive; should only be run against disposable users/bots in tests. |
|
||||
| `leave_group` | Supported | Destructive; should run at the end of a live test. |
|
||||
| `call_platform_api` | Supported | See below. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `pin_message`
|
||||
- `unpin_message`
|
||||
- `unpin_all_messages`
|
||||
- `get_chat_administrators`
|
||||
- `set_chat_title`
|
||||
- `set_chat_description`
|
||||
- `get_chat_member_count`
|
||||
- `send_chat_action`
|
||||
- `create_chat_invite_link`
|
||||
- `answer_callback_query`
|
||||
|
||||
## Live Test Record
|
||||
|
||||
The live probe is:
|
||||
|
||||
```bash
|
||||
uv run python tests/e2e/live_telegram_eba_probe.py --help
|
||||
```
|
||||
|
||||
It supports private chat tests, group/supergroup tests, moderation tests, destructive tests, and a callback-only mode.
|
||||
|
||||
Verified on May 7, 2026:
|
||||
|
||||
- Private chat message APIs: send, reply, edit, delete, forward.
|
||||
- Private chat media APIs: image/file sending and `get_file_url`.
|
||||
- User API: `get_user_info`.
|
||||
- Supergroup APIs: group info, member list, member info, administrators, member count, invite link.
|
||||
- Supergroup mutation APIs: pin, unpin, unpin all, set title, restore title, set description, restore description.
|
||||
- Moderation APIs: mute and unmute against a non-owner target bot.
|
||||
- Destructive APIs: kick a disposable target bot, then make the test bot leave the test group.
|
||||
- Event conversion observed for `message.received`, `group.member_banned`, `group.member_left`, `bot.removed_from_group`, and Telegram-specific chat-member updates.
|
||||
|
||||
The test fixed one real compatibility issue: `unmute_member` previously used Telegram's removed `can_send_media_messages` permission field. It now uses the split media permission fields required by current `python-telegram-bot`.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, Telegram Lite, `@rockchinq_bot`, and `Rock'sBotGroup`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- Private chat JSONL: `data/temp/telegram-plugin-e2e-rerun.jsonl`
|
||||
- Group chat JSONL: `data/temp/telegram-plugin-e2e-group.jsonl`
|
||||
- Private media JSONL: `data/temp/telegram-plugin-e2e-media-ui.jsonl`
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- `MessageReceived` reached the plugin with `bot_uuid=eba-telegram-live`, `adapter_name=telegram`, common sender/chat fields, and common `MessageChain` content.
|
||||
- `BotInvitedToGroup` reached the plugin after adding the bot to `Rock'sBotGroup`.
|
||||
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||
- Outbound component sweep succeeded in private and group chats: plain text, mention text/equivalent, base64 image, quoted reply, file/document, and flattened forward fallback. Group mode also covered `AtAll` fallback behavior.
|
||||
- Real Telegram Lite private-chat inbound media was verified through the plugin path: a sent document arrived as common `File`, and a sent photo arrived as common `Image`.
|
||||
- Telegram platform API sweep succeeded for safe group actions: `get_chat_administrators`, `get_chat_member_count`, and `send_chat_action`.
|
||||
- Common group/user APIs succeeded in group mode: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||
|
||||
Documented limits in this E2E run:
|
||||
|
||||
- Real Telegram UI inbound voice, sticker/emoji-as-common-component, and reply/quote messages were not completed in the plugin E2E evidence.
|
||||
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Telegram adapter.
|
||||
- Mutating/destructive Telegram-specific actions such as pin/unpin, title/description changes, invite-link creation, moderation, kick, and leave were not repeated in the plugin run. They remain opt-in live-probe cases.
|
||||
- Telegram does not expose a portable common `Face` component for native sticker/emoji semantics in the current adapter.
|
||||
|
||||
## Notes for Future Adapters
|
||||
|
||||
Telegram is the reference implementation for:
|
||||
|
||||
- Keeping platform-specific actions behind `call_platform_api`.
|
||||
- Treating unsupported common APIs as explicit `NotSupportedError`.
|
||||
- Marking destructive live test operations behind CLI flags.
|
||||
- Redacting access tokens from live probe output.
|
||||
@@ -0,0 +1,130 @@
|
||||
# WeCom EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
WeCom application messages now have an EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/wecom/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `wecom-eba`.
|
||||
|
||||
This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-eba`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom application callback settings. |
|
||||
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||
| `secret` | Yes | `""` | WeCom application secret. |
|
||||
| `token` | Yes | `""` | WeCom callback token. |
|
||||
| `EncodingAESKey` | Yes | `""` | WeCom callback encryption key. |
|
||||
| `contacts_secret` | No | `""` | Contacts secret for contact-list based helper APIs. |
|
||||
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||
|
||||
## Events
|
||||
|
||||
WeCom declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `platform.specific`
|
||||
|
||||
`message.received` currently covers text and image application callbacks. Other WeCom callback types are surfaced as `platform.specific` so plugins can inspect the raw structured payload without crashing the common message path.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Supported | Private/person target only. `target_id` must be `user_id|agent_id`. Supports text, image, voice, file, flattened forward, and quote fallback. |
|
||||
| `reply_message` | Supported | Replies to the original WeCom sender and application agent from `source_platform_object`. |
|
||||
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||
| `get_user_info` | Supported | Uses cached event users first, then WeCom `user/get`. |
|
||||
| `get_friend_list` | Partial | Returns users seen by this adapter instance. Full contacts listing is not declared as common coverage. |
|
||||
| `call_platform_api` | Supported | See below. |
|
||||
| `edit_message` | Not supported | WeCom application messages do not expose a general edit endpoint for sent messages. |
|
||||
| `delete_message` | Not supported | WeCom application messages do not expose a general delete endpoint for sent messages. |
|
||||
| `get_group_info` / member APIs | Not supported | Regular WeCom application callbacks handled here are private user messages, not group-chat bot messages. |
|
||||
| `upload_file` / `get_file_url` | Not supported as common APIs | WeCom media upload is used internally while sending image/voice/file components; no portable standalone common file URL is exposed. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `check_access_token`
|
||||
- `refresh_access_token`
|
||||
- `get_user_info`
|
||||
- `send_to_all`
|
||||
|
||||
`send_to_all` requires a configured `contacts_secret` with suitable contact visibility and should be treated as a broad-send operation in live testing.
|
||||
|
||||
## Unit Verification
|
||||
|
||||
Covered by:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit_tests/platform/test_wecom_eba_adapter.py
|
||||
```
|
||||
|
||||
The unit tests cover:
|
||||
|
||||
- Manifest events/APIs/platform actions match adapter declarations.
|
||||
- Outbound component conversion for text, image, voice, file, quote fallback, and byte-safe text splitting.
|
||||
- Text callback conversion to `MessageReceivedEvent`.
|
||||
- Legacy `FriendMessage` compatibility.
|
||||
- EBA listener dispatch and inbound message/user cache.
|
||||
- `send_message`, `reply_message`, and safe platform API dispatch against a mocked WeCom client.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and a real WeCom desktop client against the server test environment.
|
||||
|
||||
```bash
|
||||
cd langbot-plugin-sdk
|
||||
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||
|
||||
cd LangBot
|
||||
uv run main.py --standalone-runtime
|
||||
|
||||
cd data/plugins/LangBot__EBAEventProbe
|
||||
EBA_PROBE_API=1 EBA_PROBE_COMPONENT_SWEEP=1 EBA_PROBE_PLATFORM_API=1 \
|
||||
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||
```
|
||||
|
||||
Evidence:
|
||||
|
||||
- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl`
|
||||
- Bot: `wecom-eba`
|
||||
- Client: real WeCom desktop client
|
||||
- Environment: `dev.rockchin.top` test server
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-eba`, common sender/chat fields, and `Source + Plain`.
|
||||
- SDK API calls succeeded through the standalone runtime, including `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, and manifest/list APIs.
|
||||
- Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions.
|
||||
|
||||
Still required for stricter acceptance:
|
||||
|
||||
- Send a private image and confirm common `Image` reaches the plugin.
|
||||
- Have the plugin call `send_message` and `reply_message` for text and one media component, then verify the WeCom client receives the bot output.
|
||||
- Exercise `send_to_all` only with a disposable visible-contact scope.
|
||||
- Trigger one non-text/image callback, if available, and confirm it becomes `PlatformSpecificEventReceived`.
|
||||
|
||||
## Current Acceptance
|
||||
|
||||
Current status is **partial EBA acceptance**.
|
||||
|
||||
Blocked items:
|
||||
|
||||
- Real inbound image/voice/file evidence was not completed in this run.
|
||||
- Inbound voice/file callback parsing is not present in the legacy `WecomClient.get_message()` path, so the EBA adapter does not claim those receive components yet.
|
||||
- Group/member/moderation APIs do not apply to this regular WeCom application-message adapter.
|
||||
@@ -0,0 +1,148 @@
|
||||
# WeComBot EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
WeCom AI Bot now has an EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/wecombot/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `wecombot-eba`.
|
||||
|
||||
This is separate from regular WeCom internal applications (`wecom-eba`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `BotId` | Yes for WebSocket mode | `""` | WeCom AI Bot ID. |
|
||||
| `robot_name` | Yes | `""` | Bot display name used to strip bot mentions from incoming group text. |
|
||||
| `enable-webhook` | Yes | `false` | `false` uses WebSocket long connection mode; `true` uses webhook callback mode. |
|
||||
| `webhook_url` | No | `""` | Unified webhook URL, only needed when webhook mode is enabled. |
|
||||
| `Secret` | Yes for WebSocket mode | `""` | WeCom AI Bot secret for long connection mode. |
|
||||
| `Corpid` | Yes for webhook mode | `""` | WeCom corporate ID for webhook callback mode. |
|
||||
| `Token` | Yes for webhook mode | `""` | WeCom callback token. |
|
||||
| `EncodingAESKey` | Yes for webhook mode; optional for WebSocket media decrypt | `""` | Message encryption/decryption key. |
|
||||
| `enable-stream-reply` | No | `true` | Enables WeComBot streaming replies. |
|
||||
|
||||
## Events
|
||||
|
||||
WeComBot declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `feedback.received`
|
||||
- `platform.specific`
|
||||
|
||||
`message.received` covers private and group messages from the WeComBot SDK. `feedback.received` covers WeComBot like/dislike feedback callbacks. Native SDK events without a common EBA equivalent are emitted as `platform.specific`.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Supported in WebSocket mode | Sends proactive markdown/text to a person or group chat ID. Webhook mode raises `NotSupportedError` because the platform callback flow has no proactive send path here. |
|
||||
| `reply_message` | Supported | Replies through native `req_id` in WebSocket mode or stream finalization/cache in webhook mode. |
|
||||
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||
| `get_user_info` | Supported from cache | WeComBot events carry user info; no full user lookup endpoint is declared. |
|
||||
| `get_friend_list` | Partial | Returns users observed by this adapter instance. |
|
||||
| `get_group_info` | Supported from cache | Returns groups observed from inbound group messages. |
|
||||
| `get_group_member_info` | Supported from cache | Returns observed sender/group-member pairs. |
|
||||
| `get_group_member_list` | Partial | Returns observed members for the cached group only. |
|
||||
| `call_platform_api` | Supported | See below. |
|
||||
| `edit_message` / `delete_message` / `forward_message` | Not supported | WeComBot does not expose portable common APIs for these operations in the current SDK wrapper. |
|
||||
| `upload_file` / `get_file_url` | Not supported as common APIs | Media is represented inside messages; no portable standalone file upload/URL API is declared. |
|
||||
| moderation / leave APIs | Not supported | WeComBot does not expose equivalent common moderation operations through this adapter. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `is_websocket_mode`
|
||||
- `get_stream_session_status`
|
||||
- `send_markdown`
|
||||
|
||||
`send_markdown` is only available in WebSocket mode.
|
||||
|
||||
## Unit Verification
|
||||
|
||||
Covered by:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecombot_eba_adapter.py
|
||||
```
|
||||
|
||||
The unit tests cover:
|
||||
|
||||
- Manifest events/APIs/platform actions match adapter declarations.
|
||||
- Outbound common components flatten to WeComBot markdown/text.
|
||||
- Private and group native events become `MessageReceivedEvent`.
|
||||
- Inbound image, file, voice, and quote components map to common `MessageChain`.
|
||||
- Legacy `FriendMessage`/`GroupMessage` compatibility.
|
||||
- EBA listener dispatch, message/user/group/member cache, reply, send, streaming chunk, feedback, and platform API calls.
|
||||
|
||||
## Live Probe
|
||||
|
||||
The direct adapter probe is:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run python tests/e2e/live_wecombot_eba_probe.py --help
|
||||
```
|
||||
|
||||
Default mode is WebSocket long connection and requires:
|
||||
|
||||
- `WECOMBOT_BOT_ID`
|
||||
- `WECOMBOT_SECRET`
|
||||
- `WECOMBOT_ROBOT_NAME`
|
||||
- optional `WECOMBOT_ENCODING_AES_KEY`
|
||||
|
||||
Webhook mode uses `--webhook` and requires:
|
||||
|
||||
- `WECOMBOT_TOKEN`
|
||||
- `WECOMBOT_ENCODING_AES_KEY`
|
||||
- `WECOMBOT_CORPID`
|
||||
|
||||
The probe writes JSONL evidence to `data/temp/wecombot_eba_live_probe.jsonl`, waits for a real WeComBot message, records common EBA event fields and message components, then runs safe cached/common/platform API checks.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and the real WeCom desktop client in a WeCom AI Bot private chat.
|
||||
|
||||
Evidence:
|
||||
|
||||
- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl`
|
||||
- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111`
|
||||
- Adapter: `wecombot-eba`
|
||||
- Client: real WeCom desktop client, private `LangBot` BOT chat
|
||||
- Mode: WebSocket long connection (`enable-webhook=false`)
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-eba`, common sender/chat fields, and `Source + Plain`.
|
||||
- SDK API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, manifest/list APIs, and safe cached common platform APIs.
|
||||
- Outbound component sweep was visible in the WeCom client and returned `errcode=0`: plain/mention/face fallback, base64 image marker, quote fallback, file marker, and flattened forward fallback.
|
||||
- Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`.
|
||||
- The `send_markdown` platform API produced visible bot output in the WeCom client.
|
||||
|
||||
Not completed:
|
||||
|
||||
- Clicking the visible WeCom AI feedback button did not produce a `FeedbackReceived` JSONL entry in this run, so `feedback.received` remains unverified at plugin E2E level.
|
||||
- Group chat inbound and group cache/member coverage still need a real group-side trigger.
|
||||
- Real inbound image/file/voice from the WeCom client was not exercised.
|
||||
|
||||
## Current Acceptance
|
||||
|
||||
Current status is **partial EBA acceptance**.
|
||||
|
||||
Blocked or limited items:
|
||||
|
||||
- `feedback.received` is implemented and unit-covered, but real plugin E2E feedback evidence was not observed from the desktop client click.
|
||||
- Outbound image/voice/file are flattened as textual markers because the WeComBot SDK reply/proactive path used here is markdown/text oriented.
|
||||
- Group member APIs are cache-backed and only know members observed in received messages.
|
||||
- Destructive or moderation APIs are not declared because the current WeComBot protocol surface does not provide safe common equivalents.
|
||||
@@ -0,0 +1,161 @@
|
||||
# WeCom Customer Service EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
WeCom Customer Service now has an EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/wecomcs/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `wecomcs-eba`. It is separate from regular WeCom application messages (`wecom-eba`) and WeCom AI Bot (`wecombot-eba`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom Customer Service callback settings. |
|
||||
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||
| `secret` | Yes | `""` | Customer Service secret used for access tokens. |
|
||||
| `token` | Yes | `""` | Customer Service callback token. |
|
||||
| `EncodingAESKey` | Yes | `""` | Customer Service callback encryption key. |
|
||||
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| `message.received` | Plugin E2E UI covered for text | Text, image, file, and voice payloads convert to common EBA message components in unit tests. Real WeChat customer-side UI text reached `EBAEventProbe` on May 27, 2026. |
|
||||
| `platform.specific` | Unit covered | Non-message or unknown Customer Service payloads become structured `PlatformSpecificEvent` records. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Plugin E2E outbound covered | Private/person target only. `target_id` must be `external_userid|open_kfid`. Text and image are implemented; voice/file are explicitly unsupported. |
|
||||
| `reply_message` | Plugin E2E partial | Replies through Customer Service `kf/send_msg` using the original `source_platform_object`. The pipeline reply path reached the send API, but the dev account later hit WeCom `95001 send msg count limit`. |
|
||||
| `get_message` | Plugin E2E covered from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||
| `get_user_info` | Plugin E2E covered | Uses cached event users first, then Customer Service `customer/batchget`. |
|
||||
| `get_friend_list` | Plugin E2E covered, partial | Returns customer users seen by this adapter instance. |
|
||||
| `call_platform_api` | Unit covered | See platform-specific APIs below. |
|
||||
| `edit_message` / `delete_message` | Not supported | WeCom Customer Service does not expose a general edit/delete endpoint for bot-sent messages in this adapter. |
|
||||
| Group/member/moderation APIs | Not supported | Customer Service conversations handled here are private customer sessions, not group chats. |
|
||||
| `upload_file` / `get_file_url` | Not supported | Media upload is used internally for outbound image; no portable file URL common API is exposed. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
| Action | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `check_access_token` | Unit covered | Checks whether the current access token is present. |
|
||||
| `refresh_access_token` | Unit covered | Refreshes the Customer Service access token. |
|
||||
| `get_customer_info` | Unit covered | Calls Customer Service customer lookup by `external_userid`. |
|
||||
|
||||
## Message Components
|
||||
|
||||
Receive:
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `Source` | Unit covered | Uses Customer Service `msgid` and `send_time`. |
|
||||
| `Plain` | Unit covered | Text payload content is preserved. |
|
||||
| `Image` | Unit covered | Uses the base64 data URL produced by the existing SDK image download path. |
|
||||
| `Voice` | Unit covered | Maps exposed voice media ID to common `Voice.voice_id`; live UI evidence pending. |
|
||||
| `File` | Unit covered | Maps exposed file media ID/name/size to common `File`; live UI evidence pending. |
|
||||
| `Quote`, `At`, `AtAll`, `Face`, `Forward` | Not supported inbound | The current Customer Service SDK event model does not expose these as structured inbound fields. |
|
||||
| `Unknown` | Unit covered | Unsupported message types become `Unknown` in message conversion or `platform.specific` at event level. |
|
||||
|
||||
Send:
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `Plain` | Plugin E2E outbound covered | Sends through `kf/send_msg` text. |
|
||||
| `Image` | Plugin E2E outbound covered | Uploads media as WeCom image media and sends through `kf/send_msg` image. |
|
||||
| `Quote`, `At`, `AtAll`, `Forward` | Unit covered fallback, live partially blocked | Flattened to text where possible. In the May 27 sweep, later text sends hit WeCom `95001 send msg count limit` after the successful text/image sends. |
|
||||
| `Voice`, `File`, `Face` | Not supported | The adapter raises `NotSupportedError`; no tested Customer Service send path is implemented. |
|
||||
|
||||
## Unit Verification
|
||||
|
||||
Covered by:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecomcs_eba_adapter.py
|
||||
```
|
||||
|
||||
Result on May 27, 2026: `10 passed`.
|
||||
|
||||
The local `PYTHONPATH` is required in this workspace because the installed SDK package in the LangBot venv does not contain the newer `langbot_plugin.api.entities.builtin.platform.errors` module; the existing EBA adapter tests need the same SDK override.
|
||||
|
||||
## Live Probe
|
||||
|
||||
Auxiliary direct adapter probe:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/path/to/langbot-plugin-sdk/src uv run python -m py_compile tests/e2e/live_wecomcs_eba_probe.py
|
||||
|
||||
WECOMCS_CORPID=... \
|
||||
WECOMCS_SECRET=... \
|
||||
WECOMCS_TOKEN=... \
|
||||
WECOMCS_ENCODING_AES_KEY=... \
|
||||
PYTHONPATH=/path/to/langbot-plugin-sdk/src \
|
||||
uv run python tests/e2e/live_wecomcs_eba_probe.py \
|
||||
--path /wecomcs/callback \
|
||||
--log data/temp/wecomcs_eba_live_probe.jsonl
|
||||
```
|
||||
|
||||
This probe is diagnostic only. Final EBA acceptance still requires the standalone SDK runtime plus `EBAEventProbe` plugin path.
|
||||
|
||||
## Standalone Runtime Plugin E2E Record
|
||||
|
||||
Completed partial plugin E2E on May 27, 2026 against `dev.rockchin.top` and the WeChat customer-side UI entry `微信 -> 客服消息 -> 浪波智能客服`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl`
|
||||
- Trigger text: `EBA wecomcs dedupe probe 2026-05-27`
|
||||
- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4`
|
||||
- `adapter_name`: `wecomcs-eba`
|
||||
- Observed common event: `MessageReceived`, `event.type=message.received`
|
||||
- Observed message chain: `Source + Plain`
|
||||
- Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid`
|
||||
- Observed sender: customer `User` with nickname/avatar from Customer Service lookup
|
||||
- Plugin API probe: `send_message`, `get_message`, `get_user_info`, `get_friend_list`, plugin/workspace storage, and manifest/list APIs succeeded
|
||||
- Component sweep: outbound `Plain` and `Image` succeeded; `Face` and `File` returned explicit `NotSupportedError`; later quote/forward fallback sends were blocked by WeCom `95001 send msg count limit`
|
||||
|
||||
Command shape used:
|
||||
|
||||
```bash
|
||||
cd langbot-plugin-sdk
|
||||
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||
|
||||
cd LangBot
|
||||
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run main.py --standalone-runtime
|
||||
|
||||
cd data/plugins/LangBot__EBAEventProbe
|
||||
DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws \
|
||||
EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/wecomcs_eba_plugin_probe.jsonl \
|
||||
EBA_PROBE_API=1 \
|
||||
EBA_PROBE_COMPONENT_SWEEP=1 \
|
||||
EBA_PROBE_PLATFORM_API=1 \
|
||||
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||
```
|
||||
|
||||
Required real UI trigger: send a Customer Service message from the WeCom/WeChat customer-side UI to the configured `dev.rockchin.top` Customer Service account.
|
||||
|
||||
## Current Acceptance
|
||||
|
||||
Current status is **partial EBA acceptance**.
|
||||
|
||||
Blocked or pending items:
|
||||
|
||||
- Inbound UI media (`Image`, `Voice`, `File`) was not sent from the real WeChat customer UI during this run, so receive-side media remains unit-covered only.
|
||||
- Pipeline auto-reply reached `kf/send_msg`, but the test account hit WeCom `95001 send msg count limit` after successful plugin outbound text/image sends. This is recorded as an account/platform rate-limit block, not a conversion or API-shape failure.
|
||||
- The current `EBAEventProbe` run did not call the adapter-specific `call_platform_api` actions (`check_access_token`, `refresh_access_token`, `get_customer_info`); the platform API map remains unit-covered.
|
||||
- Inbound voice/file depends on whether the real Customer Service callback plus `sync_msg` endpoint returns those fields in the shape the local SDK models.
|
||||
- Group, member, edit, delete, moderation, and standalone file URL APIs are intentionally not declared because this Customer Service protocol path does not provide tested common equivalents.
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "LangBot HTTP Bot Adapter",
|
||||
"version": "1.0.0",
|
||||
"description": "Server-to-server HTTP integration for a LangBot pipeline. Inbound messages are POSTed to the unified webhook route; replies are delivered to a configured callback URL (one POST per reply part). All requests are HMAC-SHA256 signed. See docs/platforms/http-bot.md."
|
||||
},
|
||||
"paths": {
|
||||
"/bots/{bot_uuid}": {
|
||||
"post": {
|
||||
"summary": "Push a message into the pipeline (fire-and-collect)",
|
||||
"description": "Returns 202 immediately. Replies arrive asynchronously on the configured callback URL. Reuse the same session_id within the aggregation window to merge multiple messages into one turn (N->1).",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/BotUuid" },
|
||||
{ "$ref": "#/components/parameters/Timestamp" },
|
||||
{ "$ref": "#/components/parameters/Signature" },
|
||||
{ "$ref": "#/components/parameters/Idempotency" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted (queued for the pipeline)",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptedResponse" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/Error" },
|
||||
"401": { "$ref": "#/components/responses/Error" },
|
||||
"409": { "$ref": "#/components/responses/Error" },
|
||||
"413": { "$ref": "#/components/responses/Error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{bot_uuid}/sync": {
|
||||
"post": {
|
||||
"summary": "Push a message and wait for the collapsed reply",
|
||||
"description": "Blocking convenience mode. Waits for is_final and returns all reply parts collapsed into one array. Lossy (no sequence/streaming). One in-flight sync per session_id.",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/BotUuid" },
|
||||
{ "$ref": "#/components/parameters/Timestamp" },
|
||||
{ "$ref": "#/components/parameters/Signature" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The collapsed reply",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncResponse" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/Error" },
|
||||
"401": { "$ref": "#/components/responses/Error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{bot_uuid}/reset": {
|
||||
"post": {
|
||||
"summary": "Reset a session's conversation",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/BotUuid" },
|
||||
{ "$ref": "#/components/parameters/Timestamp" },
|
||||
{ "$ref": "#/components/parameters/Signature" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["session_id"],
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"session_type": { "type": "string", "enum": ["person", "group"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Reset done" },
|
||||
"400": { "$ref": "#/components/responses/Error" },
|
||||
"401": { "$ref": "#/components/responses/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"BotUuid": {
|
||||
"name": "bot_uuid", "in": "path", "required": true,
|
||||
"schema": { "type": "string", "format": "uuid" }
|
||||
},
|
||||
"Timestamp": {
|
||||
"name": "X-LB-Timestamp", "in": "header", "required": true,
|
||||
"description": "Unix seconds; rejected if more than +/-300s from server time.",
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
"Signature": {
|
||||
"name": "X-LB-Signature", "in": "header", "required": true,
|
||||
"description": "sha256=<hex> of HMAC-SHA256(secret, \"{timestamp}.\" + raw_body).",
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
"Idempotency": {
|
||||
"name": "X-LB-Idempotency-Key", "in": "header", "required": false,
|
||||
"description": "Dedup key; a repeat within the dedup window returns 409.",
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Segment": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["Plain", "Image", "Voice", "File", "At", "Quote"] },
|
||||
"text": { "type": "string", "description": "For type=Plain." },
|
||||
"url": { "type": "string", "description": "For media types." },
|
||||
"base64": { "type": "string", "description": "For media types (data URI or raw base64)." }
|
||||
}
|
||||
},
|
||||
"InboundMessage": {
|
||||
"type": "object",
|
||||
"required": ["session_id", "message"],
|
||||
"properties": {
|
||||
"session_id": { "type": "string", "description": "Caller-defined; maps 1:1 to a LangBot session." },
|
||||
"session_type": { "type": "string", "enum": ["person", "group"], "default": "person" },
|
||||
"sender": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"group_name": { "type": "string", "description": "For session_type=group." }
|
||||
}
|
||||
},
|
||||
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
|
||||
}
|
||||
},
|
||||
"AcceptedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "integer", "example": 0 },
|
||||
"msg": { "type": "string", "example": "accepted" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"accepted_message_id": { "type": "string", "example": "in_01H..." },
|
||||
"aggregating": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SyncResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "integer", "example": 0 },
|
||||
"msg": { "type": "string", "example": "ok" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"reply_to": { "type": "string" },
|
||||
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Callback": {
|
||||
"type": "object",
|
||||
"description": "Delivered by LangBot to your callback_url, one POST per reply part. Signed with the outbound secret.",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"reply_to": { "type": "string", "description": "The accepted_message_id this answers." },
|
||||
"sequence": { "type": "integer", "description": "1-based ordinal within the turn." },
|
||||
"is_final": { "type": "boolean", "description": "True on the last part of the turn." },
|
||||
"stream": { "type": "boolean" },
|
||||
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } },
|
||||
"timestamp": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"ErrorEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "integer", "example": 40101 },
|
||||
"msg": { "type": "string", "example": "invalid signature: signature_mismatch" },
|
||||
"data": { "nullable": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Error": {
|
||||
"description": "Error envelope",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
# HTTP Bot Adapter — Integration Guide
|
||||
|
||||
Integrate **any backend system** with a LangBot pipeline over plain HTTP. Push
|
||||
messages in via a signed webhook; receive replies on a callback URL. No
|
||||
long-lived connection, full support for message **aggregation** (many inbound
|
||||
messages merged into one turn) and **multi-part replies** (one turn → many
|
||||
outbound messages).
|
||||
|
||||
This is the right adapter for **server-to-server** integrations — ticketing
|
||||
systems, CRMs, internal tools, custom web backends. (For an in-browser,
|
||||
real-time chat widget, use the embeddable Web Page Bot instead.)
|
||||
|
||||
> **5-minute goal:** stand up a callback receiver, send a message, and watch a
|
||||
> multi-part reply arrive — using the reference client in
|
||||
> [`examples/http-bot/`](../../examples/http-bot/).
|
||||
|
||||
---
|
||||
|
||||
## 1. Mental model
|
||||
|
||||
```
|
||||
Your backend ──(1) POST signed message──► LangBot /bots/<bot_uuid>
|
||||
(pipeline runs: aggregate → think → reply)
|
||||
Your callback ◄─(2) POST signed reply(s)── LangBot one POST per reply part
|
||||
```
|
||||
|
||||
- **(1) Inbound** is *fire-and-collect*: LangBot answers `202 Accepted`
|
||||
immediately and does **not** return the pipeline result on that response.
|
||||
- **(2) Outbound** replies arrive later as separate signed POSTs to your
|
||||
`callback_url`. A single turn may produce **several** callbacks (e.g. a tool
|
||||
call narration followed by the final answer).
|
||||
- Everything is keyed by a **`session_id` you choose** (e.g. a ticket number).
|
||||
Each `session_id` maps to one isolated LangBot conversation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Create the bot
|
||||
|
||||
1. In the LangBot dashboard, add a bot and choose the **HTTP Bot** platform.
|
||||
2. Fill in the config:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| **Inbound Signing Secret** | yes | Your backend signs inbound requests with this. |
|
||||
| **Outbound Callback URL** | yes | Where LangBot POSTs replies. **Config-only** — cannot be overridden per message (SSRF protection). |
|
||||
| **Outbound Signing Secret** | no | LangBot signs callbacks with this; defaults to the inbound secret. |
|
||||
| **Default Session Type** | no | `person` (default) or `group`. |
|
||||
| **Require Inbound Signature** | no | Keep `true` in production. |
|
||||
| **Callback Timeout / Max Retries** | no | Defaults: 15s, 3 retries. |
|
||||
|
||||
3. Bind the bot to a **pipeline** and **enable** it.
|
||||
4. Copy the **Inbound Webhook URL** shown in the config — it looks like
|
||||
`https://your-langbot/bots/<bot_uuid>`.
|
||||
|
||||
---
|
||||
|
||||
## 3. The signature scheme
|
||||
|
||||
Both directions use the same dependency-free HMAC-SHA256 scheme:
|
||||
|
||||
```
|
||||
signing_string = "{timestamp}." + raw_body_bytes
|
||||
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
|
||||
```
|
||||
|
||||
Sent as headers:
|
||||
|
||||
| Header | Meaning |
|
||||
|---|---|
|
||||
| `X-LB-Timestamp` | Unix seconds. Rejected if more than **±300s** from server time. |
|
||||
| `X-LB-Signature` | `sha256=<hex>` over `"{timestamp}." + body`. |
|
||||
| `X-LB-Idempotency-Key` | *(optional, inbound)* dedup key; retries with the same key return `409`. |
|
||||
|
||||
Verify outbound callbacks the same way, using the **outbound** secret (or the
|
||||
inbound secret if you left it blank).
|
||||
|
||||
A six-line reference implementation is in `examples/http-bot/client.py`
|
||||
(`sign()` / `verify()`); a Node/TS version is in `client.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Send your first message (curl)
|
||||
|
||||
```bash
|
||||
BOT="https://your-langbot/bots/<bot_uuid>"
|
||||
SECRET="your-inbound-secret"
|
||||
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"Export keeps failing on the dashboard."}]}'
|
||||
TS=$(date +%s)
|
||||
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
|
||||
|
||||
curl -sS -X POST "$BOT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-LB-Timestamp: $TS" \
|
||||
-H "X-LB-Signature: $SIG" \
|
||||
-d "$BODY"
|
||||
# -> 202 {"code":0,"msg":"accepted","data":{"session_id":"ticket-10293","accepted_message_id":"in_...","aggregating":true}}
|
||||
```
|
||||
|
||||
The reply(s) will be POSTed to your configured callback URL shortly after.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inbound request format
|
||||
|
||||
`POST /bots/{bot_uuid}`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"session_id": "ticket-10293", // REQUIRED. Your stable id. Maps 1:1 to a LangBot session.
|
||||
"session_type": "person", // optional: "person" | "group"; default from config
|
||||
"sender": { // optional metadata, surfaced to the pipeline/plugins
|
||||
"id": "user-5567",
|
||||
"name": "Alice"
|
||||
},
|
||||
"message": [ // REQUIRED. A LangBot MessageChain (array of segments).
|
||||
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
|
||||
{ "type": "Image", "url": "https://example.com/screenshot.png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Message segments.** Text uses `{"type":"Plain","text":"..."}`. Images use
|
||||
`{"type":"Image","url":"..."}` (or `base64`). Other supported types: `Voice`,
|
||||
`File`, `At`, `Quote`.
|
||||
|
||||
> Note: the callback URL is **not** accepted in the body — it is taken only from
|
||||
> bot config. This is deliberate (prevents an attacker who obtains the inbound
|
||||
> secret from redirecting replies to an arbitrary host).
|
||||
|
||||
### Aggregation (N → 1)
|
||||
|
||||
If your pipeline has **message aggregation** enabled, send several messages with
|
||||
the **same `session_id`** within the aggregation window and they are merged into
|
||||
**one** pipeline turn. No special flag — just reuse the `session_id`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Outbound callback format
|
||||
|
||||
LangBot POSTs each reply part to your `callback_url`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"session_id": "ticket-10293", // echoes the inbound session
|
||||
"reply_to": "in_01H...", // the accepted_message_id this answers
|
||||
"sequence": 1, // 1-based ordinal within this turn
|
||||
"is_final": false, // true on the last part of the turn
|
||||
"stream": false, // true for streamed chunks
|
||||
"message": [ { "type": "Plain", "text": "Looking into it…" } ],
|
||||
"timestamp": "2026-06-22T09:00:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
Your endpoint should return `2xx` quickly. Non-2xx / timeout → LangBot retries
|
||||
with exponential backoff (up to `callback_max_retries`).
|
||||
|
||||
### Multi-part replies (1 → M)
|
||||
|
||||
One turn may emit multiple callbacks, delivered **in `sequence` order** for a
|
||||
given session:
|
||||
|
||||
```
|
||||
seq=1 is_final=false "Checking your export logs…"
|
||||
seq=2 is_final=false "Found 2 failed exports."
|
||||
seq=3 is_final=true "Fixed — please try again."
|
||||
```
|
||||
|
||||
Stitch by `session_id` + `sequence`; the turn is complete when
|
||||
`is_final: true` arrives.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reset a session
|
||||
|
||||
Start a fresh conversation for a `session_id` (drops history):
|
||||
|
||||
```
|
||||
POST /bots/{bot_uuid}/reset
|
||||
{ "session_id": "ticket-10293", "session_type": "person" }
|
||||
→ 200 { "code":0, "msg":"reset", "data": { "session_id":"ticket-10293", "removed": true } }
|
||||
```
|
||||
|
||||
Signed exactly like an inbound message.
|
||||
|
||||
---
|
||||
|
||||
## 8. Synchronous convenience mode
|
||||
|
||||
If you don't need streaming/multi-part and just want one reply back on the same
|
||||
HTTP call, POST to `/sync`. LangBot waits for the turn to finish and returns all
|
||||
parts **collapsed** into one array:
|
||||
|
||||
```
|
||||
POST /bots/{bot_uuid}/sync
|
||||
{ "session_id": "ticket-10293", "message": [ { "type":"Plain", "text":"hi" } ] }
|
||||
→ 200 { "code":0, "msg":"ok",
|
||||
"data": { "session_id":"ticket-10293", "reply_to":"in_...",
|
||||
"message": [ {"type":"Plain","text":"..."}, ... ] } }
|
||||
```
|
||||
|
||||
This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to
|
||||
`callback_timeout × 4` seconds. Prefer the callback model for anything
|
||||
real-time or multi-part. Only one in-flight `/sync` per `session_id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Error envelope
|
||||
|
||||
```jsonc
|
||||
{ "code": 40101, "msg": "invalid signature: signature_mismatch", "data": null }
|
||||
```
|
||||
|
||||
| HTTP | code | meaning |
|
||||
|---|---|---|
|
||||
| 202 | 0 | accepted |
|
||||
| 400 | 40001 | malformed body / missing `session_id` or `message` |
|
||||
| 401 | 40101 | bad/expired signature |
|
||||
| 409 | 40901 | duplicate idempotency key |
|
||||
| 413 | 41301 | message too large (>1 MiB) |
|
||||
| 500 | 50001 | internal error |
|
||||
|
||||
---
|
||||
|
||||
## 10. Try it end-to-end in 5 minutes
|
||||
|
||||
```bash
|
||||
cd examples/http-bot
|
||||
pip install flask requests
|
||||
|
||||
# Terminal 1 — your callback receiver (point the bot's callback_url here, e.g. via a tunnel):
|
||||
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||
|
||||
# Terminal 2 — push a message:
|
||||
python client.py push \
|
||||
--url https://your-langbot/bots/<bot_uuid> \
|
||||
--secret SHARED_SECRET \
|
||||
--session ticket-1 \
|
||||
--text "hello"
|
||||
```
|
||||
|
||||
Watch Terminal 1 print each reply part (`[part ]` / `[FINAL]`) with its
|
||||
sequence number — that's 1→M working, signatures verified.
|
||||
|
||||
A machine-readable contract is in
|
||||
[`docs/http-bot-openapi.json`](../http-bot-openapi.json).
|
||||
|
||||
---
|
||||
|
||||
## 11. Security checklist
|
||||
|
||||
- Keep **Require Inbound Signature** on in production.
|
||||
- Use **HTTPS** callback URLs; the URL is config-only (no per-message override).
|
||||
- Treat the secrets like passwords; rotate via the dashboard.
|
||||
- The inbound route is unauthenticated at the framework level **by design** —
|
||||
security comes entirely from the HMAC signature, so never disable it on a
|
||||
public deployment.
|
||||
@@ -0,0 +1,75 @@
|
||||
# HTTP Bot Adapter — Reference Clients
|
||||
|
||||
> English | [中文](./README.zh.md)
|
||||
|
||||
Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||
They show the whole loop: signing a request, pushing a message, and receiving
|
||||
multi-part replies on a callback endpoint.
|
||||
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. |
|
||||
| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). |
|
||||
| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). |
|
||||
|
||||
All three implement the identical HMAC-SHA256 scheme
|
||||
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte
|
||||
against the adapter.
|
||||
|
||||
## Interactive playground (recommended first run)
|
||||
|
||||
A self-contained web console: type a message in your browser, it is signed and
|
||||
POSTed to a **running** `http_bot` bot, and the bot's replies stream back into
|
||||
the page — with a debug panel showing the signature, the `202` ack, and each
|
||||
callback's `sequence` / signature-verification.
|
||||
|
||||
```bash
|
||||
# From the LangBot repo root, with the backend already running:
|
||||
PUBLIC_IP=<your-host-ip> ./.venv/bin/python examples/http-bot/playground.py
|
||||
# then open http://<your-host-ip>:8920/
|
||||
```
|
||||
|
||||
On startup it reads the LangBot API key + the `http_bot` bot from
|
||||
`data/langbot.db`, and configures that bot (inbound/outbound secret +
|
||||
`callback_url`) to point back at itself via the LangBot API — the bot reloads
|
||||
live, no restart needed. Requirements: an enabled `http_bot` bot bound to a
|
||||
working pipeline, and port `8920` reachable from your browser.
|
||||
|
||||
Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`).
|
||||
|
||||
## Headless clients
|
||||
|
||||
```bash
|
||||
# Python — Terminal 1: callback receiver (your callback_url target)
|
||||
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||
|
||||
# Python — Terminal 2: push a message
|
||||
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# blocking sync mode
|
||||
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# reset a session
|
||||
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1
|
||||
```
|
||||
|
||||
```bash
|
||||
# TypeScript (Node 18+)
|
||||
npx tsx client.ts serve 8900 SHARED_SECRET
|
||||
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
|
||||
```
|
||||
|
||||
When the bot replies, the receiver prints each part with its `sequence` and an
|
||||
`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action.
|
||||
|
||||
> The bot's `callback_url` must be reachable from LangBot. For local testing,
|
||||
> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in
|
||||
> the bot config.
|
||||
@@ -0,0 +1,71 @@
|
||||
# HTTP Bot 适配器 —— 参考客户端
|
||||
|
||||
> [English](./README.md) | 中文
|
||||
|
||||
面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
1→M 的多段回复。
|
||||
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 是什么 |
|
||||
|---|---|
|
||||
| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 |
|
||||
| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 |
|
||||
| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 |
|
||||
|
||||
三者实现完全一致的 HMAC-SHA256 签名方案
|
||||
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。
|
||||
|
||||
## 交互式 playground(推荐先跑这个)
|
||||
|
||||
一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个
|
||||
**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示
|
||||
签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。
|
||||
|
||||
```bash
|
||||
# 在 LangBot 仓库根目录、后端已启动的前提下:
|
||||
PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py
|
||||
# 然后打开 http://<你的主机IP>:8920/
|
||||
```
|
||||
|
||||
启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot,
|
||||
并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 ——
|
||||
bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的
|
||||
`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。
|
||||
|
||||
可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。
|
||||
|
||||
## 无头客户端
|
||||
|
||||
```bash
|
||||
# Python —— 终端 1:回调接收端(你的 callback_url 指向它)
|
||||
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||
|
||||
# Python —— 终端 2:推送一条消息
|
||||
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# 阻塞式同步模式
|
||||
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# 重置一个会话
|
||||
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1
|
||||
```
|
||||
|
||||
```bash
|
||||
# TypeScript(Node 18+)
|
||||
npx tsx client.ts serve 8900 SHARED_SECRET
|
||||
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
|
||||
```
|
||||
|
||||
当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记
|
||||
`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。
|
||||
|
||||
> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道
|
||||
> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LangBot HTTP Bot adapter — reference client (Python).
|
||||
|
||||
Two things in one file:
|
||||
|
||||
1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot.
|
||||
2. A tiny Flask callback receiver that verifies signatures and prints replies,
|
||||
so you can watch N->1 aggregation and 1->M multi-reply working live.
|
||||
|
||||
Usage
|
||||
-----
|
||||
pip install flask requests
|
||||
|
||||
# Terminal 1 — start the callback receiver (this is your callback_url):
|
||||
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||
|
||||
# Terminal 2 — push a message (async; reply lands on the receiver):
|
||||
python client.py push \
|
||||
--url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET \
|
||||
--session ticket-10293 \
|
||||
--text "Export keeps failing on the dashboard."
|
||||
|
||||
# Or push and block for the collapsed reply (sync convenience mode):
|
||||
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-10293 --text "hi"
|
||||
|
||||
The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see
|
||||
``sign()`` below — it is intentionally tiny and easy to port.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
HEADER_TIMESTAMP = 'X-LB-Timestamp'
|
||||
HEADER_SIGNATURE = 'X-LB-Signature'
|
||||
HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'
|
||||
REPLAY_WINDOW = 300
|
||||
|
||||
|
||||
def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]:
|
||||
"""Return (timestamp, signature) for *body*."""
|
||||
ts = str(timestamp if timestamp is not None else int(time.time()))
|
||||
mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256)
|
||||
return ts, 'sha256=' + mac.hexdigest()
|
||||
|
||||
|
||||
def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool:
|
||||
"""Verify an inbound signature (used by the callback receiver)."""
|
||||
if not timestamp or not signature:
|
||||
return False
|
||||
try:
|
||||
if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW:
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
_, expected = sign(secret, body, int(float(timestamp)))
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _post(url: str, secret: str, payload: dict, idempotency: bool = True):
|
||||
import requests
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||
ts, sig = sign(secret, body)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
HEADER_TIMESTAMP: ts,
|
||||
HEADER_SIGNATURE: sig,
|
||||
}
|
||||
if idempotency:
|
||||
headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex
|
||||
resp = requests.post(url, data=body, headers=headers, timeout=30)
|
||||
print(f'-> {resp.status_code} {resp.text}')
|
||||
return resp
|
||||
|
||||
|
||||
def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
|
||||
"""Fire-and-collect: returns 202 immediately; reply arrives on your callback."""
|
||||
payload = {
|
||||
'session_id': session,
|
||||
'session_type': session_type,
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
return _post(url.rstrip('/'), secret, payload)
|
||||
|
||||
|
||||
def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
|
||||
"""Blocking convenience: POST to /sync and get the collapsed reply back."""
|
||||
payload = {
|
||||
'session_id': session,
|
||||
'session_type': session_type,
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False)
|
||||
return resp
|
||||
|
||||
|
||||
def reset(url: str, secret: str, session: str, session_type: str = 'person'):
|
||||
"""Reset a session's conversation (next message starts fresh)."""
|
||||
payload = {'session_id': session, 'session_type': session_type}
|
||||
return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False)
|
||||
|
||||
|
||||
def serve(port: int, secret: str):
|
||||
"""Run a callback receiver that verifies signatures and prints replies."""
|
||||
from flask import Flask, request
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/', methods=['POST'])
|
||||
def recv():
|
||||
raw = request.get_data()
|
||||
ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE))
|
||||
if not ok:
|
||||
print('!! signature verification FAILED — rejecting')
|
||||
return {'error': 'bad signature'}, 401
|
||||
data = json.loads(raw)
|
||||
text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain']
|
||||
marker = 'FINAL' if data.get('is_final') else 'part '
|
||||
print(
|
||||
f'[{marker}] session={data["session_id"]} seq={data["sequence"]} '
|
||||
f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}'
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)')
|
||||
app.run(host='0.0.0.0', port=port)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client')
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
sp = sub.add_parser('serve', help='run the callback receiver')
|
||||
sp.add_argument('--port', type=int, default=8900)
|
||||
sp.add_argument('--secret', required=True)
|
||||
|
||||
for name in ('push', 'sync', 'reset'):
|
||||
c = sub.add_parser(name)
|
||||
c.add_argument('--url', required=True, help='https://host/bots/<BOT_UUID>')
|
||||
c.add_argument('--secret', required=True)
|
||||
c.add_argument('--session', required=True)
|
||||
c.add_argument('--session-type', default='person', choices=['person', 'group'])
|
||||
if name != 'reset':
|
||||
c.add_argument('--text', required=True)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
if args.cmd == 'serve':
|
||||
serve(args.port, args.secret)
|
||||
elif args.cmd == 'push':
|
||||
push(args.url, args.secret, args.session, args.text, args.session_type)
|
||||
elif args.cmd == 'sync':
|
||||
push_sync(args.url, args.secret, args.session, args.text, args.session_type)
|
||||
elif args.cmd == 'reset':
|
||||
reset(args.url, args.secret, args.session, args.session_type)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+).
|
||||
*
|
||||
* Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`).
|
||||
*
|
||||
* - `push()` : fire-and-collect; reply lands on your callback URL.
|
||||
* - `pushSync()` : POST /sync and await the collapsed reply.
|
||||
* - `reset()` : reset a session's conversation.
|
||||
* - `startReceiver()` : a callback server that verifies signatures and logs
|
||||
* replies, so you can watch N->1 and 1->M live.
|
||||
*
|
||||
* Run the demos:
|
||||
* npx tsx client.ts serve 8900 SHARED_SECRET
|
||||
* npx tsx client.ts push https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
||||
* npx tsx client.ts sync https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
||||
* npx tsx client.ts reset https://host/bots/<UUID> SHARED_SECRET ticket-1
|
||||
*/
|
||||
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
const HEADER_TIMESTAMP = 'X-LB-Timestamp';
|
||||
const HEADER_SIGNATURE = 'X-LB-Signature';
|
||||
const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key';
|
||||
const REPLAY_WINDOW = 300;
|
||||
|
||||
/** Compute the `sha256=<hex>` signature over `"{ts}." + body`. */
|
||||
export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] {
|
||||
const ts = String(timestamp ?? Math.floor(Date.now() / 1000));
|
||||
const buf = typeof body === 'string' ? Buffer.from(body) : body;
|
||||
const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex');
|
||||
return [ts, `sha256=${mac}`];
|
||||
}
|
||||
|
||||
/** Verify an inbound signature (used by the callback receiver). */
|
||||
export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean {
|
||||
if (!timestamp || !signature) return false;
|
||||
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false;
|
||||
const [, expected] = sign(secret, body, Number(timestamp));
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(signature);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
interface Segment { type: string; text?: string; url?: string; [k: string]: unknown }
|
||||
|
||||
async function post(url: string, secret: string, payload: object, idempotency = true) {
|
||||
const body = Buffer.from(JSON.stringify(payload));
|
||||
const [ts, sig] = sign(secret, body);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
[HEADER_TIMESTAMP]: ts,
|
||||
[HEADER_SIGNATURE]: sig,
|
||||
};
|
||||
if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID();
|
||||
const resp = await fetch(url, { method: 'POST', headers, body });
|
||||
const text = await resp.text();
|
||||
console.log(`-> ${resp.status} ${text}`);
|
||||
return { status: resp.status, text };
|
||||
}
|
||||
|
||||
/** Fire-and-collect: 202 now, reply later on your callback URL. */
|
||||
export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
||||
return post(url.replace(/\/$/, ''), secret, {
|
||||
session_id: session,
|
||||
session_type: sessionType,
|
||||
message: [{ type: 'Plain', text }] as Segment[],
|
||||
});
|
||||
}
|
||||
|
||||
/** Blocking convenience: POST /sync, get the collapsed reply. */
|
||||
export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
||||
return post(`${url.replace(/\/$/, '')}/sync`, secret, {
|
||||
session_id: session,
|
||||
session_type: sessionType,
|
||||
message: [{ type: 'Plain', text }] as Segment[],
|
||||
}, false);
|
||||
}
|
||||
|
||||
/** Reset a session's conversation. */
|
||||
export function reset(url: string, secret: string, session: string, sessionType = 'person') {
|
||||
return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false);
|
||||
}
|
||||
|
||||
/** Run a callback receiver that verifies signatures and prints replies. */
|
||||
export function startReceiver(port: number, secret: string) {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.method !== 'POST') { res.writeHead(405).end(); return; }
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks);
|
||||
const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string,
|
||||
req.headers[HEADER_SIGNATURE.toLowerCase()] as string);
|
||||
if (!ok) {
|
||||
console.log('!! signature verification FAILED — rejecting');
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' }));
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(raw.toString());
|
||||
const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' ');
|
||||
const marker = data.is_final ? 'FINAL' : 'part ';
|
||||
console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`));
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
if (cmd === 'serve') {
|
||||
startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET');
|
||||
} else if (cmd === 'push') {
|
||||
push(rest[0], rest[1], rest[2], rest[3]);
|
||||
} else if (cmd === 'sync') {
|
||||
pushSync(rest[0], rest[1], rest[2], rest[3]);
|
||||
} else if (cmd === 'reset') {
|
||||
reset(rest[0], rest[1], rest[2]);
|
||||
} else if (cmd) {
|
||||
console.error(`unknown command: ${cmd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LangBot HTTP Bot — interactive playground (public, browser-based).
|
||||
|
||||
This is a REAL end-to-end demo against the RUNNING LangBot instance on this
|
||||
host. It is NOT a mock and NOT an in-process import: every message you type in
|
||||
the browser is signed and POSTed to the live `http_bot` bot at
|
||||
http://127.0.0.1:5300/bots/<uuid>, and the bot's replies come back to this
|
||||
server's /callback endpoint over real HTTP, then stream to your browser via SSE.
|
||||
|
||||
What it does on startup:
|
||||
1. Reads the LangBot API key + the http_bot bot from data/langbot.db.
|
||||
2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/<uuid>):
|
||||
sets inbound_secret + outbound_secret + callback_url to point back here.
|
||||
(LangBot reloads the bot live — no server restart needed.)
|
||||
3. Serves a chat page on 0.0.0.0:<PORT> so you can open it from the internet.
|
||||
|
||||
Run: ./.venv/bin/python examples/http-bot/playground.py
|
||||
Then open: http://<this-host-public-ip>:<PORT>/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
sys.path.insert(0, os.path.join(REPO, 'src'))
|
||||
|
||||
from aiohttp import web # noqa: E402
|
||||
import aiohttp # noqa: E402
|
||||
|
||||
from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402
|
||||
|
||||
# ---- config -----------------------------------------------------------------
|
||||
LANGBOT_BASE = 'http://127.0.0.1:5300'
|
||||
DB_PATH = os.path.join(REPO, 'data', 'langbot.db')
|
||||
PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1')
|
||||
PORT = int(os.environ.get('PLAYGROUND_PORT', '8920'))
|
||||
SECRET = 'playground-shared-secret'
|
||||
|
||||
# SSE subscribers: list of asyncio.Queue
|
||||
subscribers: list[asyncio.Queue] = []
|
||||
|
||||
|
||||
def db_lookup() -> tuple[str, str]:
|
||||
"""Return (api_key, http_bot_uuid) from the LangBot DB."""
|
||||
db = sqlite3.connect(DB_PATH)
|
||||
db.row_factory = sqlite3.Row
|
||||
api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key']
|
||||
bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone()
|
||||
if not bot:
|
||||
raise SystemExit('No http_bot bot found. Create one in the WebUI first.')
|
||||
return api_key, bot['uuid']
|
||||
|
||||
|
||||
async def configure_bot(api_key: str, bot_uuid: str, callback_url: str):
|
||||
"""Point the live bot at this playground via the LangBot API.
|
||||
|
||||
update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a
|
||||
MINIMAL payload: only adapter_config (built from scratch, not read back —
|
||||
the GET masks secrets). LangBot reloads + reruns the bot live.
|
||||
"""
|
||||
cfg = {
|
||||
'inbound_secret': SECRET,
|
||||
'outbound_secret': SECRET,
|
||||
'callback_url': callback_url,
|
||||
'signature_required': True,
|
||||
'default_session_type': 'person',
|
||||
'callback_timeout': 15,
|
||||
'callback_max_retries': 3,
|
||||
}
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.put(
|
||||
f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}',
|
||||
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
||||
json={'adapter_config': cfg},
|
||||
) as r:
|
||||
txt = await r.text()
|
||||
print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}')
|
||||
return r.status < 400
|
||||
|
||||
|
||||
async def broadcast(event: dict):
|
||||
for q in list(subscribers):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- HTTP handlers ----------------------------------------------------------
|
||||
async def index(request: web.Request):
|
||||
return web.Response(text=PAGE, content_type='text/html')
|
||||
|
||||
|
||||
async def send(request: web.Request):
|
||||
"""Browser -> here -> signed POST -> live LangBot bot."""
|
||||
body_in = await request.json()
|
||||
session_id = body_in.get('session_id') or 'playground-1'
|
||||
text = body_in.get('text', '')
|
||||
bot_uuid = request.app['bot_uuid']
|
||||
|
||||
payload = {
|
||||
'session_id': session_id,
|
||||
'sender': {'id': 'browser-user', 'name': 'You'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False).encode()
|
||||
ts, sig = sg.sign(SECRET, raw)
|
||||
url = f'{LANGBOT_BASE}/bots/{bot_uuid}'
|
||||
|
||||
# echo what we send to the browser timeline
|
||||
await broadcast(
|
||||
{'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + '…'}
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.post(
|
||||
url,
|
||||
data=raw,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
sg.HEADER_TIMESTAMP: ts,
|
||||
sg.HEADER_SIGNATURE: sig,
|
||||
},
|
||||
) as r:
|
||||
status = r.status
|
||||
try:
|
||||
jr = await r.json()
|
||||
except Exception:
|
||||
jr = {'raw': await r.text()}
|
||||
await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr})
|
||||
return web.json_response({'status': status, 'data': jr})
|
||||
|
||||
|
||||
async def callback(request: web.Request):
|
||||
"""Live LangBot bot -> here. Verify signature, stream to browser."""
|
||||
raw = await request.read()
|
||||
ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE))
|
||||
data = json.loads(raw)
|
||||
text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain')
|
||||
await broadcast(
|
||||
{
|
||||
'dir': 'in',
|
||||
'kind': 'reply',
|
||||
'session_id': data.get('session_id'),
|
||||
'sequence': data.get('sequence'),
|
||||
'is_final': data.get('is_final'),
|
||||
'sig_ok': ok,
|
||||
'sig_why': why,
|
||||
'text': text,
|
||||
}
|
||||
)
|
||||
return web.json_response({'ok': True})
|
||||
|
||||
|
||||
async def events(request: web.Request):
|
||||
"""SSE stream to the browser."""
|
||||
resp = web.StreamResponse(
|
||||
headers={
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}
|
||||
)
|
||||
await resp.prepare(request)
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
subscribers.append(q)
|
||||
try:
|
||||
await resp.write(b': connected\n\n')
|
||||
while True:
|
||||
try:
|
||||
ev = await asyncio.wait_for(q.get(), timeout=15)
|
||||
await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode())
|
||||
except asyncio.TimeoutError:
|
||||
await resp.write(b': ping\n\n')
|
||||
except (asyncio.CancelledError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
if q in subscribers:
|
||||
subscribers.remove(q)
|
||||
return resp
|
||||
|
||||
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>LangBot HTTP Bot · 调试台</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f7f8fa; --panel:#ffffff; --line:#e8eaed; --ink:#1f2329; --mut:#8a909a;
|
||||
--brand:#2563eb; --brand-soft:#eef3ff; --ok:#16a34a; --bad:#dc2626; --code:#f3f4f6;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
.top{height:52px;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
display:flex;align-items:center;gap:10px;padding:0 18px}
|
||||
.logo{width:26px;height:26px;border-radius:7px;background:var(--brand);display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px}
|
||||
.top b{font-size:15px} .top .ver{font-size:12px;color:var(--mut)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:#cbd2dc;display:inline-block;margin-right:5px;vertical-align:middle}
|
||||
.dot.on{background:var(--ok)} .dot.off{background:var(--bad)}
|
||||
.conn{margin-left:auto;font-size:12px;color:var(--mut)}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:18px;display:grid;grid-template-columns:1fr 360px;gap:16px}
|
||||
@media(max-width:880px){.wrap{grid-template-columns:1fr}}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;min-height:0}
|
||||
.card h3{margin:0;padding:12px 16px;font-size:13px;font-weight:600;color:#4b5563;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
|
||||
.chat{height:62vh}
|
||||
.msgs{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
||||
.row{display:flex;flex-direction:column;gap:4px;max-width:82%}
|
||||
.row.me{align-self:flex-end;align-items:flex-end}
|
||||
.row.bot{align-self:flex-start}
|
||||
.bub{padding:9px 13px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
|
||||
.me .bub{background:var(--brand);color:#fff;border-bottom-right-radius:3px}
|
||||
.bot .bub{background:#f1f3f6;color:var(--ink);border-bottom-left-radius:3px}
|
||||
.meta{font-size:11px;color:var(--mut)}
|
||||
.meta .ok{color:var(--ok)} .meta .bad{color:var(--bad)}
|
||||
.sys{align-self:center;font-size:12px;color:var(--mut);background:#f1f3f6;border-radius:8px;padding:4px 12px}
|
||||
.bar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
|
||||
.bar input{flex:1;border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:14px;outline:none}
|
||||
.bar input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-soft)}
|
||||
.bar button{background:var(--brand);color:#fff;border:0;border-radius:9px;padding:0 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||||
.bar button:disabled{opacity:.5;cursor:default}
|
||||
.side{height:62vh}
|
||||
.kv{padding:12px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||
.kv .k{color:var(--mut)} .kv .v{color:var(--ink);word-break:break-all}
|
||||
.kv code{background:var(--code);border-radius:5px;padding:1px 5px;font-size:11px}
|
||||
.sessrow{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||
.sessrow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
|
||||
.sessrow button{border:1px solid var(--line);background:#fff;border-radius:7px;padding:5px 9px;font-size:12px;cursor:pointer;color:#4b5563}
|
||||
.trace{flex:1;overflow:auto;padding:10px 12px;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.ev{padding:6px 8px;border-radius:7px;margin-bottom:6px;border:1px solid var(--line)}
|
||||
.ev .t{font-weight:600;font-size:10px;letter-spacing:.3px;text-transform:uppercase}
|
||||
.ev.out{background:#f5f8ff;border-color:#dbe6ff}.ev.out .t{color:var(--brand)}
|
||||
.ev.ack{background:#f4f6f8}.ev.ack .t{color:#6b7280}
|
||||
.ev.reply{background:#f1faf3;border-color:#cdeed6}.ev.reply .t{color:var(--ok)}
|
||||
.ev pre{margin:3px 0 0;white-space:pre-wrap;word-break:break-all;color:#374151}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="logo">L</div>
|
||||
<b>HTTP Bot 调试台</b><span class="ver">examples/http-bot</span>
|
||||
<span class="conn"><span class="dot off" id="cdot"></span><span id="conn">连接中…</span></span>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<!-- chat -->
|
||||
<div class="card chat">
|
||||
<h3>对话 · 真实发往运行中的 http_bot</h3>
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="bar">
|
||||
<input id="msg" placeholder="输入消息,回车发送…" autofocus/>
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- debug -->
|
||||
<div class="card side">
|
||||
<h3>调试信息</h3>
|
||||
<div class="kv"><span class="k">入站地址</span><br><span class="v"><code id="endpoint">/bots/<uuid></code></span></div>
|
||||
<div class="kv"><span class="k">签名</span> <span class="v">HMAC-SHA256 · <code>X-LB-Signature</code></span></div>
|
||||
<div class="sessrow">
|
||||
<span class="k">会话</span>
|
||||
<input id="sid" value="playground-1"/>
|
||||
<button id="reset">新会话</button>
|
||||
</div>
|
||||
<div class="trace" id="trace"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const msgs=$('#msgs'),trace=$('#trace'),inp=$('#msg'),btn=$('#send'),
|
||||
conn=$('#conn'),cdot=$('#cdot'),sidIn=$('#sid');
|
||||
function el(c){const d=document.createElement('div');d.className=c;return d}
|
||||
function atBottom(n){n.scrollTop=n.scrollHeight}
|
||||
function bubble(side,text,metaHtml){
|
||||
const r=el('row '+side),b=el('bub');b.textContent=text;r.appendChild(b);
|
||||
if(metaHtml){const m=el('meta');m.innerHTML=metaHtml;r.appendChild(m)}
|
||||
msgs.appendChild(r);atBottom(msgs)}
|
||||
function sys(t){const d=el('sys');d.textContent=t;msgs.appendChild(d);atBottom(msgs)}
|
||||
function logEv(kind,title,obj){
|
||||
const e=el('ev '+kind),t=el('t');t.textContent=title;e.appendChild(t);
|
||||
if(obj!==undefined){const p=document.createElement('pre');
|
||||
p.textContent=typeof obj==='string'?obj:JSON.stringify(obj,null,2);e.appendChild(p)}
|
||||
trace.appendChild(e);atBottom(trace)}
|
||||
|
||||
const es=new EventSource('/events');
|
||||
es.onopen=()=>{conn.textContent='SSE 已连接';cdot.className='dot on'};
|
||||
es.onerror=()=>{conn.textContent='SSE 断开,重连…';cdot.className='dot off'};
|
||||
es.onmessage=e=>{const ev=JSON.parse(e.data);
|
||||
if(ev.kind==='request'){
|
||||
if(ev.endpoint)$('#endpoint').textContent=ev.url||ev.endpoint;
|
||||
logEv('out','出站 · 已签名 POST',{url:ev.url,session_id:ev.session_id,'X-LB-Signature':ev.sig});
|
||||
}else if(ev.kind==='ack'){
|
||||
const id=ev.data&&ev.data.data&&ev.data.data.accepted_message_id;
|
||||
sys(`LangBot 已接收 · HTTP ${ev.status}`);
|
||||
logEv('ack','入站确认 202',{status:ev.status,accepted_message_id:id||'-'});
|
||||
}else if(ev.kind==='reply'){
|
||||
const sig=ev.sig_ok?'<span class=ok>验签通过</span>':'<span class=bad>验签失败</span>';
|
||||
bubble('bot',ev.text,`seq=${ev.sequence} · ${ev.is_final?'<b>FINAL</b>':'中间段'} · ${sig}`);
|
||||
logEv('reply',`回调 · seq ${ev.sequence}${ev.is_final?' · FINAL':''}`,
|
||||
{session_id:ev.session_id,sequence:ev.sequence,is_final:ev.is_final,sig_ok:ev.sig_ok,text:ev.text});
|
||||
}};
|
||||
|
||||
async function send(){
|
||||
const t=inp.value.trim();if(!t)return;inp.value='';btn.disabled=true;
|
||||
bubble('me',t,'已签名 → POST /bots/<uuid>');
|
||||
try{await fetch('/send',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({session_id:sidIn.value.trim()||'playground-1',text:t})});}
|
||||
catch(e){sys('发送失败:'+e)}
|
||||
btn.disabled=false;inp.focus();}
|
||||
btn.onclick=send;inp.addEventListener('keydown',e=>{if(e.key==='Enter')send()});
|
||||
$('#reset').onclick=()=>{sidIn.value='playground-'+Math.random().toString(36).slice(2,7);
|
||||
sys('已切换到新会话 '+sidIn.value);};
|
||||
sys('调试台就绪 · 每条消息都会真实发往运行中的 http_bot,右侧可观察签名 / 202 / 回调全过程。');
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
async def main():
|
||||
api_key, bot_uuid = db_lookup()
|
||||
callback_url = f'http://{PUBLIC_IP}:{PORT}/callback'
|
||||
print(f'[init] http_bot uuid = {bot_uuid}')
|
||||
print(f'[init] callback_url = {callback_url}')
|
||||
ok = await configure_bot(api_key, bot_uuid, callback_url)
|
||||
if not ok:
|
||||
print('[warn] bot config update failed; check the API key / payload shape')
|
||||
|
||||
app = web.Application()
|
||||
app['bot_uuid'] = bot_uuid
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_post('/send', send)
|
||||
app.router.add_post('/callback', callback)
|
||||
app.router.add_get('/events', events)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, '0.0.0.0', PORT)
|
||||
await site.start()
|
||||
print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n')
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
# Page Bot Adapter — Embed Demo
|
||||
|
||||
> English | [中文](./README.zh.md)
|
||||
|
||||
A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||
a single `<script>` tag.
|
||||
|
||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `index.html` | **Browser demo** — open it, point it at a running LangBot instance + a Page Bot you created, and it loads the live embed widget so you can chat with the bot exactly as a site visitor would. Zero deps, no build step. |
|
||||
|
||||
## How to use
|
||||
|
||||
1. In the LangBot WebUI, create a bot with the **Page Bot** (`页面机器人`)
|
||||
adapter and bind it to a working pipeline. Copy its **bot UUID** from the
|
||||
generated embed code.
|
||||
2. Open `index.html` in a browser. Any of these work:
|
||||
- double-click the file, or
|
||||
- serve the folder: `python3 -m http.server 8930` then open
|
||||
`http://localhost:8930/examples/web-page-bot/`.
|
||||
3. Fill in:
|
||||
- **LangBot base URL** — where your instance is reachable from the browser
|
||||
(e.g. `http://localhost:5300`, or your public address).
|
||||
- **Page Bot UUID** — from step 1.
|
||||
- **Widget title** — optional, sets the `data-title` attribute.
|
||||
4. Click **Load widget**. A floating chat bubble appears in the bottom-right
|
||||
corner — click it and chat.
|
||||
|
||||
The page also renders the exact `<script>` snippet you'd paste into your own
|
||||
site (before `</body>`), and updates it live as you edit the fields.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- The embed contract: `<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`.
|
||||
- `widget.js` is served by LangBot pre-configured for that bot UUID — title,
|
||||
bubble icon, language and optional Cloudflare Turnstile protection all come
|
||||
from the bot's config, no page changes needed.
|
||||
- Messages travel over a WebSocket to the bot's bound pipeline; replies stream
|
||||
back into the bubble.
|
||||
|
||||
> The widget loads `widget.js` from your LangBot instance, so the **base URL
|
||||
> must be reachable from the browser** you open this page in. If LangBot runs on
|
||||
> a server, use its public address instead of `localhost`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 页面机器人适配器 —— 嵌入演示
|
||||
|
||||
> [English](./README.md) | 中文
|
||||
|
||||
一个自包含的单文件 HTML 页面,用于演示 LangBot **页面机器人**
|
||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||
网站上的那个组件。
|
||||
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 是什么 |
|
||||
|---|---|
|
||||
| `index.html` | **浏览器演示页** —— 打开它,填上一个运行中的 LangBot 实例地址 + 你创建的页面机器人,它就会加载真实的嵌入组件,让你像网站访客一样和机器人对话。零依赖,无需构建。 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 在 LangBot WebUI 中,用 **页面机器人**(`web_page_bot`)适配器创建一个机器人,
|
||||
并绑定一个可用的流水线。从生成的嵌入代码里复制它的 **机器人 UUID**。
|
||||
2. 在浏览器中打开 `index.html`,以下任一方式皆可:
|
||||
- 直接双击该文件;或
|
||||
- 起一个静态服务:`python3 -m http.server 8930`,然后打开
|
||||
`http://localhost:8930/examples/web-page-bot/`。
|
||||
3. 填写:
|
||||
- **LangBot base URL** —— 你的实例在该浏览器中可访问的地址
|
||||
(例如 `http://localhost:5300`,或你的公网地址)。
|
||||
- **页面机器人 UUID** —— 第 1 步里复制的。
|
||||
- **组件标题** —— 可选,对应 `data-title` 属性。
|
||||
4. 点击 **Load widget**。页面右下角会出现一个浮动聊天气泡 —— 点开即可对话。
|
||||
|
||||
页面还会实时渲染出你需要粘贴到自己网站(放在 `</body>` 前)的那段 `<script>`
|
||||
代码,并随着你编辑输入框同步更新。
|
||||
|
||||
## 它演示了什么
|
||||
|
||||
- 嵌入契约:`<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`。
|
||||
- `widget.js` 由 LangBot 针对该机器人 UUID 预配置后下发 —— 标题、气泡图标、语言
|
||||
以及可选的 Cloudflare Turnstile 防护,全部来自机器人配置,无需改动页面。
|
||||
- 消息通过 WebSocket 发往机器人绑定的流水线,回复流式回到气泡中。
|
||||
|
||||
> 组件会从你的 LangBot 实例加载 `widget.js`,因此 **base URL 必须能从你打开本页
|
||||
> 的浏览器访问到**。如果 LangBot 部署在服务器上,请用它的公网地址而非
|
||||
> `localhost`。
|
||||
@@ -0,0 +1,205 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LangBot Page Bot · Embed Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fa; --panel: #ffffff; --line: #e8eaed; --ink: #1f2329;
|
||||
--mut: #8a909a; --brand: #2563eb; --brand-soft: #eef3ff;
|
||||
--ok: #16a34a; --bad: #dc2626; --code: #f3f4f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.top {
|
||||
height: 52px; background: var(--panel); border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 10px; padding: 0 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 26px; height: 26px; border-radius: 7px; background: var(--brand);
|
||||
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 14px;
|
||||
}
|
||||
.top b { font-size: 15px; }
|
||||
.top .ver { font-size: 12px; color: var(--mut); }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 28px 18px 80px; }
|
||||
.hero h1 { margin: 8px 0 6px; font-size: 22px; }
|
||||
.hero p { margin: 0 0 4px; color: var(--mut); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 20px; margin-top: 20px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 14px; font-size: 14px; font-weight: 600; color: #4b5563;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.card h3 .num {
|
||||
width: 20px; height: 20px; border-radius: 50%; background: var(--brand-soft);
|
||||
color: var(--brand); display: grid; place-items: center; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.field { margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label { display: block; font-size: 12px; color: var(--mut); margin-bottom: 5px; }
|
||||
.field input {
|
||||
width: 100%; border: 1px solid var(--line); border-radius: 9px;
|
||||
padding: 10px 12px; font-size: 14px; outline: none; font-family: inherit;
|
||||
}
|
||||
.field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
.hint { font-size: 12px; color: var(--mut); margin-top: 5px; }
|
||||
.hint code { background: var(--code); border-radius: 5px; padding: 1px 5px; font-size: 11px; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 18px; align-items: center; }
|
||||
button {
|
||||
border: 0; border-radius: 9px; padding: 10px 18px; font-size: 14px;
|
||||
font-weight: 500; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.btn-primary { background: var(--brand); color: #fff; }
|
||||
.btn-primary:disabled { opacity: .5; cursor: default; }
|
||||
.btn-ghost { background: #fff; border: 1px solid var(--line); color: #4b5563; }
|
||||
.status { font-size: 13px; color: var(--mut); }
|
||||
.status .ok { color: var(--ok); }
|
||||
.status .bad { color: var(--bad); }
|
||||
pre {
|
||||
background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 14px 16px;
|
||||
overflow: auto; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
margin: 0;
|
||||
}
|
||||
.snippet-row { position: relative; }
|
||||
.snippet-row .copy {
|
||||
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,.12);
|
||||
color: #fff; border: 0; border-radius: 7px; padding: 5px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
ul.steps { margin: 0; padding-left: 18px; color: #4b5563; }
|
||||
ul.steps li { margin-bottom: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="logo">L</div>
|
||||
<b>Page Bot · Embed Demo</b>
|
||||
<span class="ver">examples/web-page-bot</span>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<h1>Try the LangBot Page Bot widget</h1>
|
||||
<p>Point this page at a running LangBot instance and a <strong>Page Bot</strong> you created,</p>
|
||||
<p>then load the live embed widget below to chat with it — exactly as your site visitors would.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><span class="num">1</span> Connect your Page Bot</h3>
|
||||
<div class="field">
|
||||
<label for="base">LangBot base URL</label>
|
||||
<input id="base" placeholder="http://localhost:5300" value="http://localhost:5300" />
|
||||
<div class="hint">The address where your LangBot instance is reachable from this browser. No trailing slash.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uuid">Page Bot UUID</label>
|
||||
<input id="uuid" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||
<div class="hint">Create a bot with the <code>Page Bot</code> adapter in the WebUI, then copy its UUID from the embed code.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="title">Widget title (optional)</label>
|
||||
<input id="title" placeholder="LangBot" value="LangBot" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="load" class="btn-primary">Load widget</button>
|
||||
<button id="unload" class="btn-ghost">Remove widget</button>
|
||||
<span class="status" id="status">Not loaded.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><span class="num">2</span> The embed snippet</h3>
|
||||
<p style="margin:0 0 12px;color:var(--mut)">This is exactly what you paste into your own site (before <code></body></code>). It updates as you edit the fields above.</p>
|
||||
<div class="snippet-row">
|
||||
<button class="copy" id="copy">Copy</button>
|
||||
<pre id="snippet"><script data-title="LangBot" src="http://localhost:5300/api/v1/embed/<bot-uuid>/widget.js"></script></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><span class="num">3</span> How it works</h3>
|
||||
<ul class="steps">
|
||||
<li>The <code><script></code> tag pulls <code>widget.js</code> from your LangBot instance, pre-configured for that bot UUID.</li>
|
||||
<li>A floating chat bubble appears in the bottom-right corner of the page.</li>
|
||||
<li>Messages travel over a WebSocket to the bot's bound pipeline; replies stream back into the bubble.</li>
|
||||
<li>Title, bubble icon, language and optional Cloudflare Turnstile protection are all set in the bot's config — no page changes needed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
var baseEl = $("#base"), uuidEl = $("#uuid"), titleEl = $("#title"),
|
||||
statusEl = $("#status"), snippetEl = $("#snippet");
|
||||
var WIDGET_ID = "langbot-embed-demo-script";
|
||||
|
||||
function clean(v) { return (v || "").trim().replace(/\/+$/, ""); }
|
||||
|
||||
function buildSrc() {
|
||||
var base = clean(baseEl.value) || "http://localhost:5300";
|
||||
var uuid = uuidEl.value.trim() || "<bot-uuid>";
|
||||
return base + "/api/v1/embed/" + uuid + "/widget.js";
|
||||
}
|
||||
|
||||
function refreshSnippet() {
|
||||
var title = titleEl.value.trim() || "LangBot";
|
||||
var src = buildSrc();
|
||||
snippetEl.textContent =
|
||||
'<script data-title="' + title + '" src="' + src + '"><\/script>';
|
||||
}
|
||||
|
||||
function setStatus(html) { statusEl.innerHTML = html; }
|
||||
|
||||
function removeWidget() {
|
||||
var old = document.getElementById(WIDGET_ID);
|
||||
if (old) old.remove();
|
||||
// The widget injects its own DOM (bubble + panel). Clear the common containers it creates.
|
||||
document.querySelectorAll('[id^="langbot-"]').forEach(function (n) {
|
||||
if (n.id !== WIDGET_ID) n.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function loadWidget() {
|
||||
var uuid = uuidEl.value.trim();
|
||||
var uuidRe = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
if (!uuidRe.test(uuid)) {
|
||||
setStatus('<span class="bad">Enter a valid bot UUID first.</span>');
|
||||
return;
|
||||
}
|
||||
removeWidget();
|
||||
var s = document.createElement("script");
|
||||
s.id = WIDGET_ID;
|
||||
s.setAttribute("data-title", titleEl.value.trim() || "LangBot");
|
||||
s.src = buildSrc();
|
||||
s.onload = function () {
|
||||
setStatus('<span class="ok">Widget loaded — look bottom-right.</span>');
|
||||
};
|
||||
s.onerror = function () {
|
||||
setStatus('<span class="bad">Failed to load widget.js — check the base URL and that the bot is enabled.</span>');
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
setStatus("Loading…");
|
||||
}
|
||||
|
||||
$("#load").onclick = loadWidget;
|
||||
$("#unload").onclick = function () {
|
||||
removeWidget();
|
||||
setStatus("Widget removed.");
|
||||
};
|
||||
$("#copy").onclick = function () {
|
||||
navigator.clipboard.writeText(snippetEl.textContent).then(function () {
|
||||
var b = $("#copy"); b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1200);
|
||||
});
|
||||
};
|
||||
[baseEl, uuidEl, titleEl].forEach(function (el) { el.addEventListener("input", refreshSnippet); });
|
||||
refreshSnippet();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.2"
|
||||
version = "4.10.4"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
"aiohttp>=3.14.0",
|
||||
"aiohttp>=3.14.1",
|
||||
"aioshutil>=1.5",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
@@ -16,7 +16,7 @@ dependencies = [
|
||||
"async-lru>=2.0.5",
|
||||
"certifi>=2025.4.26",
|
||||
"colorlog~=6.6.0",
|
||||
"cryptography>=46.0.7",
|
||||
"cryptography>=48.0.1",
|
||||
"dashscope>=1.25.10",
|
||||
"dingtalk-stream>=0.24.0",
|
||||
"discord-py>=2.5.2",
|
||||
@@ -61,16 +61,16 @@ dependencies = [
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain>=1.3.9",
|
||||
"langchain-core>=1.3.3",
|
||||
"langsmith>=0.8.0",
|
||||
"langsmith>=0.8.18",
|
||||
"python-multipart>=0.0.27",
|
||||
"Mako>=1.3.12",
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.5",
|
||||
"langbot-plugin==0.5.0a2",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -119,7 +119,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
{
|
||||
"directory": "langbot-mcp-ops",
|
||||
"name": "langbot-mcp-ops",
|
||||
"description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".",
|
||||
"description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".",
|
||||
"references": [],
|
||||
"cases": [],
|
||||
"case_summaries": [],
|
||||
@@ -133,6 +133,7 @@
|
||||
"references/pipeline-debug-chat.md",
|
||||
"references/plugin-e2e-smoke.md",
|
||||
"references/sandbox-skill-authoring.md",
|
||||
"references/skill-all-tool-acceptance.md",
|
||||
"references/troubleshooting.md",
|
||||
"references/web-ui-testing.md"
|
||||
],
|
||||
@@ -170,6 +171,7 @@
|
||||
"qa-plugin-smoke-live-install",
|
||||
"sandbox-skill-authoring-e2e",
|
||||
"sandbox-skill-authoring-edit-existing-e2e",
|
||||
"skill-discovery-via-mcp-gateway",
|
||||
"webui-login-state"
|
||||
],
|
||||
"case_summaries": [
|
||||
@@ -1033,6 +1035,31 @@
|
||||
"filesystem"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill-discovery-via-mcp-gateway",
|
||||
"title": "External harness discovers LangBot skills via langbot_list_assets (all-tool model)",
|
||||
"mode": "agent-browser",
|
||||
"area": "sandbox",
|
||||
"type": "regression",
|
||||
"priority": "p2",
|
||||
"risk": "medium",
|
||||
"ci_eligible": false,
|
||||
"tags": [
|
||||
"skills",
|
||||
"mcp-gateway",
|
||||
"acp-agent-runner",
|
||||
"all-tool-model",
|
||||
"tools"
|
||||
],
|
||||
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
|
||||
"setup_automation": [],
|
||||
"setup_provides_env": [],
|
||||
"evidence_required": [
|
||||
"ui",
|
||||
"screenshot",
|
||||
"backend_log"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "webui-login-state",
|
||||
"title": "Configured frontend opens with authenticated LangBot WebUI state",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: langbot-mcp-ops
|
||||
description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server".
|
||||
description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server".
|
||||
---
|
||||
|
||||
# LangBot MCP Operations
|
||||
@@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
||||
| --- | --- |
|
||||
| `get_system_info` | Version, edition, instance id |
|
||||
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
||||
| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and Pipelines |
|
||||
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
||||
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
||||
| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) |
|
||||
|
||||
@@ -42,6 +42,38 @@ MyPlugin/
|
||||
|
||||
Each component has a `.yaml` (metadata) and `.py` (implementation).
|
||||
|
||||
## README & i18n convention (enforced on the marketplace)
|
||||
|
||||
A plugin published to LangBot Space serves a localized README on its detail page.
|
||||
The resolver (`langbot-space` `PluginService.GetPluginREADME`) works like this:
|
||||
|
||||
- **Root `README.md` MUST be in English.** It is the default and the fallback —
|
||||
when no per-language README matches the viewer's locale, the page serves the
|
||||
root `README.md`. A non-English root README makes the English/default view show
|
||||
the wrong language.
|
||||
- **All other languages live under `readme/README_{lang}.md`** — e.g.
|
||||
`readme/README_zh_Hans.md`, `readme/README_ja_JP.md`. The 8 supported locales:
|
||||
`en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU`.
|
||||
- `manifest.yaml` `metadata.label` / `metadata.description` should carry the same
|
||||
8-locale i18n set (`repository` must be a real, alive URL).
|
||||
|
||||
```
|
||||
MyPlugin/
|
||||
├── manifest.yaml
|
||||
├── README.md # English (default + fallback) — REQUIRED, must be English
|
||||
└── readme/
|
||||
├── README_zh_Hans.md
|
||||
├── README_zh_Hant.md
|
||||
├── README_ja_JP.md
|
||||
├── README_th_TH.md
|
||||
├── README_vi_VN.md
|
||||
├── README_es_ES.md
|
||||
└── README_ru_RU.md
|
||||
```
|
||||
|
||||
`manifest.yaml` (incl. `repository`) is the source of truth — the marketplace
|
||||
syncs from it, so edit the package and re-publish rather than patching live data.
|
||||
|
||||
## Critical SDK Pitfalls
|
||||
|
||||
### 1. MessageChain is a RootModel — iterate directly
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.10.2'
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version('langbot')
|
||||
|
||||
@@ -438,8 +438,13 @@ class DingTalkClient:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {'text': response.text}
|
||||
if response.status_code == 200:
|
||||
return
|
||||
return body
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
except Exception:
|
||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
@@ -464,8 +469,13 @@ class DingTalkClient:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {'text': response.text}
|
||||
if response.status_code == 200:
|
||||
return
|
||||
return body
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
except Exception:
|
||||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
|
||||
@@ -93,15 +93,30 @@ class OAClient:
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
# 校验签名
|
||||
if msg_signature:
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret == 0:
|
||||
return reply_echo
|
||||
await self.logger.error(
|
||||
'OfficialAccount encrypted URL verification failed: '
|
||||
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||
f'echostr_present={bool(echostr)}'
|
||||
)
|
||||
|
||||
# Plaintext callback verification.
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
|
||||
if check_signature == signature:
|
||||
return echostr # 验证成功返回echostr
|
||||
else:
|
||||
await self.logger.error('拒绝请求')
|
||||
raise Exception('拒绝请求')
|
||||
await self.logger.error(
|
||||
'OfficialAccount plaintext URL verification failed: '
|
||||
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||
)
|
||||
return 'signature verification failed', 403
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
@@ -279,9 +294,27 @@ class OAClientForLongerResponse:
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
if msg_signature:
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret == 0:
|
||||
return reply_echo
|
||||
await self.logger.error(
|
||||
'OfficialAccount encrypted URL verification failed: '
|
||||
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||
f'echostr_present={bool(echostr)}'
|
||||
)
|
||||
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
return echostr if check_signature == signature else '拒绝请求'
|
||||
if check_signature == signature:
|
||||
return echostr
|
||||
await self.logger.error(
|
||||
'OfficialAccount plaintext URL verification failed: '
|
||||
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||
)
|
||||
return 'signature verification failed', 403
|
||||
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
@@ -7,7 +9,7 @@ import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
@@ -16,7 +18,9 @@ from quart import Quart, request, Response, jsonify
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -15,13 +15,15 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
|
||||
|
||||
@@ -207,7 +207,33 @@ class WecomCSClient:
|
||||
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送消息失败:{data}')
|
||||
raise Exception('Failed to send message')
|
||||
raise Exception(f'Failed to send message: {data}')
|
||||
return data
|
||||
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片消息失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
@@ -322,7 +348,7 @@ class WecomCSClient:
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=image'
|
||||
file_bytes = None
|
||||
file_name = 'uploaded_file.txt'
|
||||
|
||||
@@ -368,7 +394,7 @@ class WecomCSClient:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_to_work(image)
|
||||
if data.get('errcode', 0) != 0:
|
||||
raise Exception('failed to upload file')
|
||||
raise Exception(f'failed to upload image: {data}')
|
||||
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
@@ -146,8 +146,10 @@ class AgentRunnerRegistry:
|
||||
Returns:
|
||||
List of runner descriptors
|
||||
"""
|
||||
if use_cache and self._cache is not None:
|
||||
# Filter from cache
|
||||
if use_cache and self._cache is not None and self._cache:
|
||||
# Filter from cache. Do not treat an empty cache as final because the
|
||||
# plugin runtime may still be launching installed plugins when the
|
||||
# first metadata request arrives.
|
||||
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
|
||||
|
||||
# Discover fresh (always full list)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('agents', '/api/v1/agents')
|
||||
class AgentsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
sort_by = quart.request.args.get('sort_by', 'updated_at')
|
||||
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||
return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)})
|
||||
|
||||
json_data = await quart.request.json
|
||||
created = await self.ap.agent_service.create_agent(json_data)
|
||||
return self.success(data=created)
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(data=await self.ap.agent_service.get_agent_metadata())
|
||||
|
||||
@self.route('/<agent_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(agent_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
agent = await self.ap.agent_service.get_agent(agent_uuid)
|
||||
if agent is None:
|
||||
return self.http_status(404, -1, 'agent not found')
|
||||
return self.success(data={'agent': agent})
|
||||
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.agent_service.update_agent(agent_uuid, json_data)
|
||||
return self.success()
|
||||
|
||||
await self.ap.agent_service.delete_agent(agent_uuid)
|
||||
return self.success()
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
from .. import group
|
||||
from .box_visibility import should_hide_box_runtime_status
|
||||
|
||||
|
||||
@group.group_class('box', '/api/v1/box')
|
||||
@@ -9,6 +12,7 @@ class BoxRouterGroup(group.RouterGroup):
|
||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
status = await self.ap.box_service.get_status()
|
||||
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
||||
return self.success(data=status)
|
||||
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool:
|
||||
return edition == 'cloud' and box_enabled is False
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
|
||||
|
||||
AGENT_KIND_AGENT = 'agent'
|
||||
AGENT_KIND_PIPELINE = 'pipeline'
|
||||
PIPELINE_EVENT_PATTERNS = ['message.*']
|
||||
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Unified product surface for Agent orchestration instances and Pipelines."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_agent_metadata(self) -> dict[str, typing.Any]:
|
||||
"""Return metadata needed by Agent forms."""
|
||||
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata()
|
||||
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
|
||||
return {
|
||||
'runner_config': ai_metadata,
|
||||
'kinds': [
|
||||
{
|
||||
'name': AGENT_KIND_AGENT,
|
||||
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'message_only': False,
|
||||
},
|
||||
{
|
||||
'name': AGENT_KIND_PIPELINE,
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]:
|
||||
agents = await self._get_agent_rows()
|
||||
pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC')
|
||||
|
||||
items = [self._agent_to_product_item(agent) for agent in agents]
|
||||
items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines)
|
||||
|
||||
reverse = sort_order == 'DESC'
|
||||
sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at'
|
||||
return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse)
|
||||
|
||||
async def get_agent(self, agent_uuid: str) -> dict | None:
|
||||
agent = await self._get_agent_row(agent_uuid)
|
||||
if agent is not None:
|
||||
return self._agent_to_product_item(agent, include_config=True)
|
||||
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is not None:
|
||||
return self._pipeline_to_product_item(pipeline, include_config=True)
|
||||
|
||||
return None
|
||||
|
||||
async def create_agent(self, agent_data: dict) -> dict[str, str]:
|
||||
kind = agent_data.get('kind') or AGENT_KIND_AGENT
|
||||
if kind == AGENT_KIND_PIPELINE:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
{
|
||||
'name': agent_data.get('name') or 'New Pipeline',
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '⚙️',
|
||||
'config': {},
|
||||
}
|
||||
)
|
||||
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
|
||||
|
||||
if kind != AGENT_KIND_AGENT:
|
||||
raise ValueError(f'Unsupported agent kind: {kind}')
|
||||
|
||||
config = agent_data.get('config') or await self._get_default_agent_config()
|
||||
runner_id = self._resolve_runner_id(config)
|
||||
new_uuid = str(uuid.uuid4())
|
||||
values = {
|
||||
'uuid': new_uuid,
|
||||
'name': agent_data.get('name') or 'New Agent',
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '🤖',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': agent_data.get('component_ref') or runner_id,
|
||||
'config': config,
|
||||
'enabled': agent_data.get('enabled', True),
|
||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
}
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
||||
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
|
||||
|
||||
async def update_agent(self, agent_uuid: str, agent_data: dict) -> None:
|
||||
existing_agent = await self._get_agent_row(agent_uuid)
|
||||
if existing_agent is None:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Agent {agent_uuid} not found')
|
||||
await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data)
|
||||
return
|
||||
|
||||
update_data = agent_data.copy()
|
||||
for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'):
|
||||
update_data.pop(protected_field, None)
|
||||
if 'config' in update_data:
|
||||
update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id(
|
||||
update_data['config']
|
||||
)
|
||||
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
|
||||
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_agent.Agent)
|
||||
.where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
.values(**update_data)
|
||||
)
|
||||
|
||||
async def delete_agent(self, agent_uuid: str) -> None:
|
||||
existing_agent = await self._get_agent_row(agent_uuid)
|
||||
if existing_agent is not None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
)
|
||||
return
|
||||
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid)
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Agent {agent_uuid} not found')
|
||||
await self.ap.pipeline_service.delete_pipeline(agent_uuid)
|
||||
|
||||
async def _get_agent_rows(self) -> list[persistence_agent.Agent]:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent))
|
||||
return list(result.all())
|
||||
|
||||
async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def _get_default_agent_config(self) -> dict[str, typing.Any]:
|
||||
runners = []
|
||||
if getattr(self.ap, 'agent_runner_registry', None) is not None:
|
||||
try:
|
||||
runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None)
|
||||
except Exception as e:
|
||||
if getattr(self.ap, 'logger', None):
|
||||
self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}')
|
||||
|
||||
if not runners:
|
||||
return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}}
|
||||
|
||||
selected_runner = runners[0]
|
||||
return {
|
||||
'runner': {'id': selected_runner.id, 'expire-time': 0},
|
||||
'runner_config': {
|
||||
selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema(
|
||||
selected_runner.config_schema
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None:
|
||||
runner = config.get('runner') if isinstance(config, dict) else None
|
||||
if isinstance(runner, dict):
|
||||
runner_id = runner.get('id')
|
||||
if runner_id:
|
||||
return runner_id
|
||||
return None
|
||||
|
||||
def _agent_to_product_item(
|
||||
self,
|
||||
agent: persistence_agent.Agent,
|
||||
include_config: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
|
||||
item['kind'] = AGENT_KIND_AGENT
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'message_only': False,
|
||||
}
|
||||
if not include_config:
|
||||
item.pop('config', None)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]:
|
||||
item = pipeline.copy()
|
||||
item['kind'] = AGENT_KIND_PIPELINE
|
||||
item['component_ref'] = 'pipeline'
|
||||
item['enabled'] = True
|
||||
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
'message_only': True,
|
||||
}
|
||||
if not include_config:
|
||||
item.pop('config', None)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _parse_sort_time(value: typing.Any) -> datetime.datetime:
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return datetime.datetime.min
|
||||
return datetime.datetime.min
|
||||
@@ -5,6 +5,8 @@ import sqlalchemy
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....discover import engine
|
||||
from ....entity.persistence import agent as persistence_agent
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
@@ -17,6 +19,102 @@ class BotService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
|
||||
"""Return the discovered platform adapter component for an adapter name."""
|
||||
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
|
||||
if component.metadata.name == adapter_name:
|
||||
return component
|
||||
return None
|
||||
|
||||
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
|
||||
"""Whether the adapter manifest declares a generated webhook URL config item."""
|
||||
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':
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_message_event_pattern(event_pattern: str) -> bool:
|
||||
return event_pattern == 'message.*' or event_pattern.startswith('message.')
|
||||
|
||||
@staticmethod
|
||||
def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool:
|
||||
if supported_pattern == '*':
|
||||
return True
|
||||
if supported_pattern == binding_pattern:
|
||||
return True
|
||||
if binding_pattern == '*':
|
||||
return False
|
||||
if supported_pattern.endswith('.*'):
|
||||
namespace = supported_pattern[:-2]
|
||||
return binding_pattern == f'{namespace}.*' or binding_pattern.startswith(f'{namespace}.')
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool:
|
||||
patterns = supported_patterns or ['*']
|
||||
return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns)
|
||||
|
||||
async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]:
|
||||
"""Validate and normalize Bot event bindings."""
|
||||
if not bindings:
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for index, raw_binding in enumerate(bindings):
|
||||
if not isinstance(raw_binding, dict):
|
||||
continue
|
||||
|
||||
event_pattern = str(raw_binding.get('event_pattern') or '').strip()
|
||||
target_type = str(raw_binding.get('target_type') or '').strip()
|
||||
target_uuid = str(raw_binding.get('target_uuid') or '').strip()
|
||||
if not event_pattern or not target_type:
|
||||
continue
|
||||
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_pattern(event_pattern):
|
||||
raise ValueError('Pipeline can only be bound to message events')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == target_uuid
|
||||
)
|
||||
)
|
||||
if result.first() is None:
|
||||
raise ValueError('Pipeline not found')
|
||||
elif target_type == 'agent':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid)
|
||||
)
|
||||
agent = result.first()
|
||||
if agent is None:
|
||||
raise ValueError('Agent not found')
|
||||
if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern):
|
||||
raise ValueError('Agent does not support this event pattern')
|
||||
elif target_type == 'discard':
|
||||
target_uuid = ''
|
||||
else:
|
||||
raise ValueError(f'Unsupported event binding target type: {target_type}')
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
'id': raw_binding.get('id') or str(uuid.uuid4()),
|
||||
'event_pattern': event_pattern,
|
||||
'target_type': target_type,
|
||||
'target_uuid': target_uuid,
|
||||
'filters': raw_binding.get('filters') or [],
|
||||
'priority': int(raw_binding.get('priority') or 0),
|
||||
'enabled': bool(raw_binding.get('enabled', True)),
|
||||
'description': raw_binding.get('description') or '',
|
||||
'order': index,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
||||
"""获取所有机器人"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||||
@@ -58,17 +156,10 @@ class BotService:
|
||||
if runtime_bot is not None:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
|
||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||
if persistence_bot['adapter'] in [
|
||||
'wecom',
|
||||
'wecombot',
|
||||
'officialaccount',
|
||||
'qqofficial',
|
||||
'slack',
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
]:
|
||||
# Webhook URL for adapters that declare a generated webhook config item.
|
||||
# This is manifest-driven so EBA adapters do not need to be mirrored in a
|
||||
# second hard-coded list.
|
||||
if self._adapter_declares_webhook_url(persistence_bot['adapter']):
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||
webhook_url = f'/bots/{bot_uuid}'
|
||||
@@ -125,6 +216,9 @@ class BotService:
|
||||
if 'uuid' in update_data:
|
||||
del update_data['uuid']
|
||||
|
||||
if 'event_bindings' in update_data:
|
||||
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
|
||||
|
||||
# set use_pipeline_name
|
||||
if 'use_pipeline_uuid' in update_data:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
|
||||
@@ -141,15 +141,25 @@ class MCPService:
|
||||
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
|
||||
if server_name != '_':
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise ValueError(f'Server not found: {server_name}')
|
||||
|
||||
if runtime_mcp_session.status == MCPSessionStatus.ERROR:
|
||||
coroutine = runtime_mcp_session.start()
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
if persisted_session.status == MCPSessionStatus.ERROR:
|
||||
await persisted_session.start()
|
||||
else:
|
||||
coroutine = runtime_mcp_session.refresh()
|
||||
await persisted_session.refresh()
|
||||
# Surface the discovered tools so the config page can render them
|
||||
# even for an already-hosted server.
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
|
||||
@@ -160,6 +170,12 @@ class MCPService:
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
# Capture the runtime info (status + discovered tools) BEFORE
|
||||
# shutting the transient session down. The create/edit config
|
||||
# page has no persisted server to reload from, so without this
|
||||
# a successful test could only show "no tools found". The
|
||||
# frontend reads ctx.metadata.runtime_info to render the tools.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
@@ -171,7 +187,6 @@ class MCPService:
|
||||
|
||||
coroutine = _run_and_cleanup()
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
coroutine,
|
||||
kind='mcp-operation',
|
||||
|
||||
@@ -28,7 +28,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
This MCP server manages a LangBot instance. LangBot is an LLM-native instant
|
||||
messaging bot platform. Use these tools to inspect and manage bots, pipelines,
|
||||
messaging bot platform. Use these tools to inspect and manage bots, agents, pipelines,
|
||||
models, knowledge bases, MCP servers, and skills.
|
||||
|
||||
Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the
|
||||
@@ -141,6 +141,34 @@ class LangBotMCPServer:
|
||||
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Agents -------------------------------------------------- #
|
||||
@mcp.tool(description='List product-level Agents, including Agent orchestrations and Pipelines.')
|
||||
async def list_agents() -> str:
|
||||
return _dump(await ap.agent_service.get_agents())
|
||||
|
||||
@mcp.tool(description='Get a product-level Agent or Pipeline by UUID.')
|
||||
async def get_agent(agent_uuid: str) -> str:
|
||||
return _dump(await ap.agent_service.get_agent(agent_uuid))
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create an Agent orchestration or Pipeline. `agent_data` matches '
|
||||
'POST /api/v1/agents; set kind to `agent` or `pipeline`. Returns the new UUID and kind.'
|
||||
)
|
||||
)
|
||||
async def create_agent(agent_data: dict) -> str:
|
||||
return _dump(await ap.agent_service.create_agent(agent_data))
|
||||
|
||||
@mcp.tool(description='Update an Agent orchestration or Pipeline by UUID.')
|
||||
async def update_agent(agent_uuid: str, agent_data: dict) -> str:
|
||||
await ap.agent_service.update_agent(agent_uuid, agent_data)
|
||||
return _dump({'ok': True})
|
||||
|
||||
@mcp.tool(description='Delete an Agent orchestration or Pipeline by UUID.')
|
||||
async def delete_agent(agent_uuid: str) -> str:
|
||||
await ap.agent_service.delete_agent(agent_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Models -------------------------------------------------- #
|
||||
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
||||
async def list_llm_models() -> str:
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..api.http.service import space as space_service
|
||||
from ..api.http.service import model as model_service
|
||||
from ..api.http.service import provider as provider_service
|
||||
from ..api.http.service import pipeline as pipeline_service
|
||||
from ..api.http.service import agent as agent_service
|
||||
from ..api.http.service import bot as bot_service
|
||||
from ..api.http.service import knowledge as knowledge_service
|
||||
from ..api.http.service import mcp as mcp_service
|
||||
@@ -147,6 +148,8 @@ class Application:
|
||||
|
||||
pipeline_service: pipeline_service.PipelineService = None
|
||||
|
||||
agent_service: agent_service.AgentService = None
|
||||
|
||||
bot_service: bot_service.BotService = None
|
||||
|
||||
knowledge_service: knowledge_service.KnowledgeService = None
|
||||
|
||||
@@ -23,6 +23,7 @@ from ...api.http.service import space as space_service
|
||||
from ...api.http.service import model as model_service
|
||||
from ...api.http.service import provider as provider_service
|
||||
from ...api.http.service import pipeline as pipeline_service
|
||||
from ...api.http.service import agent as agent_service
|
||||
from ...api.http.service import bot as bot_service
|
||||
from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
@@ -75,6 +76,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
pipeline_service_inst = pipeline_service.PipelineService(ap)
|
||||
ap.pipeline_service = pipeline_service_inst
|
||||
|
||||
agent_service_inst = agent_service.AgentService(ap)
|
||||
ap.agent_service = agent_service_inst
|
||||
|
||||
bot_service_inst = bot_service.BotService(ap)
|
||||
ap.bot_service = bot_service_inst
|
||||
|
||||
|
||||
@@ -241,12 +241,14 @@ class ComponentDiscoveryEngine:
|
||||
return
|
||||
|
||||
for file in importutil.list_resource_files(path):
|
||||
if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')):
|
||||
comp = self.load_component_manifest(os.path.join(path, file), owner, no_save)
|
||||
file_path = os.path.join(path, file)
|
||||
is_dir = importutil.is_resource_dir(file_path)
|
||||
if (not is_dir) and (file.endswith('.yaml') or file.endswith('.yml')):
|
||||
comp = self.load_component_manifest(file_path, owner, no_save)
|
||||
if comp is not None:
|
||||
components.append(comp)
|
||||
elif os.path.isdir(os.path.join(path, file)):
|
||||
recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1)
|
||||
elif is_dir:
|
||||
recursive_load_component_manifests_in_dir(file_path, depth + 1)
|
||||
|
||||
recursive_load_component_manifests_in_dir(path)
|
||||
return components
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
"""Product-level Agent orchestration instance."""
|
||||
|
||||
__tablename__ = 'agents'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='')
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🤖')
|
||||
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
|
||||
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
@@ -17,6 +17,7 @@ class Bot(Base):
|
||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||
event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -9,7 +9,7 @@ class MCPServer(Base):
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Markdown documentation captured from LangBot Space at install time so the
|
||||
# detail page can show docs even when the server is offline / has no tools.
|
||||
|
||||
@@ -16,6 +16,7 @@ from langbot.pkg.entity.persistence.base import Base
|
||||
# Import all ORM models so they are registered with Base.metadata
|
||||
# This is required for autogenerate to detect model changes
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent, # noqa: F401
|
||||
agent_run, # noqa: F401
|
||||
agent_runner_state, # noqa: F401
|
||||
apikey, # noqa: F401
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""normalize mcp_servers transport mode to local/remote
|
||||
|
||||
The MCP transport selection for servers LangBot connects to was simplified
|
||||
from three persisted modes (``stdio`` / ``sse`` / ``http``) down to two:
|
||||
``stdio`` (local, Box-sandboxed) and ``remote`` (the runtime auto-detects
|
||||
Streamable HTTP vs. legacy SSE from the URL). This migration rewrites any
|
||||
existing ``sse`` / ``http`` rows to ``remote`` so the stored value matches the
|
||||
new two-option UI. The connection args (url / headers / timeout /
|
||||
ssereadtimeout) live in ``extra_args`` and are left untouched — the
|
||||
auto-detecting remote transport consumes them regardless.
|
||||
|
||||
Revision ID: 0006_normalize_mcp_remote_mode
|
||||
Revises: 8d3a1f2c4b6e
|
||||
Create Date: 2026-06-21
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0006_normalize_mcp_remote_mode'
|
||||
down_revision = '8d3a1f2c4b6e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Idempotent data migration: collapse legacy remote transports into the
|
||||
# unified ``remote`` mode. Guard against the table being absent (truly empty
|
||||
# DB migrated before create_all()).
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
conn.execute(sa.text("UPDATE mcp_servers SET mode = 'remote' WHERE mode IN ('sse', 'http')"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The legacy distinction between ``sse`` and ``http`` cannot be recovered
|
||||
# from ``remote`` alone (the transport is auto-detected at runtime, not
|
||||
# stored). Map everything that is not ``stdio`` back to ``http`` as a
|
||||
# best-effort reversal — both legacy modes still route correctly in the
|
||||
# backend lifecycle dispatch.
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
conn.execute(sa.text("UPDATE mcp_servers SET mode = 'http' WHERE mode = 'remote'"))
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Merge agent runner and MCP migration heads.
|
||||
|
||||
Revision ID: 0007_merge_agent_mcp_heads
|
||||
Revises: 8d3a1f2c4b6e, 0006_normalize_mcp_remote_mode
|
||||
Create Date: 2026-06-23
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0007_merge_agent_mcp_heads'
|
||||
down_revision = ('8d3a1f2c4b6e', '0006_normalize_mcp_remote_mode')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Add Agent product surface tables.
|
||||
|
||||
Revision ID: 0008_agent_product_surface
|
||||
Revises: 0007_merge_agent_mcp_heads
|
||||
Create Date: 2026-06-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0008_agent_product_surface'
|
||||
down_revision = '0007_merge_agent_mcp_heads'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, 'agents'):
|
||||
op.create_table(
|
||||
'agents',
|
||||
sa.Column('uuid', sa.String(length=255), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=False, server_default=''),
|
||||
sa.Column('emoji', sa.String(length=10), nullable=True),
|
||||
sa.Column('kind', sa.String(length=50), nullable=False, server_default='agent'),
|
||||
sa.Column('component_ref', sa.String(length=255), nullable=True),
|
||||
sa.Column('config', sa.JSON(), nullable=False, server_default='{}'),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('supported_event_patterns', sa.JSON(), nullable=False, server_default='["*"]'),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('uuid'),
|
||||
sa.UniqueConstraint('uuid'),
|
||||
)
|
||||
|
||||
if _table_exists(inspector, 'bots') and not _column_exists(inspector, 'bots', 'event_bindings'):
|
||||
with op.batch_alter_table('bots') as batch_op:
|
||||
batch_op.add_column(sa.Column('event_bindings', sa.JSON(), nullable=False, server_default='[]'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, 'bots') and _column_exists(inspector, 'bots', 'event_bindings'):
|
||||
with op.batch_alter_table('bots') as batch_op:
|
||||
batch_op.drop_column('event_bindings')
|
||||
|
||||
if _table_exists(inspector, 'agents'):
|
||||
op.drop_table('agents')
|
||||
@@ -301,6 +301,9 @@ class PreProcessor(stage.PipelineStage):
|
||||
if keep_image_inputs:
|
||||
if me.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||
else:
|
||||
content_list.append(provider_message.ContentElement.from_text('[Image]'))
|
||||
plain_text += '[Image]'
|
||||
elif isinstance(me, platform_message.Voice):
|
||||
# Convert voice input into file content for downstream model upload.
|
||||
if me.base64:
|
||||
@@ -320,6 +323,9 @@ class PreProcessor(stage.PipelineStage):
|
||||
if keep_image_inputs:
|
||||
if msg.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||
else:
|
||||
content_list.append(provider_message.ContentElement.from_text('[Image]'))
|
||||
plain_text += '[Image]'
|
||||
elif isinstance(msg, platform_message.File):
|
||||
if msg.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
|
||||
__all__ = ['AiocqhttpAdapter']
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.api_impl import AiocqhttpAPIMixin
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
run_config = dict(config)
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
run_config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||
access_token = run_config.pop('access-token', '') or None
|
||||
bot = aiocqhttp.CQHttp(access_token=access_token)
|
||||
|
||||
super().__init__(
|
||||
config=run_config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
)
|
||||
self._register_native_handlers()
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'message.deleted',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'group.member_banned',
|
||||
'friend.request_received',
|
||||
'friend.added',
|
||||
'bot.invited_to_group',
|
||||
'bot.removed_from_group',
|
||||
'bot.muted',
|
||||
'bot.unmuted',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'delete_message',
|
||||
'forward_message',
|
||||
'get_message',
|
||||
'get_group_info',
|
||||
'get_group_list',
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'set_group_name',
|
||||
'get_user_info',
|
||||
'get_friend_list',
|
||||
'approve_friend_request',
|
||||
'approve_group_invite',
|
||||
'mute_member',
|
||||
'unmute_member',
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self.bot, params)
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
registered = self.listeners.get(event_type)
|
||||
if registered is callback:
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
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)
|
||||
|
||||
async def _handle_native_event(self, event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
||||
return
|
||||
try:
|
||||
if getattr(event, 'type', None) == 'message' and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
):
|
||||
legacy_event = await self.event_converter.target2legacy(event, self.bot)
|
||||
if legacy_event:
|
||||
callback = self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
await callback(legacy_event, self)
|
||||
await self._dispatch_native_event(event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id)
|
||||
if eba_event:
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
await callback(event, self)
|
||||
return
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class AiocqhttpAPIMixin:
|
||||
bot: aiocqhttp.CQHttp
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_events.MessageResult:
|
||||
forward = message.get_first(platform_message.Forward)
|
||||
if forward and target_type == 'group':
|
||||
raw = await self._send_forward_message(int(target_id), typing.cast(platform_message.Forward, forward))
|
||||
return platform_events.MessageResult(message_id=raw.get('message_id'), raw=raw)
|
||||
|
||||
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
|
||||
if target_type == 'group':
|
||||
raw = await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
|
||||
elif target_type in ('person', 'private'):
|
||||
raw = await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
|
||||
else:
|
||||
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> platform_events.MessageResult:
|
||||
assert isinstance(message_source.source_platform_object, aiocqhttp.Event)
|
||||
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
|
||||
if quote_origin:
|
||||
source_id = getattr(message_source, 'message_id', None) or message_source.message_chain.message_id
|
||||
aiocq_msg = aiocqhttp.MessageSegment.reply(source_id) + aiocq_msg
|
||||
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
await self.bot.delete_msg(message_id=int(message_id))
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageResult:
|
||||
raw_message = await self.bot.get_msg(message_id=int(message_id))
|
||||
target_message = aiocqhttp.Message(raw_message.get('message', []))
|
||||
if to_chat_type == 'group':
|
||||
raw = await self.bot.send_group_msg(group_id=int(to_chat_id), message=target_message)
|
||||
elif to_chat_type in ('person', 'private'):
|
||||
raw = await self.bot.send_private_msg(user_id=int(to_chat_id), message=target_message)
|
||||
else:
|
||||
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
raw = await self.bot.get_msg(message_id=int(message_id))
|
||||
message_type = raw.get('message_type') or chat_type
|
||||
event = aiocqhttp.Event.from_payload(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group' if message_type == 'group' else 'private',
|
||||
'sub_type': raw.get('sub_type', 'normal'),
|
||||
'time': raw.get('time', 0),
|
||||
'self_id': self.bot_account_id or 0,
|
||||
'message_id': raw.get('message_id', message_id),
|
||||
'user_id': raw.get('sender', {}).get('user_id') or raw.get('user_id') or chat_id,
|
||||
'group_id': raw.get('group_id') or (chat_id if message_type == 'group' else None),
|
||||
'message': raw.get('message', []),
|
||||
'raw_message': raw.get('raw_message', ''),
|
||||
'sender': raw.get('sender', {}),
|
||||
}
|
||||
)
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raw = await self.bot.get_group_info(group_id=int(group_id))
|
||||
return platform_entities.UserGroup(
|
||||
id=raw.get('group_id', group_id),
|
||||
name=raw.get('group_name', ''),
|
||||
member_count=raw.get('member_count'),
|
||||
)
|
||||
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
raw_list = await self.bot.get_group_list()
|
||||
return [
|
||||
platform_entities.UserGroup(
|
||||
id=item.get('group_id', ''),
|
||||
name=item.get('group_name', ''),
|
||||
member_count=item.get('member_count'),
|
||||
)
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
|
||||
return [self._member_to_entity(item, group_id) for item in raw_list]
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> platform_entities.UserGroupMember:
|
||||
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
|
||||
return self._member_to_entity(raw, group_id)
|
||||
|
||||
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
|
||||
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
|
||||
|
||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
|
||||
|
||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
|
||||
return platform_entities.User(
|
||||
id=raw.get('user_id', user_id),
|
||||
nickname=raw.get('nickname', ''),
|
||||
avatar_url=raw.get('avatar_url'),
|
||||
)
|
||||
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
raw_list = await self.bot.get_friend_list()
|
||||
return [
|
||||
platform_entities.User(
|
||||
id=item.get('user_id', ''),
|
||||
nickname=item.get('nickname', ''),
|
||||
remark=item.get('remark'),
|
||||
)
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
remark: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
|
||||
|
||||
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
|
||||
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@staticmethod
|
||||
def _member_to_entity(raw: dict, group_id: typing.Union[int, str]) -> platform_entities.UserGroupMember:
|
||||
role = platform_entities.MemberRole.MEMBER
|
||||
if raw.get('role') == 'owner':
|
||||
role = platform_entities.MemberRole.OWNER
|
||||
elif raw.get('role') == 'admin':
|
||||
role = platform_entities.MemberRole.ADMIN
|
||||
return platform_entities.UserGroupMember(
|
||||
user=platform_entities.User(
|
||||
id=raw.get('user_id', ''),
|
||||
nickname=raw.get('nickname', ''),
|
||||
remark=raw.get('card') or raw.get('remark'),
|
||||
),
|
||||
group_id=group_id,
|
||||
role=role,
|
||||
display_name=raw.get('card') or raw.get('nickname'),
|
||||
joined_at=float(raw['join_time']) if raw.get('join_time') else None,
|
||||
title=raw.get('title'),
|
||||
)
|
||||
|
||||
async def _send_forward_message(self, group_id: int, forward: platform_message.Forward) -> dict:
|
||||
messages = []
|
||||
for node in forward.node_list:
|
||||
if not node.message_chain:
|
||||
continue
|
||||
content, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
|
||||
if not content:
|
||||
continue
|
||||
messages.append(
|
||||
{
|
||||
'type': 'node',
|
||||
'data': {
|
||||
'user_id': str(node.sender_id or self.bot_account_id or '10000'),
|
||||
'nickname': node.sender_name or 'LangBot',
|
||||
'content': list(content),
|
||||
},
|
||||
}
|
||||
)
|
||||
if not messages:
|
||||
return {}
|
||||
try:
|
||||
return await self.bot.call_action(
|
||||
'send_forward_msg', group_id=group_id, user_id=str(self.bot_account_id), messages=messages
|
||||
)
|
||||
except Exception:
|
||||
return await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
bot_user_id: int | str | None = None,
|
||||
) -> platform_events.Event | None:
|
||||
event_type = getattr(event, 'type', None)
|
||||
if event_type == 'message':
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
if event_type == 'notice':
|
||||
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
|
||||
async def target2legacy(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
if eba_event:
|
||||
return eba_event.to_legacy_event()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def message_to_eba(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
message_chain = await AiocqhttpMessageConverter.target2yiri(
|
||||
getattr(event, 'message', []),
|
||||
getattr(event, 'message_id', -1),
|
||||
getattr(event, 'time', None),
|
||||
bot,
|
||||
)
|
||||
message_type = getattr(event, 'message_type', getattr(event, 'detail_type', 'private'))
|
||||
group = None
|
||||
chat_type = platform_entities.ChatType.PRIVATE
|
||||
chat_id = getattr(event, 'user_id', '')
|
||||
if message_type == 'group':
|
||||
chat_type = platform_entities.ChatType.GROUP
|
||||
chat_id = getattr(event, 'group_id', '')
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='aiocqhttp',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=AiocqhttpEventConverter.user_from_sender(event),
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def notice_to_eba(
|
||||
event: aiocqhttp.Event,
|
||||
bot_user_id: int | str | None = None,
|
||||
) -> platform_events.EBAEvent:
|
||||
notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', ''))
|
||||
if notice_type in ('group_recall', 'friend_recall'):
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='aiocqhttp',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
||||
chat_type=platform_entities.ChatType.GROUP
|
||||
if notice_type == 'group_recall'
|
||||
else platform_entities.ChatType.PRIVATE,
|
||||
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
|
||||
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_increase':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
user = AiocqhttpEventConverter.user(getattr(event, 'user_id', ''))
|
||||
inviter_id = getattr(event, 'operator_id', None)
|
||||
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',
|
||||
group=group,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
join_type=getattr(event, 'sub_type', None) or 'direct',
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_decrease':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
|
||||
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',
|
||||
group=group,
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_ban':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
duration = int(getattr(event, 'duration', 0) or 0)
|
||||
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
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',
|
||||
'group': group,
|
||||
'operator': operator,
|
||||
'timestamp': float(getattr(event, 'time', 0) or 0),
|
||||
'source_platform_object': event,
|
||||
}
|
||||
if duration > 0:
|
||||
kwargs['duration'] = duration
|
||||
return event_cls(**kwargs)
|
||||
if duration > 0:
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
operator=operator,
|
||||
duration=duration,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'friend_add':
|
||||
return platform_events.FriendAddedEvent(
|
||||
type='friend.added',
|
||||
adapter_name='aiocqhttp',
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}')
|
||||
|
||||
@staticmethod
|
||||
def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent:
|
||||
request_type = getattr(event, 'request_type', getattr(event, 'detail_type', ''))
|
||||
if request_type == 'friend':
|
||||
return platform_events.FriendRequestReceivedEvent(
|
||||
type='friend.request_received',
|
||||
adapter_name='aiocqhttp',
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
message=getattr(event, 'comment', None),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp',
|
||||
group=AiocqhttpEventConverter.group_from_event(event),
|
||||
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}')
|
||||
|
||||
@staticmethod
|
||||
def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User:
|
||||
sender = getattr(event, 'sender', {}) or {}
|
||||
nickname = sender.get('card') or sender.get('nickname') or ''
|
||||
return platform_entities.User(
|
||||
id=sender.get('user_id', getattr(event, 'user_id', '')),
|
||||
nickname=nickname,
|
||||
remark=sender.get('remark'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user(user_id: typing.Union[int, str, None], nickname: str = '') -> platform_entities.User | None:
|
||||
if user_id is None or user_id == '':
|
||||
return None
|
||||
return platform_entities.User(id=user_id, nickname=nickname)
|
||||
|
||||
@staticmethod
|
||||
def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup:
|
||||
return platform_entities.UserGroup(
|
||||
id=getattr(event, 'group_id', ''),
|
||||
name=getattr(event, 'group_name', '') or '',
|
||||
member_count=getattr(event, 'member_count', None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='aiocqhttp',
|
||||
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),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_bot_user(user_id: typing.Any, bot_user_id: typing.Any, event: aiocqhttp.Event) -> bool:
|
||||
candidate = bot_user_id or getattr(event, 'self_id', None)
|
||||
return candidate is not None and user_id is not None and str(user_id) == str(candidate)
|
||||
@@ -0,0 +1,131 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: aiocqhttp-eba
|
||||
label:
|
||||
en_US: OneBot v11 (EBA)
|
||||
zh_Hans: OneBot v11 (EBA)
|
||||
zh_Hant: OneBot v11 (EBA)
|
||||
description:
|
||||
en_US: OneBot v11 adapter for QQ-compatible protocol endpoints (EBA architecture)
|
||||
zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端(EBA 架构版本)
|
||||
zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端(EBA 架構版本)
|
||||
icon: onebot.svg
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
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
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
zh_Hant: 連接埠
|
||||
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: 存取令牌
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured
|
||||
zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填
|
||||
zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.deleted
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- group.member_banned
|
||||
- friend.request_received
|
||||
- friend.added
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- bot.muted
|
||||
- bot.unmuted
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- delete_message
|
||||
- forward_message
|
||||
- get_message
|
||||
- get_group_info
|
||||
- get_group_list
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- set_group_name
|
||||
- get_user_info
|
||||
- get_friend_list
|
||||
- approve_friend_request
|
||||
- approve_group_invite
|
||||
- mute_member
|
||||
- unmute_member
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_login_info
|
||||
description: { en_US: "Get current bot account information", zh_Hans: "获取当前机器人账号信息" }
|
||||
- action: get_status
|
||||
description: { en_US: "Get endpoint status", zh_Hans: "获取协议端状态" }
|
||||
- action: get_version_info
|
||||
description: { en_US: "Get endpoint version information", zh_Hans: "获取协议端版本信息" }
|
||||
- action: get_group_honor_info
|
||||
description: { en_US: "Get group honor information", zh_Hans: "获取群荣誉信息" }
|
||||
- action: set_group_card
|
||||
description: { en_US: "Set a member group card", zh_Hans: "设置群名片" }
|
||||
- action: set_group_special_title
|
||||
description: { en_US: "Set a member special title", zh_Hans: "设置群专属头衔" }
|
||||
- action: set_group_admin
|
||||
description: { en_US: "Set group administrator status", zh_Hans: "设置群管理员" }
|
||||
- action: set_group_whole_ban
|
||||
description: { en_US: "Enable or disable whole-group mute", zh_Hans: "设置全员禁言" }
|
||||
- action: send_group_forward_msg
|
||||
description: { en_US: "Send a merged forward message", zh_Hans: "发送合并转发消息" }
|
||||
- action: get_forward_msg
|
||||
description: { en_US: "Get merged forward message content", zh_Hans: "获取合并转发消息内容" }
|
||||
- action: get_record
|
||||
description: { en_US: "Get voice file", zh_Hans: "获取语音文件" }
|
||||
- action: get_image
|
||||
description: { en_US: "Get image file", zh_Hans: "获取图片文件" }
|
||||
- action: can_send_image
|
||||
description: { en_US: "Check whether images can be sent", zh_Hans: "检查是否可以发送图片" }
|
||||
- action: can_send_record
|
||||
description: { en_US: "Check whether voice messages can be sent", zh_Hans: "检查是否可以发送语音" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: AiocqhttpAdapter
|
||||
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
FACE_NAMES = {
|
||||
'14': '微笑',
|
||||
'21': '可爱',
|
||||
'23': '傲慢',
|
||||
'24': '饥饿',
|
||||
'25': '困',
|
||||
'26': '惊恐',
|
||||
'27': '流汗',
|
||||
'28': '憨笑',
|
||||
'29': '悠闲',
|
||||
'30': '奋斗',
|
||||
'32': '疑问',
|
||||
'33': '嘘',
|
||||
'34': '晕',
|
||||
'38': '敲打',
|
||||
'39': '再见',
|
||||
'42': '爱情',
|
||||
'43': '跳跳',
|
||||
'49': '拥抱',
|
||||
'53': '蛋糕',
|
||||
'63': '玫瑰',
|
||||
'66': '爱心',
|
||||
'74': '太阳',
|
||||
'75': '月亮',
|
||||
'76': '赞',
|
||||
'78': '握手',
|
||||
'79': '胜利',
|
||||
'85': '飞吻',
|
||||
'89': '西瓜',
|
||||
'96': '冷汗',
|
||||
'97': '擦汗',
|
||||
'98': '抠鼻',
|
||||
'99': '鼓掌',
|
||||
'100': '糗大了',
|
||||
'101': '坏笑',
|
||||
'102': '左哼哼',
|
||||
'103': '右哼哼',
|
||||
'104': '哈欠',
|
||||
'106': '委屈',
|
||||
'111': '可怜',
|
||||
'120': '拳头',
|
||||
'122': '爱你',
|
||||
'123': 'NO',
|
||||
'124': 'OK',
|
||||
'129': '挥手',
|
||||
'144': '喝彩',
|
||||
'147': '棒棒糖',
|
||||
'171': '茶',
|
||||
'173': '泪奔',
|
||||
'174': '无奈',
|
||||
'175': '卖萌',
|
||||
'179': 'doge',
|
||||
'180': '惊喜',
|
||||
'182': '笑哭',
|
||||
'201': '点赞',
|
||||
'203': '托脸',
|
||||
'212': '托腮',
|
||||
'264': '捂脸',
|
||||
'271': '吃瓜',
|
||||
'285': '摸鱼',
|
||||
}
|
||||
|
||||
|
||||
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> tuple[aiocqhttp.Message, typing.Union[int, str, None], datetime.datetime | None]:
|
||||
target = aiocqhttp.Message()
|
||||
source_id: typing.Union[int, str, None] = None
|
||||
source_time: datetime.datetime | None = None
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Source):
|
||||
source_id = component.id
|
||||
source_time = component.time
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
target.append(aiocqhttp.MessageSegment.text(component.text))
|
||||
elif isinstance(component, platform_message.At):
|
||||
target.append(aiocqhttp.MessageSegment.at(component.target))
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
target.append(aiocqhttp.MessageSegment.at('all'))
|
||||
elif isinstance(component, platform_message.Image):
|
||||
file_arg = AiocqhttpMessageConverter._file_arg(component)
|
||||
if file_arg:
|
||||
target.append(aiocqhttp.MessageSegment.image(file_arg))
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
file_arg = AiocqhttpMessageConverter._file_arg(component)
|
||||
if file_arg:
|
||||
target.append(aiocqhttp.MessageSegment.record(file_arg))
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_arg = component.url or component.path or component.base64 or component.id
|
||||
target.append(
|
||||
aiocqhttp.MessageSegment(
|
||||
type_='file',
|
||||
data={
|
||||
'file': file_arg,
|
||||
'name': component.name or 'file',
|
||||
},
|
||||
)
|
||||
)
|
||||
elif isinstance(component, platform_message.Face):
|
||||
if component.face_type == 'rps':
|
||||
target.append(aiocqhttp.MessageSegment.rps())
|
||||
elif component.face_type == 'dice':
|
||||
target.append(aiocqhttp.MessageSegment.dice())
|
||||
else:
|
||||
target.append(aiocqhttp.MessageSegment.face(component.face_id))
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
node_message, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
|
||||
target.extend(node_message)
|
||||
elif isinstance(component, platform_message.Quote) and component.id is not None:
|
||||
target.append(aiocqhttp.MessageSegment.reply(component.id))
|
||||
else:
|
||||
target.append(aiocqhttp.MessageSegment.text(str(component)))
|
||||
|
||||
return target, source_id, source_time
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
message: typing.Any,
|
||||
message_id: typing.Union[int, str] = -1,
|
||||
timestamp: float | None = None,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_message.MessageChain:
|
||||
target = aiocqhttp.Message(message)
|
||||
message_time = datetime.datetime.fromtimestamp(timestamp) if timestamp else datetime.datetime.now()
|
||||
components: list[platform_message.MessageComponent] = [
|
||||
platform_message.Source(id=message_id, time=message_time),
|
||||
]
|
||||
|
||||
for segment in target:
|
||||
if segment.type == 'text':
|
||||
components.append(platform_message.Plain(text=segment.data.get('text', '')))
|
||||
elif segment.type == 'at':
|
||||
qq = str(segment.data.get('qq', ''))
|
||||
components.append(platform_message.AtAll() if qq == 'all' else platform_message.At(target=qq))
|
||||
elif segment.type == 'image':
|
||||
if segment.data.get('emoji_package_id'):
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_id=int(segment.data.get('emoji_package_id') or 0),
|
||||
face_name=segment.data.get('summary', ''),
|
||||
)
|
||||
)
|
||||
else:
|
||||
components.append(
|
||||
platform_message.Image(
|
||||
image_id=str(segment.data.get('file', '')),
|
||||
url=segment.data.get('url') or segment.data.get('file') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'record':
|
||||
components.append(
|
||||
platform_message.Voice(
|
||||
voice_id=str(segment.data.get('file', '')),
|
||||
url=segment.data.get('url') or segment.data.get('file') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'file':
|
||||
components.append(
|
||||
platform_message.File(
|
||||
id=str(segment.data.get('file_id') or segment.data.get('file') or ''),
|
||||
name=segment.data.get('name') or segment.data.get('file') or '',
|
||||
size=int(segment.data.get('size') or segment.data.get('file_size') or 0),
|
||||
url=segment.data.get('url') or segment.data.get('file_url') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'reply':
|
||||
quote = await AiocqhttpMessageConverter._quote_from_reply_segment(segment, bot)
|
||||
components.append(quote)
|
||||
elif segment.type == 'face':
|
||||
face_id = str(segment.data.get('id', 0))
|
||||
face_name = ''
|
||||
raw = segment.data.get('raw')
|
||||
if isinstance(raw, dict):
|
||||
face_name = str(raw.get('faceText') or '')
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_id=int(face_id or 0),
|
||||
face_name=face_name.replace('/', '') or FACE_NAMES.get(face_id, ''),
|
||||
)
|
||||
)
|
||||
elif segment.type == 'rps':
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_type='rps',
|
||||
face_id=int(segment.data.get('result') or 0),
|
||||
face_name='猜拳',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'dice':
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_type='dice',
|
||||
face_id=int(segment.data.get('result') or 0),
|
||||
face_name='骰子',
|
||||
)
|
||||
)
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@staticmethod
|
||||
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
|
||||
if component.base64:
|
||||
_, _, payload = component.base64.partition(',')
|
||||
return f'base64://{payload or component.base64}'
|
||||
if component.url:
|
||||
return component.url
|
||||
if component.path:
|
||||
return str(component.path)
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
async def _quote_from_reply_segment(
|
||||
segment: aiocqhttp.MessageSegment,
|
||||
bot: aiocqhttp.CQHttp | None,
|
||||
) -> platform_message.Quote:
|
||||
reply_id = segment.data.get('id')
|
||||
origin = platform_message.MessageChain([])
|
||||
sender_id = None
|
||||
group_id = None
|
||||
target_id = None
|
||||
if bot is not None and reply_id is not None:
|
||||
try:
|
||||
message_data = await bot.get_msg(message_id=int(reply_id))
|
||||
sender_id = message_data.get('sender', {}).get('user_id') or message_data.get('user_id')
|
||||
group_id = message_data.get('group_id')
|
||||
target_id = group_id or sender_id
|
||||
origin = await AiocqhttpMessageConverter.target2yiri(
|
||||
message_data.get('message', []),
|
||||
message_data.get('message_id', reply_id),
|
||||
message_data.get('time'),
|
||||
bot=None,
|
||||
)
|
||||
except Exception:
|
||||
origin = platform_message.MessageChain([])
|
||||
return platform_message.Quote(
|
||||
id=reply_id,
|
||||
group_id=group_id,
|
||||
sender_id=sender_id,
|
||||
target_id=target_id,
|
||||
origin=origin,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="20" fill="#16A34A"/>
|
||||
<path d="M24 33C24 25.268 30.268 19 38 19H58C65.732 19 72 25.268 72 33V51C72 58.732 65.732 65 58 65H41.5L29 77V64.059C26.024 61.514 24 57.729 24 51V33Z" fill="white"/>
|
||||
<circle cx="39" cy="42" r="5" fill="#16A34A"/>
|
||||
<circle cx="57" cy="42" r="5" fill="#16A34A"/>
|
||||
<path d="M39 53C44.5 57 51.5 57 57 53" stroke="#16A34A" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
|
||||
async def _call(bot: aiocqhttp.CQHttp, action: str, params: dict[str, typing.Any]) -> dict:
|
||||
result = await bot.call_action(action, **params)
|
||||
return result or {}
|
||||
|
||||
|
||||
async def get_login_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_login_info', params)
|
||||
|
||||
|
||||
async def get_status(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_status', params)
|
||||
|
||||
|
||||
async def get_version_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_version_info', params)
|
||||
|
||||
|
||||
async def get_group_honor_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_group_honor_info', params)
|
||||
|
||||
|
||||
async def set_group_card(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_card', params)
|
||||
|
||||
|
||||
async def set_group_special_title(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_special_title', params)
|
||||
|
||||
|
||||
async def set_group_admin(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_admin', params)
|
||||
|
||||
|
||||
async def set_group_whole_ban(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_whole_ban', params)
|
||||
|
||||
|
||||
async def send_group_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'send_group_forward_msg', params)
|
||||
|
||||
|
||||
async def get_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_forward_msg', params)
|
||||
|
||||
|
||||
async def get_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_record', params)
|
||||
|
||||
|
||||
async def get_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_image', params)
|
||||
|
||||
|
||||
async def can_send_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'can_send_image', params)
|
||||
|
||||
|
||||
async def can_send_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'can_send_record', params)
|
||||
|
||||
|
||||
PLATFORM_API_MAP = {
|
||||
'get_login_info': get_login_info,
|
||||
'get_status': get_status,
|
||||
'get_version_info': get_version_info,
|
||||
'get_group_honor_info': get_group_honor_info,
|
||||
'set_group_card': set_group_card,
|
||||
'set_group_special_title': set_group_special_title,
|
||||
'set_group_admin': set_group_admin,
|
||||
'set_group_whole_ban': set_group_whole_ban,
|
||||
'send_group_forward_msg': send_group_forward_msg,
|
||||
'get_forward_msg': get_forward_msg,
|
||||
'get_record': get_record,
|
||||
'get_image': get_image,
|
||||
'can_send_image': can_send_image,
|
||||
'can_send_record': can_send_record,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
|
||||
TargetMessage = typing.Union[str, list, dict, aiocqhttp.Message]
|
||||
OneBotResponse = dict[str, typing.Any] | None
|
||||
@@ -0,0 +1 @@
|
||||
"""DingTalk EBA platform adapter."""
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
|
||||
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
bot: DingTalkClient = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
|
||||
event_converter: DingTalkEventConverter = DingTalkEventConverter()
|
||||
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
card_instance_id_dict: dict = {}
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
|
||||
_user_cache: dict[str, platform_entities.User] = {}
|
||||
_group_cache: dict[str, platform_entities.UserGroup] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code']
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
bot = DingTalkClient(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
robot_name=config['robot_name'],
|
||||
robot_code=config['robot_code'],
|
||||
markdown_card=config.get('markdown_card', True),
|
||||
logger=logger,
|
||||
)
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
card_instance_id_dict={},
|
||||
bot_account_id=config['robot_name'],
|
||||
bot=bot,
|
||||
listeners={},
|
||||
_message_cache={},
|
||||
_user_cache={},
|
||||
_group_cache={},
|
||||
)
|
||||
self._register_native_handlers()
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
'get_group_info',
|
||||
'get_group_list',
|
||||
'get_group_member_info',
|
||||
'get_user_info',
|
||||
'get_friend_list',
|
||||
'get_file_url',
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_events.MessageResult:
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
if target_type in ('person', 'private'):
|
||||
raw = await self.bot.send_proactive_message_to_one(target_id, content)
|
||||
elif target_type == 'group':
|
||||
raw = await self.bot.send_proactive_message_to_group(target_id, content)
|
||||
else:
|
||||
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
|
||||
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> platform_events.MessageResult:
|
||||
assert isinstance(message_source.source_platform_object, DingTalkEvent)
|
||||
incoming_message = message_source.source_platform_object.incoming_message
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
raw = await self.bot.send_message(content, incoming_message, at)
|
||||
return platform_events.MessageResult(
|
||||
message_id=getattr(incoming_message, 'message_id', None),
|
||||
raw=raw if isinstance(raw, dict) else {'result': raw},
|
||||
)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
message_id = bot_message.resp_message_id
|
||||
msg_seq = bot_message.msg_sequence
|
||||
if (msg_seq - 1) % 8 != 0 and not is_final:
|
||||
return
|
||||
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
|
||||
if not content and bot_message.content:
|
||||
content = bot_message.content
|
||||
if content:
|
||||
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.card_instance_id_dict.pop(message_id)
|
||||
|
||||
async def create_message_card(self, message_id, event):
|
||||
card_template_id = self.config['card_template_id']
|
||||
incoming_message = event.source_platform_object.incoming_message
|
||||
card_auto_layout = self.config.get('card_auto_layout', False)
|
||||
card_instance, card_instance_id = await self.bot.create_and_card(
|
||||
card_template_id,
|
||||
incoming_message,
|
||||
card_auto_layout=card_auto_layout,
|
||||
)
|
||||
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
||||
return True
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable-stream-reply', False))
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self.bot, params)
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
registered = self.listeners.get(event_type)
|
||||
if registered is callback:
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
await self.logger.info('DingTalk EBA adapter starting')
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
await self.bot.stop()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
return False
|
||||
|
||||
def _register_native_handlers(self):
|
||||
async def on_message(event: DingTalkEvent):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
self.bot.on_message('FriendMessage')(on_message)
|
||||
self.bot.on_message('GroupMessage')(on_message)
|
||||
|
||||
async def _handle_native_event(self, event: DingTalkEvent):
|
||||
try:
|
||||
await self.logger.debug(
|
||||
'DingTalk EBA event received: '
|
||||
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
|
||||
)
|
||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||
legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name'])
|
||||
if legacy_event:
|
||||
callback = self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
await callback(legacy_event, self)
|
||||
|
||||
eba_event = await self.event_converter.target2yiri(event, self.config['robot_name'])
|
||||
if eba_event:
|
||||
self._cache_event(eba_event)
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
await callback(event, self)
|
||||
return
|
||||
|
||||
def _cache_event(self, event: platform_events.Event):
|
||||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||||
return
|
||||
self._message_cache[str(event.message_id)] = event
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
if event.group:
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class DingTalkAPIMixin:
|
||||
bot: DingTalkClient
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
event = self._message_cache.get(str(message_id))
|
||||
if event is None:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
|
||||
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
return list(self._group_cache.values())
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> platform_entities.UserGroupMember:
|
||||
user = self._user_cache.get(str(user_id))
|
||||
if user is None:
|
||||
raise NotSupportedError('get_group_member_info:user_not_cached')
|
||||
return platform_entities.UserGroupMember(
|
||||
user=user,
|
||||
group_id=group_id,
|
||||
role=platform_entities.MemberRole.MEMBER,
|
||||
display_name=user.nickname,
|
||||
)
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
|
||||
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return await self.bot.get_file_url(file_id)
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#4aa4f8" width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon" stroke="#4aa4f8">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm227 385.3c-1 4.2-3.5 10.4-7 17.8h.1l-.4.7c-20.3 43.1-73.1 127.7-73.1 127.7s-.1-.2-.3-.5l-15.5 26.8h74.5L575.1 810l32.3-128h-58.6l20.4-84.7c-16.5 3.9-35.9 9.4-59 16.8 0 0-31.2 18.2-89.9-35 0 0-39.6-34.7-16.6-43.4 9.8-3.7 47.4-8.4 77-12.3 40-5.4 64.6-8.2 64.6-8.2S422 517 392.7 512.5c-29.3-4.6-66.4-53.1-74.3-95.8 0 0-12.2-23.4 26.3-12.3 38.5 11.1 197.9 43.2 197.9 43.2s-207.4-63.3-221.2-78.7c-13.8-15.4-40.6-84.2-37.1-126.5 0 0 1.5-10.5 12.4-7.7 0 0 153.3 69.7 258.1 107.9 104.8 37.9 195.9 57.3 184.2 106.7z"/> </g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.types import ADAPTER_NAME
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
|
||||
if event.conversation in {'FriendMessage', 'GroupMessage'}:
|
||||
return await DingTalkEventConverter.message_to_eba(event, bot_name)
|
||||
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
|
||||
|
||||
@staticmethod
|
||||
async def target2legacy(
|
||||
event: DingTalkEvent,
|
||||
bot_name: str,
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name)
|
||||
if eba_event:
|
||||
return eba_event.to_legacy_event()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent:
|
||||
incoming_message = event.incoming_message
|
||||
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
|
||||
sender = DingTalkEventConverter.user_from_event(event)
|
||||
chat_type = platform_entities.ChatType.PRIVATE
|
||||
chat_id = getattr(incoming_message, 'sender_staff_id', '')
|
||||
group = None
|
||||
if event.conversation == 'GroupMessage':
|
||||
chat_type = platform_entities.ChatType.GROUP
|
||||
chat_id = getattr(incoming_message, 'conversation_id', '')
|
||||
group = DingTalkEventConverter.group_from_event(event)
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name=ADAPTER_NAME,
|
||||
message_id=getattr(incoming_message, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=sender,
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
timestamp=DingTalkEventConverter._timestamp(incoming_message),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user_from_event(event: DingTalkEvent) -> platform_entities.User:
|
||||
incoming_message = event.incoming_message
|
||||
return platform_entities.User(
|
||||
id=getattr(incoming_message, 'sender_staff_id', ''),
|
||||
nickname=getattr(incoming_message, 'sender_nick', '') or '',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def group_from_event(event: DingTalkEvent) -> platform_entities.UserGroup:
|
||||
incoming_message = event.incoming_message
|
||||
return platform_entities.UserGroup(
|
||||
id=getattr(incoming_message, 'conversation_id', ''),
|
||||
name=getattr(incoming_message, 'conversation_title', '') or '',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def platform_specific(event: DingTalkEvent, action: str) -> platform_events.PlatformSpecificEvent:
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name=ADAPTER_NAME,
|
||||
action=action,
|
||||
data={
|
||||
key: value for key, value in dict(event).items() if key not in {'IncomingMessage', 'Picture', 'Audio'}
|
||||
},
|
||||
timestamp=DingTalkEventConverter._timestamp(event.incoming_message),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(incoming_message: typing.Any) -> float:
|
||||
value = getattr(incoming_message, 'create_at', None)
|
||||
if isinstance(value, (int, float)):
|
||||
timestamp = float(value)
|
||||
return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp
|
||||
if hasattr(value, 'timestamp'):
|
||||
return float(value.timestamp())
|
||||
return 0.0
|
||||
@@ -0,0 +1,126 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: dingtalk-eba
|
||||
label:
|
||||
en_US: DingTalk (EBA)
|
||||
zh_Hans: 钉钉 (EBA)
|
||||
zh_Hant: 釘釘 (EBA)
|
||||
description:
|
||||
en_US: DingTalk adapter (EBA architecture)
|
||||
zh_Hans: 钉钉适配器(EBA 架构版本)
|
||||
zh_Hant: 釘釘適配器(EBA 架構版本)
|
||||
icon: dingtalk.svg
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
zh_Hant: 用戶端ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: client_secret
|
||||
label:
|
||||
en_US: Client Secret
|
||||
zh_Hans: 客户端密钥
|
||||
zh_Hant: 用戶端密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: robot_code
|
||||
label:
|
||||
en_US: Robot Code
|
||||
zh_Hans: 机器人代码
|
||||
zh_Hant: 機器人代碼
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: robot_name
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: markdown_card
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
zh_Hant: 是否使用 Markdown 卡片
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use DingTalk card streaming replies.
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: card_auto_layout
|
||||
label:
|
||||
en_US: Card Auto Layout
|
||||
zh_Hans: 卡片宽屏自动布局
|
||||
zh_Hant: 卡片寬螢幕自動佈局
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: card_template_id
|
||||
label:
|
||||
en_US: Card Template ID
|
||||
zh_Hans: 卡片模板ID
|
||||
zh_Hant: 卡片範本ID
|
||||
type: string
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- get_message
|
||||
- get_group_info
|
||||
- get_group_list
|
||||
- get_group_member_info
|
||||
- get_user_info
|
||||
- get_friend_list
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
description: { en_US: "Check whether the current DingTalk access token is usable", zh_Hans: "检查当前钉钉 access token 是否可用" }
|
||||
- action: refresh_access_token
|
||||
description: { en_US: "Refresh the DingTalk access token", zh_Hans: "刷新钉钉 access token" }
|
||||
- action: get_file_url
|
||||
description: { en_US: "Resolve a DingTalk download code to a file URL", zh_Hans: "将钉钉 downloadCode 解析为文件 URL" }
|
||||
- action: get_audio_base64
|
||||
description: { en_US: "Download DingTalk audio as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉语音并转为 base64" }
|
||||
- action: download_image_base64
|
||||
description: { en_US: "Download DingTalk image as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉图片并转为 base64" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: DingTalkAdapter
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
def _format_image_as_markdown(msg: platform_message.Image) -> str:
|
||||
if msg.url:
|
||||
return f'\n\n'
|
||||
if msg.base64:
|
||||
if msg.base64.startswith('data:'):
|
||||
return f'\n\n'
|
||||
return f'\n\n'
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def _component_text_fallback(component: platform_message.MessageComponent) -> str:
|
||||
if isinstance(component, platform_message.At):
|
||||
return f'@{component.display or component.target}'
|
||||
if isinstance(component, platform_message.AtAll):
|
||||
return '@所有人'
|
||||
if isinstance(component, platform_message.File):
|
||||
if component.url:
|
||||
return f'\n[{component.name or "file"}]({component.url})\n'
|
||||
return f'\n[File]{component.name or component.id or "file"}\n'
|
||||
if isinstance(component, platform_message.Voice):
|
||||
return component.url or '[Voice]'
|
||||
if isinstance(component, platform_message.Face):
|
||||
return str(component)
|
||||
if isinstance(component, platform_message.Unknown):
|
||||
return component.text
|
||||
return str(component)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain,
|
||||
markdown_enabled: bool = True,
|
||||
) -> tuple[str, bool]:
|
||||
content = ''
|
||||
at = False
|
||||
for msg in message_chain:
|
||||
if isinstance(msg, platform_message.Source):
|
||||
continue
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
content += msg.text
|
||||
elif isinstance(msg, platform_message.At):
|
||||
at = True
|
||||
content += DingTalkMessageConverter._component_text_fallback(msg)
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
content += DingTalkMessageConverter._component_text_fallback(msg)
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
if markdown_enabled:
|
||||
content += DingTalkMessageConverter._format_image_as_markdown(msg)
|
||||
else:
|
||||
content += '[Image]'
|
||||
elif isinstance(msg, platform_message.File):
|
||||
content += DingTalkMessageConverter._component_text_fallback(msg)
|
||||
elif isinstance(msg, platform_message.Voice):
|
||||
content += DingTalkMessageConverter._component_text_fallback(msg)
|
||||
elif isinstance(msg, platform_message.Quote):
|
||||
if msg.id is not None:
|
||||
content += f'[引用消息 {msg.id}] '
|
||||
if msg.origin:
|
||||
quote_content, quote_at = await DingTalkMessageConverter.yiri2target(msg.origin, markdown_enabled)
|
||||
content += quote_content
|
||||
at = at or quote_at
|
||||
elif isinstance(msg, platform_message.Forward):
|
||||
for node in msg.node_list:
|
||||
sender = node.sender_name or node.sender_id or ''
|
||||
if sender:
|
||||
content += f'\n[{sender}] '
|
||||
if node.message_chain:
|
||||
forwarded_content, forwarded_at = await DingTalkMessageConverter.yiri2target(
|
||||
node.message_chain, markdown_enabled
|
||||
)
|
||||
content += forwarded_content
|
||||
at = at or forwarded_at
|
||||
else:
|
||||
content += DingTalkMessageConverter._component_text_fallback(msg)
|
||||
return content, at
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain:
|
||||
incoming_message = event.incoming_message
|
||||
components: list[platform_message.MessageComponent] = [
|
||||
platform_message.Source(
|
||||
id=getattr(incoming_message, 'message_id', ''),
|
||||
time=DingTalkMessageConverter._message_time(incoming_message),
|
||||
)
|
||||
]
|
||||
|
||||
for at_user in getattr(incoming_message, 'at_users', []) or []:
|
||||
if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None):
|
||||
components.append(platform_message.At(target=bot_name, display=bot_name))
|
||||
|
||||
rich_content = event.rich_content
|
||||
if rich_content:
|
||||
for element in rich_content.get('Elements') or []:
|
||||
if element.get('Type') == 'text':
|
||||
text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name)
|
||||
if text.strip():
|
||||
components.append(platform_message.Plain(text=text))
|
||||
elif element.get('Type') == 'image' and element.get('Picture'):
|
||||
components.append(platform_message.Image(base64=element['Picture']))
|
||||
else:
|
||||
if event.content and event.type != 'audio':
|
||||
components.append(
|
||||
platform_message.Plain(
|
||||
text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name),
|
||||
)
|
||||
)
|
||||
if event.picture:
|
||||
components.append(platform_message.Image(base64=event.picture))
|
||||
|
||||
if event.file:
|
||||
components.append(platform_message.File(url=event.file, name=event.name or 'file'))
|
||||
if event.audio:
|
||||
if event.content and event.type == 'audio':
|
||||
components.append(platform_message.Plain(text=event.content))
|
||||
else:
|
||||
components.append(platform_message.Voice(base64=event.audio))
|
||||
|
||||
quote = DingTalkMessageConverter._quote_component(event)
|
||||
if quote:
|
||||
components.append(quote)
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@staticmethod
|
||||
def _quote_component(event: DingTalkEvent) -> platform_message.Quote | None:
|
||||
quote_info = event.quoted_message
|
||||
if not quote_info:
|
||||
return None
|
||||
origin_components: list[platform_message.MessageComponent] = []
|
||||
msg_type = quote_info.get('msg_type', '')
|
||||
if msg_type == 'file' and quote_info.get('file_url'):
|
||||
origin_components.append(
|
||||
platform_message.File(url=quote_info['file_url'], name=quote_info.get('file_name', 'file'))
|
||||
)
|
||||
elif msg_type == 'picture' and quote_info.get('picture'):
|
||||
origin_components.append(platform_message.Image(base64=quote_info['picture']))
|
||||
elif msg_type == 'audio' and quote_info.get('audio'):
|
||||
origin_components.append(platform_message.Voice(base64=quote_info['audio']))
|
||||
elif quote_info.get('content'):
|
||||
origin_components.append(platform_message.Plain(text=str(quote_info['content'])))
|
||||
|
||||
incoming_message = event.incoming_message
|
||||
return platform_message.Quote(
|
||||
id=quote_info.get('message_id') or None,
|
||||
group_id=getattr(incoming_message, 'conversation_id', None),
|
||||
sender_id=quote_info.get('sender_id') or None,
|
||||
target_id=getattr(incoming_message, 'conversation_id', None)
|
||||
or getattr(incoming_message, 'sender_staff_id', None),
|
||||
origin=platform_message.MessageChain(origin_components),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _strip_bot_mention(text: str, bot_name: str) -> str:
|
||||
return text.replace('@' + bot_name, '')
|
||||
|
||||
@staticmethod
|
||||
def _message_time(incoming_message: typing.Any) -> datetime.datetime:
|
||||
value = getattr(incoming_message, 'create_at', None)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
timestamp = float(value)
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp / 1000
|
||||
return datetime.datetime.fromtimestamp(timestamp)
|
||||
return datetime.datetime.now()
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||
|
||||
|
||||
async def check_access_token(bot: DingTalkClient, params: dict) -> dict:
|
||||
return {'valid': await bot.check_access_token()}
|
||||
|
||||
|
||||
async def refresh_access_token(bot: DingTalkClient, params: dict) -> dict:
|
||||
await bot.get_access_token()
|
||||
return {'ok': bool(bot.access_token)}
|
||||
|
||||
|
||||
async def get_file_url(bot: DingTalkClient, params: dict) -> dict:
|
||||
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
|
||||
if not download_code:
|
||||
raise ValueError('download_code is required')
|
||||
return {'url': await bot.get_file_url(str(download_code))}
|
||||
|
||||
|
||||
async def get_audio_base64(bot: DingTalkClient, params: dict) -> dict:
|
||||
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
|
||||
if not download_code:
|
||||
raise ValueError('download_code is required')
|
||||
return {'base64': await bot.get_audio_url(str(download_code))}
|
||||
|
||||
|
||||
async def download_image_base64(bot: DingTalkClient, params: dict) -> dict:
|
||||
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
|
||||
if not download_code:
|
||||
raise ValueError('download_code is required')
|
||||
return {'base64': await bot.download_image(str(download_code))}
|
||||
|
||||
|
||||
PLATFORM_API_MAP: dict[str, typing.Callable[[DingTalkClient, dict], typing.Awaitable[dict]]] = {
|
||||
'check_access_token': check_access_token,
|
||||
'refresh_access_token': refresh_access_token,
|
||||
'get_file_url': get_file_url,
|
||||
'get_audio_base64': get_audio_base64,
|
||||
'download_image_base64': download_image_base64,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'dingtalk'
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
|
||||
__all__ = ['DiscordAdapter']
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import discord
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
bot: discord.Client = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: DiscordMessageConverter = DiscordMessageConverter()
|
||||
event_converter: DiscordEventConverter = DiscordEventConverter()
|
||||
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
adapter_self = self
|
||||
|
||||
class LangBotDiscordClient(discord.Client):
|
||||
async def on_ready(self: discord.Client):
|
||||
adapter_self.bot_account_id = str(self.user.id) if self.user else ''
|
||||
await adapter_self.logger.info(f'Discord adapter running as {self.user}')
|
||||
|
||||
async def on_message(self: discord.Client, message: discord.Message):
|
||||
if self.user and message.author.id == self.user.id:
|
||||
return
|
||||
if message.author.bot:
|
||||
return
|
||||
try:
|
||||
if (
|
||||
platform_events.FriendMessage in adapter_self.listeners
|
||||
or platform_events.GroupMessage in adapter_self.listeners
|
||||
):
|
||||
legacy_event = await adapter_self.event_converter.target2legacy(message)
|
||||
callback = adapter_self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
await callback(legacy_event, adapter_self)
|
||||
|
||||
eba_event = await adapter_self.event_converter.target2yiri(
|
||||
message, self.user.id if self.user else None
|
||||
)
|
||||
if eba_event:
|
||||
await adapter_self._dispatch_eba_event(eba_event)
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
||||
|
||||
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_edit', (before, after), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
async def on_message_delete(self: discord.Client, message: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_delete', message, self.user.id if self.user else None
|
||||
)
|
||||
|
||||
async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'raw_message_delete',
|
||||
payload,
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
async def on_reaction_add(
|
||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||
):
|
||||
if self.user and user.id == self.user.id:
|
||||
return
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'reaction_add', (reaction, user), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
async def on_reaction_remove(
|
||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||
):
|
||||
if self.user and user.id == self.user.id:
|
||||
return
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'reaction_remove', (reaction, user), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
if self.user and payload.user_id == self.user.id:
|
||||
return
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'raw_reaction_add',
|
||||
payload,
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
if self.user and payload.user_id == self.user.id:
|
||||
return
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'raw_reaction_remove',
|
||||
payload,
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
async def on_member_join(self: discord.Client, member: discord.Member):
|
||||
await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None)
|
||||
|
||||
async def on_member_remove(self: discord.Client, member: discord.Member):
|
||||
await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None)
|
||||
|
||||
async def on_guild_join(self: discord.Client, guild: discord.Guild):
|
||||
await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None)
|
||||
|
||||
async def on_guild_remove(self: discord.Client, guild: discord.Guild):
|
||||
await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
intents.reactions = True
|
||||
|
||||
args = {}
|
||||
if os.getenv('http_proxy'):
|
||||
args['proxy'] = os.getenv('http_proxy')
|
||||
bot = LangBotDiscordClient(intents=intents, **args)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id=config.get('client_id', ''),
|
||||
listeners={},
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'message.edited',
|
||||
'message.deleted',
|
||||
'message.reaction',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'bot.invited_to_group',
|
||||
'bot.removed_from_group',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'edit_message',
|
||||
'delete_message',
|
||||
'forward_message',
|
||||
'get_group_info',
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'get_user_info',
|
||||
'get_file_url',
|
||||
'mute_member',
|
||||
'unmute_member',
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content, files = await self.message_converter.yiri2target(message)
|
||||
channel = await self._get_channel(target_id)
|
||||
kwargs = {'content': content}
|
||||
if files:
|
||||
kwargs['files'] = files
|
||||
sent = await channel.send(**kwargs)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
assert isinstance(message_source.source_platform_object, discord.Message)
|
||||
content, files = await self.message_converter.yiri2target(message)
|
||||
kwargs = {'content': content}
|
||||
if files:
|
||||
kwargs['files'] = files
|
||||
if quote_origin:
|
||||
kwargs['reference'] = message_source.source_platform_object
|
||||
kwargs['mention_author'] = any(isinstance(component, platform_message.At) for component in message.root)
|
||||
sent = await message_source.source_platform_object.channel.send(**kwargs)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None):
|
||||
try:
|
||||
event = await self.event_converter.target2yiri((kind, payload), bot_user_id)
|
||||
if event:
|
||||
await self._dispatch_eba_event(event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
await callback(event, self)
|
||||
return
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self.bot, params)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot.start(self.config['token'], reconnect=True)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
await self.bot.close()
|
||||
return True
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import discord
|
||||
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class DiscordAPIMixin:
|
||||
bot: discord.Client
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: platform_message.MessageChain,
|
||||
) -> None:
|
||||
channel = await self._get_channel(chat_id)
|
||||
message = await channel.fetch_message(int(message_id))
|
||||
content, files = await DiscordMessageConverter.yiri2target(new_content)
|
||||
if files:
|
||||
await message.edit(content=content, attachments=[])
|
||||
await channel.send(content=content, files=files)
|
||||
return
|
||||
await message.edit(content=content)
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
channel = await self._get_channel(chat_id)
|
||||
message = await channel.fetch_message(int(message_id))
|
||||
await message.delete()
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageResult:
|
||||
from_channel = await self._get_channel(from_chat_id)
|
||||
to_channel = await self._get_channel(to_chat_id)
|
||||
message = await from_channel.fetch_message(int(message_id))
|
||||
files = [await attachment.to_file() for attachment in message.attachments]
|
||||
sent = await to_channel.send(content=message.content, files=files)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
guild = await self._get_guild(group_id)
|
||||
return DiscordEventConverter.group_from_guild(guild)
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
guild = await self._get_guild(group_id)
|
||||
members = guild.members or [member async for member in guild.fetch_members(limit=None)]
|
||||
return [self._member_to_entity(member) for member in members]
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> platform_entities.UserGroupMember:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
return self._member_to_entity(member)
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id))
|
||||
return DiscordEventConverter.user_from_author(user)
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return file_id
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
until = None
|
||||
if duration > 0:
|
||||
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
||||
await member.timeout(until, reason='LangBot EBA mute_member')
|
||||
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.timeout(None, reason='LangBot EBA unmute_member')
|
||||
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.kick(reason='LangBot EBA kick_member')
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
await guild.leave()
|
||||
|
||||
async def _get_channel(self, channel_id: typing.Union[int, str]) -> discord.abc.Messageable:
|
||||
channel = self.bot.get_channel(int(channel_id))
|
||||
if channel is None:
|
||||
channel = await self.bot.fetch_channel(int(channel_id))
|
||||
return channel
|
||||
|
||||
async def _get_guild(self, guild_id: typing.Union[int, str]) -> discord.Guild:
|
||||
guild = self.bot.get_guild(int(guild_id))
|
||||
if guild is None:
|
||||
guild = await self.bot.fetch_guild(int(guild_id))
|
||||
return guild
|
||||
|
||||
@staticmethod
|
||||
def _member_to_entity(member: discord.Member) -> platform_entities.UserGroupMember:
|
||||
role = platform_entities.MemberRole.MEMBER
|
||||
if member.guild.owner_id == member.id:
|
||||
role = platform_entities.MemberRole.OWNER
|
||||
elif member.guild_permissions.administrator or member.guild_permissions.manage_guild:
|
||||
role = platform_entities.MemberRole.ADMIN
|
||||
return platform_entities.UserGroupMember(
|
||||
user=DiscordEventConverter.user_from_author(member),
|
||||
group_id=member.guild.id,
|
||||
role=role,
|
||||
display_name=member.display_name,
|
||||
joined_at=member.joined_at.timestamp() if member.joined_at else None,
|
||||
title=member.top_role.name if member.top_role else None,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="80px" height="80px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" fill="#000000">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <g> <path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero"> </path> </g> </g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import discord
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event) -> discord.Message:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None:
|
||||
if isinstance(event, discord.Message):
|
||||
return await DiscordEventConverter.message_to_eba(event)
|
||||
if isinstance(event, tuple) and len(event) == 2:
|
||||
kind, payload = event
|
||||
if kind == 'message_edit':
|
||||
before, after = payload
|
||||
return await DiscordEventConverter.message_edit_to_eba(before, after)
|
||||
if kind == 'message_delete':
|
||||
return await DiscordEventConverter.message_delete_to_eba(payload)
|
||||
if kind == 'raw_message_delete':
|
||||
return DiscordEventConverter.raw_message_delete_to_eba(payload)
|
||||
if kind == 'reaction_add':
|
||||
reaction, user = payload
|
||||
return DiscordEventConverter.reaction_to_eba(reaction, user, True)
|
||||
if kind == 'reaction_remove':
|
||||
reaction, user = payload
|
||||
return DiscordEventConverter.reaction_to_eba(reaction, user, False)
|
||||
if kind == 'raw_reaction_add':
|
||||
return DiscordEventConverter.raw_reaction_to_eba(payload, True)
|
||||
if kind == 'raw_reaction_remove':
|
||||
return DiscordEventConverter.raw_reaction_to_eba(payload, False)
|
||||
if kind == 'member_join':
|
||||
return DiscordEventConverter.member_join_to_eba(payload, bot_user_id)
|
||||
if kind == 'member_remove':
|
||||
return DiscordEventConverter.member_left_to_eba(payload, bot_user_id)
|
||||
if kind == 'guild_join':
|
||||
return DiscordEventConverter.guild_join_to_eba(payload)
|
||||
if kind == 'guild_remove':
|
||||
return DiscordEventConverter.guild_remove_to_eba(payload)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def message_to_eba(message: discord.Message) -> platform_events.MessageReceivedEvent:
|
||||
message_chain = await DiscordMessageConverter.target2yiri(message)
|
||||
group = DiscordEventConverter.group_from_message(message)
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='discord',
|
||||
message_id=message.id,
|
||||
message_chain=message_chain,
|
||||
sender=DiscordEventConverter.user_from_author(message.author),
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if isinstance(message.channel, discord.DMChannel)
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=message.channel.id,
|
||||
group=group,
|
||||
timestamp=message.created_at.timestamp(),
|
||||
source_platform_object=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def message_edit_to_eba(
|
||||
before: discord.Message, after: discord.Message
|
||||
) -> platform_events.MessageEditedEvent:
|
||||
return platform_events.MessageEditedEvent(
|
||||
type='message.edited',
|
||||
adapter_name='discord',
|
||||
message_id=after.id,
|
||||
new_content=await DiscordMessageConverter.target2yiri(after),
|
||||
editor=DiscordEventConverter.user_from_author(after.author),
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if isinstance(after.channel, discord.DMChannel)
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=after.channel.id,
|
||||
group=DiscordEventConverter.group_from_message(after),
|
||||
timestamp=after.edited_at.timestamp() if after.edited_at else after.created_at.timestamp(),
|
||||
source_platform_object=after,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent:
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='discord',
|
||||
message_id=message.id,
|
||||
operator=None,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if isinstance(message.channel, discord.DMChannel)
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=message.channel.id,
|
||||
group=DiscordEventConverter.group_from_message(message),
|
||||
timestamp=message.created_at.timestamp() if message.created_at else 0.0,
|
||||
source_platform_object=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent:
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='discord',
|
||||
message_id=payload.message_id,
|
||||
operator=None,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if payload.guild_id is None
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=payload.channel_id,
|
||||
group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None,
|
||||
source_platform_object=payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def reaction_to_eba(
|
||||
reaction: discord.Reaction,
|
||||
user: discord.User | discord.Member,
|
||||
is_add: bool,
|
||||
) -> platform_events.MessageReactionEvent:
|
||||
message = reaction.message
|
||||
return platform_events.MessageReactionEvent(
|
||||
type='message.reaction',
|
||||
adapter_name='discord',
|
||||
message_id=message.id,
|
||||
user=DiscordEventConverter.user_from_author(user),
|
||||
reaction=str(reaction.emoji),
|
||||
is_add=is_add,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if isinstance(message.channel, discord.DMChannel)
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=message.channel.id,
|
||||
group=DiscordEventConverter.group_from_message(message),
|
||||
source_platform_object=reaction,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def raw_reaction_to_eba(
|
||||
payload: discord.RawReactionActionEvent,
|
||||
is_add: bool,
|
||||
) -> platform_events.MessageReactionEvent:
|
||||
member = getattr(payload, 'member', None)
|
||||
user = member or getattr(payload, 'user', None)
|
||||
if user is None:
|
||||
user = platform_entities.User(id=payload.user_id)
|
||||
else:
|
||||
user = DiscordEventConverter.user_from_author(user)
|
||||
return platform_events.MessageReactionEvent(
|
||||
type='message.reaction',
|
||||
adapter_name='discord',
|
||||
message_id=payload.message_id,
|
||||
user=user,
|
||||
reaction=str(payload.emoji),
|
||||
is_add=is_add,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
if payload.guild_id is None
|
||||
else platform_entities.ChatType.GROUP,
|
||||
chat_id=payload.channel_id,
|
||||
group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None,
|
||||
source_platform_object=payload,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def member_join_to_eba(
|
||||
member: discord.Member,
|
||||
bot_user_id: int | None,
|
||||
) -> platform_events.BotInvitedToGroupEvent | platform_events.MemberJoinedEvent:
|
||||
group = DiscordEventConverter.group_from_guild(member.guild)
|
||||
user = DiscordEventConverter.user_from_author(member)
|
||||
if bot_user_id is not None and member.id == bot_user_id:
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='discord',
|
||||
group=group,
|
||||
inviter=None,
|
||||
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
|
||||
source_platform_object=member,
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='discord',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=None,
|
||||
join_type='direct',
|
||||
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
|
||||
source_platform_object=member,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def member_left_to_eba(
|
||||
member: discord.Member,
|
||||
bot_user_id: int | None,
|
||||
) -> platform_events.BotRemovedFromGroupEvent | platform_events.MemberLeftEvent:
|
||||
group = DiscordEventConverter.group_from_guild(member.guild)
|
||||
user = DiscordEventConverter.user_from_author(member)
|
||||
if bot_user_id is not None and member.id == bot_user_id:
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='discord',
|
||||
group=group,
|
||||
operator=None,
|
||||
source_platform_object=member,
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='discord',
|
||||
group=group,
|
||||
member=user,
|
||||
is_kicked=False,
|
||||
operator=None,
|
||||
source_platform_object=member,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent:
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='discord',
|
||||
group=DiscordEventConverter.group_from_guild(guild),
|
||||
inviter=None,
|
||||
source_platform_object=guild,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent:
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='discord',
|
||||
group=DiscordEventConverter.group_from_guild(guild),
|
||||
operator=None,
|
||||
source_platform_object=guild,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||
message_chain = await DiscordMessageConverter.target2yiri(message)
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=message.author.id,
|
||||
nickname=message.author.name,
|
||||
remark=str(message.channel.id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=message.created_at.timestamp(),
|
||||
source_platform_object=message,
|
||||
)
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=message.author.id,
|
||||
member_name=message.author.display_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=message.channel.id,
|
||||
name=message.channel.name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=message.created_at.timestamp(),
|
||||
source_platform_object=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user_from_author(author: discord.User | discord.Member) -> platform_entities.User:
|
||||
return platform_entities.User(
|
||||
id=author.id,
|
||||
nickname=getattr(author, 'display_name', None) or author.name,
|
||||
avatar_url=str(author.display_avatar.url) if getattr(author, 'display_avatar', None) else None,
|
||||
is_bot=author.bot,
|
||||
username=author.name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def group_from_message(message: discord.Message) -> platform_entities.UserGroup | None:
|
||||
guild = getattr(message, 'guild', None)
|
||||
if guild is None:
|
||||
return None
|
||||
return DiscordEventConverter.group_from_guild(guild)
|
||||
|
||||
@staticmethod
|
||||
def group_from_guild(guild: discord.Guild) -> platform_entities.UserGroup:
|
||||
return platform_entities.UserGroup(
|
||||
id=guild.id,
|
||||
name=guild.name,
|
||||
member_count=guild.member_count,
|
||||
avatar_url=str(guild.icon.url) if guild.icon else None,
|
||||
owner_id=guild.owner_id,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: discord-eba
|
||||
label:
|
||||
en_US: Discord (EBA)
|
||||
zh_Hans: Discord (EBA)
|
||||
description:
|
||||
en_US: Discord adapter (EBA architecture)
|
||||
zh_Hans: Discord 适配器(EBA 架构版本)
|
||||
icon: discord.svg
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.edited
|
||||
- message.deleted
|
||||
- message.reaction
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- edit_message
|
||||
- delete_message
|
||||
- forward_message
|
||||
- get_group_info
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- get_user_info
|
||||
- get_file_url
|
||||
- mute_member
|
||||
- unmute_member
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_channel
|
||||
description: { en_US: "Get channel information", zh_Hans: "获取频道信息" }
|
||||
- action: get_guild
|
||||
description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" }
|
||||
- action: get_guild_channels
|
||||
description: { en_US: "Get guild channels", zh_Hans: "获取服务器频道列表" }
|
||||
- action: get_guild_roles
|
||||
description: { en_US: "Get guild roles", zh_Hans: "获取服务器角色列表" }
|
||||
- action: create_invite
|
||||
description: { en_US: "Create channel invite", zh_Hans: "创建频道邀请链接" }
|
||||
- action: pin_message
|
||||
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
|
||||
- action: unpin_message
|
||||
description: { en_US: "Unpin a message", zh_Hans: "取消置顶消息" }
|
||||
- action: add_reaction
|
||||
description: { en_US: "Add a reaction", zh_Hans: "添加表情回应" }
|
||||
- action: remove_reaction
|
||||
description: { en_US: "Remove a reaction", zh_Hans: "移除表情回应" }
|
||||
- action: typing
|
||||
description: { en_US: "Send typing indicator", zh_Hans: "发送正在输入状态" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: DiscordAdapter
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user