Compare commits

..

12 Commits

Author SHA1 Message Date
Junyan Qin
2e061a8713 feat: update version to 4.9.0 in pyproject.toml, __init__.py, and uv.lock 2026-03-09 20:09:00 +08:00
Junyan Qin
2cd8c56fe8 feat: update migration messages for knowledge base in multiple languages 2026-03-09 19:57:13 +08:00
youhuanghe
e09ae8f1a8 feat: add external plugin auto download 2026-03-09 09:55:12 +00:00
youhuanghe
aa7b0deb2b fix: show 2026-03-09 09:26:29 +00:00
youhuanghe
1c9a800f9d wq
Merge branch 'master' into feat/dbm20-rag
2026-03-09 08:26:05 +00:00
youhuanghe
96f24d73d5 feat: add external migration 2026-03-09 08:18:23 +00:00
youhuanghe
14ea8ca7b6 fix ruff lint 2026-03-09 01:26:39 +00:00
youhuanghe
f0093dab69 fix lint 2026-03-09 01:23:56 +00:00
youhuanghe
c29e6586b3 refactor: to red and no more 2026-03-09 01:08:56 +00:00
youhuanghe
1b37dababa feat(rag): add data-only migration option and fix dialog width
Add option to migrate knowledge base data without auto-installing
the LangRAG plugin (for offline/intranet environments). Also
narrow the migration dialog to match other confirmation dialogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:05:05 +00:00
youhuanghe
8da52b6dc7 fix(rag): query marketplace for actual plugin version instead of 'latest'
The marketplace API does not support 'latest' as a version string.
Fetch the plugin info first to get latest_version, then use that
concrete version for installation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:41:43 +00:00
youhuanghe
67c5c3af20 feat(rag): add knowledge base migration from v4.9.0 to plugin architecture
Rewrite dbm020 to backup old knowledge_bases data and preserve
external_knowledge_bases table. Add migration API endpoints and
frontend dialog so users can opt-in to auto-install LangRAG plugin
and restore their knowledge bases with original UUIDs preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:17:34 +00:00
505 changed files with 14197 additions and 63264 deletions

View File

@@ -1,5 +1,5 @@
name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题参考文档 https://link.langbot.app/zh/docs/network
description: 【供中文用户】报错或漏洞请使用这个模板创建不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题参考文档 https://docs.langbot.app/zh/workshop/network-details.html
title: "[Bug]: "
labels: ["bug?"]
body:

View File

@@ -1,5 +1,5 @@
name: Bug report
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
title: "[Bug]: "
labels: ["bug?"]
body:

View File

@@ -43,10 +43,10 @@ jobs:
run: |
cd /tmp/langbot_build_web/web
npm install
npx vite build
npm run build
- name: Package Output
run: |
cp -r /tmp/langbot_build_web/web/dist ./web
cp -r /tmp/langbot_build_web/web/out ./web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -1,25 +0,0 @@
name: Check i18n Keys
on:
push:
branches:
- main
- master
jobs:
check-i18n:
name: Check i18n Key Consistency
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check i18n keys against en-US reference
run: node web/scripts/check-i18n.mjs

View File

@@ -29,8 +29,8 @@ jobs:
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
mkdir -p ../src/langbot/web/out
cp -r out ../src/langbot/web/
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6

View File

@@ -1,171 +0,0 @@
name: Test Migrations
on:
push:
branches:
- main
- master
- dev
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
jobs:
test-migrations-sqlite:
name: Migrations (SQLite)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (SQLite)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
async def main():
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
# Create all tables (simulates existing DB)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None, 'Expected a revision after upgrade'
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: upgrade from scratch
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All SQLite migration tests passed!')
asyncio.run(main())
"
test-migrations-postgres:
name: Migrations (PostgreSQL)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langbot
POSTGRES_PASSWORD: langbot
POSTGRES_DB: langbot_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langbot"
--health-interval=5s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (PostgreSQL)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
async def main():
engine = create_async_engine(DB_URL)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: drop all and upgrade from scratch
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
# Create fresh database
from sqlalchemy import text
async with engine.connect() as conn:
await conn.execute(text('COMMIT'))
await conn.execute(text('CREATE DATABASE langbot_fresh'))
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All PostgreSQL migration tests passed!')
asyncio.run(main())
"

4
.gitignore vendored
View File

@@ -47,12 +47,8 @@ plugins.bak
coverage.xml
.coverage
src/langbot/web/
testsdk/
# Build artifacts
/dist
/build
*.egg-info
# Next.js build cache (legacy)
web/.next/

View File

@@ -9,14 +9,16 @@ repos:
# Run the formatter of backend.
- id: ruff-format
- repo: local
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
name: prettier
entry: npx --prefix web prettier --write --ignore-unknown
language: system
types_or: [javascript, jsx, ts, tsx, css, scss]
additional_dependencies:
- prettier@3.1.0
- repo: local
hooks:
- id: lint-staged
name: lint-staged
entry: cd web && pnpm lint-staged

View File

@@ -70,7 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
## Some Principles

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY web ./web
RUN cd web && npm install && npx vite build
RUN cd web && npm install && npm run build
FROM python:3.12.7-slim
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . .
COPY --from=node /app/web/dist ./web/dist
COPY --from=node /app/web/out ./web/out
RUN apt update \
&& apt install gcc -y \

View File

@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a>
<a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features">Features</a>
<a href="https://docs.langbot.app/en/insight/guide">Docs</a>
<a href="https://docs.langbot.app/en/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -45,7 +45,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
---
@@ -76,7 +76,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)
---
@@ -84,72 +84,68 @@ docker compose up -d
| Platform | Status | Notes |
|----------|--------|-------|
| Discord | ✅ | Official |
| Telegram | ✅ | Official |
| Slack | ✅ | Official |
| LINE | ✅ | Official |
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personal & Official API |
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
| WeChat | ✅ | Personal & Official Account |
| Lark | ✅ | Official |
| DingTalk | ✅ | Official |
| KOOK | ✅ | Official |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
---
## Supported LLMs & Integrations
| Provider | Type | Status |
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
| [xAI](https://x.ai/) | LLM | ✅ |
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
| [Dify](https://dify.ai) | LLMOps | ✅ |
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
| Provider | Type | Status |
|----------|------|--------|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
| [xAI](https://x.ai/) | LLM | ✅ |
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
| [Dify](https://dify.ai) | LLMOps | ✅ |
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features)
[→ View all integrations](https://docs.langbot.app/en/insight/features)
---
## Why LangBot?
| Use Case | How LangBot Helps |
| --------------------------- | ------------------------------------------------------------------------------------------ |
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
| Use Case | How LangBot Helps |
|----------|-------------------|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
*Note: Public demo environment. Do not enter sensitive information.*
---

View File

@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">插件市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -34,6 +34,8 @@
---
## 什么是 LangBot
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型LLM连接到各种聊天平台帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
### 核心能力
@@ -41,11 +43,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
---
@@ -76,7 +78,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -87,16 +89,13 @@ docker compose up -d
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
| 微信 | ✅ | 个人微信、微信公众号 |
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
| 飞书 | ✅ | 官方 |
| 钉钉 | ✅ | 官方 |
| Satori | ✅ | |
| Discord | ✅ | 官方 |
| Telegram | ✅ | 官方 |
| Slack | ✅ | 官方 |
| LINE | ✅ | 官方 |
| KOOK | ✅ | 官方 |
| Email | ✅ | 只 Matrix、Satori |
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| KOOK | ✅ | |
---
@@ -127,9 +126,8 @@ docker compose up -d
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
### TTS语音合成

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Inicio</a>
<a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features.html">Características</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
@@ -44,7 +44,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| Plataforma | Estado | Notas |
|----------|--------|-------|
| Discord | ✅ | Oficial |
| Telegram | ✅ | Oficial |
| Slack | ✅ | Oficial |
| LINE | ✅ | Oficial |
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personal y API Oficial |
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
| WeChat | ✅ | Personal y Cuenta Oficial |
| Lark | ✅ | Oficial |
| DingTalk | ✅ | Oficial |
| KOOK | ✅ | Oficial |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Accueil</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
@@ -44,7 +44,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| Plateforme | Statut | Notes |
|----------|--------|-------|
| Discord | ✅ | Officiel |
| Telegram | ✅ | Officiel |
| Slack | ✅ | Officiel |
| LINE | ✅ | Officiel |
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personnel & API Officielle |
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
| WeChat | ✅ | Personnel & Compte Officiel |
| Lark | ✅ | Officiel |
| DingTalk | ✅ | Officiel |
| KOOK | ✅ | Officiel |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">ホーム</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a>
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a>
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| プラットフォーム | ステータス | 備考 |
|----------|--------|-------|
| Discord | ✅ | 公式 |
| Telegram | ✅ | 公式 |
| Slack | ✅ | 公式 |
| LINE | ✅ | 公式 |
| QQ | ✅ | 個人公式APIチャンネル・DM・グループ |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | 個人 & 公式API |
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
| WeChat | ✅ | 個人公式アカウント |
| Lark | ✅ | 公式 |
| DingTalk | ✅ | 公式 |
| KOOK | ✅ | 公式 |
| WeChat | ✅ | 個人 & 公式アカウント |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix、Satori |
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">홈</a>
<a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features.html">기능</a>
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| 플랫폼 | 상태 | 비고 |
|--------|------|------|
| Discord | ✅ | 공식 |
| Telegram | ✅ | 공식 |
| Slack | ✅ | 공식 |
| LINE | ✅ | 공식 |
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | 개인 및 공식 API |
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
| WeChat | ✅ | 개인 및 공식 계정 |
| Lark | ✅ | 공식 |
| DingTalk | ✅ | 공식 |
| KOOK | ✅ | 공식 |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Главная</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a>
<a href="https://link.langbot.app/en/docs/guide">Документация</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| Платформа | Статус | Примечания |
|-----------|--------|------------|
| Discord | ✅ | Официальный |
| Telegram | ✅ | Официальный |
| Slack | ✅ | Официальный |
| LINE | ✅ | Официальный |
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Личный и официальный API |
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
| WeChat | ✅ | Личный и официальный аккаунт |
| Lark | ✅ | Официальный |
| DingTalk | ✅ | Официальный |
| KOOK | ✅ | Официальный |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
---
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -85,19 +85,17 @@ docker compose up -d
| 平台 | 狀態 | 備註 |
|------|------|------|
| Discord | ✅ | 官方 |
| Telegram | ✅ | 官方 |
| Slack | ✅ | 官方 |
| LINE | ✅ | 官方 |
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 微信 | ✅ | 個人微信、微信公眾號 |
| 飛書 | ✅ | 官方 |
| 釘釘 | ✅ | 官方 |
| KOOK | ✅ | 官方 |
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 飛書 | ✅ | |
| 釘釘 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | 只 Matrix、Satori |
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
---
@@ -126,7 +124,6 @@ docker compose up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
### TTS語音合成
@@ -142,7 +139,7 @@ docker compose up -d
|-----------|------|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Trang chủ</a>
<a href="https://link.langbot.app/en/docs/features">Tính năng</a>
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
@@ -44,7 +44,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
---
@@ -83,19 +83,17 @@ docker compose up -d
| Nền tảng | Trạng thái | Ghi chú |
|----------|--------|-------|
| Discord | ✅ | Chính thức |
| Telegram | ✅ | Chính thức |
| Slack | ✅ | Chính thức |
| LINE | ✅ | Chính thức |
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Cá nhân & API chính thức |
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
| Lark | ✅ | Chính thức |
| DingTalk | ✅ | Chính thức |
| KOOK | ✅ | Chính thức |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
---
@@ -124,9 +122,8 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)
---

View File

@@ -312,7 +312,7 @@ spec:
### 参考资源
- [LangBot 官方文档](https://docs.langbot.app)
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
---
@@ -625,5 +625,5 @@ spec:
### References
- [LangBot Official Documentation](https://docs.langbot.app)
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)

View File

@@ -34,4 +34,4 @@ services:
networks:
langbot_network:
driver: bridge
driver: bridge

View File

@@ -1,197 +0,0 @@
# 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 AgentsEBA架构旨在将 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
四种处理器类型,用户在 WebUI 的 Bot 管理页面配置:
| 类型 | 说明 | 适用场景 |
|------|------|----------|
| **pipeline** | 现有流水线机制,完整的多 Stage 处理链PreProcessor → MessageProcessor → PostProcessor 等) | 复杂消息处理,需要完整的预处理/后处理流程 |
| **agent** | 直接调用 RequestRunnerlocal-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) | 分阶段迁移计划 |
## 6. 涉及的代码仓库
| 仓库 | 改动范围 |
|------|----------|
| **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 |
| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot 实体扩展、数据库迁移、RequestRunner 解耦 |
| **LangBot**(前端) | Bot 事件处理器编排面板 |
| **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 |
| **langbot-plugin-demo** | 示例更新(使用新事件和 API |

View File

@@ -1,561 +0,0 @@
# 统一事件体系
## 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.*' 匹配所有消息事件
│ 未匹配到 → 走默认 Handlerplugin保持向后兼容
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. 文档自动生成

View File

@@ -1,546 +0,0 @@
# 统一平台 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` 承载。

View File

@@ -1,483 +0,0 @@
# 适配器新目录结构
## 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 等)
- 新增透传 APIcall_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/ # 新路径(已迁移的适配器)
```

View File

@@ -1,743 +0,0 @@
# 事件路由与编排
## 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
```

View File

@@ -1,738 +0,0 @@
# 插件 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。

View File

@@ -1,429 +0,0 @@
# 分阶段迁移计划
## 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 1SDK 实体层
**目标**:在 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 5WebUI 编排面板
**目标**:在 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.0M4-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 通信中是否正确

View File

@@ -1,39 +0,0 @@
# 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) |
## 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.

View File

@@ -1,208 +0,0 @@
# 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.

View File

@@ -1,171 +0,0 @@
# 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.

View File

@@ -1,162 +0,0 @@
# 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.

View File

@@ -1,114 +0,0 @@
# 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.

View File

@@ -1,147 +0,0 @@
# 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.

View File

@@ -1,135 +0,0 @@
# 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.

View File

@@ -1,101 +0,0 @@
# 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.

View File

@@ -1,114 +0,0 @@
# 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.

View File

@@ -1,84 +0,0 @@
# 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`.

View File

@@ -1,139 +0,0 @@
# 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.

View File

@@ -1,130 +0,0 @@
# 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.

View File

@@ -1,148 +0,0 @@
# 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.

View File

@@ -1,161 +0,0 @@
# 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.

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.6"
version = "4.9.0"
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.13.4",
"aiohttp>=3.11.18",
"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>=44.0.3",
"dashscope>=1.25.10",
"dingtalk-stream>=0.24.0",
"discord-py>=2.5.2",
@@ -27,7 +27,7 @@ dependencies = [
"nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8",
"openai>1.0.0",
"pillow>=12.2.0",
"pillow>=11.2.1",
"psutil>=7.0.0",
"pycryptodome>=3.22.0",
"pydantic>2.0",
@@ -39,7 +39,6 @@ dependencies = [
"quart-cors>=0.8.0",
"requests>=2.32.3",
"slack-sdk>=3.35.0",
"alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40",
"sqlmodel>=0.0.24",
"telegramify-markdown>=0.5.1",
@@ -50,7 +49,7 @@ dependencies = [
"pip>=25.1.1",
"ruff>=0.11.9",
"pre-commit>=4.2.0",
"uv>=0.11.6",
"uv>=0.7.11",
"mypy>=1.16.0",
"PyPDF2>=3.0.1",
"python-docx>=1.1.0",
@@ -61,18 +60,13 @@ dependencies = [
"ebooklib>=0.18",
"html2text>=2024.2.26",
"langchain>=0.2.0",
"langchain-core>=1.2.28",
"langsmith>=0.7.31",
"python-multipart>=0.0.26",
"Mako>=1.3.11",
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.10",
"langbot-plugin==0.3.0",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
"tboxsdk>=0.0.10",
"boto3>=1.35.0",
"pymilvus>=2.6.4",
@@ -117,12 +111,12 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] }
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
[dependency-groups]
dev = [
"pre-commit>=4.2.0",
"pytest>=9.0.3",
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
"pytest-cov>=7.0.0",
"ruff>=0.11.9",
@@ -221,3 +215,4 @@ skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.6'
__version__ = '4.9.0'

View File

@@ -182,88 +182,6 @@ class DingTalkClient:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
"""Parse the quoted/replied message and extract its content.
Args:
replied_msg: The repliedMsg object from DingTalk message
Returns:
A dict containing the quoted message info with keys:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
quote_info = {
'message_id': replied_msg.get('msgId', ''),
'msg_type': replied_msg.get('msgType', ''),
'sender_id': replied_msg.get('senderId', ''),
}
msg_type = replied_msg.get('msgType', '')
content = replied_msg.get('content', {})
# Handle content as string (JSON) or dict
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
content = {}
if msg_type == 'text':
# Text message
if isinstance(content, dict):
quote_info['content'] = content.get('content', '')
else:
quote_info['content'] = str(content)
elif msg_type == 'file':
# File message
download_code = content.get('downloadCode')
file_name = content.get('fileName')
if download_code and file_name:
try:
quote_info['file_url'] = await self.get_file_url(download_code)
quote_info['file_name'] = file_name
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted file URL: {e}')
elif msg_type == 'picture':
# Picture message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['picture'] = await self.download_image(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to download quoted image: {e}')
elif msg_type == 'audio':
# Audio message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['audio'] = await self.get_audio_url(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted audio: {e}')
elif msg_type == 'richText':
# Rich text message - extract text content
rich_text = content.get('richText', [])
texts = []
for item in rich_text:
if 'text' in item and item['text'] != '\n':
texts.append(item['text'])
quote_info['content'] = '\n'.join(texts)
return quote_info
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
try:
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
@@ -275,15 +193,6 @@ class DingTalkClient:
elif str(incoming_message.conversation_type) == '2':
message_data['conversation_type'] = 'GroupMessage'
# Check for quoted/replied message
raw_data = incoming_message.to_dict()
text_data = raw_data.get('text', {})
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
replied_msg = text_data.get('repliedMsg', {})
if replied_msg:
quote_info = await self._parse_quoted_message(replied_msg)
message_data['QuotedMessage'] = quote_info
if incoming_message.message_type == 'richText':
data = incoming_message.rich_text_content.to_dict()
@@ -359,52 +268,19 @@ class DingTalkClient:
message_data['Type'] = 'image'
elif incoming_message.message_type == 'audio':
raw_content = incoming_message.to_dict().get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(raw_content, str):
try:
raw_content = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
raw_content = {}
if self.logger:
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
# 提取钉钉自带的语音转写文字Powered by Qwen
recognition = raw_content.get('recognition', '')
if recognition:
message_data['Content'] = recognition
download_code = raw_content.get('downloadCode')
if download_code:
message_data['Audio'] = await self.get_audio_url(download_code)
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file':
# 获取原始数据字典并提取嵌套的文件信息
raw_data = incoming_message.to_dict()
file_info = raw_data.get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(file_info, str):
try:
file_info = json.loads(file_info)
except (json.JSONDecodeError, TypeError):
file_info = {}
download_code = file_info.get('downloadCode')
file_name = file_info.get('fileName')
if download_code and file_name:
# 转换 downloadCode 为可下载的真实 URL
message_data['File'] = await self.get_file_url(download_code)
message_data['Name'] = file_name
down_list = incoming_message.get_down_list()
if len(down_list) >= 2:
message_data['File'] = await self.get_file_url(down_list[0])
message_data['Name'] = down_list[1]
else:
if self.logger:
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
message_data['File'] = None
message_data['Name'] = None
message_data['Type'] = 'file'
copy_message_data = message_data.copy()
@@ -438,13 +314,8 @@ 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 body
raise Exception(f'Error: {response.status_code}, {body}')
return
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()}')
@@ -469,13 +340,8 @@ 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 body
raise Exception(f'Error: {response.status_code}, {body}')
return
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()}')
@@ -491,12 +357,6 @@ class DingTalkClient:
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
# 将用户的消息内容作为卡片的查询参数,方便后续处理
if incoming_message.message_type == 'text':
card_data['query'] = incoming_message.get_text_list()[0]
else:
card_data['query'] = '...'
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards

View File

@@ -47,22 +47,6 @@ class DingTalkEvent(dict):
def conversation(self):
return self.get('conversation_type', '')
@property
def quoted_message(self) -> Optional[Dict[str, Any]]:
"""Get the quoted/replied message info if this is a reply message.
Returns:
A dict containing:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
return self.get('QuotedMessage')
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。

View File

@@ -93,30 +93,15 @@ 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(
'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
await self.logger.error('拒绝请求')
raise Exception('拒绝请求')
elif req.method == 'POST':
encryt_msg = await req.data
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
@@ -294,27 +279,9 @@ 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()
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
return echostr if check_signature == signature else '拒绝请求'
elif req.method == 'POST':
encryt_msg = await req.data

View File

@@ -1,3 +0,0 @@
from .client import OpenClawWeixinClient as OpenClawWeixinClient
from .types import ApiError as ApiError
from .types import LoginResult as LoginResult

View File

@@ -1,807 +0,0 @@
"""Async HTTP client for the OpenClaw WeChat API.
Implements the iLink Bot API protocol.
Reference: https://github.com/epiral/weixin-bot
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
import os
import struct
import typing
import uuid
from typing import Optional
from urllib.parse import quote
import aiohttp
from .types import (
ApiError,
CDNMedia,
FileItem,
GetConfigResponse,
GetUpdatesResponse,
GetUploadUrlResponse,
ImageItem,
LoginResult,
MessageItem,
QRCodeResponse,
QRStatusResponse,
RefMessage,
TextItem,
VideoItem,
VoiceItem,
WeixinMessage,
)
logger = logging.getLogger('openclaw-weixin-sdk')
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
CHANNEL_VERSION = '1.0.0'
DEFAULT_API_TIMEOUT = 15
DEFAULT_LONG_POLL_TIMEOUT = 40
DEFAULT_CONFIG_TIMEOUT = 10
DEFAULT_QR_POLL_TIMEOUT = 35
SESSION_EXPIRED_ERRCODE = -14
DEFAULT_BOT_TYPE = '3'
# Maximum text length per message chunk (WeChat limit)
MAX_TEXT_CHUNK_SIZE = 2000
def _random_wechat_uin() -> str:
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
rand_bytes = os.urandom(4)
uint32_val = struct.unpack('>I', rand_bytes)[0]
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
def _build_base_info() -> dict:
"""Build the base_info payload included in every API request."""
return {'channel_version': CHANNEL_VERSION}
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
"""Split long text into chunks that fit within WeChat's message size limit."""
if len(text) <= max_size:
return [text]
chunks = []
while text:
chunks.append(text[:max_size])
text = text[max_size:]
return chunks
class OpenClawWeixinClient:
"""Async client for the OpenClaw WeChat HTTP JSON API."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip('/')
self.token = token
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
if self._session and not self._session.closed:
await self._session.close()
def _build_headers(self) -> dict[str, str]:
headers = {
'Content-Type': 'application/json',
'AuthorizationType': 'ilink_bot_token',
'X-WECHAT-UIN': _random_wechat_uin(),
}
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
return headers
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
"""Make a POST request and return the JSON response.
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
"""
payload['base_info'] = _build_base_info()
session = await self._get_session()
url = f'{self.base_url}/{endpoint}'
headers = self._build_headers()
async with session.post(
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
# Check for application-level errors in the response body
errcode = data.get('errcode') or data.get('ret')
if errcode and errcode != 0:
raise ApiError(
data.get('errmsg') or f'API errcode {errcode}',
status=200,
code=errcode,
payload=data,
)
return data
async def get_updates(
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
) -> GetUpdatesResponse:
"""Long-poll for new messages.
Note: This method does NOT raise ApiError for errcode responses —
it returns them in the GetUpdatesResponse so the caller can handle
session expiry and other errors with full context.
"""
try:
# Bypass the errcode check in _post since get_updates needs
# to return error info (e.g. session expired) to the caller.
payload: dict = {'get_updates_buf': get_updates_buf}
payload['base_info'] = _build_base_info()
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/getupdates'
headers = self._build_headers()
async with session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
except ApiError:
raise
except Exception as e:
if 'timeout' in str(e).lower():
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
raise
return _parse_get_updates_response(data)
async def send_message(
self,
to_user_id: str,
item_list: list[MessageItem],
context_token: str = '',
) -> None:
"""Send a message to a user."""
items_payload = [_message_item_to_dict(item) for item in item_list]
payload = {
'msg': {
'from_user_id': '',
'to_user_id': to_user_id,
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
'message_type': WeixinMessage.TYPE_BOT,
'message_state': WeixinMessage.STATE_FINISH,
'item_list': items_payload,
'context_token': context_token or None,
}
}
await self._post('ilink/bot/sendmessage', payload)
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
"""Send a plain text message, automatically chunking if too long."""
chunks = _chunk_text(text)
for chunk in chunks:
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
await self.send_message(to_user_id, [item], context_token)
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
"""Get bot config including typing_ticket."""
data = await self._post(
'ilink/bot/getconfig',
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
timeout=DEFAULT_CONFIG_TIMEOUT,
)
return GetConfigResponse(
ret=data.get('ret'),
errmsg=data.get('errmsg'),
typing_ticket=data.get('typing_ticket'),
)
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
"""Send typing indicator. status: 1=typing, 2=cancel."""
await self._post(
'ilink/bot/sendtyping',
{
'ilink_user_id': ilink_user_id,
'typing_ticket': typing_ticket,
'status': status,
},
timeout=DEFAULT_CONFIG_TIMEOUT,
)
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
"""Cancel the typing indicator for a user."""
await self.send_typing(ilink_user_id, typing_ticket, status=2)
async def download_media(
self,
media: CDNMedia,
) -> bytes:
"""Download and decrypt a file from the WeChat CDN.
Args:
media: CDNMedia object with encrypt_query_param and aes_key.
Returns:
Decrypted file bytes.
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
if not media.encrypt_query_param:
raise ApiError('CDN media has no encrypt_query_param', status=0)
if not media.aes_key:
raise ApiError('CDN media has no aes_key', status=0)
# Derive 16-byte AES key
# aes_key is base64-encoded; the decoded content may be:
# - raw 16 bytes (direct AES key)
# - 32-char hex string (decode hex to get 16 bytes)
raw = base64.b64decode(media.aes_key)
if len(raw) == 16:
aes_key = raw
elif len(raw) == 32:
# Hex-encoded 16-byte key
aes_key = bytes.fromhex(raw.decode('utf-8'))
else:
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
# Download encrypted bytes from CDN
session = await self._get_session()
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
encrypted = await resp.read()
# Decrypt AES-128-ECB with PKCS7 padding
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
decryptor = cipher.decryptor()
padded = decryptor.update(encrypted) + decryptor.finalize()
unpadder = PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
async def upload_media(
self,
file_bytes: bytes,
to_user_id: str,
media_type: int,
) -> CDNMedia:
"""Encrypt and upload media to WeChat CDN.
Args:
file_bytes: Raw file bytes to upload.
to_user_id: Recipient user ID.
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
Returns:
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
"""
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
# 1. Generate random 16-byte AES key
raw_key = os.urandom(16)
aes_key_hex = raw_key.hex() # 32-char hex string
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
# 3. Encrypt file with AES-128-ECB + PKCS7
padder = PKCS7(128).padder()
padded = padder.update(file_bytes) + padder.finalize()
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
encryptor = cipher.encryptor()
encrypted = encryptor.update(padded) + encryptor.finalize()
# 4. Get upload URL
raw_md5 = hashlib.md5(file_bytes).hexdigest()
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
upload_resp = await self.get_upload_url(
filekey=filekey,
media_type=media_type,
to_user_id=to_user_id,
rawsize=len(file_bytes),
rawfilemd5=raw_md5,
filesize=len(encrypted),
aeskey=aes_key_hex, # hex string, as expected by the API
)
if not upload_resp.upload_param:
raise ApiError('Failed to get upload URL', status=0)
# 5. Upload to CDN
# upload_param is an opaque token from the server — pass it as-is
session = await self._get_session()
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
logger.debug(
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
cdn_url,
len(file_bytes),
len(encrypted),
raw_md5,
encoded_key,
)
async with session.post(
cdn_url,
data=encrypted,
headers={'Content-Type': 'application/octet-stream'},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status != 200:
text = await resp.text()
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
download_param = resp.headers.get('x-encrypted-param', '')
if not download_param:
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
return CDNMedia(
encrypt_query_param=download_param,
aes_key=encoded_key,
encrypt_type=1,
)
async def send_image(
self,
to_user_id: str,
image_bytes: bytes,
context_token: str = '',
) -> None:
"""Upload an image to CDN and send it."""
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
item = MessageItem(
type=MessageItem.IMAGE,
image_item=ImageItem(
media=media,
aeskey=media.aes_key,
),
)
await self.send_message(to_user_id, [item], context_token)
async def send_file(
self,
to_user_id: str,
file_bytes: bytes,
file_name: str,
context_token: str = '',
) -> None:
"""Upload a file to CDN and send it."""
import hashlib
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
item = MessageItem(
type=MessageItem.FILE,
file_item=FileItem(
media=media,
file_name=file_name,
md5=hashlib.md5(file_bytes).hexdigest(),
len=str(len(file_bytes)),
),
)
await self.send_message(to_user_id, [item], context_token)
async def send_voice(
self,
to_user_id: str,
voice_bytes: bytes,
playtime: int = 0,
context_token: str = '',
) -> None:
"""Upload a voice message to CDN and send it."""
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
item = MessageItem(
type=MessageItem.VOICE,
voice_item=VoiceItem(
media=media,
playtime=playtime,
),
)
await self.send_message(to_user_id, [item], context_token)
async def get_upload_url(
self,
filekey: str,
media_type: int,
to_user_id: str,
rawsize: int,
rawfilemd5: str,
filesize: int,
thumb_rawsize: Optional[int] = None,
thumb_rawfilemd5: Optional[str] = None,
thumb_filesize: Optional[int] = None,
aeskey: Optional[str] = None,
) -> GetUploadUrlResponse:
"""Get a pre-signed CDN upload URL."""
payload: dict = {
'filekey': filekey,
'media_type': media_type,
'to_user_id': to_user_id,
'rawsize': rawsize,
'rawfilemd5': rawfilemd5,
'filesize': filesize,
'no_need_thumb': True,
}
if thumb_rawsize is not None:
payload['thumb_rawsize'] = thumb_rawsize
if thumb_rawfilemd5 is not None:
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
if thumb_filesize is not None:
payload['thumb_filesize'] = thumb_filesize
if aeskey is not None:
payload['aeskey'] = aeskey
data = await self._post('ilink/bot/getuploadurl', payload)
logger.debug('get_upload_url response: %s', data)
return GetUploadUrlResponse(
upload_param=data.get('upload_param'),
thumb_upload_param=data.get('thumb_upload_param'),
)
# -----------------------------------------------------------------------
# QR Code Login
# -----------------------------------------------------------------------
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'Failed to fetch QR code: {resp.status} {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
logger.debug(
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
)
return QRCodeResponse(
qrcode=data.get('qrcode'),
qrcode_img_content=data.get('qrcode_img_content'),
)
async def _fetch_qr_image_base64(self, url: str) -> str:
"""Generate a QR code image from the URL and return a data URI string.
The qrcode_img_content URL points to an HTML page (not a raw image),
so we generate the QR code locally using the qrcode library.
"""
import qrcode
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
return f'data:image/png;base64,{b64}'
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
headers = {'iLink-App-ClientVersion': '1'}
try:
async with session.get(
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'Failed to poll QR status: {resp.status} {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
logger.debug('QR status poll response: %s', data)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return QRStatusResponse(status='wait')
return QRStatusResponse(
status=data.get('status'),
bot_token=data.get('bot_token'),
ilink_bot_id=data.get('ilink_bot_id'),
baseurl=data.get('baseurl'),
ilink_user_id=data.get('ilink_user_id'),
)
async def login(
self,
max_retries: int = 5,
poll_timeout_ms: int = 480_000,
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
) -> LoginResult:
"""Complete QR code login flow with auto-retry on expiry.
Args:
max_retries: Max number of QR code refreshes on expiry.
poll_timeout_ms: Timeout per QR code in milliseconds.
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
new QR code is fetched. Use this to display the QR code.
on_status: Callback(status_str) called on each status poll change.
Returns:
LoginResult with token, base_url, and account_id.
Raises:
ApiError: On unrecoverable API errors.
Exception: If all retries are exhausted.
"""
last_qr_base64: Optional[str] = None
for attempt in range(max_retries):
qr_resp = await self.fetch_qrcode()
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
raise ApiError('Failed to get QR code from server', status=0)
# Convert QR image to base64 and notify caller
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
if on_qrcode:
try:
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
await result
except Exception as e:
logger.warning('on_qrcode callback error: %s', e)
# Poll until confirmed / expired / timeout
loop = asyncio.get_running_loop()
deadline = loop.time() + poll_timeout_ms / 1000.0
while loop.time() < deadline:
try:
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
except Exception as e:
logger.error('Error polling QR status: %s', e)
await asyncio.sleep(2)
continue
if on_status:
try:
cb_result = on_status(status_resp.status or 'unknown')
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
await cb_result
except Exception as e:
logger.warning('on_status callback error: %s', e)
if status_resp.status == 'confirmed' and status_resp.bot_token:
new_base_url = status_resp.baseurl or self.base_url
# Update this client instance as well
self.token = status_resp.bot_token
self.base_url = new_base_url.rstrip('/')
return LoginResult(
token=status_resp.bot_token,
base_url=new_base_url,
account_id=status_resp.ilink_bot_id or '',
qr_image_base64=last_qr_base64,
)
if status_resp.status == 'expired':
break # retry with a new QR code
await asyncio.sleep(1)
else:
# While-loop ended without break → poll timeout, treat as expired
pass
remaining = max_retries - attempt - 1
if remaining > 0:
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
else:
raise ApiError('QR code login failed: max retries exceeded', status=0)
# Should not reach here, but just in case
raise ApiError('QR code login failed', status=0)
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
if not data:
return None
return CDNMedia(
encrypt_query_param=data.get('encrypt_query_param'),
aes_key=data.get('aes_key'),
encrypt_type=data.get('encrypt_type'),
)
def _parse_message_item(data: dict) -> MessageItem:
item = MessageItem(
type=data.get('type'),
create_time_ms=data.get('create_time_ms'),
update_time_ms=data.get('update_time_ms'),
is_completed=data.get('is_completed'),
msg_id=data.get('msg_id'),
)
if data.get('text_item'):
item.text_item = TextItem(text=data['text_item'].get('text'))
if data.get('image_item'):
img = data['image_item']
item.image_item = ImageItem(
media=_parse_cdn_media(img.get('media')),
thumb_media=_parse_cdn_media(img.get('thumb_media')),
aeskey=img.get('aeskey'),
url=img.get('url'),
mid_size=img.get('mid_size'),
)
if data.get('voice_item'):
v = data['voice_item']
item.voice_item = VoiceItem(
media=_parse_cdn_media(v.get('media')),
encode_type=v.get('encode_type'),
playtime=v.get('playtime'),
text=v.get('text'),
)
if data.get('file_item'):
f = data['file_item']
item.file_item = FileItem(
media=_parse_cdn_media(f.get('media')),
file_name=f.get('file_name'),
md5=f.get('md5'),
len=f.get('len'),
)
if data.get('video_item'):
vid = data['video_item']
item.video_item = VideoItem(
media=_parse_cdn_media(vid.get('media')),
video_size=vid.get('video_size'),
play_length=vid.get('play_length'),
video_md5=vid.get('video_md5'),
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
)
if data.get('ref_msg'):
ref = data['ref_msg']
item.ref_msg = RefMessage(
title=ref.get('title'),
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
)
return item
def _parse_weixin_message(data: dict) -> WeixinMessage:
msg = WeixinMessage(
seq=data.get('seq'),
message_id=data.get('message_id'),
from_user_id=data.get('from_user_id'),
to_user_id=data.get('to_user_id'),
client_id=data.get('client_id'),
create_time_ms=data.get('create_time_ms'),
session_id=data.get('session_id'),
group_id=data.get('group_id'),
message_type=data.get('message_type'),
message_state=data.get('message_state'),
context_token=data.get('context_token'),
)
if data.get('item_list'):
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
return msg
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
resp = GetUpdatesResponse(
ret=data.get('ret'),
errcode=data.get('errcode'),
errmsg=data.get('errmsg'),
get_updates_buf=data.get('get_updates_buf'),
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
)
if data.get('msgs'):
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
return resp
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
if not media:
return None
d: dict = {}
if media.encrypt_query_param is not None:
d['encrypt_query_param'] = media.encrypt_query_param
if media.aes_key is not None:
d['aes_key'] = media.aes_key
if media.encrypt_type is not None:
d['encrypt_type'] = media.encrypt_type
return d or None
def _message_item_to_dict(item: MessageItem) -> dict:
d: dict = {'type': item.type}
if item.text_item:
d['text_item'] = {'text': item.text_item.text}
if item.image_item:
img_d: dict = {}
if item.image_item.media:
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
if item.image_item.mid_size is not None:
img_d['mid_size'] = item.image_item.mid_size
d['image_item'] = img_d
if item.voice_item:
voice_d: dict = {}
if item.voice_item.media:
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
if item.voice_item.playtime is not None:
voice_d['playtime'] = item.voice_item.playtime
d['voice_item'] = voice_d
if item.file_item:
file_d: dict = {}
if item.file_item.media:
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
if item.file_item.file_name:
file_d['file_name'] = item.file_item.file_name
if item.file_item.len:
file_d['len'] = item.file_item.len
d['file_item'] = file_d
if item.video_item:
vid_d: dict = {}
if item.video_item.media:
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
if item.video_item.video_size is not None:
vid_d['video_size'] = item.video_item.video_size
d['video_item'] = vid_d
return d

View File

@@ -1,200 +0,0 @@
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
SESSION_EXPIRED_ERRCODE = -14
class ApiError(Exception):
"""Structured error raised by the OpenClaw WeChat API."""
def __init__(
self,
message: str,
*,
status: int = 0,
code: int | None = None,
payload: Any = None,
):
super().__init__(message)
self.status = status
self.code = code
self.payload = payload
@property
def is_session_expired(self) -> bool:
return self.code == SESSION_EXPIRED_ERRCODE
@dataclass
class CDNMedia:
encrypt_query_param: Optional[str] = None
aes_key: Optional[str] = None
encrypt_type: Optional[int] = None
@dataclass
class TextItem:
text: Optional[str] = None
@dataclass
class ImageItem:
media: Optional[CDNMedia] = None
thumb_media: Optional[CDNMedia] = None
aeskey: Optional[str] = None
url: Optional[str] = None
mid_size: Optional[int] = None
thumb_size: Optional[int] = None
thumb_height: Optional[int] = None
thumb_width: Optional[int] = None
hd_size: Optional[int] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class VoiceItem:
media: Optional[CDNMedia] = None
encode_type: Optional[int] = None
bits_per_sample: Optional[int] = None
sample_rate: Optional[int] = None
playtime: Optional[int] = None
text: Optional[str] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class FileItem:
media: Optional[CDNMedia] = None
file_name: Optional[str] = None
md5: Optional[str] = None
len: Optional[str] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class VideoItem:
media: Optional[CDNMedia] = None
video_size: Optional[int] = None
play_length: Optional[int] = None
video_md5: Optional[str] = None
thumb_media: Optional[CDNMedia] = None
thumb_size: Optional[int] = None
thumb_height: Optional[int] = None
thumb_width: Optional[int] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class RefMessage:
message_item: Optional[MessageItem] = None
title: Optional[str] = None
@dataclass
class MessageItem:
"""A single content item inside a WeixinMessage."""
# Item types
NONE = 0
TEXT = 1
IMAGE = 2
VOICE = 3
FILE = 4
VIDEO = 5
type: Optional[int] = None
create_time_ms: Optional[int] = None
update_time_ms: Optional[int] = None
is_completed: Optional[bool] = None
msg_id: Optional[str] = None
ref_msg: Optional[RefMessage] = None
text_item: Optional[TextItem] = None
image_item: Optional[ImageItem] = None
voice_item: Optional[VoiceItem] = None
file_item: Optional[FileItem] = None
video_item: Optional[VideoItem] = None
@dataclass
class WeixinMessage:
"""Unified message from getUpdates or for sendMessage."""
# Message types
TYPE_USER = 1
TYPE_BOT = 2
# Message states
STATE_NEW = 0
STATE_GENERATING = 1
STATE_FINISH = 2
seq: Optional[int] = None
message_id: Optional[int] = None
from_user_id: Optional[str] = None
to_user_id: Optional[str] = None
client_id: Optional[str] = None
create_time_ms: Optional[int] = None
update_time_ms: Optional[int] = None
delete_time_ms: Optional[int] = None
session_id: Optional[str] = None
group_id: Optional[str] = None
message_type: Optional[int] = None
message_state: Optional[int] = None
item_list: Optional[list[MessageItem]] = None
context_token: Optional[str] = None
@dataclass
class GetUpdatesResponse:
ret: Optional[int] = None
errcode: Optional[int] = None
errmsg: Optional[str] = None
msgs: list[WeixinMessage] = field(default_factory=list)
get_updates_buf: Optional[str] = None
longpolling_timeout_ms: Optional[int] = None
@dataclass
class GetConfigResponse:
ret: Optional[int] = None
errmsg: Optional[str] = None
typing_ticket: Optional[str] = None
@dataclass
class GetUploadUrlResponse:
upload_param: Optional[str] = None
thumb_upload_param: Optional[str] = None
@dataclass
class QRCodeResponse:
"""Response from get_bot_qrcode endpoint."""
qrcode: Optional[str] = None
qrcode_img_content: Optional[str] = None
@dataclass
class QRStatusResponse:
"""Response from get_qrcode_status endpoint."""
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
bot_token: Optional[str] = None
ilink_bot_id: Optional[str] = None
baseurl: Optional[str] = None
ilink_user_id: Optional[str] = None
@dataclass
class LoginResult:
"""Result returned by the login flow."""
token: str
base_url: str
account_id: str
qr_image_base64: Optional[str] = None # data URI of the last QR code shown

View File

@@ -1,10 +1,8 @@
import re
import time
import asyncio
from quart import request
import httpx
from quart import Quart
from typing import Callable, Dict, Any, Optional
from typing import Callable, Dict, Any
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from .qqofficialevent import QQOfficialEvent
import json
@@ -34,8 +32,6 @@ class QQOfficialClient:
self.access_token = ''
self.access_token_expiry_time = None
self.logger = logger
self._msg_seq_counter = 0
self._token_refresh_task: Optional[asyncio.Task] = None
async def check_access_token(self):
"""检查access_token是否存在"""
@@ -54,18 +50,18 @@ class QQOfficialClient:
headers = {
'content-type': 'application/json',
}
response = await client.post(url, json=params, headers=headers)
if response.status_code != 200:
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
response_data = response.json()
access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
else:
raise Exception('Failed to get access_token: no access_token in response')
try:
response = await client.post(url, json=params, headers=headers)
if response.status_code == 200:
response_data = response.json()
access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
except Exception as e:
await self.logger.error(f'获取access_token失败: {response_data}')
raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
@@ -91,10 +87,10 @@ class QQOfficialClient:
try:
body = await req.get_data()
await self.logger.info(f'Received request, body length: {len(body)}')
print(f'[QQ Official] Received request, body length: {len(body)}')
if not body or len(body) == 0:
await self.logger.info('Received empty body, might be health check or GET request')
print('[QQ Official] Received empty body, might be health check or GET request')
return {'code': 0, 'message': 'ok'}, 200
payload = json.loads(body)
@@ -115,6 +111,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'}
except Exception as e:
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
return {'error': str(e)}, 400
@@ -142,24 +139,21 @@ class QQOfficialClient:
async def get_message(self, msg: dict) -> Dict[str, Any]:
"""获取消息"""
d = msg.get('d', {})
if not isinstance(d, dict):
return {}
message_data = {
't': msg.get('t', {}),
'user_openid': d.get('author', {}).get('user_openid', {}),
'timestamp': d.get('timestamp', {}),
'd_author_id': d.get('author', {}).get('id', {}),
'content': d.get('content', {}),
'd_id': d.get('id', {}),
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
'timestamp': msg.get('d', {}).get('timestamp', {}),
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
'content': msg.get('d', {}).get('content', {}),
'd_id': msg.get('d', {}).get('id', {}),
'id': msg.get('id', {}),
'channel_id': d.get('channel_id', {}),
'username': d.get('author', {}).get('username', {}),
'guild_id': d.get('guild_id', {}),
'member_openid': d.get('author', {}).get('openid', {}),
'group_openid': d.get('group_openid', {}),
'channel_id': msg.get('d', {}).get('channel_id', {}),
'username': msg.get('d', {}).get('author', {}).get('username', {}),
'guild_id': msg.get('d', {}).get('guild_id', {}),
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
'group_openid': msg.get('d', {}).get('group_openid', {}),
}
attachments = d.get('attachments', [])
attachments = msg.get('d', {}).get('attachments', [])
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
@@ -198,7 +192,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f'Failed to send private message: {response_data}')
await self.logger.error(f'发送私聊消息失败: {response_data}')
raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
@@ -221,7 +215,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f'Failed to send group message: {response.json()}')
await self.logger.error(f'发送群聊消息失败:{response.json()}')
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
@@ -244,7 +238,7 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'Failed to send channel group message: {response.json()}')
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
@@ -267,224 +261,9 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'Failed to send channel private message: {response.json()}')
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
raise Exception(response)
# ---- 富媒体消息 ----
# 媒体文件类型
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
async def upload_media(
self,
target_type: str,
target_id: str,
file_type: int,
file_url: str = None,
file_data: str = None,
file_name: str = None,
) -> str:
"""上传媒体文件,返回 file_info。
Args:
target_type: 'c2c' | 'group'
target_id: 用户 openid 或群 openid
file_type: 1=图片, 2=视频, 3=语音, 4=文件
file_url: 在线 URL与 file_data 二选一)
file_data: base64 编码的文件数据或 data URL与 file_url 二选一)
file_name: 文件名file_type=4 时必填)
"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/files'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/files'
else:
raise ValueError(f'Unsupported target_type: {target_type}')
body = {
'file_type': file_type,
'srv_send_msg': False,
}
if file_url:
body['url'] = file_url
elif file_data:
# 处理 data URL 格式: data:image/png;base64,xxxxx
if file_data.startswith('data:'):
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
if match:
body['file_data'] = match.group(1)
else:
body['file_data'] = file_data
else:
body['file_data'] = file_data
else:
raise ValueError('file_url or file_data is required')
if file_type == self.MEDIA_TYPE_FILE and file_name:
body['file_name'] = file_name
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code == 200:
data = response.json()
file_info = data.get('file_info', '')
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
await self.logger.info(f'Upload media success, file_info={preview}')
return file_info
else:
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
async def _send_media_msg(
self,
target_type: str,
target_id: str,
file_info: str,
msg_id: str = None,
content: str = None,
):
"""发送富媒体消息msg_type=7"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
else:
raise ValueError(f'Unsupported target_type: {target_type}')
self._msg_seq_counter += 1
msg_seq = self._msg_seq_counter
body = {
'msg_type': 7,
'media': {'file_info': file_info},
'msg_seq': msg_seq,
}
if content:
body['content'] = content
if msg_id:
body['msg_id'] = msg_id
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
async def send_image_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
msg_id: str = None,
content: str = None,
):
"""发送图片消息"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_IMAGE,
file_url=file_url,
file_data=file_data,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
async def send_voice_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
msg_id: str = None,
):
"""发送语音消息"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_VOICE,
file_url=file_url,
file_data=file_data,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id)
async def send_file_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
file_name: str = None,
msg_id: str = None,
):
"""发送文件消息(含视频)"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_FILE,
file_url=file_url,
file_data=file_data,
file_name=file_name,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id)
async def send_stream_msg(
self,
user_openid: str,
content: str,
event_id: str,
msg_id: str,
msg_seq: int = 1,
index: int = 0,
stream_msg_id: str = None,
input_state: int = 1,
):
"""发送流式消息C2C 私聊)。
Args:
input_state: 1=生成中, 10=生成结束
"""
if not await self.check_access_token():
await self.get_access_token()
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
body = {
'input_mode': 'replace',
'input_state': input_state,
'content_type': 'markdown',
'content_raw': content,
'event_id': event_id,
'msg_id': msg_id,
'msg_seq': msg_seq,
'index': index,
}
if stream_msg_id:
body['stream_msg_id'] = stream_msg_id
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
return response.json()
async def is_token_expired(self):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
@@ -513,325 +292,3 @@ class QQOfficialClient:
'signature': signature,
}
return response
# ---- WebSocket Gateway ----
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
INTENT_GUILDS = 1 << 0
INTENT_GUILD_MEMBERS = 1 << 1
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
INTENT_DIRECT_MESSAGE = 1 << 12
INTENT_GROUP_AND_C2C = 1 << 25
INTENT_INTERACTION = 1 << 26
FULL_INTENTS = (
INTENT_GUILDS
| INTENT_GUILD_MEMBERS
| INTENT_PUBLIC_GUILD_MESSAGES
| INTENT_DIRECT_MESSAGE
| INTENT_GROUP_AND_C2C
| INTENT_INTERACTION
)
async def get_gateway_url(self) -> str:
"""获取 WebSocket 网关地址"""
if not await self.check_access_token():
await self.get_access_token()
url = f'{self.base_url}/gateway'
async with httpx.AsyncClient() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
}
response = await client.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
ws_url = data.get('url', '')
if not ws_url:
raise Exception('Gateway URL is empty')
return ws_url
else:
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
async def _background_token_refresh(self):
"""在 token 到期前主动刷新"""
try:
while True:
if self.access_token_expiry_time:
remain = self.access_token_expiry_time - time.time()
if remain > 120:
await asyncio.sleep(remain - 60)
continue
self.access_token = ''
self.access_token_expiry_time = None
if await self.check_access_token():
await asyncio.sleep(60)
else:
await self.get_access_token()
await asyncio.sleep(60)
except asyncio.CancelledError:
pass
async def connect_gateway(
self,
on_event: Callable[[str, dict], Any],
on_ready: Optional[Callable[[], Any]] = None,
on_error: Optional[Callable[[Exception], Any]] = None,
):
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
Args:
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
on_ready: 连接就绪 (收到 READY) 时的回调
on_error: 发生错误时的回调
"""
import websockets
session_id = ''
last_seq = 0
reconnect_attempts = 0
max_reconnect_attempts = 100
backoff_delays = [1, 2, 5, 10, 30, 60]
rate_limit_delay = 60
# Cancel previous token refresh task if any
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = None
while reconnect_attempts <= max_reconnect_attempts:
heartbeat_interval = 45000
should_refresh_token = False
ws = None
heartbeat_task = None
# Refresh token if needed
if should_refresh_token:
self.access_token = ''
self.access_token_expiry_time = None
try:
ws_url = await self.get_gateway_url()
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
except Exception as e:
error_msg = str(e)
await self.logger.error(f'Failed to get gateway URL: {e}')
reconnect_attempts += 1
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
delay = rate_limit_delay
else:
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
continue
try:
await self.logger.info('Connecting to WebSocket gateway...')
ws = await websockets.connect(ws_url)
await self.logger.info('WebSocket connected')
except Exception as e:
await self.logger.error(f'WebSocket connection failed: {e}')
reconnect_attempts += 1
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
continue
try:
async for raw_msg in ws:
try:
payload = json.loads(raw_msg)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {raw_msg}')
continue
op = payload.get('op')
d = payload.get('d', {})
s = payload.get('s')
t = payload.get('t')
if not isinstance(d, dict):
d = {}
if op == 10: # Hello
heartbeat_interval = d.get('heartbeat_interval', 45000)
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
# Send Identify or Resume
if session_id and last_seq > 0:
resume_payload = {
'op': 6,
'd': {
'token': f'QQBot {self.access_token}',
'session_id': session_id,
'seq': last_seq,
},
}
await ws.send(json.dumps(resume_payload))
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
else:
identify_payload = {
'op': 2,
'd': {
'token': f'QQBot {self.access_token}',
'intents': self.FULL_INTENTS,
'shard': [0, 1],
},
}
await ws.send(json.dumps(identify_payload))
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
# Start heartbeat
async def _heartbeat_loop(conn, interval_ms):
interval_sec = interval_ms / 1000.0
try:
while True:
await asyncio.sleep(interval_sec)
try:
hb_payload = {'op': 1, 'd': last_seq}
await conn.send(json.dumps(hb_payload))
except Exception:
break
except asyncio.CancelledError:
pass
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
elif op == 0: # Dispatch
if s is not None:
last_seq = s
if t == 'READY':
session_id = d.get('session_id', '')
reconnect_attempts = 0
await self.logger.info(f'READY, session_id={session_id}')
if on_ready:
try:
result = on_ready()
if asyncio.iscoroutine(result):
await result
except Exception:
pass
# Track token refresh task to avoid leaks
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
elif t == 'RESUMED':
reconnect_attempts = 0
await self.logger.info('RESUMED')
else:
await self.logger.debug(f'Received event: {t}, seq={s}')
if on_event:
try:
result = on_event(t, d)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
elif op == 11: # Heartbeat ACK
pass
elif op == 7: # Reconnect
await self.logger.info('Received Reconnect directive')
break
elif op == 9: # Invalid Session
can_resume = d.get('can_resume', False)
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
if not can_resume:
session_id = ''
last_seq = 0
should_refresh_token = True
break
# Connection closed normally (end of async for)
try:
close_code = ws.close_code
close_reason = ws.close_reason or ''
except Exception:
close_code = None
close_reason = ''
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
if close_code == 4004:
should_refresh_token = True
elif close_code in (4006, 4007, 4009):
session_id = ''
last_seq = 0
should_refresh_token = True
elif close_code == 4008:
reconnect_attempts += 1
delay = rate_limit_delay
await self.logger.info(
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
)
await asyncio.sleep(delay)
continue
elif close_code in (4914, 4915):
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
if on_error:
await self._safe_callback(on_error, err)
return
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
session_id = ''
last_seq = 0
if close_code == 1000:
return
except asyncio.CancelledError:
raise
except Exception:
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
finally:
if heartbeat_task:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
if ws:
try:
await ws.close()
except Exception:
pass
# If we reach here, we need to reconnect
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
if on_error:
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
return
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
async def _safe_callback(self, callback, *args):
"""Safely invoke a callback, handling both sync and async functions."""
try:
result = callback(*args)
if asyncio.iscoroutine(result):
await result
except Exception:
pass
async def connect_gateway_loop(
self,
on_event: Callable[[str, dict], Any],
on_ready: Optional[Callable[[], Any]] = None,
on_error: Optional[Callable[[Exception], Any]] = None,
):
"""持续重连的网关循环。"""
await self.connect_gateway(on_event, on_ready, on_error)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import asyncio
import base64
import json
@@ -8,8 +6,7 @@ import traceback
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
import re
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
from typing import Any, Callable, Optional
from urllib.parse import unquote
import httpx
@@ -18,9 +15,7 @@ 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
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.platform.logger import EventLogger
@dataclass
@@ -68,25 +63,16 @@ class StreamSession:
# 缓存最近一次片段,处理重试或超时兜底
last_chunk: Optional[StreamChunk] = None
# 反馈 ID用于接收用户点赞/点踩反馈
feedback_id: Optional[str] = None
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
# Sessions with registered feedback_ids use a longer TTL to survive the
# full like → cancel → dislike feedback flow. Must align with the adapter's
# _stream_to_monitoring_msg TTL (wecombot.py).
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
self.logger = logger
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
if not msg_id:
@@ -96,32 +82,6 @@ class StreamSessionManager:
def get_session(self, stream_id: str) -> Optional[StreamSession]:
return self._sessions.get(stream_id)
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
"""根据 feedback_id 查找会话。
Args:
feedback_id: 企业微信反馈事件中的反馈 ID。
Returns:
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
"""
if not feedback_id:
return None
stream_id = self._feedback_index.get(feedback_id)
if stream_id:
return self._sessions.get(stream_id)
return None
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
"""注册 feedback_id 与 stream_id 的映射。
Args:
stream_id: 企业微信流式会话 ID。
feedback_id: 反馈 ID。
"""
if feedback_id and stream_id:
self._feedback_index[feedback_id] = stream_id
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
"""根据企业微信回调创建或获取会话。
@@ -223,17 +183,11 @@ class StreamSessionManager:
session.last_access = time.time()
def cleanup(self) -> None:
"""定期清理过期会话,防止队列与映射无上限累积。
已注册 feedback_id 的会话使用更长的 TTL确保用户在点赞/取消/点踩流程中
不会因为 session 被提前清除而丢失上下文信息。
"""
"""定期清理过期会话,防止队列与映射无上限累积。"""
now = time.time()
expired: list[str] = []
for stream_id, session in self._sessions.items():
# Sessions with registered feedback_ids use a longer TTL
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
if now - session.last_access > effective_ttl:
if now - session.last_access > self.ttl:
expired.append(stream_id)
for stream_id in expired:
@@ -243,488 +197,6 @@ class StreamSessionManager:
msg_id = session.msg_id
if msg_id and self._msg_index.get(msg_id) == stream_id:
self._msg_index.pop(msg_id, None)
# Clean up feedback index for expired sessions
if session.feedback_id:
self._feedback_index.pop(session.feedback_id, None)
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
"""Decrypt AES-256-CBC encrypted file data.
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
Args:
encrypted_data: The raw encrypted bytes.
aes_key_str: Base64-encoded AES key (may lack padding).
Returns:
Decrypted bytes with PKCS#7 padding removed.
"""
if not encrypted_data:
raise ValueError('encrypted_data is empty')
if not aes_key_str:
raise ValueError('aes_key is empty')
# Python's base64.b64decode requires proper padding (length % 4 == 0).
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
remainder = len(aes_key_str) % 4
if remainder != 0:
aes_key_str = aes_key_str + '=' * (4 - remainder)
key = base64.b64decode(aes_key_str)
iv = key[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
# Ensure encrypted data is aligned to AES block size (16 bytes).
# Node.js setAutoPadding(false) silently handles unaligned data,
# but PyCryptodome will raise an error.
block_size = 16
data_remainder = len(encrypted_data) % block_size
if data_remainder != 0:
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
decrypted = cipher.decrypt(encrypted_data)
# Remove PKCS#7 padding with validation
if len(decrypted) == 0:
raise ValueError('Decrypted data is empty')
pad_len = decrypted[-1]
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
# Verify all padding bytes are consistent
for i in range(len(decrypted) - pad_len, len(decrypted)):
if decrypted[i] != pad_len:
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
return decrypted[: len(decrypted) - pad_len]
def _extract_filename(content_disposition: str) -> Optional[str]:
"""Extract filename from a Content-Disposition header value."""
if not content_disposition:
return None
# RFC 5987: filename*=UTF-8''xxx
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
if utf8_match:
return unquote(utf8_match.group(1))
# Standard: filename="xxx" or filename=xxx
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
if match:
return unquote(match.group(1))
return None
def _bytes_to_data_uri(data: bytes) -> str:
"""Convert raw bytes to a data URI with auto-detected MIME type."""
if data.startswith(b'\xff\xd8'):
mime_type = 'image/jpeg'
elif data.startswith(b'\x89PNG'):
mime_type = 'image/png'
elif data.startswith((b'GIF87a', b'GIF89a')):
mime_type = 'image/gif'
elif data.startswith(b'BM'):
mime_type = 'image/bmp'
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
mime_type = 'image/tiff'
elif data[:4] == b'%PDF':
mime_type = 'application/pdf'
elif data[:4] == b'PK\x03\x04':
mime_type = 'application/zip'
else:
mime_type = 'application/octet-stream'
base64_str = base64.b64encode(data).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
async def download_encrypted_file(
download_url: str, aes_key: str, logger: EventLogger
) -> Tuple[Optional[bytes], Optional[str]]:
"""Download an AES-encrypted file from WeChat Work and decrypt it.
Args:
download_url: The encrypted file download URL.
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
or platform EncodingAESKey).
logger: Logger instance.
Returns:
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
"""
if not download_url:
return None, None
if not aes_key:
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
return None, None
filename: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(download_url)
if response.status_code != 200:
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
return None, None
encrypted_bytes = response.content
filename = _extract_filename(response.headers.get('content-disposition', ''))
except Exception:
await logger.error(f'Failed to download file: {traceback.format_exc()}')
return None, None
try:
decrypted = _decrypt_file(encrypted_bytes, aes_key)
return decrypted, filename
except Exception:
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
return None, None
async def parse_wecom_bot_message(
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
) -> dict[str, Any]:
"""Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.
This is the shared message parsing logic used by both webhook and WebSocket modes.
Args:
msg_json: The decrypted message JSON from WeChat Work.
encoding_aes_key: AES key for file decryption.
logger: Logger instance.
Returns:
A dict suitable for constructing a WecomBotEvent.
"""
message_data: dict[str, Any] = {}
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
max_inline_file_size = 5 * 1024 * 1024
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
if not url:
return None, None
key = per_msg_aeskey or encoding_aes_key
if not key:
await logger.warning('No AES key available for file decryption, skipping download')
return None, None
return await download_encrypted_file(url, key, logger)
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
"""Download, decrypt, and convert to data URI for backward compatibility."""
data, _filename = await _safe_download(url, per_msg_aeskey)
if data:
return _bytes_to_data_uri(data)
return None
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
image_info = msg_json.get('image', {})
picurl = image_info.get('url', '')
per_msg_aeskey = image_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
if base64_data:
message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
per_msg_aeskey = voice_info.get('aeskey', '')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if voice_base64:
# message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
per_msg_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if video_base64:
# video_data['base64'] = video_base64
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
per_msg_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
# if file_bytes:
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
# if dl_filename and not file_data.get('filename'):
# file_data['filename'] = dl_filename
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
voices = []
videos = []
links = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
if texts:
message_data['content'] = ' '.join(texts)
if images:
message_data['images'] = images
message_data['picurl'] = images[0]
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
from_info = msg_json.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
if msg_json.get('chattype', '') == 'group':
message_data['chatid'] = msg_json.get('chatid', '')
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
message_data['msgid'] = msg_json.get('msgid', '')
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
# Handle quote (referenced message) - important for group chat file references
quote_info = msg_json.get('quote')
if quote_info:
quote_data: dict[str, Any] = {}
quote_type = quote_info.get('msgtype', '')
quote_data['msgtype'] = quote_type
if quote_type == 'text':
quote_data['content'] = quote_info.get('text', {}).get('content', '')
elif quote_type == 'image':
img_info = quote_info.get('image', {})
img_url = img_info.get('url', '')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
quote_data['picurl'] = base64_data
quote_data['images'] = [base64_data]
elif quote_type == 'file':
file_info = quote_info.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['file'] = file_data
elif quote_type == 'voice':
voice_info = quote_info.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
quote_data['content'] = voice_info.get('content')
# Same as private chat: append aeskey to url for plugin processing
if download_url and item_aeskey:
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['voice'] = voice_data
elif quote_type == 'video':
video_info = quote_info.get('video', {}) or {}
download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['video'] = video_data
elif quote_type == 'link':
quote_data['link'] = quote_info.get('link', {})
link = quote_data['link']
title = link.get('title', '')
desc = link.get('description') or link.get('digest', '')
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
elif quote_type == 'mixed':
# Handle mixed type in quote (text + images + files etc.)
items = quote_info.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
files.append(file_data)
if texts:
quote_data['content'] = ' '.join(texts)
if images:
quote_data['images'] = images
quote_data['picurl'] = images[0]
if files:
quote_data['files'] = files
quote_data['file'] = files[0]
message_data['quote'] = quote_data
return message_data
class WecomBotClient:
@@ -764,27 +236,14 @@ class WecomBotClient:
self.stream_sessions = StreamSessionManager(logger=logger)
self.stream_poll_timeout = 0.5
self._feedback_callback: Optional[Callable] = None
def set_feedback_callback(self, callback: Callable) -> None:
"""设置反馈回调函数。
Args:
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
"""
self._feedback_callback = callback
@staticmethod
def _build_stream_payload(
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
) -> dict[str, Any]:
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
"""按照企业微信协议拼装返回报文。
Args:
stream_id: 企业微信会话 ID。
content: 推送的文本内容。
finish: 是否为最终片段。
feedback_id: 反馈 ID用于接收用户点赞/点踩反馈。
Returns:
dict[str, Any]: 可直接加密返回的 payload。
@@ -792,16 +251,13 @@ class WecomBotClient:
Example:
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
return {
'msgtype': 'stream',
'stream': stream_payload,
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
}
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -857,14 +313,9 @@ class WecomBotClient:
"""
session, is_new = self.stream_sessions.create_or_get(msg_json)
feedback_id = str(uuid.uuid4())
session.feedback_id = feedback_id
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
message_data = await self.get_message(msg_json)
if message_data:
message_data['stream_id'] = session.stream_id
message_data['feedback_id'] = feedback_id
try:
event = wecombotevent.WecomBotEvent(message_data)
except Exception:
@@ -873,7 +324,7 @@ class WecomBotClient:
if is_new:
asyncio.create_task(self._dispatch_event(event))
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
payload = self._build_stream_payload(session.stream_id, '', False)
return await self._encrypt_and_reply(payload, nonce)
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -998,83 +449,202 @@ class WecomBotClient:
msg_json = json.loads(decrypted_xml)
event = msg_json.get('event', {})
event_type = event.get('eventtype', '')
if event_type == 'feedback_event':
return await self._handle_feedback_event(msg_json, nonce)
if msg_json.get('msgtype') == 'stream':
return await self._handle_post_followup_response(msg_json, nonce)
return await self._handle_post_initial_response(msg_json, nonce)
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信用户反馈事件(点赞/点踩)。
Args:
msg_json: 解密后的企业微信反馈事件 JSON。
nonce: 企业微信回调参数 nonce。
Returns:
Tuple[Response, int]: Quart Response 及状态码。
Note:
企业微信协议要求:反馈事件目前仅支持回复空包。
"""
try:
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
if session:
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
# Dispatch feedback event regardless of session availability
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
except Exception:
await self.logger.error(traceback.format_exc())
return await self._encrypt_and_reply({}, nonce)
async def get_message(self, msg_json):
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
message_data = {}
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
async def _safe_download(url: str):
if not url:
return None
return await self.download_url_to_base64(url, self.EnCodingAESKey)
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '')
base64_data = await _safe_download(picurl)
if base64_data:
message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
voices = []
videos = []
links = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_url = item.get('image', {}).get('url')
base64_data = await _safe_download(img_url)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
if texts:
message_data['content'] = ' '.join(texts) # 拼接所有 text
if images:
message_data['images'] = images
message_data['picurl'] = images[0] # 只保留第一个 image
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
# Extract user information
from_info = msg_json.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = (
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
)
# Extract chat/group information
if msg_json.get('chattype', '') == 'group':
message_data['chatid'] = msg_json.get('chatid', '')
# Try to get group name if available
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
message_data['msgid'] = msg_json.get('msgid', '')
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
return message_data
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
"""
@@ -1141,20 +711,40 @@ class WecomBotClient:
return decorator
def on_feedback(self):
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key):
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data:
return _bytes_to_data_uri(data)
return None
async with httpx.AsyncClient() as client:
response = await client.get(download_url)
if response.status_code != 200:
await self.logger.error(f'failed to get file: {response.text}')
return None
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
iv = aes_key[:16]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted_bytes)
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
if decrypted.startswith(b'\xff\xd8'): # JPEG
mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'): # PNG
mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
mime_type = 'image/gif'
elif decrypted.startswith(b'BM'): # BMP
mime_type = 'image/bmp'
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
mime_type = 'image/tiff'
else:
mime_type = 'application/octet-stream'
# 转 base64
base64_str = base64.b64encode(decrypted).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
async def run_task(self, host: str, port: int, *args, **kwargs):
"""

View File

@@ -133,24 +133,3 @@ class WecomBotEvent(dict):
AI Bot ID
"""
return self.get('aibotid', '')
@property
def feedback_id(self) -> str:
"""
反馈 ID用于关联用户点赞/点踩反馈
"""
return self.get('feedback_id', '')
@property
def stream_id(self) -> str:
"""
流式消息 ID
"""
return self.get('stream_id', '')
@property
def quote(self):
"""
引用消息信息(群聊中用户引用其他消息时返回)
"""
return self.get('quote', {})

View File

@@ -1,685 +0,0 @@
"""WeChat Work AI Bot WebSocket long connection client.
Implements the WebSocket protocol for receiving messages and sending replies
via a persistent connection to wss://openws.work.weixin.qq.com, as an
alternative to the HTTP callback (webhook) mode.
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
"""
from __future__ import annotations
import asyncio
import json
import secrets
import time
import traceback
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
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
# WebSocket frame command constants
CMD_SUBSCRIBE = 'aibot_subscribe'
CMD_HEARTBEAT = 'ping'
CMD_MSG_CALLBACK = 'aibot_msg_callback'
CMD_EVENT_CALLBACK = 'aibot_event_callback'
CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg'
def _generate_req_id(prefix: str) -> str:
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
ts = int(time.time() * 1000)
rand = secrets.token_hex(4)
return f'{prefix}_{ts}_{rand}'
class WecomBotWsClient:
"""WeChat Work AI Bot WebSocket long connection client.
Provides message receiving, streaming reply, proactive message sending,
and event callback handling over a persistent WebSocket connection.
"""
def __init__(
self,
bot_id: str,
secret: str,
logger: EventLogger,
encoding_aes_key: str = '',
ws_url: str = DEFAULT_WS_URL,
heartbeat_interval: float = 30.0,
max_reconnect_attempts: int = -1,
reconnect_base_delay: float = 1.0,
reconnect_max_delay: float = 30.0,
):
self.bot_id = bot_id
self.secret = secret
self.logger = logger
self.encoding_aes_key = encoding_aes_key
self.ws_url = ws_url
self.heartbeat_interval = heartbeat_interval
self.max_reconnect_attempts = max_reconnect_attempts
self.reconnect_base_delay = reconnect_base_delay
self.reconnect_max_delay = reconnect_max_delay
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._session: Optional[aiohttp.ClientSession] = None
self._running = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._missed_pong_count = 0
self._max_missed_pong = 2
self._reconnect_attempts = 0
# Message handler registry (same pattern as WecomBotClient)
self._message_handlers: dict[str, list[Callable]] = {}
# Message deduplication
self._msg_id_map: dict[str, int] = {}
# Pending ACK futures: req_id -> Future[dict]
self._pending_acks: dict[str, asyncio.Future] = {}
# Per-req_id serial reply queues
self._reply_queues: dict[str, asyncio.Queue] = {}
self._reply_workers: dict[str, asyncio.Task] = {}
self._reply_ack_timeout = 5.0
# Stream ID tracking for WebSocket mode
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# Stream session info for feedback tracking
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
# Feedback tracking: feedback_id -> session info
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
# msg_id -> feedback_id (for associating feedback with message)
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
# ── Public API ──────────────────────────────────────────────────
async def connect(self):
"""Connect to WebSocket server with automatic reconnection.
This method blocks until disconnect() is called or max reconnect
attempts are exhausted.
"""
self._running = True
self._reconnect_attempts = 0
while self._running:
try:
await self._connect_once()
except Exception:
if not self._running:
break
await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')
if not self._running:
break
# Reconnect with exponential backoff
if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:
await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')
break
self._reconnect_attempts += 1
delay = min(
self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),
self.reconnect_max_delay,
)
await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')
await asyncio.sleep(delay)
async def disconnect(self):
"""Gracefully disconnect from the WebSocket server."""
self._running = False
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
for task in self._reply_workers.values():
if not task.done():
task.cancel()
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def on_message(self, msg_type: str) -> Callable:
"""Decorator to register a message handler.
Same interface as WecomBotClient.on_message for compatibility.
Args:
msg_type: 'single', 'group', or specific message type.
"""
def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
def on_feedback(self) -> Callable:
"""Decorator to register a feedback event handler.
Same interface as WecomBotClient.on_feedback for compatibility.
"""
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def reply_stream(
self,
req_id: str,
stream_id: str,
content: str,
finish: bool = False,
feedback_id: str = '',
) -> Optional[dict]:
"""Send a streaming reply frame.
Args:
req_id: The req_id from the original message frame (must be passed through).
stream_id: The stream ID for this streaming session.
content: The content to send (supports Markdown).
finish: Whether this is the final chunk.
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
Returns:
The ACK frame dict, or None on failure.
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
body = {
'msgtype': 'stream',
'stream': stream_payload,
}
return await self._send_reply(req_id, body)
async def reply_text(self, req_id: str, content: str) -> Optional[dict]:
"""Send a non-streaming text reply.
Args:
req_id: The req_id from the original message frame.
content: The text content to reply.
Returns:
The ACK frame dict, or None on failure.
"""
body = {
'msgtype': 'markdown',
'markdown': {
'content': content,
},
}
return await self._send_reply(req_id, body)
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
"""Proactively send a message to a specified chat.
Args:
chat_id: The chat ID (userid for single chat, chatid for group chat).
content: The message content.
msgtype: Message type, 'markdown' by default.
Returns:
The ACK frame dict, or None on failure.
"""
req_id = _generate_req_id(CMD_SEND_MSG)
body: dict[str, Any] = {
'chatid': chat_id,
'msgtype': msgtype,
}
if msgtype == 'markdown':
body['markdown'] = {'content': content}
elif msgtype == 'text':
body['text'] = {'content': content}
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID.
Compatible interface with WecomBotClient.push_stream_chunk.
Args:
msg_id: The original message ID.
content: The cumulative content from the pipeline.
is_final: Whether this is the final chunk.
Returns:
True if the stream session exists and chunk was sent.
"""
key = self._stream_ids.get(msg_id)
if not key:
return False
req_id, stream_id = key.split('|', 1)
try:
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
return True
# Generate feedback_id for final chunk
feedback_id = ''
if is_final:
feedback_id = _generate_req_id('feedback')
self._msg_feedback_ids[msg_id] = feedback_id
# Store session info for feedback tracking
session_info = self._stream_sessions.get(msg_id)
if session_info:
self._feedback_sessions[feedback_id] = session_info
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
except Exception:
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
return False
async def set_message(self, msg_id: str, content: str):
"""Fallback: send content as a final stream chunk or direct reply.
Compatible interface with WecomBotClient.set_message.
"""
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
if not handled:
await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')
# ── Connection lifecycle ────────────────────────────────────────
async def _connect_once(self):
"""Establish a single WebSocket connection, authenticate, and listen."""
await self.logger.info(f'Connecting to {self.ws_url}...')
self._session = aiohttp.ClientSession()
try:
self._ws = await self._session.ws_connect(self.ws_url)
self._missed_pong_count = 0
self._reconnect_attempts = 0
await self.logger.info('WebSocket connected, sending auth...')
await self._send_auth()
# Wait for auth response
auth_ok = await self._wait_for_auth()
if not auth_ok:
await self.logger.error('Authentication failed')
return
await self.logger.info('Authenticated successfully')
# Start heartbeat
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
await self._listen_loop()
finally:
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
self._clear_pending_acks('Connection closed')
finally:
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def _send_auth(self):
"""Send the authentication frame."""
frame = {
'cmd': CMD_SUBSCRIBE,
'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},
'body': {
'bot_id': self.bot_id,
'secret': self.secret,
},
}
await self._send_frame(frame)
async def _wait_for_auth(self) -> bool:
"""Wait for and validate the authentication response."""
try:
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
if msg.type in (aiohttp.WSMsgType.TEXT,):
frame = json.loads(msg.data)
req_id = frame.get('headers', {}).get('req_id', '')
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
return True
await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}')
return False
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
await self.logger.error(f'WebSocket closed during auth: {msg.type}')
return False
await self.logger.error(f'Unexpected message type during auth: {msg.type}')
return False
except asyncio.TimeoutError:
await self.logger.error('Auth response timeout')
return False
async def _heartbeat_loop(self):
"""Periodically send heartbeat pings."""
try:
while self._running and self._ws and not self._ws.closed:
await asyncio.sleep(self.heartbeat_interval)
if not self._running or not self._ws or self._ws.closed:
break
if self._missed_pong_count >= self._max_missed_pong:
await self.logger.warning(
f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'
)
await self._ws.close()
break
self._missed_pong_count += 1
frame = {
'cmd': CMD_HEARTBEAT,
'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},
}
try:
await self._send_frame(frame)
except Exception:
break
except asyncio.CancelledError:
pass
async def _listen_loop(self):
"""Listen for incoming WebSocket frames and dispatch them."""
async for msg in self._ws:
if not self._running:
break
if msg.type == aiohttp.WSMsgType.TEXT:
try:
frame = json.loads(msg.data)
await self._handle_frame(frame)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
except Exception:
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
elif msg.type == aiohttp.WSMsgType.BINARY:
try:
frame = json.loads(msg.data)
await self._handle_frame(frame)
except Exception:
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
await self.logger.warning(f'WebSocket connection closed: {msg.type}')
break
# ── Frame handling ──────────────────────────────────────────────
async def _handle_frame(self, frame: dict):
"""Route an incoming frame to the appropriate handler."""
cmd = frame.get('cmd', '')
# Message push
if cmd == CMD_MSG_CALLBACK:
asyncio.create_task(self._handle_message_callback(frame))
return
# Event push
if cmd == CMD_EVENT_CALLBACK:
asyncio.create_task(self._handle_event_callback(frame))
return
# No cmd → response/ACK frame, dispatch by req_id prefix
req_id = frame.get('headers', {}).get('req_id', '')
# Check pending ACKs first
if req_id in self._pending_acks:
future = self._pending_acks.pop(req_id)
if not future.done():
future.set_result(frame)
return
# Heartbeat response
if req_id.startswith(CMD_HEARTBEAT):
if frame.get('errcode') == 0:
self._missed_pong_count = 0
return
# Unknown frame
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
# Parse message using shared logic
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
if not message_data:
return
# Generate stream_id for this message and store the mapping
stream_id = _generate_req_id('stream')
msg_id = message_data.get('msgid', '')
if msg_id:
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
# Store session info for feedback tracking
self._stream_sessions[msg_id] = {
'req_id': req_id,
'stream_id': stream_id,
'msg_id': msg_id,
'user_id': message_data.get('userid', ''),
'chat_id': message_data.get('chatid', ''),
'chat_type': message_data.get('type', 'single'),
}
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
event = wecombotevent.WecomBotEvent(message_data)
await self._dispatch_event(event)
except Exception:
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
async def _handle_event_callback(self, frame: dict):
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_info = body.get('event', {})
event_type = event_info.get('eventtype', '')
message_data = {
'msgtype': 'event',
'type': body.get('chattype', 'single'),
'event': event_info,
'eventtype': event_type,
'msgid': body.get('msgid', ''),
'aibotid': body.get('aibotid', ''),
'req_id': req_id,
}
from_info = body.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')
if body.get('chatid'):
message_data['chatid'] = body.get('chatid', '')
if event_type == 'feedback_event':
feedback_event = event_info.get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
# Look up session by feedback_id
session_info = self._feedback_sessions.get(feedback_id)
session = None
if session_info:
session = StreamSession(
stream_id=session_info.get('stream_id', ''),
msg_id=session_info.get('msg_id', ''),
chat_id=session_info.get('chat_id') or None,
user_id=session_info.get('user_id') or None,
feedback_id=feedback_id,
)
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
return
event = wecombotevent.WecomBotEvent(message_data)
if event_type in self._message_handlers:
for handler in self._message_handlers[event_type]:
await handler(event)
if 'event' in self._message_handlers:
for handler in self._message_handlers['event']:
await handler(event)
except Exception:
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
"""Dispatch a message event to registered handlers with deduplication."""
try:
message_id = event.message_id
if message_id in self._msg_id_map:
self._msg_id_map[message_id] += 1
return
self._msg_id_map[message_id] = 1
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
except Exception:
await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')
# ── Reply sending with serial queue ─────────────────────────────
async def _send_reply(
self,
req_id: str,
body: dict,
cmd: str = CMD_RESPOND_MSG,
) -> Optional[dict]:
"""Send a reply frame and wait for ACK.
Replies with the same req_id are serialized to maintain ordering.
"""
if not self._ws or self._ws.closed:
return None
frame = {
'cmd': cmd,
'headers': {'req_id': req_id},
'body': body,
}
# Ensure serial delivery per req_id
if req_id not in self._reply_queues:
self._reply_queues[req_id] = asyncio.Queue()
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
future: asyncio.Future = asyncio.get_event_loop().create_future()
await self._reply_queues[req_id].put((frame, future))
return await future
async def _reply_queue_worker(self, req_id: str):
"""Process reply queue items serially for a given req_id."""
queue = self._reply_queues[req_id]
try:
while self._running:
try:
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError:
# Queue idle, clean up worker
break
try:
ack = await self._send_and_wait_ack(frame)
if not future.done():
future.set_result(ack)
except Exception as e:
if not future.done():
future.set_exception(e)
except asyncio.CancelledError:
pass
finally:
self._reply_queues.pop(req_id, None)
self._reply_workers.pop(req_id, None)
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
"""Send a frame and wait for the corresponding ACK."""
req_id = frame['headers']['req_id']
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending_acks[req_id] = ack_future
try:
await self._send_frame(frame)
result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)
if result.get('errcode', 0) != 0:
await self.logger.warning(
f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}'
)
return result
except asyncio.TimeoutError:
self._pending_acks.pop(req_id, None)
await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')
return None
async def _send_frame(self, frame: dict):
"""Send a JSON frame over the WebSocket connection."""
if self._ws and not self._ws.closed:
await self._ws.send_str(json.dumps(frame, ensure_ascii=False))
def _clear_pending_acks(self, reason: str):
"""Reject all pending ACK futures on disconnection."""
for req_id, future in self._pending_acks.items():
if not future.done():
future.set_exception(ConnectionError(reason))
self._pending_acks.clear()

View File

@@ -4,7 +4,6 @@ import base64
import binascii
import httpx
import traceback
from urllib.parse import quote
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
@@ -68,31 +67,6 @@ class WecomClient:
await self.logger.error(f'获取accesstoken失败:{response.json()}')
raise Exception(f'未获取access token: {data}')
async def get_user_info(self, userid: str) -> dict:
"""
Get user information by user ID using the application secret.
Args:
userid: The user ID to look up.
Returns:
dict: User information including 'name' field.
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_user_info(userid)
if data.get('errcode', 0) != 0:
await self.logger.error(f'获取用户信息失败:{data}')
return {}
return data
async def get_users(self):
if not self.check_access_token_for_contacts():
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)

View File

@@ -10,7 +10,6 @@ from typing import Callable
from .wecomcsevent import WecomCSEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles
import time
class WecomCSClient:
@@ -35,10 +34,6 @@ class WecomCSClient:
self.unified_mode = unified_mode
self.app = Quart(__name__)
# Customer info cache: {external_userid: (info_dict, timestamp)}
self._customer_cache: dict[str, tuple[dict, float]] = {}
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
@@ -207,33 +202,7 @@ 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(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')
raise Exception('Failed to send message')
return data
async def handle_callback_request(self):
@@ -348,7 +317,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=image'
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'uploaded_file.txt'
@@ -394,7 +363,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(f'failed to upload image: {data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
@@ -409,53 +378,3 @@ class WecomCSClient:
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id
async def get_customer_info(self, external_userid: str) -> dict | None:
"""
Get customer information by external_userid with caching.
Uses a 1-minute cache to avoid repeated API calls for the same user.
Args:
external_userid: The external user ID of the customer.
Returns:
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
"""
# Check cache first
current_time = time.time()
if external_userid in self._customer_cache:
cached_info, cached_time = self._customer_cache[external_userid]
if current_time - cached_time < self._cache_ttl:
return cached_info
# Cache miss or expired, fetch from API
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
payload = {
'external_userid_list': [external_userid],
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_customer_info(external_userid)
if data.get('errcode', 0) != 0:
if self.logger:
await self.logger.warning(f'Failed to get customer info: {data}')
return None
customer_list = data.get('customer_list', [])
if customer_list:
customer_info = customer_list[0]
# Store in cache
self._customer_cache[external_userid] = (customer_info, current_time)
return customer_info
return None

View File

@@ -13,9 +13,9 @@ from .. import group
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
if '..' in image_key or '\\' in image_key:
if '/' in image_key or '\\' in image_key:
return quart.Response(status=404)
if not await self.ap.storage_mgr.storage_provider.exists(image_key):

View File

@@ -456,31 +456,6 @@ class MonitoringRouterGroup(group.RouterGroup):
'platform',
'user_id',
]
elif export_type == 'feedback':
data = await self.ap.monitoring_service.export_feedback(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
)
headers = [
'id',
'timestamp',
'feedback_id',
'feedback_type',
'feedback_content',
'inaccurate_reasons',
'bot_id',
'bot_name',
'pipeline_id',
'pipeline_name',
'session_id',
'message_id',
'stream_id',
'user_id',
'platform',
]
else:
return self.error(message=f'Invalid export type: {export_type}', code=400)
@@ -511,63 +486,3 @@ class MonitoringRouterGroup(group.RouterGroup):
)
return response, 200
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback_stats() -> str:
"""Get feedback statistics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_feedback_stats(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
)
return self.success(data=stats)
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback() -> str:
"""Get feedback list"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
feedback_type_str = quart.request.args.get('feedbackType')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
# Parse feedback type
feedback_type = int(feedback_type_str) if feedback_type_str else None
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'feedback': feedback_list,
'total': total,
'limit': limit,
'offset': offset,
}
)

View File

@@ -1,384 +0,0 @@
"""Embed widget routes - serve embeddable chat widget for external websites.
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
internal pipeline identifiers are never exposed to end-users. Each handler
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
the bound pipeline_uuid for internal routing.
"""
import asyncio
import datetime
import json
import logging
import uuid
import hmac
import hashlib
import time
import re
import httpx
import quart
from ... import group
from ......utils import paths
from ......platform.sources.websocket_manager import ws_connection_manager
logger = logging.getLogger(__name__)
# Cache the widget template content
_widget_template_cache: str | None = None
_logo_bytes_cache: bytes | None = None
def _is_valid_uuid(s: str) -> bool:
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
def _get_widget_template() -> str:
"""Load and cache the widget JS template."""
global _widget_template_cache
if _widget_template_cache is None:
template_path = paths.get_resource_path('templates/embed/widget.js')
with open(template_path, 'r', encoding='utf-8') as f:
_widget_template_cache = f.read()
return _widget_template_cache
def _get_logo_bytes() -> bytes:
"""Load and cache the logo image."""
global _logo_bytes_cache
if _logo_bytes_cache is None:
logo_path = paths.get_resource_path('templates/embed/logo.webp')
with open(logo_path, 'rb') as f:
_logo_bytes_cache = f.read()
return _logo_bytes_cache
@group.group_class('embed', '/api/v1/embed')
class EmbedRouterGroup(group.RouterGroup):
# -- helpers -------------------------------------------------------------
def _resolve_bot(self, bot_uuid: str):
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
Returns ``(None, None)`` when the bot does not exist, is not a
``web_page_bot``, is disabled, or has no pipeline bound.
"""
for bot in self.ap.platform_mgr.bots:
if (
bot.bot_entity.uuid == bot_uuid
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and bot.bot_entity.use_pipeline_uuid
):
return bot, bot.bot_entity.use_pipeline_uuid
return None, None
def _get_bot_config(self, bot_uuid: str) -> dict:
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
return bot.bot_entity.adapter_config
return {}
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
config = self._get_bot_config(bot_uuid)
secret = config.get('turnstile_secret_key', '')
if not secret:
return True
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:]
try:
ts_str, mac = token.split('.', 1)
ts = float(ts_str)
if time.time() - ts > 86400:
return False
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, expected_mac)
except Exception:
return False
# -- routes --------------------------------------------------------------
async def initialize(self) -> None:
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def verify_turnstile(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
try:
data = await quart.request.get_json()
token = data.get('token')
if not token:
return self.http_status(400, -1, 'Token is required')
config = self._get_bot_config(bot_uuid)
secret = config.get('turnstile_secret_key', '')
if not secret:
ts = time.time()
return self.success(data={'token': f'{ts}.dummy'})
async with httpx.AsyncClient() as client:
resp = await client.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={'secret': secret, 'response': token},
)
result = resp.json()
if not result.get('success'):
return self.http_status(403, -1, 'Turnstile verification failed')
ts = time.time()
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
session_token = f'{ts}.{mac}'
return self.success(data={'token': session_token})
except Exception as e:
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def serve_widget(bot_uuid: str) -> quart.Response:
"""Serve the embed widget JavaScript with injected configuration."""
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return quart.Response(
'// Bot not found or not available', status=404, content_type='application/javascript'
)
try:
template = _get_widget_template()
except FileNotFoundError:
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
base_url = quart.request.host_url.rstrip('/')
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
if webhook_prefix:
base_url = webhook_prefix.rstrip('/')
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
base_url = quart.request.host_url.rstrip('/')
config = self._get_bot_config(bot_uuid)
site_key = config.get('turnstile_site_key', '')
locale = config.get('language', 'en_US') or 'en_US'
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
response.headers['Cache-Control'] = 'public, max-age=300'
return response
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
async def serve_logo() -> quart.Response:
"""Serve the LangBot logo for the embed widget."""
try:
logo_data = _get_logo_bytes()
except FileNotFoundError:
return quart.Response('', status=404)
response = quart.Response(logo_data, content_type='image/webp')
response.headers['Cache-Control'] = 'public, max-age=86400'
return response
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
return self.success(data={'messages': messages})
except Exception as e:
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(pipeline_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
async def submit_feedback(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
data = await quart.request.get_json()
message_id = data.get('message_id', '')
feedback_type = data.get('feedback_type')
if feedback_type not in (1, 2, 3):
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
await self.ap.monitoring_service.record_feedback(
feedback_id=feedback_id,
feedback_type=feedback_type,
bot_id=runtime_bot.bot_entity.uuid,
bot_name=runtime_bot.bot_entity.name or bot_uuid,
pipeline_id=pipeline_uuid,
message_id=str(message_id),
platform='web_page_bot',
)
return self.success(data={'feedback_id': feedback_id})
except Exception as e:
logger.error(f'Failed to record feedback: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
# -- Embed WebSocket endpoint ----------------------------------------
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
async def embed_websocket_connect(bot_uuid: str):
"""WebSocket connection for embed widget, keyed by bot_uuid."""
if not _is_valid_uuid(bot_uuid):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
return
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
return
session_type = quart.websocket.args.get('session_type', 'person')
if session_type not in ['person', 'group']:
await quart.websocket.send(
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
)
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
try:
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
)
await quart.websocket.send(
json.dumps(
{
'type': 'connected',
'connection_id': connection.connection_id,
'bot_uuid': bot_uuid,
'session_type': session_type,
'timestamp': connection.created_at.isoformat(),
}
)
)
logger.debug(
f'Embed WebSocket connected: {connection.connection_id} '
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
)
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
send_task = asyncio.create_task(self._handle_send(connection))
try:
await asyncio.gather(receive_task, send_task)
except Exception as e:
logger.error(f'Embed WebSocket task error: {e}')
finally:
await ws_connection_manager.remove_connection(connection.connection_id)
except Exception as e:
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
try:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
except Exception:
pass
# -- WebSocket receive/send helpers --------------------------------------
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
try:
while connection.is_active:
message = await quart.websocket.receive()
await ws_connection_manager.update_activity(connection.connection_id)
try:
data = json.loads(message)
message_type = data.get('type', 'message')
if message_type == 'ping':
await connection.send_queue.put(
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
elif message_type == 'disconnect':
break
except json.JSONDecodeError:
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
except Exception as e:
logger.error(f'Embed receive error: {e}', exc_info=True)
finally:
connection.is_active = False
async def _handle_send(self, connection):
try:
while connection.is_active:
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
await quart.websocket.send(json.dumps(message))
except asyncio.TimeoutError:
continue
except Exception as e:
logger.error(f'Embed send error: {e}', exc_info=True)
finally:
connection.is_active = False

View File

@@ -43,9 +43,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
# Find the owning bot for this pipeline (e.g. a web_page_bot)
owner_bot = self._find_owner_bot(pipeline_uuid)
# 注册连接
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
@@ -73,7 +70,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
)
# 创建接收和发送任务
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
send_task = asyncio.create_task(self._handle_send(connection))
# 等待任务完成
@@ -181,14 +178,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
def _find_owner_bot(self, pipeline_uuid: str):
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
return bot
return None
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
async def _handle_receive(self, connection, websocket_adapter):
"""处理接收消息的任务"""
try:
while connection.is_active:
@@ -213,7 +203,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
logger.debug(f'收到消息: {data} from {connection.connection_id}')
# 处理消息不等待响应响应会通过broadcast异步发送
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
await websocket_adapter.handle_websocket_message(connection, data)
elif message_type == 'disconnect':
# 客户端主动断开

View File

@@ -6,38 +6,11 @@ import re
import httpx
import uuid
import os
import posixpath
from .....core import taskmgr
from .. import group
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
# Resolve the built-in page SDK JS from the langbot_plugin package
_PAGE_SDK_PATH = None
try:
import langbot_plugin.assets as _assets_pkg
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
if os.path.exists(_candidate):
_PAGE_SDK_PATH = _candidate
except Exception:
pass
def _normalize_plugin_asset_path(filepath: str) -> str | None:
filepath = filepath.replace('\\', '/')
if filepath.startswith('/'):
return None
normalized = posixpath.normpath(filepath)
if normalized == '.' or normalized.startswith('../') or normalized == '..':
return None
if normalized.startswith('components/pages/'):
return normalized
return f'assets/{normalized}'
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
@@ -54,15 +27,6 @@ class PluginsRouterGroup(group.RouterGroup):
return None
async def initialize(self) -> None:
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
"""Serve the built-in LangBot page SDK JavaScript."""
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
with open(_PAGE_SDK_PATH, 'r') as f:
content = f.read()
return quart.Response(content, mimetype='application/javascript')
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
plugins = await self.ap.plugin_connector.list_plugins()
@@ -171,62 +135,15 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type)
@self.route(
'/<author>/<plugin_name>/assets/<path:filepath>',
'/<author>/<plugin_name>/assets/<filepath>',
methods=['GET'],
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
asset_bytes = base64.b64decode(asset_data['asset_base64'])
mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type)
# CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'):
origin = f'{quart.request.scheme}://{quart.request.host}'
resp.headers['Content-Security-Policy'] = (
f'default-src {origin}; '
f"script-src {origin} 'unsafe-inline'; "
f"style-src {origin} 'unsafe-inline'; "
f'img-src {origin} data:; '
f'connect-src {origin}; '
"frame-src 'none'; "
"object-src 'none'"
)
return resp
@self.route(
'/<author>/<plugin_name>/page-api',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(author: str, plugin_name: str) -> str:
"""Forward a page API request to the plugin."""
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
page_id = data.get('page_id', '')
endpoint = data.get('endpoint', '')
method = data.get('method', 'POST')
body = data.get('body')
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
return self.http_status(400, -1, 'invalid page api request')
if not endpoint.startswith('/') or '..' in endpoint:
return self.http_status(400, -1, 'invalid endpoint')
result = await self.ap.plugin_connector.handle_page_api(
author, plugin_name, page_id, endpoint, method.upper(), body
)
if result.get('error'):
return self.http_status(400, -1, result['error'])
return self.success(data=result.get('data'))
return quart.Response(asset_bytes, mimetype=mime_type)
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@@ -348,8 +265,6 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing asset_url parameter')
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
install_info = {
'asset_url': asset_url,
'owner': owner,
@@ -380,17 +295,12 @@ class PluginsRouterGroup(group.RouterGroup):
data = await quart.request.json
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
ctx.metadata['install_source'] = 'marketplace'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-marketplace',
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
label=f'Installing plugin from marketplace ...{data}',
context=ctx,
)
@@ -413,13 +323,11 @@ class PluginsRouterGroup(group.RouterGroup):
}
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
ctx.metadata['install_source'] = 'local'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-local',
label=f'Installing plugin from local {file.filename}',
label=f'Installing plugin from local ...{file.filename}',
context=ctx,
)

View File

@@ -97,51 +97,3 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
return self.success()
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
class RerankModelsRouterGroup(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':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
}
)
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
return self.success()

View File

@@ -15,7 +15,6 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'providers': providers})
elif quart.request.method == 'POST':
json_data = await quart.request.json
@@ -33,7 +32,6 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'provider': provider})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
@@ -45,12 +43,3 @@ class ModelProvidersRouterGroup(group.RouterGroup):
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(provider_uuid: str) -> str:
try:
model_type = quart.request.args.get('type')
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
return self.success(data=result)
except ValueError as e:
return self.http_status(400, -1, str(e))

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
return self.success(data={'tools': tool_list})
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
for tool in tools:
if tool.name == tool_name:
return self.success(
data={
'tool': {
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
}
)
return self.http_status(404, -1, f'Tool not found: {tool_name}')

View File

@@ -1,11 +1,7 @@
import json
import quart
import sqlalchemy
from .. import group
from .....utils import constants
from .....entity.persistence.metadata import Metadata
@group.group_class('system', '/api/v1/system')
@@ -13,24 +9,6 @@ class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
wizard_status = 'none'
wizard_progress = None
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
)
for row in result:
if row.key == 'wizard_status':
wizard_status = row.value
elif row.key == 'wizard_progress':
try:
wizard_progress = json.loads(row.value)
except (json.JSONDecodeError, TypeError):
wizard_progress = None
except Exception:
pass
return self.success(
data={
'version': constants.semantic_version,
@@ -49,83 +27,17 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service', False
),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
}
)
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" }
"""
data = await quart.request.get_json(silent=True) or {}
status = data.get('status', 'completed')
if status not in ('skipped', 'completed'):
return self.http_status(400, 400, f'Invalid wizard status: {status}')
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
)
# Clear wizard progress when wizard is completed/skipped
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
)
except Exception as e:
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
return self.success(data={})
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Save wizard progress to metadata table.
Accepts JSON body with wizard state fields:
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
"bot_saved": bool, "selected_runner": str|null }
"""
data = await quart.request.get_json(silent=True) or {}
progress_json = json.dumps(data, ensure_ascii=False)
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
)
except Exception as e:
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
return self.success(data={})
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
if task_type == '':
task_type = None
if task_kind == '':
task_kind = None
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(task_id: str) -> str:
@@ -136,10 +48,6 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data=task.to_dict())
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
if not constants.debug_mode:

View File

@@ -105,29 +105,6 @@ class HTTPController:
):
if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html'
elif not path.startswith('api/'):
# SPA fallback: serve index.html for all non-API, non-static routes
# so that React Router can handle client-side routing (Vite SPA).
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
if path.startswith('home/'):
segments = path.rstrip('/').split('/')
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(
frontend_path, parent_path, mimetype='text/html'
)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Fallback to index.html for SPA client-side routing
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
else:
return await quart.send_from_directory(frontend_path, '404.html')

View File

@@ -5,7 +5,6 @@ import sqlalchemy
import typing
from ....core import app
from ....discover import engine
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
@@ -18,24 +17,6 @@ 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
async def get_bots(self, include_secret: bool = True) -> list[dict]:
"""获取所有机器人"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
@@ -77,22 +58,24 @@ class BotService:
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# 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 URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in [
'wecom',
'wecombot',
'officialaccount',
'qqofficial',
'slack',
'wecomcs',
'LINE',
'lark',
]:
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}'
adapter_runtime_values['webhook_url'] = webhook_url
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
adapter_runtime_values['extra_webhook_full_url'] = (
f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''
)
else:
adapter_runtime_values['webhook_url'] = None
adapter_runtime_values['webhook_full_url'] = None
adapter_runtime_values['extra_webhook_full_url'] = None
persistence_bot['adapter_runtime_values'] = adapter_runtime_values

View File

@@ -1,309 +0,0 @@
from __future__ import annotations
import datetime
import os
import re
from pathlib import Path
from typing import Any
import sqlalchemy
from ....core import app
from ....entity.persistence import bstorage as persistence_bstorage
from ....entity.persistence import monitoring as persistence_monitoring
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
DEFAULT_LOG_RETENTION_DAYS = 3
class MaintenanceService:
"""Storage maintenance and diagnostics."""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def cleanup_expired_files(self) -> dict[str, int]:
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
'storage.cleanup.uploaded_file_retention_days',
)
log_retention_days = self._positive_int(
cleanup_cfg.get('log_retention_days'),
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
return {
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
'log_files': self._cleanup_expired_log_files(log_retention_days),
}
async def get_storage_analysis(self) -> dict[str, Any]:
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
'storage.cleanup.uploaded_file_retention_days',
)
log_retention_days = self._positive_int(
cleanup_cfg.get('log_retention_days'),
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
database_cfg = self.ap.instance_config.data.get('database', {})
database_type = database_cfg.get('use', 'sqlite')
database_path = (
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
)
roots: list[tuple[str, Path | None]] = [
('database', database_path),
('logs', Path('data/logs')),
('storage', Path('data/storage')),
('vector_store', Path('data/chroma')),
('plugins', Path('data/plugins')),
('mcp', Path('data/mcp')),
('temp', Path('data/temp')),
]
sections = []
for key, path in roots:
sections.append(
{
'key': key,
'path': str(path) if path else '',
'exists': path.exists() if path else False,
'size_bytes': self._path_size(path) if path else 0,
'file_count': self._file_count(path) if path else 0,
}
)
monitoring_counts = await self._monitoring_counts()
binary_storage = await self._binary_storage_stats()
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
log_candidates = self._expired_log_candidates(log_retention_days)
return {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'cleanup_policy': {
'uploaded_file_retention_days': upload_retention_days,
'log_retention_days': log_retention_days,
},
'sections': sections,
'database': {
'type': database_type,
'monitoring_counts': monitoring_counts,
'binary_storage': binary_storage,
},
'cleanup_candidates': {
'uploaded_files': upload_candidates,
'log_files': log_candidates,
},
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
}
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
provider = self.ap.storage_mgr.storage_provider
provider_name = provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
deleted = 0
for item in candidates:
try:
os.remove(item['path'])
deleted += 1
except FileNotFoundError:
pass
except Exception as e:
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
return deleted
if provider_name == 'S3StorageProvider':
return await self._cleanup_expired_s3_uploaded_files(retention_days)
return 0
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
return self._expired_local_upload_candidates(retention_days)
if provider_name == 'S3StorageProvider':
return await self._expired_s3_upload_candidates(retention_days)
return []
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
provider = self.ap.storage_mgr.storage_provider
candidates = await self._expired_s3_upload_candidates(retention_days)
deleted = 0
for item in candidates:
await provider.delete(item['key'])
deleted += 1
return deleted
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
provider = self.ap.storage_mgr.storage_provider
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
candidates = []
paginator = provider.s3_client.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=provider.bucket_name):
for obj in page.get('Contents', []):
key = obj.get('Key', '')
last_modified = obj.get('LastModified')
if not self._is_uploaded_file_key(key):
continue
if last_modified and last_modified < cutoff:
candidates.append(
{
'key': key,
'size_bytes': obj.get('Size', 0),
'modified_at': last_modified.isoformat(),
}
)
return candidates
def _cleanup_expired_log_files(self, retention_days: int) -> int:
deleted = 0
for item in self._expired_log_candidates(retention_days, include_paths=True):
try:
os.remove(item['path'])
deleted += 1
except FileNotFoundError:
pass
except Exception as e:
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
return deleted
def _expired_local_upload_candidates(
self, retention_days: int, include_paths: bool = False
) -> list[dict[str, Any]]:
storage_root = Path('data/storage')
if not storage_root.exists():
return []
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
candidates = []
for entry in storage_root.iterdir():
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
continue
stat = entry.stat()
if stat.st_mtime >= cutoff:
continue
item = {
'key': entry.name,
'size_bytes': stat.st_size,
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
return candidates
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
log_root = Path('data/logs')
if not log_root.exists():
return []
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
candidates = []
for entry in log_root.iterdir():
if not entry.is_file():
continue
match = LOG_FILE_PATTERN.match(entry.name)
if not match:
continue
try:
file_date = datetime.date.fromisoformat(match.group(1))
except ValueError:
continue
if file_date >= cutoff_date:
continue
stat = entry.stat()
item = {
'name': entry.name,
'date': file_date.isoformat(),
'size_bytes': stat.st_size,
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
return candidates
def _is_uploaded_file_key(self, key: str) -> bool:
return '/' not in key and not key.startswith('plugin_config_')
async def _monitoring_counts(self) -> dict[str, int]:
tables = {
'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id,
'feedback': persistence_monitoring.MonitoringFeedback.id,
}
counts: dict[str, int] = {}
for key, column in tables.items():
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
counts[key] = result.scalar() or 0
return counts
async def _binary_storage_stats(self) -> dict[str, Any]:
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
)
size_bytes = None
try:
size_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
)
size_bytes = size_result.scalar() or 0
except Exception as e:
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
return {
'count': count_result.scalar() or 0,
'size_bytes': size_bytes,
}
def _path_size(self, path: Path) -> int:
if not path.exists():
return 0
if path.is_file():
return path.stat().st_size
total = 0
for root, _, files in os.walk(path):
for file_name in files:
file_path = Path(root) / file_name
try:
total += file_path.stat().st_size
except FileNotFoundError:
pass
return total
def _file_count(self, path: Path) -> int:
if not path.exists():
return 0
if path.is_file():
return 1
count = 0
for _, _, files in os.walk(path):
count += len(files)
return count
def _positive_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 1:
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed

View File

@@ -23,17 +23,6 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
return provider_dict
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
"""Return model data for rebuilding runtime models after an update.
Update payloads intentionally omit uuid before writing to the database.
Runtime model entities still need the stable uuid so pipeline configs can
resolve the in-memory model immediately after an edit, without requiring a
process restart.
"""
return {**model_data, 'uuid': model_uuid}
class LLMModelsService:
ap: app.Application
@@ -116,16 +105,11 @@ class LLMModelsService:
)
)
pipeline = result.first()
if pipeline is not None:
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
if not model_config.get('primary', ''):
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = {
'primary': model_data['uuid'],
'fallbacks': [],
}
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
return model_data['uuid']
@@ -184,7 +168,7 @@ class LLMModelsService:
raise Exception('provider not found')
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
@@ -345,7 +329,7 @@ class EmbeddingModelsService:
raise Exception('provider not found')
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
@@ -378,162 +362,3 @@ class EmbeddingModelsService:
input_text=['Hello, world!'],
extra_args={},
)
class RerankModelsService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_rerank_models(self) -> list[dict]:
"""Get all rerank models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
models_list.append(model_dict)
return models_list
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get rerank models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
return model_data['uuid']
async def get_rerank_model(self, model_uuid: str) -> dict | None:
"""Get a single rerank model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
model = result.first()
if model is None:
return None
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
return model_dict
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
if 'uuid' in model_data:
del model_data['uuid']
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.RerankModel)
.where(persistence_model.RerankModel.uuid == model_uuid)
.values(**model_data)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
async def delete_rerank_model(self, model_uuid: str) -> None:
"""Delete a rerank model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Test a rerank model"""
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.rerank_models:
if model.model_entity.uuid == model_uuid:
runtime_rerank_model = model
break
if runtime_rerank_model is None:
raise Exception('model not found')
else:
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
await runtime_rerank_model.provider.invoke_rerank(
model=runtime_rerank_model,
query='What is artificial intelligence?',
documents=[
'Artificial intelligence is a branch of computer science.',
'The weather is nice today.',
],
)

View File

@@ -16,121 +16,6 @@ class MonitoringService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
# ========== Cleanup Methods ==========
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
"""Delete monitoring records older than the specified retention period.
Args:
retention_days: Number of days to retain records.
batch_size: Maximum rows to delete per table batch.
Returns:
A dict mapping table name to the number of deleted rows.
"""
if retention_days < 1:
raise ValueError('retention_days must be >= 1')
if batch_size < 1:
raise ValueError('batch_size must be >= 1')
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
days=retention_days
)
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
(
'monitoring_messages',
persistence_monitoring.MonitoringMessage,
persistence_monitoring.MonitoringMessage.timestamp,
persistence_monitoring.MonitoringMessage.id,
),
(
'monitoring_llm_calls',
persistence_monitoring.MonitoringLLMCall,
persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id,
),
(
'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall,
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
persistence_monitoring.MonitoringEmbeddingCall.id,
),
(
'monitoring_errors',
persistence_monitoring.MonitoringError,
persistence_monitoring.MonitoringError.timestamp,
persistence_monitoring.MonitoringError.id,
),
(
'monitoring_sessions',
persistence_monitoring.MonitoringSession,
persistence_monitoring.MonitoringSession.last_activity,
persistence_monitoring.MonitoringSession.session_id,
),
(
'monitoring_feedback',
persistence_monitoring.MonitoringFeedback,
persistence_monitoring.MonitoringFeedback.timestamp,
persistence_monitoring.MonitoringFeedback.id,
),
]
deleted_counts: dict[str, int] = {}
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
deleted_counts[table_name] = await self._delete_expired_in_batches(
model_cls=model_cls,
ts_column=ts_column,
pk_column=pk_column,
cutoff=cutoff,
batch_size=batch_size,
)
if sum(deleted_counts.values()) > 0:
await self._release_sqlite_space()
return deleted_counts
async def _delete_expired_in_batches(
self,
model_cls: type,
ts_column: sqlalchemy.Column,
pk_column: sqlalchemy.Column,
cutoff: datetime.datetime,
batch_size: int,
) -> int:
deleted_total = 0
while True:
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
)
pk_values = list(select_result.scalars().all())
if not pk_values:
break
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
)
deleted = delete_result.rowcount or 0
deleted_total += deleted
if len(pk_values) < batch_size:
break
return deleted_total
async def _release_sqlite_space(self) -> None:
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
if database_type != 'sqlite':
return
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
# ========== Recording Methods ==========
async def record_message(
@@ -145,7 +30,6 @@ class MonitoringService:
level: str = 'info',
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
role: str = 'user',
@@ -165,7 +49,6 @@ class MonitoringService:
'level': level,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
'role': role,
@@ -269,7 +152,6 @@ class MonitoringService:
pipeline_name: str,
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
) -> None:
"""Record a new session"""
session_data = {
@@ -284,7 +166,6 @@ class MonitoringService:
'is_active': True,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
}
await self.ap.persistence_mgr.execute_async(
@@ -1247,314 +1128,3 @@ class MonitoringService:
}
for row in rows
]
# ========== Feedback Methods ==========
async def record_feedback(
self,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
inaccurate_reasons: list[str] | None = None,
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
platform: str | None = None,
) -> str:
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
feedback_content: Optional user feedback text
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
bot_id: Bot ID
bot_name: Bot name
pipeline_id: Pipeline ID
pipeline_name: Pipeline name
session_id: Session ID
message_id: Message ID
stream_id: Stream ID (for WeChat Work streaming messages)
user_id: User ID
platform: Platform name (e.g., 'wecom')
Returns:
The record ID
"""
import json
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
return None
# Check if record with this feedback_id already exists
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
existing_row = existing_result.first()
if existing_row:
# UPDATE existing record
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
bot_id=bot_id or existing.bot_id,
bot_name=bot_name or existing.bot_name,
pipeline_id=pipeline_id or existing.pipeline_id,
pipeline_name=pipeline_name or existing.pipeline_name,
session_id=session_id or existing.session_id,
message_id=message_id or existing.message_id,
stream_id=stream_id or existing.stream_id,
user_id=user_id or existing.user_id,
platform=platform or existing.platform,
)
)
return existing.id
else:
# INSERT new record with IntegrityError defense
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': now,
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': reasons_json,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
return record_id
except Exception:
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
)
)
return feedback_id
async def get_feedback_stats(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get feedback statistics.
Returns:
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total likes (feedback_type = 1)
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 1
)
if conditions:
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
total_likes = likes_result.scalar() or 0
# Get total dislikes (feedback_type = 2)
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 2
)
if conditions:
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
total_dislikes = dislikes_result.scalar() or 0
# Get total feedback count
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
total_query = total_query.where(sqlalchemy.and_(*conditions))
total_result = await self.ap.persistence_mgr.execute_async(total_query)
total_feedback = total_result.scalar() or 0
# Calculate satisfaction rate
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
# Get feedback by bot
bot_stats_query = sqlalchemy.select(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
sqlalchemy.func.sum(
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
).label('likes'),
sqlalchemy.func.sum(
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
).label('dislikes'),
).group_by(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
)
if conditions:
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
bot_stats = [
{
'bot_id': row.bot_id,
'bot_name': row.bot_name,
'total': row.total,
'likes': row.likes or 0,
'dislikes': row.dislikes or 0,
}
for row in bot_stats_result.all()
]
return {
'total_feedback': total_feedback,
'total_likes': total_likes,
'total_dislikes': total_dislikes,
'satisfaction_rate': round(satisfaction_rate, 2),
'by_bot': bot_stats,
}
async def get_feedback_list(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
feedback_type: int | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if feedback_type is not None:
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
# Get feedback list
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
)
for row in rows
],
total,
)
async def export_feedback(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100000,
) -> list[dict]:
"""Export feedback as list of dictionaries for CSV conversion."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return [
{
'id': row[0].id if isinstance(row, tuple) else row.id,
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
'feedback_type': 'like'
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
else 'dislike',
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
}
for row in rows
]

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import uuid
import traceback
import sqlalchemy
@@ -98,14 +97,6 @@ class ModelProviderService:
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
@@ -130,14 +121,7 @@ class ModelProviderService:
)
embedding_count = embedding_result.scalar() or 0
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.RerankModel)
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
)
rerank_count = rerank_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
return {'llm_count': llm_count, 'embedding_count': embedding_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one"""
@@ -180,66 +164,3 @@ class ModelProviderService:
.values(api_keys=[api_key])
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
provider = await self.get_provider(provider_uuid)
if provider is None:
raise ValueError('provider not found')
runtime_provider = await self.ap.model_mgr.load_provider(provider)
try:
scan_result = await runtime_provider.requester.scan_models(
runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None
)
except NotImplementedError:
raise ValueError('current provider does not support model scanning')
except Exception as exc:
self.ap.logger.warning(
f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}'
)
raise ValueError(str(exc)) from exc
if isinstance(scan_result, dict):
scanned_models = scan_result.get('models', [])
debug_info = scan_result.get('debug')
else:
scanned_models = scan_result
debug_info = None
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
filtered_models = []
for model in scanned_models:
scanned_type = model.get('type', 'llm')
if model_type and scanned_type != model_type:
continue
model_name = model.get('name') or model.get('id')
if not model_name:
continue
filtered_models.append(
{
'id': model.get('id', model_name),
'name': model_name,
'type': scanned_type,
'abilities': model.get('abilities', []),
'display_name': model.get('display_name'),
'description': model.get('description'),
'context_length': model.get('context_length'),
'owned_by': model.get('owned_by'),
'input_modalities': model.get('input_modalities', []),
'output_modalities': model.get('output_modalities', []),
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_llm_names
),
}
)
return {'models': filtered_models, 'debug': debug_info}

View File

@@ -179,7 +179,7 @@ class SpaceService:
space_url = space_config['url']
session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json()

View File

@@ -65,8 +65,8 @@ class UserService:
user_obj = result_list[0]
# Check if this user has a local password set
if not user_obj.password:
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher()
@@ -108,8 +108,9 @@ class UserService:
if user_obj is None:
raise ValueError('User not found')
if not user_obj.password:
raise ValueError('No local password set, please set a password first')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password)

View File

@@ -9,7 +9,6 @@ from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
from ..provider.session import sessionmgr as llm_session_mgr
from ..provider.modelmgr import modelmgr as llm_model_mgr
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
from ..config import manager as config_mgr
from ..command import cmdmgr
@@ -31,8 +30,6 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
from ..utils import logcache
@@ -134,8 +131,6 @@ class Application:
embedding_models_service: model_service.EmbeddingModelsService = None
rerank_models_service: model_service.RerankModelsService = None
provider_service: provider_service.ModelProviderService = None
pipeline_service: pipeline_service.PipelineService = None
@@ -156,8 +151,6 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None
maintenance_service: maintenance_service.MaintenanceService = None
def __init__(self):
pass
@@ -193,77 +186,6 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start monitoring data cleanup task if enabled
monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
if auto_cleanup_cfg.get('enabled', True):
retention_days = self._get_positive_int_config(
auto_cleanup_cfg.get('retention_days', 30),
default=30,
name='monitoring.auto_cleanup.retention_days',
)
delete_batch_size = self._get_positive_int_config(
auto_cleanup_cfg.get('delete_batch_size', 1000),
default=1000,
name='monitoring.auto_cleanup.delete_batch_size',
)
check_interval_hours = self._get_positive_float_config(
auto_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='monitoring.auto_cleanup.check_interval_hours',
)
async def monitoring_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.monitoring_service.cleanup_expired_records(
retention_days,
batch_size=delete_batch_size,
)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
f'(retention={retention_days}d): {deleted}'
)
except Exception as e:
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
await asyncio.sleep(check_interval_seconds)
self.task_mgr.create_task(
monitoring_cleanup_loop(),
name='monitoring-cleanup',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start storage/log maintenance task if enabled
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
check_interval_hours = self._get_positive_float_config(
storage_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='storage.cleanup.check_interval_hours',
)
async def storage_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.maintenance_service.cleanup_expired_files()
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
except Exception as e:
self.logger.warning(f'Storage maintenance error: {e}')
await asyncio.sleep(check_interval_seconds)
self.task_mgr.create_task(
storage_cleanup_loop(),
name='storage-maintenance',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
self.task_mgr.create_task(
never_ending(),
name='never-ending-task',
@@ -278,28 +200,6 @@ class Application:
self.logger.error(f'Application runtime fatal exception: {e}')
self.logger.debug(f'Traceback: {traceback.format_exc()}')
def _get_positive_int_config(self, value, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 1:
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed
def _get_positive_float_config(self, value, default: float, name: str) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed <= 0:
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed
def dispose(self):
self.plugin_connector.dispose()

View File

@@ -28,7 +28,6 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -62,9 +61,6 @@ class BuildAppStage(stage.BootingStage):
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
rerank_models_service_inst = model_service.RerankModelsService(ap)
ap.rerank_models_service = rerank_models_service_inst
provider_service_inst = provider_service.ModelProviderService(ap)
ap.provider_service = provider_service_inst
@@ -168,9 +164,6 @@ class BuildAppStage(stage.BootingStage):
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
ap.maintenance_service = maintenance_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()

View File

@@ -74,30 +74,20 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key
if key in current:
if isinstance(current[key], list):
# Convert comma-separated string to list
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
elif isinstance(current[key], dict):
# Skip dict types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Key doesn't exist yet - create it as string
current[key] = env_value
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else:
# Navigate deeper - create intermediate dict if needed
if key not in current:
current[key] = {}
# Navigate deeper
current = current[key]
return cfg
@@ -156,50 +146,16 @@ class LoadConfigStage(stage.BootingStage):
await ap.instance_config.dump_config()
# load or generate instance id
# Priority:
# 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
# 2. data/labels/instance_id.json (if file exists)
# 3. Generate new and save to file
config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': f'instance_{str(uuid.uuid4())}',
'instance_create_ts': int(time.time()),
},
completion=False,
)
if config_instance_id:
# Use the instance_id from config.yaml
constants.instance_id = config_instance_id
# Still load/create the file for backward compat, but don't use its value
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': f'instance_{str(uuid.uuid4())}',
'instance_create_ts': int(time.time()),
},
completion=False,
)
else:
# Try loading file-based instance id
instance_id_path = os.path.join('data', 'labels', 'instance_id.json')
if os.path.exists(instance_id_path):
# File exists, read it
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': '',
'instance_create_ts': 0,
},
completion=False,
)
constants.instance_id = ap.instance_id.data['instance_id']
else:
# Neither config nor file, generate new and save to file
new_id = f'instance_{str(uuid.uuid4())}'
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': new_id,
'instance_create_ts': int(time.time()),
},
completion=False,
)
constants.instance_id = new_id
constants.instance_id = ap.instance_id.data['instance_id']
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
print(f'LangBot instance id: {constants.instance_id}')

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
import typing
import datetime
import time
from . import app
from . import entities as core_entities
@@ -18,13 +17,9 @@ class TaskContext:
log: str
"""Log"""
metadata: dict
"""Structured metadata for progress reporting"""
def __init__(self):
self.current_action = 'default'
self.log = ''
self.metadata = {}
def _log(self, msg: str):
self.log += msg + '\n'
@@ -43,7 +38,7 @@ class TaskContext:
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
def to_dict(self) -> dict:
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
return {'current_action': self.current_action, 'log': self.log}
@staticmethod
def new() -> TaskContext:
@@ -120,7 +115,6 @@ class TaskWrapper:
self.label = label if label != '' else name
self.task.set_name(name)
self.scopes = scopes
self.created_at = time.time()
def assume_exception(self):
try:
@@ -156,7 +150,6 @@ class TaskWrapper:
'name': self.name,
'label': self.label,
'scopes': [scope.value for scope in self.scopes],
'created_at': self.created_at,
'task_context': self.task_context.to_dict(),
'runtime': {
'done': self.task.done(),
@@ -196,8 +189,6 @@ class AsyncTaskManager:
) -> TaskWrapper:
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
self.tasks.append(wrapper)
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
self._prune_completed_tasks()
return wrapper
def create_user_task(
@@ -220,23 +211,9 @@ class AsyncTaskManager:
def get_tasks_dict(
self,
type: str = None,
kind: str = None,
) -> dict:
return {
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
],
'id_index': TaskWrapper._id_index,
}
def get_stats(self) -> dict:
completed = sum(1 for t in self.tasks if t.task.done())
return {
'total': len(self.tasks),
'running': len(self.tasks) - completed,
'completed': completed,
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
'id_index': TaskWrapper._id_index,
}
@@ -257,27 +234,3 @@ class AsyncTaskManager:
if not wrapper.task.done():
wrapper.task.cancel()
return
def _prune_completed_tasks(self):
completed_limit = (
self.ap.instance_config.data.get('system', {})
.get('task_retention', {})
.get(
'completed_limit',
200,
)
)
try:
completed_limit = int(completed_limit)
except (TypeError, ValueError):
completed_limit = 200
if completed_limit < 1:
completed_limit = 1
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
overflow = len(completed_tasks) - completed_limit
if overflow <= 0:
return
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]

View File

@@ -17,23 +17,11 @@ class I18nString(pydantic.BaseModel):
"""英文"""
zh_Hans: typing.Optional[str] = None
"""简体中文"""
zh_Hant: typing.Optional[str] = None
"""繁体中文"""
"""中文"""
ja_JP: typing.Optional[str] = None
"""日文"""
th_TH: typing.Optional[str] = None
"""泰文"""
vi_VN: typing.Optional[str] = None
"""越南文"""
es_ES: typing.Optional[str] = None
"""西班牙文"""
def to_dict(self) -> dict:
"""转换为字典"""
dic = {}
@@ -41,16 +29,8 @@ class I18nString(pydantic.BaseModel):
dic['en_US'] = self.en_US
if self.zh_Hans is not None:
dic['zh_Hans'] = self.zh_Hans
if self.zh_Hant is not None:
dic['zh_Hant'] = self.zh_Hant
if self.ja_JP is not None:
dic['ja_JP'] = self.ja_JP
if self.th_TH is not None:
dic['th_TH'] = self.th_TH
if self.vi_VN is not None:
dic['vi_VN'] = self.vi_VN
if self.es_ES is not None:
dic['es_ES'] = self.es_ES
return dic
@@ -241,14 +221,12 @@ class ComponentDiscoveryEngine:
return
for file in importutil.list_resource_files(path):
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 (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)
if comp is not None:
components.append(comp)
elif is_dir:
recursive_load_component_manifests_in_dir(file_path, depth + 1)
elif os.path.isdir(os.path.join(path, file)):
recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1)
recursive_load_component_manifests_in_dir(path)
return components

View File

@@ -16,7 +16,6 @@ class Bot(Base):
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
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='[]')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -59,22 +59,3 @@ class EmbeddingModel(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
class RerankModel(Base):
"""Rerank model"""
__tablename__ = 'rerank_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
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(),
)

View File

@@ -20,7 +20,6 @@ class MonitoringMessage(Base):
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
@@ -65,7 +64,6 @@ class MonitoringSession(Base):
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
class MonitoringError(Base):
@@ -106,26 +104,3 @@ class MonitoringEmbeddingCall(Base):
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
class MonitoringFeedback(Base):
"""User feedback records (like/dislike) from AI Bot conversations"""
__tablename__ = 'monitoring_feedback'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
# Context fields
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom

View File

@@ -1,51 +0,0 @@
"""Alembic environment for LangBot.
This env.py is designed to be called programmatically (not via CLI).
It supports both SQLite and PostgreSQL.
The sync connection is passed via config attributes by the runner.
"""
from __future__ import annotations
from alembic import context
from sqlalchemy.engine import Connection
from langbot.pkg.entity.persistence.base import Base
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
url = context.config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations with a live sync connection passed via config attributes."""
connection: Connection = context.config.attributes.get('connection')
if connection is None:
raise RuntimeError('connection not provided in alembic config attributes')
context.configure(
connection=connection,
target_metadata=target_metadata,
# render_as_batch=True is critical for SQLite ALTER TABLE support
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,24 +0,0 @@
# Alembic script.py.mako — template for auto-generated revisions
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,24 +0,0 @@
"""baseline: stamp existing schema (db version 25)
This is a no-op migration that marks the starting point for Alembic.
All tables already exist via create_all() + legacy DBMigration system.
Revision ID: 0001_baseline
Revises: None
Create Date: 2026-04-08
"""
revision = '0001_baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: existing schema is already at database_version=25
# This revision serves as the Alembic baseline.
pass
def downgrade() -> None:
pass

View File

@@ -1,62 +0,0 @@
"""example: sample migration demonstrating Alembic patterns
This is a SAMPLE showing how to write migrations that work
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
Revision ID: 0002_sample
Revises: 0001_baseline
Create Date: 2026-04-08
Patterns demonstrated:
1. Schema change (add column) — works on both DBs via render_as_batch
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
"""
revision = '0002_sample'
down_revision = '0001_baseline'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
EXAMPLE: Uncomment to use. This shows the patterns.
# --- Pattern 1: Schema change (add/drop column) ---
# render_as_batch=True in env.py makes this work on SQLite too.
#
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
# --- Pattern 2: Data migration (read + modify JSON field) ---
# No if/else for sqlite vs postgres needed!
#
# conn = op.get_bind()
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
# for row in rows:
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
# # Modify the config
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
# conn.execute(
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
# {"cfg": json.dumps(config), "uuid": row[0]}
# )
# --- Pattern 3: Create a new table ---
#
# op.create_table(
# 'audit_log',
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
# sa.Column('action', sa.String(255), nullable=False),
# sa.Column('detail', sa.Text),
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
# )
"""
pass
def downgrade() -> None:
"""
# op.drop_column('pipelines', 'description')
# op.drop_table('audit_log')
"""
pass

View File

@@ -1,35 +0,0 @@
"""add rerank_models table
Revision ID: 0003_add_rerank_models
Revises: 0002_sample
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = '0003_add_rerank_models'
down_revision = '0002_sample'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if table already exists (may have been created by create_all())
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'rerank_models' not in inspector.get_table_names():
op.create_table(
'rerank_models',
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('provider_uuid', sa.String(255), nullable=False),
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('rerank_models')

View File

@@ -1,150 +0,0 @@
"""Programmatic Alembic runner for LangBot.
Usage from async code:
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
await run_alembic_upgrade(async_engine)
CLI usage (autogenerate):
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
python -m langbot.pkg.persistence.alembic_runner upgrade
python -m langbot.pkg.persistence.alembic_runner current
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Connection
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
def _build_config(connection: Connection) -> Config:
"""Build an Alembic Config with sync connection attached."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
cfg.attributes['connection'] = connection
return cfg
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
"""Synchronous upgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.upgrade(cfg, revision)
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
"""Synchronous stamp — runs inside run_sync."""
cfg = _build_config(connection)
command.stamp(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
command.revision(cfg, message=message, autogenerate=True)
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Run Alembic upgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_upgrade, revision)
await conn.commit()
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Stamp the database with a revision without running migrations."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_stamp, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
return await conn.run_sync(_do_get_current)
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
"""Compare ORM models against DB schema and generate a migration script."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_autogenerate, message)
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
if __name__ == '__main__':
import sys
import asyncio
def _get_engine():
"""Create engine from data/config.yaml or default SQLite."""
from sqlalchemy.ext.asyncio import create_async_engine
try:
import yaml
with open('data/config.yaml') as f:
config = yaml.safe_load(f)
db_cfg = config.get('database', {})
db_type = db_cfg.get('use', 'sqlite')
if db_type == 'postgresql':
pg = db_cfg.get('postgresql', {})
url = (
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
)
else:
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
url = f'sqlite+aiosqlite:///{path}'
except Exception:
url = 'sqlite+aiosqlite:///data/langbot.db'
return create_async_engine(url)
def main():
if len(sys.argv) < 2:
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
cmd = sys.argv[1]
engine = _get_engine()
if cmd == 'autogenerate':
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
asyncio.run(run_alembic_autogenerate(engine, msg))
print(f'Migration generated: {msg}')
elif cmd == 'upgrade':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_upgrade(engine, rev))
print(f'Upgraded to: {rev}')
elif cmd == 'stamp':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
else:
print(f'Unknown command: {cmd}')
sys.exit(1)
main()

View File

@@ -2,16 +2,18 @@ from __future__ import annotations
import datetime
import typing
import json
import uuid
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database, migration
from ..entity.persistence import base, metadata, model as persistence_model
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
from ..api.http.service import pipeline as pipeline_service
from . import databases, migrations
importutil.import_modules_in_pkg(databases)
@@ -76,9 +78,7 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
await self.write_default_pipeline()
await self.write_space_model_providers()
async def create_tables(self):
@@ -101,6 +101,29 @@ class PersistenceManager:
if row is None:
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
async def write_default_pipeline(self):
# write default pipeline
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
default_pipeline_uuid = None
if result.first() is None:
self.ap.logger.info('Creating default pipeline...')
pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))
default_pipeline_uuid = str(uuid.uuid4())
pipeline_data = {
'uuid': default_pipeline_uuid,
'for_version': self.ap.ver_mgr.get_current_version(),
'stages': pipeline_service.default_stage_order,
'is_default': True,
'name': 'ChatPipeline',
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
'config': pipeline_config,
'extensions_preferences': {},
}
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
async def write_space_model_providers(self):
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
@@ -138,28 +161,6 @@ class PersistenceManager:
# =================================
async def _run_alembic_migrations(self):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
engine = self.get_db_engine()
try:
current_rev = await alembic_runner.get_alembic_current(engine)
if current_rev is None:
# First time: stamp baseline so Alembic knows existing schema is up-to-date
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
# Upgrade to head
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)

View File

@@ -1,74 +0,0 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(21)
class DBMigrateMergeExceptionHandling(migration.DBMigration):
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
and add failure-hint field.
Conversion logic:
- block-failed-request-output=true -> exception-handling: hide
- hide-exception=true -> exception-handling: show-hint
- hide-exception=false -> exception-handling: show-error
"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'output' not in config:
config['output'] = {}
if 'misc' not in config['output']:
config['output']['misc'] = {}
misc = config['output']['misc']
# Determine new exception-handling value from legacy fields
hide_exception = misc.get('hide-exception', True)
block_failed = misc.get('block-failed-request-output', False)
if block_failed:
exception_handling = 'hide'
elif hide_exception:
exception_handling = 'show-hint'
else:
exception_handling = 'show-error'
misc['exception-handling'] = exception_handling
# Add failure-hint with default value
misc['failure-hint'] = 'Request failed.'
# Remove legacy fields
misc.pop('hide-exception', None)
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -1,73 +0,0 @@
import sqlalchemy
from .. import migration
@migration.migration_class(22)
class DBMigrateMonitoringUserId(migration.DBMigration):
"""Add user_id and user_name columns to monitoring_sessions table
This migration adds the missing user_id column and also ensures user_name
column exists (in case migration 21 failed or was skipped).
"""
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return bool(result.scalar())
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def _get_table_columns(self, table_name: str) -> list[str]:
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'
).bindparams(table_name=table_name)
)
return [row[0] for row in result.fetchall()]
else:
if not table_name.isidentifier():
raise ValueError(f'Invalid table name: {table_name}')
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
return [row[1] for row in result.fetchall()]
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it does not already exist."""
columns = await self._get_table_columns(table_name)
if column_name in columns:
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
return
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
)
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
async def upgrade(self):
# Check if monitoring_sessions table exists
if not await self._table_exists('monitoring_sessions'):
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
return
# Add user_id column to monitoring_sessions table
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
# Add user_name column to monitoring_messages table (in case migration 21 failed)
if await self._table_exists('monitoring_messages'):
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
async def downgrade(self):
pass

View File

@@ -1,102 +0,0 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(23)
class DBMigrateModelFallbackConfig(migration.DBMigration):
"""Convert model field from plain UUID string to object with primary/fallbacks"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
changed = False
# Convert model from string to object
model_value = local_agent.get('model', '')
if isinstance(model_value, str):
local_agent['model'] = {
'primary': model_value,
'fallbacks': [],
}
changed = True
# Remove leftover fallback-models field if present
if 'fallback-models' in local_agent:
del local_agent['fallback-models']
changed = True
if not changed:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self):
"""Downgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
# Convert model from object back to string
model_value = local_agent.get('model', '')
if isinstance(model_value, dict):
local_agent['model'] = model_value.get('primary', '')
else:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)

View File

@@ -1,49 +0,0 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(24)
class DBMigrateWecomBotWebSocketMode(migration.DBMigration):
"""Add enable-webhook field to existing wecombot adapter configs.
Existing wecombot bots were all using webhook mode, so we set
enable-webhook=true to preserve their behavior after the new
WebSocket long connection mode is introduced as default.
"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'")
)
bots = result.fetchall()
for bot_row in bots:
bot_uuid = bot_row[0]
adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]
if 'enable-webhook' in adapter_config:
continue
# Determine mode based on existing config: if webhook fields are present, keep webhook mode
has_webhook_config = bool(
adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')
)
adapter_config['enable-webhook'] = has_webhook_config
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -1,15 +0,0 @@
import sqlalchemy
from .. import migration
@migration.migration_class(25)
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
"""Add pipeline_routing_rules column to bots table"""
async def upgrade(self):
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
await self.ap.persistence_mgr.execute_async(sql_text)
async def downgrade(self):
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
await self.ap.persistence_mgr.execute_async(sql_text)

View File

@@ -37,7 +37,6 @@ class PendingMessage:
message_chain: platform_message.MessageChain
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
pipeline_uuid: typing.Optional[str]
routed_by_rule: bool = False
timestamp: float = field(default_factory=time.time)
@@ -126,7 +125,6 @@ class MessageAggregator:
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
) -> None:
"""Add a message to the aggregation buffer
@@ -147,7 +145,6 @@ class MessageAggregator:
message_chain=message_chain,
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
return
@@ -162,7 +159,6 @@ class MessageAggregator:
message_chain=message_chain,
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
force_flush = False
@@ -221,7 +217,6 @@ class MessageAggregator:
message_chain=msg.message_chain,
adapter=msg.adapter,
pipeline_uuid=msg.pipeline_uuid,
routed_by_rule=msg.routed_by_rule,
)
return
@@ -236,7 +231,6 @@ class MessageAggregator:
message_chain=merged_msg.message_chain,
adapter=merged_msg.adapter,
pipeline_uuid=merged_msg.pipeline_uuid,
routed_by_rule=merged_msg.routed_by_rule,
)
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:

View File

@@ -63,14 +63,6 @@ class Controller:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
if pipeline:
await pipeline.run(selected_query)
else:
self.ap.logger.warning(
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
)
else:
self.ap.logger.warning(
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
)
async with self.ap.query_pool:
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()

View File

@@ -34,15 +34,6 @@ class MonitoringHelper:
# Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
@@ -66,7 +57,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
variables=None, # Will be updated in record_query_success
)
@@ -90,7 +80,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
)
return message_id
@@ -139,15 +128,6 @@ class MonitoringHelper:
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Extract response content from resp_message_chain
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
# Serialize the last response message chain
@@ -182,7 +162,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
role='assistant',
)
@@ -204,15 +183,6 @@ class MonitoringHelper:
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Record error message
message_id = await ap.monitoring_service.record_message(
bot_id=bot_id,
@@ -227,7 +197,6 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name,
)

View File

@@ -297,9 +297,6 @@ class RuntimePipeline:
)
# Store message_id in query variables for LLM call monitoring
query.variables['_monitoring_message_id'] = message_id
# Notify adapter so it can map platform-specific IDs to monitoring message ID
if hasattr(query.adapter, 'on_monitoring_message_created'):
await query.adapter.on_monitoring_message_created(query, message_id)
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')
@@ -326,9 +323,6 @@ class RuntimePipeline:
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
if event_ctx.is_prevented_default():
self.ap.logger.debug(
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
)
return
self.ap.logger.debug(f'Processing query {query.query_id}')

View File

@@ -41,7 +41,6 @@ class QueryPool:
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
) -> pipeline_query.Query:
async with self.condition:
query_id = self.query_id_counter
@@ -53,7 +52,7 @@ class QueryPool:
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables={'_routed_by_rule': routed_by_rule},
variables={},
resp_messages=[],
resp_message_chain=[],
adapter=adapter,

Some files were not shown because too many files have changed in this diff Show More