mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
Compare commits
183 Commits
copilot/fi
...
fix/api-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8275cfd140 | ||
|
|
b251fc4b89 | ||
|
|
075c85e2bc | ||
|
|
62b63ca2ca | ||
|
|
3680a80248 | ||
|
|
6713b57d01 | ||
|
|
ea13ef87f2 | ||
|
|
59bd581e88 | ||
|
|
cba83a62e8 | ||
|
|
f412127fb0 | ||
|
|
5273bbb23f | ||
|
|
0ceab3f6a5 | ||
|
|
aedc097188 | ||
|
|
18b27dd9ef | ||
|
|
3f50a56623 | ||
|
|
1fcdbd472f | ||
|
|
547006cb4a | ||
|
|
92bf9a7ea5 | ||
|
|
832efb4069 | ||
|
|
8f1847d480 | ||
|
|
fe619e415f | ||
|
|
0154ea6cd3 | ||
|
|
8db55267d8 | ||
|
|
b9662250a6 | ||
|
|
d9378c3a88 | ||
|
|
86a4d1bf0b | ||
|
|
ce6e79db8e | ||
|
|
d53e2cb9a0 | ||
|
|
c1168745b7 | ||
|
|
69b87a0d8a | ||
|
|
6637b153f1 | ||
|
|
e768fc6116 | ||
|
|
2442d3bf52 | ||
|
|
42d78817f4 | ||
|
|
4b9f25a05d | ||
|
|
d1f0e07cc0 | ||
|
|
78e55509ae | ||
|
|
2c28635a39 | ||
|
|
5f3cecfbe2 | ||
|
|
12df9d6ee9 | ||
|
|
195f6efeff | ||
|
|
564d829e25 | ||
|
|
58c1916712 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b | ||
|
|
5a5c4295b1 | ||
|
|
88111d87ac | ||
|
|
4e5a6ee79a | ||
|
|
05c684d757 | ||
|
|
2838020580 | ||
|
|
9b34ae2db4 | ||
|
|
f8010a20eb | ||
|
|
917edb3413 | ||
|
|
10425ede34 | ||
|
|
e4b40a8fa0 | ||
|
|
0b8ab4b54b | ||
|
|
49239e0e08 | ||
|
|
aec2a30445 | ||
|
|
c8915ca964 | ||
|
|
a715eddd06 | ||
|
|
2f9c235b41 | ||
|
|
cc4d8838eb | ||
|
|
fa0a77f09f | ||
|
|
fd6a7b73d4 | ||
|
|
bf0848d60b | ||
|
|
e06fac2bb7 | ||
|
|
bec61427a0 | ||
|
|
5fae7b2eb0 | ||
|
|
2eebdfe16a | ||
|
|
9cd3544d59 | ||
|
|
de4d14fee3 | ||
|
|
f29c568381 | ||
|
|
af3f557055 | ||
|
|
b894842736 | ||
|
|
e190029e1f | ||
|
|
e4940a8050 | ||
|
|
617c95ebc4 | ||
|
|
1cdd428bcc | ||
|
|
71ac719aee | ||
|
|
4621e6cc9f | ||
|
|
66087f83e1 | ||
|
|
25f9330491 | ||
|
|
14b1e0d33b | ||
|
|
83ccb33fd3 | ||
|
|
05bcf543ba | ||
|
|
7cd063bb5d | ||
|
|
8f1317b39e | ||
|
|
77a0de5ef0 | ||
|
|
875227a2fe | ||
|
|
2317392ee5 | ||
|
|
c7efa4dd7f | ||
|
|
e701daa8e0 | ||
|
|
1ae99199b2 | ||
|
|
7c067a1cb3 | ||
|
|
478bc62576 | ||
|
|
a740eb8ee9 | ||
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 | ||
|
|
f4db53b759 | ||
|
|
9f90341dcb | ||
|
|
67b726afb2 | ||
|
|
01852b81d4 | ||
|
|
4d6f109788 | ||
|
|
e1e5e7aedf | ||
|
|
cd53abc440 | ||
|
|
16a15a122a | ||
|
|
6fa653f232 | ||
|
|
c13971d7d6 | ||
|
|
9c659ce8fa | ||
|
|
c9fc64360f | ||
|
|
88a04fdbe8 | ||
|
|
865f6ee81b | ||
|
|
bd5ec59b7c | ||
|
|
9c0cc1003d | ||
|
|
ea07d8ad00 | ||
|
|
3ac3fad4bc | ||
|
|
254a13bba3 | ||
|
|
4355f0fa78 | ||
|
|
031737f05d | ||
|
|
9e366fc536 | ||
|
|
8bd6442965 | ||
|
|
1a1eadb282 | ||
|
|
eed72b1c12 | ||
|
|
351350ea03 | ||
|
|
bc3d6ba92f | ||
|
|
345e4baf2a | ||
|
|
6c64dc057f | ||
|
|
eec0a9c9d9 | ||
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda | ||
|
|
20f5ebd9b8 | ||
|
|
d2c75329cf | ||
|
|
7e2fe082f0 | ||
|
|
d451b059fd | ||
|
|
93c52fcd4c | ||
|
|
f1608682e6 | ||
|
|
077e631c13 | ||
|
|
d7df1f05d1 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
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
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
4
.github/workflows/publish-to-pypi.yml
vendored
4
.github/workflows/publish-to-pypi.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
171
.github/workflows/test-migrations.yml
vendored
Normal file
171
.github/workflows/test-migrations.yml
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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
4
.gitignore
vendored
@@ -47,8 +47,12 @@ plugins.bak
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
|
testsdk/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ repos:
|
|||||||
# Run the formatter of backend.
|
# Run the formatter of backend.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
|
||||||
additional_dependencies:
|
|
||||||
- prettier@3.1.0
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx --prefix web prettier --write --ignore-unknown
|
||||||
|
language: system
|
||||||
|
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||||
|
|
||||||
- id: lint-staged
|
- id: lint-staged
|
||||||
name: lint-staged
|
name: lint-staged
|
||||||
entry: cd web && pnpm lint-staged
|
entry: cd web && pnpm lint-staged
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
## Some Principles
|
## Some Principles
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</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.
|
- **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.
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,68 +84,72 @@ docker compose up -d
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Official |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Official |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Official |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Official |
|
||||||
| QQ | ✅ | Personal & Official API |
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Official |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Official |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Official |
|
||||||
| Satori | ✅ | |
|
| 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
|
## Supported LLMs & Integrations
|
||||||
|
|
||||||
| Provider | Type | Status |
|
| Provider | Type | Status |
|
||||||
|----------|------|--------|
|
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | 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 | ✅ |
|
| [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 | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
| [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 | ✅ |
|
| [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 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://docs.langbot.app/en/insight/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why LangBot?
|
## Why LangBot?
|
||||||
|
|
||||||
| Use Case | How LangBot Helps |
|
| Use Case | How LangBot Helps |
|
||||||
|----------|-------------------|
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
## Live Demo
|
||||||
|
|
||||||
**Try it now:** https://demo.langbot.dev/
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
- Email: `demo@langbot.app`
|
- Email: `demo@langbot.app`
|
||||||
- Password: `langbot123456`
|
- Password: `langbot123456`
|
||||||
|
|
||||||
*Note: Public demo environment. Do not enter sensitive information.*
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
34
README_CN.md
34
README_CN.md
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">插件市场</a> |
|
<a href="https://space.langbot.app">插件市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
@@ -34,8 +34,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 什么是 LangBot?
|
|
||||||
|
|
||||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||||
|
|
||||||
### 核心能力
|
### 核心能力
|
||||||
@@ -43,11 +41,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
- **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 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [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)
|
**更多方式:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,13 +87,16 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | |
|
| 飞书 | ✅ | 官方 |
|
||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| KOOK | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,8 +127,9 @@ docker compose up -d
|
|||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
31
README_ES.md
31
README_ES.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</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.
|
- **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.
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Oficial |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Oficial |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Oficial |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Oficial |
|
||||||
| QQ | ✅ | Personal y API Oficial |
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Oficial |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Oficial |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Oficial |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
README_FR.md
31
README_FR.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</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.
|
- **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.
|
- **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://docs.langbot.app/en/insight/features.html)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Officiel |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Officiel |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Officiel |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Officiel |
|
||||||
| QQ | ✅ | Personnel & API Officielle |
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Officiel |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Officiel |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Officiel |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [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 | ✅ |
|
| [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 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
33
README_JP.md
33
README_JP.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [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)
|
**その他:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| QQ | ✅ | 個人 & 公式API |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
README_KO.md
31
README_KO.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [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)
|
**더 많은 옵션:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 공식 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 공식 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 공식 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 공식 |
|
||||||
| QQ | ✅ | 개인 및 공식 API |
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 공식 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 공식 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 공식 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
README_RU.md
31
README_RU.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [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)
|
**Другие варианты:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Официальный |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Официальный |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Официальный |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Официальный |
|
||||||
| QQ | ✅ | Личный и официальный API |
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Официальный |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Официальный |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Официальный |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [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 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
README_TW.md
31
README_TW.md
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [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)
|
**更多方式:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 飛書 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 釘釘 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| KOOK | ✅ | |
|
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ docker compose up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
README_VI.md
31
README_VI.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<a href="https://langbot.app">Trang chủ</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a> |
|
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</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.
|
- **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ệ.
|
- **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://docs.langbot.app/en/insight/features.html)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Chính thức |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Chính thức |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Chính thức |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Chính thức |
|
||||||
| QQ | ✅ | Cá nhân & API chính thức |
|
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||||
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Chính thức |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Chính thức |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Chính thức |
|
||||||
| Satori | ✅ | |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 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://docs.langbot.app/en/insight/features.html)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ spec:
|
|||||||
### 参考资源
|
### 参考资源
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
- [LangBot 官方文档](https://docs.langbot.app)
|
||||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,5 +625,5 @@ spec:
|
|||||||
### References
|
### References
|
||||||
|
|
||||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||||
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
langbot_network:
|
langbot_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.0"
|
version = "4.9.7"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocqhttp>=1.4.4",
|
"aiocqhttp>=1.4.4",
|
||||||
"aiofiles>=24.1.0",
|
"aiofiles>=24.1.0",
|
||||||
"aiohttp>=3.11.18",
|
"aiohttp>=3.13.4",
|
||||||
"aioshutil>=1.5",
|
"aioshutil>=1.5",
|
||||||
"aiosqlite>=0.21.0",
|
"aiosqlite>=0.21.0",
|
||||||
"anthropic>=0.51.0",
|
"anthropic>=0.51.0",
|
||||||
@@ -16,18 +16,18 @@ dependencies = [
|
|||||||
"async-lru>=2.0.5",
|
"async-lru>=2.0.5",
|
||||||
"certifi>=2025.4.26",
|
"certifi>=2025.4.26",
|
||||||
"colorlog~=6.6.0",
|
"colorlog~=6.6.0",
|
||||||
"cryptography>=44.0.3",
|
"cryptography>=46.0.7",
|
||||||
"dashscope>=1.25.10",
|
"dashscope>=1.25.10",
|
||||||
"dingtalk-stream>=0.24.0",
|
"dingtalk-stream>=0.24.0",
|
||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
"openai>1.0.0",
|
"openai>1.0.0",
|
||||||
"pillow>=11.2.1",
|
"pillow>=12.2.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pycryptodome>=3.22.0",
|
"pycryptodome>=3.22.0",
|
||||||
"pydantic>2.0",
|
"pydantic>2.0",
|
||||||
@@ -35,10 +35,12 @@ dependencies = [
|
|||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -49,7 +51,7 @@ dependencies = [
|
|||||||
"pip>=25.1.1",
|
"pip>=25.1.1",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"uv>=0.7.11",
|
"uv>=0.11.6",
|
||||||
"mypy>=1.16.0",
|
"mypy>=1.16.0",
|
||||||
"PyPDF2>=3.0.1",
|
"PyPDF2>=3.0.1",
|
||||||
"python-docx>=1.1.0",
|
"python-docx>=1.1.0",
|
||||||
@@ -60,13 +62,18 @@ dependencies = [
|
|||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"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",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.0",
|
"langbot-plugin==0.3.11",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
@@ -111,12 +118,12 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=8.4.1",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.0'
|
__version__ = '4.9.7'
|
||||||
|
|||||||
@@ -182,6 +182,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
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):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
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':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,19 +359,52 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
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['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
down_list = incoming_message.get_down_list()
|
# 获取原始数据字典并提取嵌套的文件信息
|
||||||
if len(down_list) >= 2:
|
raw_data = incoming_message.to_dict()
|
||||||
message_data['File'] = await self.get_file_url(down_list[0])
|
file_info = raw_data.get('content', {})
|
||||||
message_data['Name'] = down_list[1]
|
|
||||||
|
# 兼容处理:如果 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
|
||||||
else:
|
else:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||||
message_data['File'] = None
|
message_data['File'] = None
|
||||||
message_data['Name'] = None
|
message_data['Name'] = None
|
||||||
|
|
||||||
message_data['Type'] = 'file'
|
message_data['Type'] = 'file'
|
||||||
|
|
||||||
copy_message_data = message_data.copy()
|
copy_message_data = message_data.copy()
|
||||||
@@ -357,6 +481,12 @@ class DingTalkClient:
|
|||||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||||
card_data['content'] = ''
|
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)
|
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||||
# print(card_instance)
|
# print(card_instance)
|
||||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
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]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
||||||
|
from .types import ApiError as ApiError
|
||||||
|
from .types import LoginResult as LoginResult
|
||||||
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
"""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
|
||||||
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""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
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
from .qqofficialevent import QQOfficialEvent
|
from .qqofficialevent import QQOfficialEvent
|
||||||
import json
|
import json
|
||||||
@@ -32,6 +34,8 @@ class QQOfficialClient:
|
|||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._msg_seq_counter = 0
|
||||||
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
"""检查access_token是否存在"""
|
"""检查access_token是否存在"""
|
||||||
@@ -50,18 +54,18 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
try:
|
response = await client.post(url, json=params, headers=headers)
|
||||||
response = await client.post(url, json=params, headers=headers)
|
if response.status_code != 200:
|
||||||
if response.status_code == 200:
|
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
access_token = response_data.get('access_token')
|
access_token = response_data.get('access_token')
|
||||||
expires_in = int(response_data.get('expires_in', 7200))
|
expires_in = int(response_data.get('expires_in', 7200))
|
||||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||||
if access_token:
|
if access_token:
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
except Exception as e:
|
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
else:
|
||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception('Failed to get access_token: no access_token in response')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
@@ -87,10 +91,10 @@ class QQOfficialClient:
|
|||||||
try:
|
try:
|
||||||
body = await req.get_data()
|
body = await req.get_data()
|
||||||
|
|
||||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||||
|
|
||||||
if not body or len(body) == 0:
|
if not body or len(body) == 0:
|
||||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
await self.logger.info('Received empty body, might be health check or GET request')
|
||||||
return {'code': 0, 'message': 'ok'}, 200
|
return {'code': 0, 'message': 'ok'}, 200
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
@@ -111,7 +115,6 @@ class QQOfficialClient:
|
|||||||
return {'code': 0, 'message': 'success'}
|
return {'code': 0, 'message': 'success'}
|
||||||
|
|
||||||
except Exception as e:
|
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()}')
|
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||||
return {'error': str(e)}, 400
|
return {'error': str(e)}, 400
|
||||||
|
|
||||||
@@ -139,21 +142,24 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||||
"""获取消息"""
|
"""获取消息"""
|
||||||
|
d = msg.get('d', {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return {}
|
||||||
message_data = {
|
message_data = {
|
||||||
't': msg.get('t', {}),
|
't': msg.get('t', {}),
|
||||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
'timestamp': d.get('timestamp', {}),
|
||||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
'd_author_id': d.get('author', {}).get('id', {}),
|
||||||
'content': msg.get('d', {}).get('content', {}),
|
'content': d.get('content', {}),
|
||||||
'd_id': msg.get('d', {}).get('id', {}),
|
'd_id': d.get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
'channel_id': d.get('channel_id', {}),
|
||||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
'username': d.get('author', {}).get('username', {}),
|
||||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
'guild_id': d.get('guild_id', {}),
|
||||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
'member_openid': d.get('author', {}).get('openid', {}),
|
||||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
'group_openid': d.get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = msg.get('d', {}).get('attachments', [])
|
attachments = d.get('attachments', [])
|
||||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||||
image_attachments_type = [
|
image_attachments_type = [
|
||||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||||
@@ -192,7 +198,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||||
@@ -215,7 +221,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||||
raise Exception(response.read().decode())
|
raise Exception(response.read().decode())
|
||||||
|
|
||||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||||
@@ -238,7 +244,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||||
@@ -261,9 +267,224 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||||
raise Exception(response)
|
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):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
@@ -292,3 +513,325 @@ class QQOfficialClient:
|
|||||||
'signature': signature,
|
'signature': signature,
|
||||||
}
|
}
|
||||||
return response
|
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)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
import re
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -63,16 +64,25 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 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:
|
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
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]:
|
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
@@ -82,6 +92,32 @@ class StreamSessionManager:
|
|||||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||||
return self._sessions.get(stream_id)
|
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]:
|
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -183,11 +219,17 @@ class StreamSessionManager:
|
|||||||
session.last_access = time.time()
|
session.last_access = time.time()
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||||
|
|
||||||
|
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||||
|
不会因为 session 被提前清除而丢失上下文信息。
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired: list[str] = []
|
expired: list[str] = []
|
||||||
for stream_id, session in self._sessions.items():
|
for stream_id, session in self._sessions.items():
|
||||||
if now - session.last_access > self.ttl:
|
# 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:
|
||||||
expired.append(stream_id)
|
expired.append(stream_id)
|
||||||
|
|
||||||
for stream_id in expired:
|
for stream_id in expired:
|
||||||
@@ -197,6 +239,488 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
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:
|
class WecomBotClient:
|
||||||
@@ -236,14 +760,27 @@ class WecomBotClient:
|
|||||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||||
self.stream_poll_timeout = 0.5
|
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
|
@staticmethod
|
||||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
def _build_stream_payload(
|
||||||
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""按照企业微信协议拼装返回报文。
|
"""按照企业微信协议拼装返回报文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stream_id: 企业微信会话 ID。
|
stream_id: 企业微信会话 ID。
|
||||||
content: 推送的文本内容。
|
content: 推送的文本内容。
|
||||||
finish: 是否为最终片段。
|
finish: 是否为最终片段。
|
||||||
|
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: 可直接加密返回的 payload。
|
dict[str, Any]: 可直接加密返回的 payload。
|
||||||
@@ -251,13 +788,16 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
return {
|
return {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -313,9 +853,14 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
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)
|
message_data = await self.get_message(msg_json)
|
||||||
if message_data:
|
if message_data:
|
||||||
message_data['stream_id'] = session.stream_id
|
message_data['stream_id'] = session.stream_id
|
||||||
|
message_data['feedback_id'] = feedback_id
|
||||||
try:
|
try:
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -324,7 +869,7 @@ class WecomBotClient:
|
|||||||
if is_new:
|
if is_new:
|
||||||
asyncio.create_task(self._dispatch_event(event))
|
asyncio.create_task(self._dispatch_event(event))
|
||||||
|
|
||||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||||
return await self._encrypt_and_reply(payload, nonce)
|
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]:
|
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -449,202 +994,83 @@ class WecomBotClient:
|
|||||||
|
|
||||||
msg_json = json.loads(decrypted_xml)
|
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':
|
if msg_json.get('msgtype') == 'stream':
|
||||||
return await self._handle_post_followup_response(msg_json, nonce)
|
return await self._handle_post_followup_response(msg_json, nonce)
|
||||||
|
|
||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
message_data = {}
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
msg_type = msg_json.get('msgtype', '')
|
Args:
|
||||||
if msg_type:
|
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||||
message_data['msgtype'] = msg_type
|
nonce: 企业微信回调参数 nonce。
|
||||||
|
|
||||||
if msg_json.get('chattype', '') == 'single':
|
Returns:
|
||||||
message_data['type'] = 'single'
|
Tuple[Response, int]: Quart Response 及状态码。
|
||||||
elif msg_json.get('chattype', '') == 'group':
|
|
||||||
message_data['type'] = 'group'
|
|
||||||
|
|
||||||
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
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', [])
|
||||||
|
|
||||||
async def _safe_download(url: str):
|
await self.logger.info(
|
||||||
if not url:
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
return None
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
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:
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
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
|
if session:
|
||||||
from_info = msg_json.get('from', {})
|
await self.logger.info(
|
||||||
message_data['userid'] = from_info.get('userid', '')
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
message_data['username'] = (
|
)
|
||||||
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
else:
|
||||||
)
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
|
||||||
|
|
||||||
# Extract chat/group information
|
# Dispatch feedback event regardless of session availability
|
||||||
if msg_json.get('chattype', '') == 'group':
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
message_data['chatid'] = msg_json.get('chatid', '')
|
try:
|
||||||
# Try to get group name if available
|
await handler(
|
||||||
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
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())
|
||||||
|
|
||||||
message_data['msgid'] = msg_json.get('msgid', '')
|
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())
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
except Exception:
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
return message_data
|
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)
|
||||||
|
|
||||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""
|
"""
|
||||||
@@ -711,40 +1137,20 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return decorator
|
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):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
async with httpx.AsyncClient() as client:
|
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
response = await client.get(download_url)
|
if data:
|
||||||
if response.status_code != 200:
|
return _bytes_to_data_uri(data)
|
||||||
await self.logger.error(f'failed to get file: {response.text}')
|
return None
|
||||||
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):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -133,3 +133,24 @@ class WecomBotEvent(dict):
|
|||||||
AI Bot ID
|
AI Bot ID
|
||||||
"""
|
"""
|
||||||
return self.get('aibotid', '')
|
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', {})
|
||||||
|
|||||||
683
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
683
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
"""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 Any, Callable, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -13,9 +13,9 @@ from .. import group
|
|||||||
@group.group_class('files', '/api/v1/files')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _(image_key: str) -> quart.Response:
|
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)
|
return quart.Response(status=404)
|
||||||
|
|
||||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||||
|
|||||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
'platform',
|
'platform',
|
||||||
'user_id',
|
'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:
|
else:
|
||||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||||
|
|
||||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response, 200
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""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
|
||||||
@@ -43,6 +43,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
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(
|
connection = await ws_connection_manager.add_connection(
|
||||||
websocket=quart.websocket._get_current_object(),
|
websocket=quart.websocket._get_current_object(),
|
||||||
@@ -70,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -178,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
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):
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -203,7 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import asyncio
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -35,3 +36,640 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In-memory session store for active registrations
|
||||||
|
_create_app_sessions: dict = {}
|
||||||
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
def _cleanup_expired_sessions():
|
||||||
|
"""Remove sessions that have exceeded their TTL."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
|
||||||
|
for sid in expired:
|
||||||
|
session = _create_app_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/lark/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import lark_oapi as lark
|
||||||
|
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
|
||||||
|
|
||||||
|
_cleanup_expired_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'app_id': None,
|
||||||
|
'app_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_create_app_sessions[session_id] = session
|
||||||
|
|
||||||
|
def on_qr_code(info):
|
||||||
|
# May be called from a background thread by the SDK;
|
||||||
|
# use call_soon_threadsafe to safely update session state.
|
||||||
|
def _update():
|
||||||
|
session['qr_url'] = info['url']
|
||||||
|
session['expire_at'] = time.time() + 600 # 10 minutes
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
async def run_registration():
|
||||||
|
try:
|
||||||
|
result = await lark.aregister_app(
|
||||||
|
on_qr_code=on_qr_code,
|
||||||
|
source='langbot',
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['app_id'] = result['client_id']
|
||||||
|
session['app_secret'] = result['client_secret']
|
||||||
|
except AppAccessDeniedError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'User denied authorization'
|
||||||
|
except AppExpiredError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_registration())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll registration status."""
|
||||||
|
session = _create_app_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['app_id'] = session['app_id']
|
||||||
|
data['app_secret'] = session['app_secret']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a registration session."""
|
||||||
|
session = _create_app_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeChat QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_weixin_login_sessions: dict = {}
|
||||||
|
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
|
||||||
|
|
||||||
|
def _cleanup_expired_weixin_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _weixin_login_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/weixin/login', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
_cleanup_expired_weixin_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_data_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'token': None,
|
||||||
|
'base_url': None,
|
||||||
|
'account_id': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_weixin_login_sessions[session_id] = session
|
||||||
|
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=DEFAULT_BASE_URL,
|
||||||
|
token='',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_login():
|
||||||
|
try:
|
||||||
|
import qrcode as qr_lib
|
||||||
|
|
||||||
|
for _attempt in range(3):
|
||||||
|
qr_resp = await client.fetch_qrcode()
|
||||||
|
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||||
|
raise Exception('Failed to get QR code from server')
|
||||||
|
|
||||||
|
# Generate QR code image locally
|
||||||
|
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
|
||||||
|
qr.add_data(qr_resp.qrcode_img_content)
|
||||||
|
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')
|
||||||
|
data_url = f'data:image/png;base64,{b64}'
|
||||||
|
|
||||||
|
def _update_qr():
|
||||||
|
session['qr_data_url'] = data_url
|
||||||
|
session['expire_at'] = time.time() + 480 # 8 minutes
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update_qr)
|
||||||
|
|
||||||
|
# Poll for scan status
|
||||||
|
deadline = loop.time() + 180
|
||||||
|
while loop.time() < deadline:
|
||||||
|
try:
|
||||||
|
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
|
||||||
|
except Exception:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['token'] = status_resp.bot_token
|
||||||
|
session['base_url'] = status_resp.baseurl or client.base_url
|
||||||
|
session['account_id'] = status_resp.ilink_bot_id or ''
|
||||||
|
return
|
||||||
|
|
||||||
|
if status_resp.status == 'expired':
|
||||||
|
break # retry with new QR code
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
pass # timeout, retry
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code login failed: max retries exceeded'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_login())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_data_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_data_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeChat login status."""
|
||||||
|
session = _weixin_login_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['token'] = session['token']
|
||||||
|
data['base_url'] = session['base_url']
|
||||||
|
data['account_id'] = session['account_id']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeChat login session."""
|
||||||
|
session = _weixin_login_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# DingTalk Device Flow QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_dingtalk_sessions: dict = {}
|
||||||
|
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_dingtalk_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _dingtalk_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
|
||||||
|
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'client_id': None,
|
||||||
|
'client_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'device_code': None,
|
||||||
|
'interval': 5,
|
||||||
|
}
|
||||||
|
_dingtalk_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_device_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Init — get nonce
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/init',
|
||||||
|
json={'source': 'langbot'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to init')
|
||||||
|
return
|
||||||
|
nonce = data['nonce']
|
||||||
|
|
||||||
|
# Step 2: Begin — get device_code + QR URL
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/begin',
|
||||||
|
json={'nonce': nonce},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to begin authorization')
|
||||||
|
return
|
||||||
|
|
||||||
|
device_code = data['device_code']
|
||||||
|
verification_uri_complete = data.get('verification_uri_complete', '')
|
||||||
|
expires_in = data.get('expires_in', 7200)
|
||||||
|
interval = data.get('interval', 5)
|
||||||
|
|
||||||
|
session['device_code'] = device_code
|
||||||
|
session['interval'] = interval
|
||||||
|
session['qr_url'] = verification_uri_complete
|
||||||
|
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 3: Poll for authorization result
|
||||||
|
deadline = time.time() + expires_in
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/poll',
|
||||||
|
json={'device_code': device_code},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if poll_data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('errmsg', 'Poll failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
status = poll_data.get('status', '')
|
||||||
|
|
||||||
|
if status == 'SUCCESS':
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['client_id'] = poll_data.get('client_id', '')
|
||||||
|
session['client_secret'] = poll_data.get('client_secret', '')
|
||||||
|
return
|
||||||
|
elif status == 'FAIL':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
|
||||||
|
return
|
||||||
|
elif status == 'EXPIRED':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status == 'WAITING': continue polling
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_device_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll DingTalk Device Flow status."""
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
session = _dingtalk_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['client_id'] = session['client_id']
|
||||||
|
data['client_secret'] = session['client_secret']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||||
|
session = _dingtalk_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeComBot QR Code One-Click Create
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_wecombot_sessions: dict = {}
|
||||||
|
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_wecombot_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _wecombot_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
|
||||||
|
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
|
||||||
|
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'botid': None,
|
||||||
|
'secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'scode': None,
|
||||||
|
'task': None,
|
||||||
|
}
|
||||||
|
_wecombot_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Generate QR code
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from WeCom service'
|
||||||
|
return
|
||||||
|
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to generate QR code')
|
||||||
|
return
|
||||||
|
|
||||||
|
scode = data['data']['scode']
|
||||||
|
auth_url = data['data']['auth_url']
|
||||||
|
|
||||||
|
session['scode'] = scode
|
||||||
|
session['qr_url'] = auth_url
|
||||||
|
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: Poll for scan result
|
||||||
|
deadline = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = poll_data.get('data', {}).get('status', '')
|
||||||
|
if status == 'success':
|
||||||
|
bot_info = poll_data.get('data', {}).get('bot_info', {})
|
||||||
|
if bot_info.get('botid') and bot_info.get('secret'):
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['botid'] = bot_info['botid']
|
||||||
|
session['secret'] = bot_info['secret']
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Scan succeeded but bot info is incomplete'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_qr_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeComBot creation status."""
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
session = _wecombot_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['botid'] = session['botid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeComBot creation session."""
|
||||||
|
session = _wecombot_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|||||||
@@ -6,11 +6,48 @@ import re
|
|||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
from .. import group
|
from .. import group
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
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}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_origin() -> str:
|
||||||
|
"""Return the public request origin, respecting reverse-proxy headers."""
|
||||||
|
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
|
||||||
|
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
|
||||||
|
|
||||||
|
scheme = forwarded_proto or quart.request.scheme
|
||||||
|
host = forwarded_host or quart.request.host
|
||||||
|
return f'{scheme}://{host}'
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
@@ -27,6 +64,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def initialize(self) -> 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)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
plugins = await self.ap.plugin_connector.list_plugins()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
@@ -135,15 +181,62 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(icon_data, mimetype=mime_type)
|
return quart.Response(icon_data, mimetype=mime_type)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/assets/<filepath>',
|
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.NONE,
|
auth_type=group.AuthType.NONE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
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_bytes = base64.b64decode(asset_data['asset_base64'])
|
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||||
mime_type = asset_data['mime_type']
|
mime_type = asset_data['mime_type']
|
||||||
return quart.Response(asset_bytes, mimetype=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 = _get_request_origin()
|
||||||
|
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'))
|
||||||
|
|
||||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
@@ -265,6 +358,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||||
|
ctx.metadata['install_source'] = 'github'
|
||||||
install_info = {
|
install_info = {
|
||||||
'asset_url': asset_url,
|
'asset_url': asset_url,
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
@@ -295,12 +390,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
|
plugin_author = data.get('plugin_author', '')
|
||||||
|
plugin_name = data.get('plugin_name', '')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
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(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-marketplace',
|
name='plugin-install-marketplace',
|
||||||
label=f'Installing plugin from marketplace ...{data}',
|
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,11 +423,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
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(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-local',
|
name='plugin-install-local',
|
||||||
label=f'Installing plugin from local ...{file.filename}',
|
label=f'Installing plugin from local {file.filename}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||||
|
|
||||||
return self.success()
|
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()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'providers': providers})
|
return self.success(data={'providers': providers})
|
||||||
elif quart.request.method == 'POST':
|
elif quart.request.method == 'POST':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'provider': provider})
|
return self.success(data={'provider': provider})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -43,3 +45,12 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return self.http_status(400, -1, str(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))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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}')
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
from .....utils import constants
|
from .....utils import constants
|
||||||
|
from .....entity.persistence.metadata import Metadata
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('system', '/api/v1/system')
|
@group.group_class('system', '/api/v1/system')
|
||||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
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(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
'disable_models_service', False
|
'disable_models_service', False
|
||||||
),
|
),
|
||||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
'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)
|
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
task_type = quart.request.args.get('type')
|
task_type = quart.request.args.get('type')
|
||||||
|
task_kind = quart.request.args.get('kind')
|
||||||
|
|
||||||
if task_type == '':
|
if task_type == '':
|
||||||
task_type = None
|
task_type = None
|
||||||
|
if task_kind == '':
|
||||||
|
task_kind = None
|
||||||
|
|
||||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||||
|
|
||||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(task_id: str) -> str:
|
async def _(task_id: str) -> str:
|
||||||
@@ -48,6 +136,10 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=task.to_dict())
|
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)
|
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
if not constants.debug_mode:
|
if not constants.debug_mode:
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(3, str(e))
|
return self.fail(3, str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
||||||
return self.fail(1, str(e))
|
return self.fail(1, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
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:
|
else:
|
||||||
return await quart.send_from_directory(frontend_path, '404.html')
|
return await quart.send_from_directory(frontend_path, '404.html')
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,17 @@ class BotService:
|
|||||||
'lark',
|
'lark',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
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}'
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
adapter_runtime_values['webhook_url'] = webhook_url
|
adapter_runtime_values['webhook_url'] = webhook_url
|
||||||
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{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:
|
else:
|
||||||
adapter_runtime_values['webhook_url'] = None
|
adapter_runtime_values['webhook_url'] = None
|
||||||
adapter_runtime_values['webhook_full_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
|
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||||
|
|
||||||
@@ -94,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# checkout the default pipeline
|
# bind the most recently updated pipeline if any exist
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
@@ -115,24 +120,26 @@ class BotService:
|
|||||||
|
|
||||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||||
"""Update bot"""
|
"""Update bot"""
|
||||||
if 'uuid' in bot_data:
|
update_data = bot_data.copy()
|
||||||
del bot_data['uuid']
|
|
||||||
|
if 'uuid' in update_data:
|
||||||
|
del update_data['uuid']
|
||||||
|
|
||||||
# set use_pipeline_name
|
# set use_pipeline_name
|
||||||
if 'use_pipeline_uuid' in bot_data:
|
if 'use_pipeline_uuid' in update_data:
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
update_data['use_pipeline_name'] = pipeline.name
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
raise Exception('Pipeline not found')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,126 @@ class KnowledgeService:
|
|||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
raise ValueError('knowledge_engine_plugin_id is required')
|
raise ValueError('knowledge_engine_plugin_id is required')
|
||||||
|
|
||||||
|
creation_settings = kb_data.get('creation_settings', {})
|
||||||
|
retrieval_settings = kb_data.get('retrieval_settings', {})
|
||||||
|
|
||||||
|
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
||||||
|
await self._validate_schema_required_fields(
|
||||||
|
knowledge_engine_plugin_id,
|
||||||
|
creation_settings,
|
||||||
|
retrieval_settings,
|
||||||
|
)
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||||
name=kb_data.get('name', 'Untitled'),
|
name=kb_data.get('name', 'Untitled'),
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||||
creation_settings=kb_data.get('creation_settings', {}),
|
creation_settings=creation_settings,
|
||||||
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
retrieval_settings=retrieval_settings,
|
||||||
description=kb_data.get('description', ''),
|
description=kb_data.get('description', ''),
|
||||||
)
|
)
|
||||||
return kb.uuid
|
return kb.uuid
|
||||||
|
|
||||||
|
async def _validate_schema_required_fields(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
creation_settings: dict,
|
||||||
|
retrieval_settings: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
|
||||||
|
|
||||||
|
This is a business-agnostic validation that checks all fields marked as
|
||||||
|
required in the plugin's schema, regardless of field type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Knowledge Engine plugin ID.
|
||||||
|
creation_settings: User-provided creation settings.
|
||||||
|
retrieval_settings: User-provided retrieval settings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any required field is missing or empty.
|
||||||
|
"""
|
||||||
|
# Validate creation_schema
|
||||||
|
try:
|
||||||
|
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||||
|
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
||||||
|
|
||||||
|
# Validate retrieval_schema
|
||||||
|
try:
|
||||||
|
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||||
|
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
|
||||||
|
|
||||||
|
def _check_required_fields(
|
||||||
|
self,
|
||||||
|
schema: dict | list,
|
||||||
|
settings: dict,
|
||||||
|
context: str,
|
||||||
|
) -> None:
|
||||||
|
"""Check required fields in schema against provided settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Plugin-defined schema (can be list or dict with 'schema' key).
|
||||||
|
settings: User-provided settings values.
|
||||||
|
context: Context name for error messages (e.g., 'creation_settings').
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a required field is missing or empty.
|
||||||
|
"""
|
||||||
|
if not schema:
|
||||||
|
return
|
||||||
|
|
||||||
|
# schema can be a list directly, or a dict with 'schema' key
|
||||||
|
items = schema if isinstance(schema, list) else schema.get('schema', [])
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
field_name = item.get('name')
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_required = item.get('required', False)
|
||||||
|
if not is_required:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check show_if condition - if field is conditionally shown, only validate when condition is met
|
||||||
|
show_if = item.get('show_if')
|
||||||
|
if show_if:
|
||||||
|
depend_field = show_if.get('field')
|
||||||
|
operator = show_if.get('operator')
|
||||||
|
expected_value = show_if.get('value')
|
||||||
|
|
||||||
|
if depend_field and operator:
|
||||||
|
depend_value = settings.get(depend_field)
|
||||||
|
# If show_if condition is not met, skip validation for this field
|
||||||
|
if operator == 'eq' and depend_value != expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'neq' and depend_value == expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = settings.get(field_name)
|
||||||
|
|
||||||
|
# Validate required field has a non-empty value
|
||||||
|
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||||
|
# Get field label for friendly error message
|
||||||
|
label = item.get('label', {})
|
||||||
|
field_label = (
|
||||||
|
label.get('en_US', field_name)
|
||||||
|
or label.get('zh_Hans', field_name)
|
||||||
|
or label.get('zh_Hant', field_name)
|
||||||
|
or field_name
|
||||||
|
)
|
||||||
|
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
||||||
|
|
||||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
# Filter to only mutable fields
|
# Filter to only mutable fields
|
||||||
|
|||||||
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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
|
||||||
@@ -23,6 +23,17 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
|||||||
return provider_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:
|
class LLMModelsService:
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
@@ -105,11 +116,16 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
if pipeline is not None:
|
||||||
pipeline_config = pipeline.config
|
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
if not model_config.get('primary', ''):
|
||||||
pipeline_data = {'config': pipeline_config}
|
pipeline_config = pipeline.config
|
||||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
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)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
@@ -168,7 +184,7 @@ class LLMModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
persistence_model.LLMModel(**model_data),
|
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
@@ -329,7 +345,7 @@ class EmbeddingModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||||
persistence_model.EmbeddingModel(**model_data),
|
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||||
@@ -362,3 +378,162 @@ class EmbeddingModelsService:
|
|||||||
input_text=['Hello, world!'],
|
input_text=['Hello, world!'],
|
||||||
extra_args={},
|
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.',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,121 @@ class MonitoringService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
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 ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -1132,3 +1247,314 @@ class MonitoringService:
|
|||||||
}
|
}
|
||||||
for row in rows
|
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
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import traceback
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -16,6 +17,24 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if api_keys is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
|
||||||
|
normalized_keys = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
for raw_key in raw_keys:
|
||||||
|
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
|
||||||
|
if not normalized_key or normalized_key in seen_keys:
|
||||||
|
continue
|
||||||
|
normalized_keys.append(normalized_key)
|
||||||
|
seen_keys.add(normalized_key)
|
||||||
|
|
||||||
|
return normalized_keys
|
||||||
|
|
||||||
async def get_providers(self) -> list[dict]:
|
async def get_providers(self) -> list[dict]:
|
||||||
"""Get all providers"""
|
"""Get all providers"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||||
@@ -58,6 +77,7 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, provider_data: dict) -> str:
|
async def create_provider(self, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -71,6 +91,8 @@ class ModelProviderService:
|
|||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
if 'uuid' in provider_data:
|
if 'uuid' in provider_data:
|
||||||
del provider_data['uuid']
|
del provider_data['uuid']
|
||||||
|
if 'api_keys' in provider_data:
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
@@ -97,6 +119,14 @@ class ModelProviderService:
|
|||||||
if embedding_result.first() is not None:
|
if embedding_result.first() is not None:
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
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(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
@@ -121,10 +151,19 @@ class ModelProviderService:
|
|||||||
)
|
)
|
||||||
embedding_count = embedding_result.scalar() or 0
|
embedding_count = embedding_result.scalar() or 0
|
||||||
|
|
||||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
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}
|
||||||
|
|
||||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
|
api_keys = self._normalize_api_keys(api_keys)
|
||||||
|
|
||||||
# Try to find existing provider with same config
|
# Try to find existing provider with same config
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
@@ -152,7 +191,7 @@ class ModelProviderService:
|
|||||||
'name': provider_name,
|
'name': provider_name,
|
||||||
'requester': requester,
|
'requester': requester,
|
||||||
'base_url': base_url,
|
'base_url': base_url,
|
||||||
'api_keys': api_keys or [],
|
'api_keys': api_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,6 +200,69 @@ class ModelProviderService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||||
.values(api_keys=[api_key])
|
.values(api_keys=self._normalize_api_keys(api_key))
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
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}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class SpaceService:
|
|||||||
space_url = space_config['url']
|
space_url = space_config['url']
|
||||||
|
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
|
|||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
|
|
||||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ..config import manager as config_mgr
|
from ..config import manager as config_mgr
|
||||||
from ..command import cmdmgr
|
from ..command import cmdmgr
|
||||||
@@ -30,6 +31,8 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_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 ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
from ..utils import logcache
|
from ..utils import logcache
|
||||||
@@ -131,6 +134,8 @@ class Application:
|
|||||||
|
|
||||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||||
|
|
||||||
|
rerank_models_service: model_service.RerankModelsService = None
|
||||||
|
|
||||||
provider_service: provider_service.ModelProviderService = None
|
provider_service: provider_service.ModelProviderService = None
|
||||||
|
|
||||||
pipeline_service: pipeline_service.PipelineService = None
|
pipeline_service: pipeline_service.PipelineService = None
|
||||||
@@ -151,6 +156,8 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
|
maintenance_service: maintenance_service.MaintenanceService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -186,6 +193,77 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.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(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
@@ -200,6 +278,28 @@ class Application:
|
|||||||
self.logger.error(f'Application runtime fatal exception: {e}')
|
self.logger.error(f'Application runtime fatal exception: {e}')
|
||||||
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
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):
|
def dispose(self):
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_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 ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -61,6 +62,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||||
ap.embedding_models_service = embedding_models_service_inst
|
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)
|
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||||
ap.provider_service = provider_service_inst
|
ap.provider_service = provider_service_inst
|
||||||
|
|
||||||
@@ -164,6 +168,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
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:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
|
|||||||
@@ -74,20 +74,30 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
current = cfg
|
current = cfg
|
||||||
|
|
||||||
for i, key in enumerate(keys):
|
for i, key in enumerate(keys):
|
||||||
if not isinstance(current, dict) or key not in current:
|
if not isinstance(current, dict):
|
||||||
break
|
break
|
||||||
|
|
||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key - check if it's a scalar value
|
# At the final key
|
||||||
if isinstance(current[key], (dict, list)):
|
if key in current:
|
||||||
# Skip dict and list types
|
if isinstance(current[key], list):
|
||||||
pass
|
# 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
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Key doesn't exist yet - create it as string
|
||||||
converted_value = convert_value(env_value, current[key])
|
current[key] = env_value
|
||||||
current[key] = converted_value
|
|
||||||
else:
|
else:
|
||||||
# Navigate deeper
|
# Navigate deeper - create intermediate dict if needed
|
||||||
|
if key not in current:
|
||||||
|
current[key] = {}
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
@@ -146,16 +156,50 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
await ap.instance_config.dump_config()
|
await ap.instance_config.dump_config()
|
||||||
|
|
||||||
# load or generate instance id
|
# load or generate instance id
|
||||||
ap.instance_id = await config.load_json_config(
|
# Priority:
|
||||||
'data/labels/instance_id.json',
|
# 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
|
||||||
template_data={
|
# 2. data/labels/instance_id.json (if file exists)
|
||||||
'instance_id': f'instance_{str(uuid.uuid4())}',
|
# 3. Generate new and save to file
|
||||||
'instance_create_ts': int(time.time()),
|
config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
|
||||||
},
|
|
||||||
completion=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
constants.instance_id = ap.instance_id.data['instance_id']
|
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.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
||||||
|
|
||||||
print(f'LangBot instance id: {constants.instance_id}')
|
print(f'LangBot instance id: {constants.instance_id}')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
from . import entities as core_entities
|
from . import entities as core_entities
|
||||||
@@ -17,9 +18,13 @@ class TaskContext:
|
|||||||
log: str
|
log: str
|
||||||
"""Log"""
|
"""Log"""
|
||||||
|
|
||||||
|
metadata: dict
|
||||||
|
"""Structured metadata for progress reporting"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.current_action = 'default'
|
self.current_action = 'default'
|
||||||
self.log = ''
|
self.log = ''
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
def _log(self, msg: str):
|
def _log(self, msg: str):
|
||||||
self.log += msg + '\n'
|
self.log += msg + '\n'
|
||||||
@@ -38,7 +43,7 @@ class TaskContext:
|
|||||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {'current_action': self.current_action, 'log': self.log}
|
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new() -> TaskContext:
|
def new() -> TaskContext:
|
||||||
@@ -115,6 +120,7 @@ class TaskWrapper:
|
|||||||
self.label = label if label != '' else name
|
self.label = label if label != '' else name
|
||||||
self.task.set_name(name)
|
self.task.set_name(name)
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
def assume_exception(self):
|
def assume_exception(self):
|
||||||
try:
|
try:
|
||||||
@@ -150,6 +156,7 @@ class TaskWrapper:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'label': self.label,
|
'label': self.label,
|
||||||
'scopes': [scope.value for scope in self.scopes],
|
'scopes': [scope.value for scope in self.scopes],
|
||||||
|
'created_at': self.created_at,
|
||||||
'task_context': self.task_context.to_dict(),
|
'task_context': self.task_context.to_dict(),
|
||||||
'runtime': {
|
'runtime': {
|
||||||
'done': self.task.done(),
|
'done': self.task.done(),
|
||||||
@@ -189,6 +196,8 @@ class AsyncTaskManager:
|
|||||||
) -> TaskWrapper:
|
) -> TaskWrapper:
|
||||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||||
self.tasks.append(wrapper)
|
self.tasks.append(wrapper)
|
||||||
|
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||||
|
self._prune_completed_tasks()
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def create_user_task(
|
def create_user_task(
|
||||||
@@ -211,9 +220,23 @@ class AsyncTaskManager:
|
|||||||
def get_tasks_dict(
|
def get_tasks_dict(
|
||||||
self,
|
self,
|
||||||
type: str = None,
|
type: str = None,
|
||||||
|
kind: str = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
'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,
|
||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,3 +257,27 @@ class AsyncTaskManager:
|
|||||||
if not wrapper.task.done():
|
if not wrapper.task.done():
|
||||||
wrapper.task.cancel()
|
wrapper.task.cancel()
|
||||||
return
|
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]
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
|||||||
"""英文"""
|
"""英文"""
|
||||||
|
|
||||||
zh_Hans: typing.Optional[str] = None
|
zh_Hans: typing.Optional[str] = None
|
||||||
"""中文"""
|
"""简体中文"""
|
||||||
|
|
||||||
|
zh_Hant: typing.Optional[str] = None
|
||||||
|
"""繁体中文"""
|
||||||
|
|
||||||
ja_JP: 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:
|
def to_dict(self) -> dict:
|
||||||
"""转换为字典"""
|
"""转换为字典"""
|
||||||
dic = {}
|
dic = {}
|
||||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
|||||||
dic['en_US'] = self.en_US
|
dic['en_US'] = self.en_US
|
||||||
if self.zh_Hans is not None:
|
if self.zh_Hans is not None:
|
||||||
dic['zh_Hans'] = self.zh_Hans
|
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:
|
if self.ja_JP is not None:
|
||||||
dic['ja_JP'] = self.ja_JP
|
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
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = 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())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
|
|||||||
server_default=sqlalchemy.func.now(),
|
server_default=sqlalchemy.func.now(),
|
||||||
onupdate=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(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
|||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
message_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
|
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
|
||||||
|
|||||||
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""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()
|
||||||
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""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
|
||||||
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""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')
|
||||||
150
src/langbot/pkg/persistence/alembic_runner.py
Normal file
150
src/langbot/pkg/persistence/alembic_runner.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""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()
|
||||||
@@ -2,18 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import typing
|
import typing
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from . import database, migration
|
from . import database, migration
|
||||||
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
|
from ..entity.persistence import base, metadata, model as persistence_model
|
||||||
from ..entity import persistence
|
from ..entity import persistence
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from ..utils import constants, importutil
|
from ..utils import constants, importutil
|
||||||
from ..api.http.service import pipeline as pipeline_service
|
|
||||||
from . import databases, migrations
|
from . import databases, migrations
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(databases)
|
importutil.import_modules_in_pkg(databases)
|
||||||
@@ -78,7 +76,9 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
await self.write_default_pipeline()
|
# Run Alembic migrations (new migration system)
|
||||||
|
await self._run_alembic_migrations()
|
||||||
|
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -101,29 +101,6 @@ class PersistenceManager:
|
|||||||
if row is None:
|
if row is None:
|
||||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
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):
|
async def write_space_model_providers(self):
|
||||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||||
@@ -161,6 +138,28 @@ 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 def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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},
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
|||||||
message_chain: platform_message.MessageChain
|
message_chain: platform_message.MessageChain
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
pipeline_uuid: typing.Optional[str]
|
pipeline_uuid: typing.Optional[str]
|
||||||
|
routed_by_rule: bool = False
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a message to the aggregation buffer
|
"""Add a message to the aggregation buffer
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
force_flush = False
|
force_flush = False
|
||||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
|||||||
message_chain=msg.message_chain,
|
message_chain=msg.message_chain,
|
||||||
adapter=msg.adapter,
|
adapter=msg.adapter,
|
||||||
pipeline_uuid=msg.pipeline_uuid,
|
pipeline_uuid=msg.pipeline_uuid,
|
||||||
|
routed_by_rule=msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_msg.message_chain,
|
message_chain=merged_msg.message_chain,
|
||||||
adapter=merged_msg.adapter,
|
adapter=merged_msg.adapter,
|
||||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=merged_msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Controller:
|
|||||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||||
if pipeline:
|
if pipeline:
|
||||||
await pipeline.run(selected_query)
|
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:
|
async with self.ap.query_pool:
|
||||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||||
|
|||||||
@@ -297,6 +297,9 @@ class RuntimePipeline:
|
|||||||
)
|
)
|
||||||
# Store message_id in query variables for LLM call monitoring
|
# Store message_id in query variables for LLM call monitoring
|
||||||
query.variables['_monitoring_message_id'] = message_id
|
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:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||||
|
|
||||||
@@ -323,6 +326,9 @@ class RuntimePipeline:
|
|||||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class QueryPool:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
@@ -52,7 +53,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={},
|
variables={'_routed_by_rule': routed_by_rule},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
session = await self.ap.sess_mgr.get_session(query)
|
session = await self.ap.sess_mgr.get_session(query)
|
||||||
|
|
||||||
# When not local-agent, llm_model is None
|
# When not local-agent, llm_model is None
|
||||||
try:
|
llm_model = None
|
||||||
llm_model = (
|
if selected_runner == 'local-agent':
|
||||||
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
if selected_runner == 'local-agent'
|
# but handle legacy plain string for backward compatibility
|
||||||
else None
|
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||||
)
|
if isinstance(model_config, str):
|
||||||
except ValueError:
|
# Legacy format: plain UUID string
|
||||||
self.ap.logger.warning(
|
primary_uuid = model_config
|
||||||
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
fallback_uuids = []
|
||||||
)
|
else:
|
||||||
llm_model = None
|
primary_uuid = model_config.get('primary', '')
|
||||||
|
fallback_uuids = model_config.get('fallbacks', [])
|
||||||
|
|
||||||
|
if primary_uuid:
|
||||||
|
try:
|
||||||
|
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
||||||
|
|
||||||
|
# Resolve fallback model UUIDs
|
||||||
|
if fallback_uuids:
|
||||||
|
valid_fallbacks = []
|
||||||
|
for fb_uuid in fallback_uuids:
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
valid_fallbacks.append(fb_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
if valid_fallbacks:
|
||||||
|
query.variables['_fallback_model_uuids'] = valid_fallbacks
|
||||||
|
|
||||||
conversation = await self.ap.sess_mgr.get_conversation(
|
conversation = await self.ap.sess_mgr.get_conversation(
|
||||||
query,
|
query,
|
||||||
@@ -56,25 +75,54 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.bot_uuid,
|
query.bot_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Expire externally managed conversation ids after the conversation has
|
||||||
|
# been idle for longer than the configured conversation expire time.
|
||||||
|
# The idle window is measured from the last preprocess/update time, not
|
||||||
|
# from the conversation creation time.
|
||||||
|
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
if conversation_expire_time is not None and conversation_expire_time > 0:
|
||||||
|
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||||
|
if last_update_time is not None:
|
||||||
|
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
|
||||||
|
if conversation_idle_time > conversation_expire_time:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
|
||||||
|
)
|
||||||
|
conversation.uuid = None
|
||||||
|
|
||||||
|
# Treat every preprocess pass as a conversation activity update. This
|
||||||
|
# makes future expiry checks use the latest incoming message/preprocess
|
||||||
|
# time instead of the first message/creation time.
|
||||||
|
conversation.update_time = now
|
||||||
|
|
||||||
# 设置query
|
# 设置query
|
||||||
query.session = session
|
query.session = session
|
||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
query.messages = conversation.messages.copy()
|
query.messages = conversation.messages.copy()
|
||||||
|
|
||||||
if selected_runner == 'local-agent' and llm_model:
|
if selected_runner == 'local-agent':
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
if llm_model:
|
||||||
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||||
# Get bound plugins and MCP servers for filtering tools
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
|
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||||
|
|
||||||
|
# If primary model doesn't support func_call but fallback models exist,
|
||||||
|
# load tools anyway since fallback models may support them
|
||||||
|
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
|
||||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||||
@@ -133,8 +181,10 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
elif isinstance(me, platform_message.File):
|
||||||
# if me.url is not None:
|
if me.base64:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
|
||||||
|
elif me.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
if isinstance(msg, platform_message.Plain):
|
if isinstance(msg, platform_message.Plain):
|
||||||
@@ -145,10 +195,32 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
):
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
|
elif isinstance(msg, platform_message.File):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
|
elif isinstance(msg, platform_message.Voice):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(
|
||||||
|
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
|
||||||
|
)
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
|
||||||
|
|
||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
query.user_message = provider_message.Message(role='user', content=content_list)
|
query.user_message = provider_message.Message(role='user', content=content_list)
|
||||||
|
|
||||||
|
# Extract knowledge base UUIDs into query variables so plugins can modify them
|
||||||
|
# during PromptPreProcessing before the runner performs retrieval.
|
||||||
|
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
||||||
|
if not kb_uuids:
|
||||||
|
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
kb_uuids = [old_kb_uuid]
|
||||||
|
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
|
||||||
|
|
||||||
# =========== 触发事件 PromptPreProcessing
|
# =========== 触发事件 PromptPreProcessing
|
||||||
|
|
||||||
event = events.PromptPreProcessing(
|
event = events.PromptPreProcessing(
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
else:
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||||
|
)
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||||
else:
|
else:
|
||||||
if event_ctx.event.user_message_alter is not None:
|
if event_ctx.event.user_message_alter is not None:
|
||||||
@@ -205,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
|||||||
if query.launcher_type.value != 'group': # 只处理群消息
|
if query.launcher_type.value != 'group': # 只处理群消息
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||||
|
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||||
|
|
||||||
use_rule = rules
|
use_rule = rules
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ from ..core import app, entities as core_entities, taskmgr
|
|||||||
from ..discover import engine
|
from ..discover import engine
|
||||||
|
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
|
from ..entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
from ..entity.errors import platform as platform_errors
|
from ..entity.errors import platform as platform_errors
|
||||||
|
|
||||||
@@ -51,6 +54,148 @@ class RuntimeBot:
|
|||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||||
|
"""Evaluate a single operator condition."""
|
||||||
|
if operator == 'eq':
|
||||||
|
return actual == expected
|
||||||
|
elif operator == 'neq':
|
||||||
|
return actual != expected
|
||||||
|
elif operator == 'contains':
|
||||||
|
return expected in actual
|
||||||
|
elif operator == 'not_contains':
|
||||||
|
return expected not in actual
|
||||||
|
elif operator == 'starts_with':
|
||||||
|
return actual.startswith(expected)
|
||||||
|
elif operator == 'regex':
|
||||||
|
try:
|
||||||
|
return bool(re.search(expected, actual))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
PIPELINE_DISCARD = '__discard__'
|
||||||
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
|
def resolve_pipeline_uuid(
|
||||||
|
self,
|
||||||
|
launcher_type: str,
|
||||||
|
launcher_id: str,
|
||||||
|
message_text: str,
|
||||||
|
message_element_types: list[str] | None = None,
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
|
Rules are evaluated in order; first match wins.
|
||||||
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
|
|
||||||
|
Rule types:
|
||||||
|
- launcher_type: session type ("person" / "group")
|
||||||
|
- launcher_id: session / group id
|
||||||
|
- message_content: message text content
|
||||||
|
- message_has_element: message contains element of given type
|
||||||
|
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||||
|
Operators: eq (has), neq (doesn't have)
|
||||||
|
|
||||||
|
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||||
|
|
||||||
|
When pipeline_uuid is ``__discard__``, the message should be
|
||||||
|
silently dropped by the caller.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
|
when a routing rule matched, False when falling back to default.
|
||||||
|
"""
|
||||||
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
rule_value = rule.get('value', '')
|
||||||
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule_type == 'launcher_type':
|
||||||
|
if self._match_operator(launcher_type, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_content':
|
||||||
|
if self._match_operator(message_text, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_has_element':
|
||||||
|
has_element = rule_value in element_type_set
|
||||||
|
if operator == 'eq' and has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
elif operator == 'neq' and not has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
|
||||||
|
return self.bot_entity.use_pipeline_uuid, False
|
||||||
|
|
||||||
|
async def _record_discarded_message(
|
||||||
|
self,
|
||||||
|
launcher_type: provider_session.LauncherTypes,
|
||||||
|
launcher_id: str | int,
|
||||||
|
sender_id: str | int,
|
||||||
|
message_event: platform_events.MessageEvent,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Record a discarded message in the monitoring system."""
|
||||||
|
try:
|
||||||
|
if hasattr(message_chain, 'model_dump'):
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
message_content = str(message_chain)
|
||||||
|
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(message_event, 'sender'):
|
||||||
|
if hasattr(message_event.sender, 'nickname'):
|
||||||
|
sender_name = message_event.sender.nickname
|
||||||
|
elif hasattr(message_event.sender, 'member_name'):
|
||||||
|
sender_name = message_event.sender.member_name
|
||||||
|
|
||||||
|
# Use the same session_id format as monitoring_helper.py
|
||||||
|
session_id = f'{launcher_type}_{launcher_id}'
|
||||||
|
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='discarded',
|
||||||
|
level='info',
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the session exists so the message appears in the session monitor.
|
||||||
|
# Don't overwrite pipeline info — a session may have messages from
|
||||||
|
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||||
|
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if not session_updated:
|
||||||
|
# No session yet (first message for this launcher was discarded).
|
||||||
|
await self.ap.monitoring_service.record_session_start(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
@@ -82,6 +227,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'person', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Person message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -90,7 +252,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||||
@@ -125,6 +288,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'group', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Group message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -133,7 +313,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||||
@@ -141,6 +322,50 @@ class RuntimeBot:
|
|||||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
|
|
||||||
|
# Register feedback listener (only effective on adapters that support it)
|
||||||
|
async def on_feedback(
|
||||||
|
event: platform_events.FeedbackEvent,
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# Resolve pipeline name
|
||||||
|
pipeline_name = ''
|
||||||
|
if self.bot_entity.use_pipeline_uuid:
|
||||||
|
try:
|
||||||
|
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||||||
|
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pipeline_row = pipeline_result.first()
|
||||||
|
if pipeline_row:
|
||||||
|
pipeline_name = pipeline_row[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=event.feedback_id,
|
||||||
|
feedback_type=event.feedback_type,
|
||||||
|
feedback_content=event.feedback_content,
|
||||||
|
inaccurate_reasons=event.inaccurate_reasons,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name,
|
||||||
|
pipeline_id=self.bot_entity.use_pipeline_uuid or '',
|
||||||
|
pipeline_name=pipeline_name,
|
||||||
|
session_id=event.session_id,
|
||||||
|
message_id=event.message_id,
|
||||||
|
stream_id=event.stream_id,
|
||||||
|
user_id=event.user_id,
|
||||||
|
platform=adapter.__class__.__name__,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
async def exception_wrapper():
|
async def exception_wrapper():
|
||||||
try:
|
try:
|
||||||
@@ -196,12 +421,20 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||||
|
|
||||||
|
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||||
|
|
||||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
if component.metadata.name in disabled_adapters:
|
||||||
|
continue
|
||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
|
# Filter out disabled adapters from components list (for API responses)
|
||||||
|
if disabled_adapters:
|
||||||
|
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||||
|
|
||||||
# initialize websocket adapter
|
# initialize websocket adapter
|
||||||
websocket_adapter_class = self.adapter_dict['websocket']
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
@@ -290,7 +523,7 @@ class PlatformManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def remove_bot(self, bot_uuid: str):
|
async def remove_bot(self, bot_uuid: str):
|
||||||
for bot in self.bots:
|
for bot in self.bots[:]:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
if bot.enable:
|
if bot.enable:
|
||||||
await bot.shutdown()
|
await bot.shutdown()
|
||||||
|
|||||||
@@ -5,19 +5,29 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: OneBot v11
|
en_US: OneBot v11
|
||||||
zh_Hans: OneBot v11
|
zh_Hans: OneBot v11
|
||||||
|
zh_Hant: OneBot v11
|
||||||
description:
|
description:
|
||||||
en_US: OneBot v11 Adapter
|
en_US: OneBot v11 Adapter, used for QQ bots
|
||||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
||||||
|
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
|
||||||
icon: onebot.png
|
icon: onebot.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||||
|
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||||
|
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机
|
zh_Hans: 主机
|
||||||
|
zh_Hant: 主機
|
||||||
description:
|
description:
|
||||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||||
|
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 0.0.0.0
|
default: 0.0.0.0
|
||||||
@@ -25,9 +35,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 端口
|
zh_Hans: 端口
|
||||||
|
zh_Hant: 連接埠
|
||||||
description:
|
description:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听的端口
|
zh_Hans: 监听的端口
|
||||||
|
zh_Hant: 監聽的連接埠
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 2280
|
default: 2280
|
||||||
@@ -35,9 +47,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Access Token
|
en_US: Access Token
|
||||||
zh_Hans: 访问令牌
|
zh_Hans: 访问令牌
|
||||||
|
zh_Hant: 存取令牌
|
||||||
description:
|
description:
|
||||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||||
|
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
||||||
|
if event.content and event.type == 'audio':
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
|
|
||||||
|
# Handle quoted/replied message - extract content as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
if event.quoted_message:
|
||||||
|
quote_info = event.quoted_message
|
||||||
|
msg_type = quote_info.get('msg_type', '')
|
||||||
|
|
||||||
|
# Process quoted file - add as top-level File component (same as private chat)
|
||||||
|
if msg_type == 'file' and quote_info.get('file_url'):
|
||||||
|
file_name = quote_info.get('file_name', 'file')
|
||||||
|
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
|
||||||
|
|
||||||
|
# Process quoted image - add as top-level Image component
|
||||||
|
elif msg_type == 'picture' and quote_info.get('picture'):
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
|
||||||
|
|
||||||
|
# Process quoted audio - add as top-level Voice component
|
||||||
|
elif msg_type == 'audio' and quote_info.get('audio'):
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
|
||||||
|
|
||||||
|
# Process quoted text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'text' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
|
# Process quoted rich text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'richText' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,37 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: DingTalk
|
en_US: DingTalk
|
||||||
zh_Hans: 钉钉
|
zh_Hans: 钉钉
|
||||||
|
zh_Hant: 釘釘
|
||||||
description:
|
description:
|
||||||
en_US: DingTalk Adapter
|
en_US: DingTalk Adapter
|
||||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: 釘釘適配器,請查看文件了解使用方式
|
||||||
icon: dingtalk.svg
|
icon: dingtalk.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||||
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
|
||||||
|
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
|
||||||
|
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: dingtalk
|
||||||
|
required: false
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +43,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Client Secret
|
en_US: Client Secret
|
||||||
zh_Hans: 客户端密钥
|
zh_Hans: 客户端密钥
|
||||||
|
zh_Hant: 用戶端密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +51,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
|
zh_Hant: 機器人代碼
|
||||||
|
description:
|
||||||
|
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
|
||||||
|
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
|
||||||
|
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +63,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Name
|
en_US: Robot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +71,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -50,9 +79,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用钉钉卡片流式回复模式
|
zh_Hans: 启用钉钉卡片流式回复模式
|
||||||
|
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -60,6 +91,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Card Auto Layout
|
en_US: Card Auto Layout
|
||||||
zh_Hans: 卡片宽屏自动布局
|
zh_Hans: 卡片宽屏自动布局
|
||||||
|
zh_Hant: 卡片寬螢幕自動佈局
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -67,6 +99,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: card template id
|
en_US: card template id
|
||||||
zh_Hans: 卡片模板ID
|
zh_Hans: 卡片模板ID
|
||||||
|
zh_Hant: 卡片範本ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Discord
|
en_US: Discord
|
||||||
zh_Hans: Discord
|
zh_Hans: Discord
|
||||||
|
zh_Hant: Discord
|
||||||
|
ja_JP: Discord
|
||||||
|
th_TH: Discord
|
||||||
|
vi_VN: Discord
|
||||||
|
es_ES: Discord
|
||||||
description:
|
description:
|
||||||
en_US: Discord Adapter
|
en_US: Discord Adapter
|
||||||
zh_Hans: Discord 适配器,请查看文档了解使用方式
|
zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境
|
||||||
|
zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境
|
||||||
|
ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です
|
||||||
|
th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้
|
||||||
|
vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord
|
||||||
|
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
|
||||||
icon: discord.svg
|
icon: discord.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/discord
|
||||||
|
en: https://link.langbot.app/en/platforms/discord
|
||||||
|
ja: https://link.langbot.app/ja/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
|
ja_JP: クライアント ID
|
||||||
|
th_TH: รหัสไคลเอนต์
|
||||||
|
vi_VN: ID khách hàng
|
||||||
|
es_ES: ID de cliente
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +44,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: KOOK
|
en_US: KOOK
|
||||||
zh_Hans: KOOK
|
zh_Hans: KOOK
|
||||||
|
zh_Hant: KOOK
|
||||||
description:
|
description:
|
||||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||||
|
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
|
||||||
icon: kook.png
|
icon: kook.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/kook
|
||||||
|
en: https://link.langbot.app/en/platforms/kook
|
||||||
|
ja: https://link.langbot.app/ja/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -575,6 +575,127 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
|
|
||||||
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
_processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {}
|
||||||
|
_processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096
|
||||||
|
_processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None:
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
expire_before = now - cls._processed_thread_quote_cache_ttl_seconds
|
||||||
|
while cls._processed_thread_quote_cache:
|
||||||
|
oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))
|
||||||
|
if oldest_ts >= expire_before:
|
||||||
|
break
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:
|
||||||
|
oldest_key = next(iter(cls._processed_thread_quote_cache))
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _mark_thread_quote_processed(cls, thread_id: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
cls._prune_processed_thread_quote_cache(now)
|
||||||
|
cls._processed_thread_quote_cache[thread_id] = now
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract the message ID to quote from the given message.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- First thread reply in a topic: return parent_id and mark topic as processed
|
||||||
|
- Follow-up thread replies in the same topic: return None
|
||||||
|
- Non-thread message: return parent_id if valid (non-empty, different from message_id)
|
||||||
|
|
||||||
|
Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth.
|
||||||
|
"""
|
||||||
|
parent_id = getattr(message, 'parent_id', None)
|
||||||
|
if not parent_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_id = getattr(message, 'message_id', None)
|
||||||
|
if parent_id == message_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if thread_id:
|
||||||
|
cls._prune_processed_thread_quote_cache()
|
||||||
|
if thread_id in cls._processed_thread_quote_cache:
|
||||||
|
return None
|
||||||
|
cls._mark_thread_quote_processed(thread_id)
|
||||||
|
|
||||||
|
return parent_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]:
|
||||||
|
"""
|
||||||
|
Build EventMessage from SDK typed Message item.
|
||||||
|
|
||||||
|
Returns None if body or content is missing.
|
||||||
|
"""
|
||||||
|
body = getattr(message_item, 'body', None)
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = getattr(body, 'content', None)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
'message_id': message_item.message_id,
|
||||||
|
'message_type': message_item.msg_type,
|
||||||
|
'content': content,
|
||||||
|
'create_time': message_item.create_time,
|
||||||
|
'mentions': getattr(message_item, 'mentions', []) or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve thread-related fields
|
||||||
|
if hasattr(message_item, 'parent_id') and message_item.parent_id:
|
||||||
|
event_data['parent_id'] = message_item.parent_id
|
||||||
|
if hasattr(message_item, 'root_id') and message_item.root_id:
|
||||||
|
event_data['root_id'] = message_item.root_id
|
||||||
|
if hasattr(message_item, 'thread_id') and message_item.thread_id:
|
||||||
|
event_data['thread_id'] = message_item.thread_id
|
||||||
|
if hasattr(message_item, 'chat_id') and message_item.chat_id:
|
||||||
|
event_data['chat_id'] = message_item.chat_id
|
||||||
|
|
||||||
|
return EventMessage(event_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _fetch_quoted_message(
|
||||||
|
quote_message_id: str,
|
||||||
|
api_client: lark_oapi.Client,
|
||||||
|
) -> typing.Optional[platform_message.MessageChain]:
|
||||||
|
"""
|
||||||
|
Fetch the quoted message and convert to MessageChain.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- API call fails
|
||||||
|
- Response items is empty
|
||||||
|
- Message item normalization fails
|
||||||
|
"""
|
||||||
|
request = GetMessageRequest.builder().message_id(quote_message_id).build()
|
||||||
|
response = await api_client.im.v1.message.aget(request)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = getattr(response.data, 'items', None)
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_item = items[0]
|
||||||
|
event_message = LarkEventConverter._build_event_message_from_message_item(message_item)
|
||||||
|
if event_message is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client)
|
||||||
|
return quote_chain
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
@@ -587,6 +708,31 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
) -> platform_events.Event:
|
) -> platform_events.Event:
|
||||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
|
# Check for quote/reply message
|
||||||
|
# Extract files/images/voice from quote and add them as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
|
if quote_message_id:
|
||||||
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
|
if quote_chain:
|
||||||
|
# Filter out Source component from quoted chain, keep only content
|
||||||
|
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
|
|
||||||
|
# Add quoted content as top-level components instead of wrapping in Quote
|
||||||
|
for comp in quote_components:
|
||||||
|
if isinstance(comp, platform_message.File):
|
||||||
|
# Add file as top-level component (same as direct message)
|
||||||
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Image):
|
||||||
|
# Add image as top-level component
|
||||||
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Voice):
|
||||||
|
# Add voice as top-level component
|
||||||
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Plain):
|
||||||
|
# Add text with context prefix
|
||||||
|
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
@@ -641,6 +787,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||||
|
|
||||||
|
# Monitoring message ID mapping for feedback correlation
|
||||||
|
# Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card)
|
||||||
|
pending_monitoring_msg: dict[str, str]
|
||||||
|
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
|
||||||
|
reply_to_monitoring_msg: dict[str, tuple[str, float]]
|
||||||
|
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||||
bot_uuid: str = None # 机器人UUID
|
bot_uuid: str = None # 机器人UUID
|
||||||
app_ticket: str = None # 商店应用用到
|
app_ticket: str = None # 商店应用用到
|
||||||
@@ -659,8 +812,71 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
asyncio.create_task(on_message(event))
|
asyncio.create_task(on_message(event))
|
||||||
|
|
||||||
|
def sync_on_card_action(event):
|
||||||
|
try:
|
||||||
|
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||||
|
|
||||||
|
operator = getattr(event.event, 'operator', None)
|
||||||
|
context = getattr(event.event, 'context', None)
|
||||||
|
|
||||||
|
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
|
||||||
|
open_chat_id = getattr(context, 'open_chat_id', None)
|
||||||
|
open_message_id = getattr(context, 'open_message_id', None)
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
# Resolve monitoring message ID from reply message mapping
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
stream_id=monitoring_msg_id,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||||
|
except Exception:
|
||||||
|
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||||
|
|
||||||
event_handler = (
|
event_handler = (
|
||||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||||
|
.register_p2_im_message_receive_v1(sync_on_message)
|
||||||
|
.register_p2_card_action_trigger(sync_on_card_action)
|
||||||
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
@@ -675,6 +891,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
logger=logger,
|
logger=logger,
|
||||||
lark_tenant_key=config.get('lark_tenant_key', ''),
|
lark_tenant_key=config.get('lark_tenant_key', ''),
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
|
pending_monitoring_msg={},
|
||||||
|
reply_to_monitoring_msg={},
|
||||||
seq=1,
|
seq=1,
|
||||||
listeners={},
|
listeners={},
|
||||||
quart_app=quart_app,
|
quart_app=quart_app,
|
||||||
@@ -770,6 +988,32 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.request_tenant_access_token(tenant_key)
|
self.request_tenant_access_token(tenant_key)
|
||||||
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
||||||
|
|
||||||
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
|
"""
|
||||||
|
Get topic-scoped launcher_id for thread-aware session isolation.
|
||||||
|
|
||||||
|
For group thread messages, returns "{group_id}_{thread_id}"
|
||||||
|
to ensure conversation context stays stable per topic.
|
||||||
|
|
||||||
|
Returns None for non-thread messages or P2P messages.
|
||||||
|
"""
|
||||||
|
source_event = getattr(event.source_platform_object, 'event', None)
|
||||||
|
if not source_event:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = getattr(source_event, 'message', None)
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(event, platform_events.GroupMessage):
|
||||||
|
return f'{event.group.id}_{thread_id}'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
@@ -781,7 +1025,90 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
||||||
|
|
||||||
|
# Map standard target_type to Feishu receive_id_type
|
||||||
|
if target_type == 'person':
|
||||||
|
receive_id_type = 'open_id'
|
||||||
|
elif target_type == 'group':
|
||||||
|
receive_id_type = 'chat_id'
|
||||||
|
else:
|
||||||
|
receive_id_type = target_type
|
||||||
|
|
||||||
|
# Send text message if there are text elements
|
||||||
|
if text_elements:
|
||||||
|
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||||
|
|
||||||
|
if needs_post:
|
||||||
|
msg_type = 'post'
|
||||||
|
final_content = json.dumps(
|
||||||
|
{
|
||||||
|
'zh_Hans': {
|
||||||
|
'title': '',
|
||||||
|
'content': text_elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg_type = 'text'
|
||||||
|
parts = []
|
||||||
|
for paragraph in text_elements:
|
||||||
|
para_text = ''.join(ele.get('text', '') for ele in paragraph)
|
||||||
|
if para_text:
|
||||||
|
parts.append(para_text)
|
||||||
|
final_content = json.dumps({'text': '\n\n'.join(parts)})
|
||||||
|
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(final_content)
|
||||||
|
.msg_type(msg_type)
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send media messages separately (image, audio, file, etc.)
|
||||||
|
for media in media_items:
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(json.dumps(media['content']))
|
||||||
|
.msg_type(media['msg_type'])
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
is_stream = False
|
is_stream = False
|
||||||
@@ -789,6 +1116,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
is_stream = True
|
is_stream = True
|
||||||
return is_stream
|
return is_stream
|
||||||
|
|
||||||
|
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||||
|
"""Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID."""
|
||||||
|
try:
|
||||||
|
user_msg_id = query.message_event.message_chain.message_id
|
||||||
|
if user_msg_id:
|
||||||
|
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
|
||||||
|
|
||||||
|
def _cleanup_monitoring_mapping(self):
|
||||||
|
"""Remove entries older than TTL from the reply-to-monitoring mapping."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del self.reply_to_monitoring_msg[k]
|
||||||
|
|
||||||
async def create_card_id(self, message_id):
|
async def create_card_id(self, message_id):
|
||||||
try:
|
try:
|
||||||
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
||||||
@@ -924,6 +1267,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -947,6 +1291,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1026,6 +1371,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Transfer monitoring message mapping: user msg ID → reply msg ID
|
||||||
|
try:
|
||||||
|
user_msg_id = event.message_chain.message_id
|
||||||
|
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||||
|
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
|
||||||
|
if reply_msg_id and monitoring_msg_id:
|
||||||
|
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
||||||
|
self._cleanup_monitoring_mapping()
|
||||||
|
except Exception as e:
|
||||||
|
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
@@ -1308,6 +1665,58 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if event.__class__ in self.listeners:
|
if event.__class__ in self.listeners:
|
||||||
await self.listeners[event.__class__](event, self)
|
await self.listeners[event.__class__](event, self)
|
||||||
|
elif 'card.action.trigger' == type:
|
||||||
|
try:
|
||||||
|
event_data = data.get('event', {})
|
||||||
|
operator = event_data.get('operator', {})
|
||||||
|
action = event_data.get('action', {})
|
||||||
|
context_data = event_data.get('context', {})
|
||||||
|
|
||||||
|
action_value_obj = action.get('value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
return {'toast': {'type': 'success', 'content': '操作成功'}}
|
||||||
|
|
||||||
|
user_id = operator.get('open_id') or operator.get('user_id')
|
||||||
|
open_chat_id = context_data.get('open_chat_id')
|
||||||
|
open_message_id = context_data.get('open_message_id')
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
# Resolve monitoring message ID from reply message mapping
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
stream_id=monitoring_msg_id,
|
||||||
|
source_platform_object=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||||
|
|
||||||
|
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||||
|
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||||
|
|
||||||
elif 'im.chat.member.bot.added_v1' == type:
|
elif 'im.chat.member.bot.added_v1' == type:
|
||||||
try:
|
try:
|
||||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||||
|
|||||||
@@ -5,16 +5,44 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Lark
|
en_US: Lark
|
||||||
zh_Hans: 飞书
|
zh_Hans: 飞书
|
||||||
|
zh_Hant: 飛書
|
||||||
|
ja_JP: Lark
|
||||||
description:
|
description:
|
||||||
en_US: Lark Adapter
|
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
||||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||||
|
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||||
|
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
icon: lark.svg
|
icon: lark.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/lark
|
||||||
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
ja_JP: ワンクリックでアプリ作成
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
||||||
|
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
||||||
|
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
||||||
|
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: feishu
|
||||||
|
required: false
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
|
ja_JP: アプリ ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +50,8 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
|
ja_JP: アプリシークレット
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,9 +59,13 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Bot Name
|
en_US: Bot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
|
ja_JP: ボット名
|
||||||
description:
|
description:
|
||||||
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
||||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||||
|
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
|
||||||
|
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -39,29 +73,63 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Webhook Mode
|
en_US: Enable Webhook Mode
|
||||||
zh_Hans: 启用Webhook模式
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
ja_JP: Webhook モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||||
|
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Lark app's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中
|
||||||
|
ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: encrypt-key
|
- name: encrypt-key
|
||||||
label:
|
label:
|
||||||
en_US: Encrypt Key
|
en_US: Encrypt Key
|
||||||
zh_Hans: 加密密钥
|
zh_Hans: 加密密钥
|
||||||
|
zh_Hant: 加密密鑰
|
||||||
|
ja_JP: 暗号化キー
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||||
|
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
|
||||||
|
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: enable-stream-reply
|
- name: enable-stream-reply
|
||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用飞书流式回复模式
|
zh_Hans: 启用飞书流式回复模式
|
||||||
|
zh_Hant: 啟用飛書串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -69,28 +137,40 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Type
|
en_US: App Type
|
||||||
zh_Hans: 应用类型
|
zh_Hans: 应用类型
|
||||||
|
zh_Hant: 應用類型
|
||||||
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
label:
|
label:
|
||||||
en_US: Self-built Application
|
en_US: Self-built Application
|
||||||
zh_Hans: 自建应用
|
zh_Hans: 自建应用
|
||||||
|
zh_Hant: 自建應用
|
||||||
|
ja_JP: カスタムアプリ
|
||||||
- name: isv
|
- name: isv
|
||||||
label:
|
label:
|
||||||
en_US: Store Application
|
en_US: Store Application
|
||||||
zh_Hans: 商店应用
|
zh_Hans: 商店应用
|
||||||
|
zh_Hant: 商店應用
|
||||||
|
ja_JP: ストアアプリ
|
||||||
required: false
|
required: false
|
||||||
default: self
|
default: self
|
||||||
- name: bot_added_welcome
|
- name: bot_added_welcome
|
||||||
label:
|
label:
|
||||||
en_US: Bot Welcome Message
|
en_US: Bot Welcome Message
|
||||||
zh_Hans: 机器人进群欢迎语
|
zh_Hans: 机器人进群欢迎语
|
||||||
|
zh_Hant: 機器人進群歡迎語
|
||||||
|
ja_JP: ボット参加時のウェルカムメッセージ
|
||||||
description:
|
description:
|
||||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||||
|
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
|
||||||
|
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
|
||||||
type: text
|
type: text
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,20 +5,56 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: LINE
|
en_US: LINE
|
||||||
zh_Hans: LINE
|
zh_Hans: LINE
|
||||||
|
zh_Hant: LINE
|
||||||
|
th_TH: LINE
|
||||||
|
vi_VN: LINE
|
||||||
|
es_ES: LINE
|
||||||
description:
|
description:
|
||||||
en_US: LINE Adapter
|
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
||||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
||||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
|
||||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng
|
||||||
|
es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso
|
||||||
icon: line.png
|
icon: line.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/line
|
||||||
|
en: https://link.langbot.app/en/platforms/line
|
||||||
|
ja: https://link.langbot.app/ja/platforms/line
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your LINE channel's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中
|
||||||
|
ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください
|
||||||
|
zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: channel_access_token
|
- name: channel_access_token
|
||||||
label:
|
label:
|
||||||
en_US: Channel access token
|
en_US: Channel access token
|
||||||
zh_Hans: 频道访问令牌
|
zh_Hans: 频道访问令牌
|
||||||
ja_JP: チャンネルアクセストークン
|
ja_JP: チャンネルアクセストークン
|
||||||
zh_Hant: 頻道訪問令牌
|
zh_Hant: 頻道存取令牌
|
||||||
|
th_TH: โทเค็นการเข้าถึงช่อง
|
||||||
|
vi_VN: Mã truy cập kênh
|
||||||
|
es_ES: Token de acceso del canal
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -27,12 +63,18 @@ spec:
|
|||||||
en_US: Channel secret
|
en_US: Channel secret
|
||||||
zh_Hans: 消息密钥
|
zh_Hans: 消息密钥
|
||||||
ja_JP: チャンネルシークレット
|
ja_JP: チャンネルシークレット
|
||||||
zh_Hant: 消息密钥
|
zh_Hant: 訊息密鑰
|
||||||
|
th_TH: รหัสลับช่อง
|
||||||
|
vi_VN: Khóa bí mật kênh
|
||||||
|
es_ES: Secreto del canal
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 请填写加密密钥
|
zh_Hans: 请填写加密密钥
|
||||||
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
||||||
zh_Hant: 請填寫加密密钥
|
zh_Hant: 請填寫加密密鑰
|
||||||
|
th_TH: กรุณากรอกคีย์เข้ารหัส
|
||||||
|
vi_VN: Vui lòng điền khóa mã hóa
|
||||||
|
es_ES: Por favor, introduzca la clave de cifrado
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
BIN
src/langbot/pkg/platform/sources/matrix.png
Normal file
BIN
src/langbot/pkg/platform/sources/matrix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
693
src/langbot/pkg/platform/sources/matrix.py
Normal file
693
src/langbot/pkg/platform/sources/matrix.py
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
import asyncio
|
||||||
|
import traceback
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import nio
|
||||||
|
|
||||||
|
from langbot.pkg.utils import httpclient
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
|
||||||
|
components = []
|
||||||
|
for component in message_chain:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
components.append({'type': 'text', 'text': component.text})
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
image_bytes = None
|
||||||
|
if component.base64:
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
image_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
image_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
if image_bytes:
|
||||||
|
resp = await client.upload(image_bytes, content_type='image/png')
|
||||||
|
if isinstance(resp, nio.UploadResponse):
|
||||||
|
components.append({'type': 'image', 'mxc_url': resp.content_uri})
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = None
|
||||||
|
if component.base64:
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
file_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
if file_bytes:
|
||||||
|
file_name = getattr(component, 'name', None) or 'file'
|
||||||
|
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
|
||||||
|
if isinstance(resp, nio.UploadResponse):
|
||||||
|
components.append(
|
||||||
|
{
|
||||||
|
'type': 'file',
|
||||||
|
'mxc_url': resp.content_uri,
|
||||||
|
'filename': file_name,
|
||||||
|
'size': len(file_bytes),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
|
||||||
|
return components
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
|
||||||
|
message_components = []
|
||||||
|
|
||||||
|
if isinstance(event, nio.RoomMessageText):
|
||||||
|
text = event.body
|
||||||
|
if bot_user_id and bot_user_id in text:
|
||||||
|
message_components.append(platform_message.At(target=bot_user_id))
|
||||||
|
text = text.replace(bot_user_id, '').strip()
|
||||||
|
message_components.append(platform_message.Plain(text=text))
|
||||||
|
|
||||||
|
elif isinstance(event, nio.RoomMessageImage):
|
||||||
|
mxc_url = event.url
|
||||||
|
if mxc_url:
|
||||||
|
resp = await client.download(mxc_url)
|
||||||
|
if isinstance(resp, nio.DownloadResponse):
|
||||||
|
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||||
|
content_type = resp.content_type or 'image/png'
|
||||||
|
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
|
||||||
|
if event.body:
|
||||||
|
message_components.append(platform_message.Plain(text=event.body))
|
||||||
|
|
||||||
|
return platform_message.MessageChain(message_components)
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(event: platform_events.MessageEvent):
|
||||||
|
return event.source_platform_object
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(
|
||||||
|
event: nio.RoomMessageText | nio.RoomMessageImage,
|
||||||
|
room: nio.MatrixRoom,
|
||||||
|
client: nio.AsyncClient,
|
||||||
|
bot_user_id: str,
|
||||||
|
bridge_user_ids: list[str] | None = None,
|
||||||
|
):
|
||||||
|
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
|
||||||
|
|
||||||
|
# Determine if this is a direct/private chat or a group chat.
|
||||||
|
# Exclude bot itself and bridge bots, count remaining real users.
|
||||||
|
exclude_ids = {bot_user_id}
|
||||||
|
if bridge_user_ids:
|
||||||
|
exclude_ids.update(bridge_user_ids)
|
||||||
|
real_users = [uid for uid in room.users if uid not in exclude_ids]
|
||||||
|
is_direct = len(real_users) <= 1
|
||||||
|
|
||||||
|
if is_direct:
|
||||||
|
return platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=event.sender,
|
||||||
|
nickname=room.user_name(event.sender) or event.sender,
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=lb_message,
|
||||||
|
time=event.server_timestamp / 1000.0,
|
||||||
|
source_platform_object={'event': event, 'room': room},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=event.sender,
|
||||||
|
member_name=room.user_name(event.sender) or event.sender,
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=room.room_id,
|
||||||
|
name=room.display_name or room.room_id,
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
),
|
||||||
|
message_chain=lb_message,
|
||||||
|
time=event.server_timestamp / 1000.0,
|
||||||
|
source_platform_object={'event': event, 'room': room},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeState:
|
||||||
|
"""Per-bridge runtime state."""
|
||||||
|
|
||||||
|
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.login_command = login_command
|
||||||
|
self.logout_command = logout_command
|
||||||
|
self.success_keyword = success_keyword
|
||||||
|
self.check_command = check_command or login_command
|
||||||
|
self.logged_in = False
|
||||||
|
self.dm_room_id: str | None = None
|
||||||
|
self.login_task: asyncio.Task | None = None
|
||||||
|
self.check_task: asyncio.Task | None = None
|
||||||
|
self.check_responded = False
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
client: typing.Any = None
|
||||||
|
message_converter: MatrixMessageConverter = MatrixMessageConverter()
|
||||||
|
event_converter: MatrixEventConverter = MatrixEventConverter()
|
||||||
|
config: dict
|
||||||
|
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||||
|
_running: bool = False
|
||||||
|
_initial_sync_done: bool = False
|
||||||
|
_bridges: list = []
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
|
homeserver_url = config.get('homeserver_url', '')
|
||||||
|
access_token = config.get('access_token', '')
|
||||||
|
user_id = config.get('user_id', '')
|
||||||
|
|
||||||
|
if not homeserver_url or not access_token or not user_id:
|
||||||
|
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
|
||||||
|
|
||||||
|
client = nio.AsyncClient(homeserver_url, user_id)
|
||||||
|
client.access_token = access_token
|
||||||
|
client.user_id = user_id
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
bot_account_id=user_id,
|
||||||
|
client=client,
|
||||||
|
listeners={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
|
||||||
|
self._bridges = []
|
||||||
|
bridges_raw = config.get('bridges', '')
|
||||||
|
if bridges_raw:
|
||||||
|
if isinstance(bridges_raw, str):
|
||||||
|
try:
|
||||||
|
bridges_list = json.loads(bridges_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
|
||||||
|
else:
|
||||||
|
bridges_list = bridges_raw
|
||||||
|
for b in bridges_list:
|
||||||
|
if isinstance(b, dict) and b.get('user_id', '').strip():
|
||||||
|
self._bridges.append(
|
||||||
|
BridgeState(
|
||||||
|
user_id=b['user_id'].strip(),
|
||||||
|
login_command=b.get('login_command', '').strip(),
|
||||||
|
logout_command=b.get('logout_command', '').strip(),
|
||||||
|
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
|
||||||
|
check_command=b.get('check_command', '').strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Backward compatibility: old single-bridge config
|
||||||
|
if not self._bridges:
|
||||||
|
old_user_id = config.get('bridge_user_id', '').strip()
|
||||||
|
old_command = config.get('bridge_login_command', '').strip()
|
||||||
|
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
|
||||||
|
old_check = config.get('bridge_check_command', '').strip()
|
||||||
|
old_logout = config.get('bridge_logout_command', '').strip()
|
||||||
|
if old_user_id:
|
||||||
|
self._bridges.append(
|
||||||
|
BridgeState(
|
||||||
|
user_id=old_user_id,
|
||||||
|
login_command=old_command,
|
||||||
|
logout_command=old_logout,
|
||||||
|
success_keyword=old_keyword,
|
||||||
|
check_command=old_check,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
|
components = await self.message_converter.yiri2target(message, self.client)
|
||||||
|
for component in components:
|
||||||
|
await self._send_component(target_id, component)
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
):
|
||||||
|
source_obj = message_source.source_platform_object
|
||||||
|
room_id = source_obj['room'].room_id
|
||||||
|
components = await self.message_converter.yiri2target(message, self.client)
|
||||||
|
|
||||||
|
for component in components:
|
||||||
|
if quote_origin:
|
||||||
|
original_event = source_obj['event']
|
||||||
|
await self._send_component(room_id, component, reply_to=original_event.event_id)
|
||||||
|
else:
|
||||||
|
await self._send_component(room_id, component)
|
||||||
|
|
||||||
|
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
|
||||||
|
content = {}
|
||||||
|
if component['type'] == 'text':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.text',
|
||||||
|
'body': component['text'],
|
||||||
|
}
|
||||||
|
elif component['type'] == 'image':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.image',
|
||||||
|
'body': 'image.png',
|
||||||
|
'url': component['mxc_url'],
|
||||||
|
}
|
||||||
|
elif component['type'] == 'file':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.file',
|
||||||
|
'body': component.get('filename', 'file'),
|
||||||
|
'url': component['mxc_url'],
|
||||||
|
'info': {'size': component.get('size', 0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply_to and content:
|
||||||
|
content['m.relates_to'] = {
|
||||||
|
'm.in_reply_to': {'event_id': reply_to},
|
||||||
|
}
|
||||||
|
|
||||||
|
if content:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners[event_type] = callback
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
self._running = True
|
||||||
|
await self.logger.info('Matrix adapter starting...')
|
||||||
|
|
||||||
|
# Debug: log bridge parsing result
|
||||||
|
bridges_raw = self.config.get('bridges', '')
|
||||||
|
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
|
||||||
|
await self.logger.debug(
|
||||||
|
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect all bridge bot user IDs for filtering
|
||||||
|
_bridge_user_ids = [b.user_id for b in self._bridges]
|
||||||
|
_bridge_user_id_set = set(_bridge_user_ids)
|
||||||
|
|
||||||
|
# Auto-join invited rooms
|
||||||
|
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
|
||||||
|
if event.membership == 'invite' and event.state_key == self.client.user_id:
|
||||||
|
await self.client.join(room.room_id)
|
||||||
|
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
|
||||||
|
|
||||||
|
# Handle text messages
|
||||||
|
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender == self.client.user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Admin commands (from any non-bridge user)
|
||||||
|
if event.sender not in _bridge_user_id_set:
|
||||||
|
body = (event.body or '').strip()
|
||||||
|
if body == '!relogin':
|
||||||
|
await self._handle_relogin_command(room.room_id)
|
||||||
|
return
|
||||||
|
if body == '!status':
|
||||||
|
await self._handle_status_command(room.room_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.sender in _bridge_user_id_set:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lb_event = await self.event_converter.target2yiri(
|
||||||
|
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||||
|
)
|
||||||
|
if type(lb_event) in self.listeners:
|
||||||
|
result = self.listeners[type(lb_event)](lb_event, self)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_message, nio.RoomMessageText)
|
||||||
|
|
||||||
|
# Handle image messages
|
||||||
|
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender == self.client.user_id:
|
||||||
|
return
|
||||||
|
if event.sender in _bridge_user_id_set:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lb_event = await self.event_converter.target2yiri(
|
||||||
|
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||||
|
)
|
||||||
|
if type(lb_event) in self.listeners:
|
||||||
|
result = self.listeners[type(lb_event)](lb_event, self)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_image, nio.RoomMessageImage)
|
||||||
|
|
||||||
|
# Set up bridge-specific callbacks for each bridge
|
||||||
|
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
|
||||||
|
|
||||||
|
for bridge in self._bridges:
|
||||||
|
# Login success detection (notice)
|
||||||
|
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
_b.check_responded = True
|
||||||
|
if _b.success_keyword in (event.body or ''):
|
||||||
|
_b.logged_in = True
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||||
|
# Disconnect detection
|
||||||
|
body_lower = (event.body or '').lower()
|
||||||
|
for kw in _disconnect_keywords:
|
||||||
|
if kw in body_lower and _b.logged_in:
|
||||||
|
_b.logged_in = False
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||||
|
self._restart_bridge_login(_b)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
|
||||||
|
|
||||||
|
# Login success + disconnect detection (text)
|
||||||
|
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
_b.check_responded = True
|
||||||
|
if _b.success_keyword in (event.body or ''):
|
||||||
|
_b.logged_in = True
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||||
|
body_lower = (event.body or '').lower()
|
||||||
|
for kw in _disconnect_keywords:
|
||||||
|
if kw in body_lower and _b.logged_in:
|
||||||
|
_b.logged_in = False
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||||
|
self._restart_bridge_login(_b)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
|
||||||
|
|
||||||
|
# QR code image forwarding
|
||||||
|
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
mxc_url = event.url
|
||||||
|
if not mxc_url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = await self.client.download(mxc_url)
|
||||||
|
if isinstance(resp, nio.DownloadResponse):
|
||||||
|
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||||
|
content_type = resp.content_type or 'image/png'
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
|
||||||
|
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
|
||||||
|
|
||||||
|
await self.logger.debug('Matrix adapter running, starting sync...')
|
||||||
|
|
||||||
|
# Initial sync to skip old messages
|
||||||
|
resp = await self.client.sync(timeout=10000)
|
||||||
|
if isinstance(resp, nio.SyncResponse):
|
||||||
|
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
|
||||||
|
self._initial_sync_done = True
|
||||||
|
|
||||||
|
# Display account info
|
||||||
|
display_name = self.client.user_id
|
||||||
|
try:
|
||||||
|
profile_resp = await self.client.get_displayname(self.client.user_id)
|
||||||
|
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
|
||||||
|
display_name = profile_resp.displayname
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
joined_rooms = len(self.client.rooms)
|
||||||
|
homeserver = self.config.get('homeserver_url', '')
|
||||||
|
bridge_info = ''
|
||||||
|
if self._bridges:
|
||||||
|
bridge_names = ', '.join(b.user_id for b in self._bridges)
|
||||||
|
bridge_info = f' | 桥接: [{bridge_names}]'
|
||||||
|
await self.logger.info(
|
||||||
|
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
|
||||||
|
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start bridge login and status check tasks for each bridge
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if bridge.login_command:
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
|
||||||
|
f'关键词: "{bridge.success_keyword}")'
|
||||||
|
)
|
||||||
|
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||||
|
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
|
||||||
|
|
||||||
|
# Main sync loop
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self.client.sync(timeout=30000)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
async def _periodic_bridge_login(self, bridge: BridgeState):
|
||||||
|
"""Periodically send login command to a bridge bot until login succeeds."""
|
||||||
|
try:
|
||||||
|
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
|
||||||
|
dm_room_id = None
|
||||||
|
for room_id, room in self.client.rooms.items():
|
||||||
|
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
|
||||||
|
dm_room_id = room_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if not dm_room_id:
|
||||||
|
resp = await self.client.room_create(
|
||||||
|
is_direct=True,
|
||||||
|
invite=[bridge.user_id],
|
||||||
|
)
|
||||||
|
if isinstance(resp, nio.RoomCreateResponse):
|
||||||
|
dm_room_id = resp.room_id
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
|
||||||
|
else:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
|
||||||
|
return
|
||||||
|
|
||||||
|
bridge.dm_room_id = dm_room_id
|
||||||
|
|
||||||
|
# Force logout first on every adapter start
|
||||||
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
|
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
while self._running and not bridge.logged_in:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': bridge.login_command},
|
||||||
|
)
|
||||||
|
for _ in range(60):
|
||||||
|
if not self._running or bridge.logged_in:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if bridge.logged_in:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _restart_bridge_login(self, bridge: BridgeState):
|
||||||
|
"""Cancel existing login task and start a new one."""
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||||
|
|
||||||
|
async def _periodic_bridge_check(self, bridge: BridgeState):
|
||||||
|
"""Periodically check a bridge's login status."""
|
||||||
|
try:
|
||||||
|
while self._running and not bridge.logged_in:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
check_interval = 300 # 5 minutes
|
||||||
|
response_timeout = 30
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
for _ in range(check_interval):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if not bridge.logged_in or not bridge.dm_room_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
bridge.check_responded = False
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=bridge.dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': bridge.check_command},
|
||||||
|
)
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
|
||||||
|
|
||||||
|
for _ in range(response_timeout):
|
||||||
|
if bridge.check_responded or not self._running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if bridge.check_responded:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
|
||||||
|
else:
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
|
||||||
|
)
|
||||||
|
bridge.logged_in = False
|
||||||
|
self._restart_bridge_login(bridge)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_relogin_command(self, room_id: str):
|
||||||
|
"""Handle !relogin command: logout then re-login all bridges."""
|
||||||
|
if not self._bridges:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ['开始重新登录所有桥...']
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if not bridge.login_command or not bridge.dm_room_id:
|
||||||
|
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use configured logout command, fallback to deriving from login command
|
||||||
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
|
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||||
|
|
||||||
|
# Cancel existing tasks
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
if bridge.check_task and not bridge.check_task.done():
|
||||||
|
bridge.check_task.cancel()
|
||||||
|
|
||||||
|
# Send logout
|
||||||
|
try:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=bridge.dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Reset state and restart login
|
||||||
|
bridge.logged_in = False
|
||||||
|
self._restart_bridge_login(bridge)
|
||||||
|
lines.append(f'[{bridge.user_id}] 已触发重新登录')
|
||||||
|
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_status_command(self, room_id: str):
|
||||||
|
"""Handle !status command: show bridge states."""
|
||||||
|
if not self._bridges:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ['桥状态:']
|
||||||
|
for bridge in self._bridges:
|
||||||
|
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
|
||||||
|
dm = bridge.dm_room_id or '无'
|
||||||
|
lines.append(f'• {bridge.user_id}: {status} (DM: {dm})')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
self._running = False
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
if bridge.check_task and not bridge.check_task.done():
|
||||||
|
bridge.check_task.cancel()
|
||||||
|
if self.client:
|
||||||
|
await self.client.close()
|
||||||
|
await self.logger.debug('Matrix adapter stopped')
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
|
],
|
||||||
|
):
|
||||||
|
if event_type in self.listeners:
|
||||||
|
del self.listeners[event_type]
|
||||||
123
src/langbot/pkg/platform/sources/matrix.yaml
Normal file
123
src/langbot/pkg/platform/sources/matrix.yaml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: matrix
|
||||||
|
label:
|
||||||
|
en_US: Matrix
|
||||||
|
zh_Hans: Matrix
|
||||||
|
zh_Hant: Matrix
|
||||||
|
ja_JP: Matrix
|
||||||
|
th_TH: Matrix
|
||||||
|
vi_VN: Matrix
|
||||||
|
es_ES: Matrix
|
||||||
|
description:
|
||||||
|
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
|
||||||
|
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
|
||||||
|
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
|
||||||
|
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
|
||||||
|
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
|
||||||
|
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
|
||||||
|
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
|
||||||
|
icon: matrix.png
|
||||||
|
spec:
|
||||||
|
categories:
|
||||||
|
- global
|
||||||
|
- protocol
|
||||||
|
config:
|
||||||
|
- name: homeserver_url
|
||||||
|
label:
|
||||||
|
en_US: Homeserver URL
|
||||||
|
zh_Hans: Homeserver 地址
|
||||||
|
zh_Hant: Homeserver 地址
|
||||||
|
ja_JP: Homeserver URL
|
||||||
|
th_TH: URL ของ Homeserver
|
||||||
|
vi_VN: URL Homeserver
|
||||||
|
es_ES: URL del Homeserver
|
||||||
|
description:
|
||||||
|
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
|
||||||
|
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "http://localhost:8008"
|
||||||
|
- name: user_id
|
||||||
|
label:
|
||||||
|
en_US: Bot User ID
|
||||||
|
zh_Hans: 机器人用户 ID
|
||||||
|
zh_Hant: 機器人用戶 ID
|
||||||
|
ja_JP: ボットユーザー ID
|
||||||
|
th_TH: ID ผู้ใช้บอท
|
||||||
|
vi_VN: ID người dùng bot
|
||||||
|
es_ES: ID de usuario del bot
|
||||||
|
description:
|
||||||
|
en_US: "The full Matrix user ID, e.g. @bot:localhost"
|
||||||
|
zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "@langbot:localhost"
|
||||||
|
- name: access_token
|
||||||
|
label:
|
||||||
|
en_US: Access Token
|
||||||
|
zh_Hans: 访问令牌
|
||||||
|
zh_Hant: 訪問令牌
|
||||||
|
ja_JP: アクセストークン
|
||||||
|
th_TH: โทเค็นการเข้าถึง
|
||||||
|
vi_VN: Mã truy cập
|
||||||
|
es_ES: Token de acceso
|
||||||
|
description:
|
||||||
|
en_US: "Access token obtained by logging in via the Matrix client API"
|
||||||
|
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: bridge_user_id
|
||||||
|
label:
|
||||||
|
en_US: Bridge Bot User ID (single bridge, legacy)
|
||||||
|
zh_Hans: 桥机器人用户 ID(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
|
||||||
|
zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: bridge_login_command
|
||||||
|
label:
|
||||||
|
en_US: Bridge Login Command (single bridge, legacy)
|
||||||
|
zh_Hans: 桥登录命令(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Login command for single bridge (legacy). e.g. !discord login"
|
||||||
|
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: bridge_login_success_keyword
|
||||||
|
label:
|
||||||
|
en_US: Bridge Login Success Keyword (single bridge, legacy)
|
||||||
|
zh_Hans: 桥登录成功关键词(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
|
||||||
|
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "Successfully logged in"
|
||||||
|
- name: bridges
|
||||||
|
label:
|
||||||
|
en_US: Bridges Config (Multi-bridge)
|
||||||
|
zh_Hans: 桥配置(多桥)
|
||||||
|
description:
|
||||||
|
en_US: >
|
||||||
|
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||||
|
"success_keyword": "logged in", "check_command": "!xx ping"}.
|
||||||
|
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||||
|
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||||
|
zh_Hans: >
|
||||||
|
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||||
|
"success_keyword": "logged in", "check_command": "!xx ping"}。
|
||||||
|
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||||
|
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./matrix.py
|
||||||
|
attr: MatrixAdapter
|
||||||
@@ -5,23 +5,44 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Official Account
|
en_US: Official Account
|
||||||
zh_Hans: 微信公众号
|
zh_Hans: 微信公众号
|
||||||
|
zh_Hant: 微信公眾號
|
||||||
description:
|
description:
|
||||||
en_US: Official Account Adapter
|
en_US: Official Account Adapter
|
||||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: officialaccount.png
|
icon: officialaccount.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||||
|
en: https://link.langbot.app/en/platforms/officialaccount
|
||||||
|
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Official Account webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
type: string
|
zh_Hant: 令牌
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
|
zh_Hant: 訊息加解密密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +50,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +58,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +66,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Mode
|
en_US: Mode
|
||||||
zh_Hans: 接入模式
|
zh_Hans: 接入模式
|
||||||
|
zh_Hant: 接入模式
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "drop"
|
default: "drop"
|
||||||
@@ -50,6 +74,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Loading Message
|
en_US: Loading Message
|
||||||
zh_Hans: 加载消息
|
zh_Hans: 加载消息
|
||||||
|
zh_Hant: 載入訊息
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||||
@@ -57,9 +82,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://api.weixin.qq.com"
|
default: "https://api.weixin.qq.com"
|
||||||
|
|||||||
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
"""OpenClaw WeChat adapter for LangBot.
|
||||||
|
|
||||||
|
Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage)
|
||||||
|
to integrate personal WeChat accounts with LangBot.
|
||||||
|
|
||||||
|
Reference: https://github.com/epiral/weixin-bot
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import traceback
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import (
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
SESSION_EXPIRED_ERRCODE,
|
||||||
|
OpenClawWeixinClient,
|
||||||
|
)
|
||||||
|
from langbot.libs.openclaw_weixin_api.types import (
|
||||||
|
MessageItem,
|
||||||
|
WeixinMessage,
|
||||||
|
)
|
||||||
|
from langbot.pkg.entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
|
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||||
|
"""Convert LangBot MessageChain to a list of OpenClaw message item dicts."""
|
||||||
|
items = []
|
||||||
|
for component in message_chain:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}})
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
# OpenClaw WeChat only supports text messages without CDN upload.
|
||||||
|
# For images, we send a placeholder text with the URL if available.
|
||||||
|
if component.url:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': f'[Image: {component.url}]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif component.base64:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': '[Image]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
if component.name:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': f'[File: {component.name}]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
if node.message_chain:
|
||||||
|
items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(
|
||||||
|
msg: WeixinMessage,
|
||||||
|
) -> platform_message.MessageChain:
|
||||||
|
"""Convert an OpenClaw WeixinMessage to LangBot MessageChain."""
|
||||||
|
components: list[platform_message.MessageComponent] = []
|
||||||
|
|
||||||
|
if not msg.item_list:
|
||||||
|
return platform_message.MessageChain(components)
|
||||||
|
|
||||||
|
for item in msg.item_list:
|
||||||
|
if item.type == MessageItem.TEXT and item.text_item and item.text_item.text:
|
||||||
|
text = item.text_item.text
|
||||||
|
|
||||||
|
# Handle quoted messages
|
||||||
|
if item.ref_msg:
|
||||||
|
ref_parts = []
|
||||||
|
if item.ref_msg.title:
|
||||||
|
ref_parts.append(item.ref_msg.title)
|
||||||
|
if item.ref_msg.message_item:
|
||||||
|
ref_item = item.ref_msg.message_item
|
||||||
|
if ref_item.text_item and ref_item.text_item.text:
|
||||||
|
ref_parts.append(ref_item.text_item.text)
|
||||||
|
if ref_parts:
|
||||||
|
components.append(
|
||||||
|
platform_message.Quote(
|
||||||
|
sender_id='',
|
||||||
|
origin=platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=' | '.join(ref_parts))]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
|
||||||
|
elif item.type == MessageItem.IMAGE and item.image_item:
|
||||||
|
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
|
||||||
|
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
|
||||||
|
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Image]'))
|
||||||
|
|
||||||
|
elif item.type == MessageItem.VOICE and item.voice_item:
|
||||||
|
# Voice with speech-to-text: use the transcribed text
|
||||||
|
if item.voice_item.text:
|
||||||
|
components.append(platform_message.Plain(text=item.voice_item.text))
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Voice]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.VOICE and item.voice_item:
|
||||||
|
# if item.voice_item.text:
|
||||||
|
# components.append(platform_message.Plain(text=item.voice_item.text))
|
||||||
|
# elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.Voice(
|
||||||
|
# base64=b64,
|
||||||
|
# length=item.voice_item.playtime or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.Voice(
|
||||||
|
# length=item.voice_item.playtime or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
elif item.type == MessageItem.FILE and item.file_item:
|
||||||
|
components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.FILE and item.file_item:
|
||||||
|
# file_name = item.file_item.file_name or ''
|
||||||
|
# file_size = int(item.file_item.len) if item.file_item.len else 0
|
||||||
|
# if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name=file_name,
|
||||||
|
# size=file_size,
|
||||||
|
# base64=b64,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name=file_name,
|
||||||
|
# size=file_size,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
elif item.type == MessageItem.VIDEO and item.video_item:
|
||||||
|
components.append(platform_message.Unknown(text='[Video]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.VIDEO and item.video_item:
|
||||||
|
# if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name='video.mp4',
|
||||||
|
# size=item.video_item.video_size or 0,
|
||||||
|
# base64=b64,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name='video.mp4',
|
||||||
|
# size=item.video_item.video_size or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Unknown message type]'))
|
||||||
|
|
||||||
|
return platform_message.MessageChain(components)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
"""Converts OpenClaw WeChat messages to LangBot events."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||||
|
return event.source_platform_object
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]:
|
||||||
|
"""Convert an inbound WeixinMessage to a LangBot event."""
|
||||||
|
if msg.message_type != WeixinMessage.TYPE_USER:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from_user_id = msg.from_user_id or ''
|
||||||
|
if not from_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg)
|
||||||
|
if not message_chain:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamp = (msg.create_time_ms or 0) / 1000.0
|
||||||
|
|
||||||
|
return platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=from_user_id,
|
||||||
|
nickname=from_user_id,
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=timestamp,
|
||||||
|
source_platform_object=msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
"""LangBot adapter for OpenClaw WeChat (long-poll based)."""
|
||||||
|
|
||||||
|
name: str = 'openclaw-weixin'
|
||||||
|
|
||||||
|
client: OpenClawWeixinClient = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter()
|
||||||
|
event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter()
|
||||||
|
|
||||||
|
# context_token cache: from_user_id -> context_token
|
||||||
|
_context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict)
|
||||||
|
|
||||||
|
_polling: bool = pydantic.PrivateAttr(default=False)
|
||||||
|
_poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None)
|
||||||
|
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||||
|
|
||||||
|
listeners: typing.Dict[
|
||||||
|
typing.Type[platform_events.Event],
|
||||||
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=config.get('base_url', DEFAULT_BASE_URL),
|
||||||
|
token=config.get('token', ''),
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
client=client,
|
||||||
|
bot_account_id='',
|
||||||
|
listeners={},
|
||||||
|
name='openclaw-weixin',
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
|
"""Called by BotManager to provide the bot's UUID for config persistence."""
|
||||||
|
self._bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
async def _persist_config(self) -> None:
|
||||||
|
"""Persist current self.config to the database so token survives restart."""
|
||||||
|
if not self._bot_uuid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ap = self.logger.ap
|
||||||
|
await ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bot.Bot)
|
||||||
|
.where(persistence_bot.Bot.uuid == self._bot_uuid)
|
||||||
|
.values(adapter_config=self.config)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.warning(f'Failed to persist adapter config: {e}')
|
||||||
|
|
||||||
|
async def _do_login(self) -> None:
|
||||||
|
"""Run the QR code login flow via client.login() and update config."""
|
||||||
|
adapter_logger = self.logger
|
||||||
|
|
||||||
|
async def _on_qrcode(qr_base64: str, _qr_url: str):
|
||||||
|
await adapter_logger.info(
|
||||||
|
f'Please scan the QR code to login WeChat: {_qr_url}',
|
||||||
|
images=[platform_message.Image(base64=qr_base64)],
|
||||||
|
)
|
||||||
|
|
||||||
|
login_result = await self.client.login(
|
||||||
|
on_qrcode=_on_qrcode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# client.login() already updates client.token and client.base_url
|
||||||
|
self.config['token'] = login_result.token
|
||||||
|
self.config['base_url'] = login_result.base_url
|
||||||
|
if login_result.account_id:
|
||||||
|
self.config['account_id'] = login_result.account_id
|
||||||
|
|
||||||
|
await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}')
|
||||||
|
|
||||||
|
# Persist token to database so it survives restart
|
||||||
|
await self._persist_config()
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
):
|
||||||
|
"""Send a message to a user."""
|
||||||
|
context_token = self._context_tokens.get(target_id, '')
|
||||||
|
|
||||||
|
for component in message:
|
||||||
|
try:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
if component.text:
|
||||||
|
await self.client.send_text(target_id, component.text, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
img_bytes, _ = await component.get_bytes()
|
||||||
|
await self.client.send_image(target_id, img_bytes, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = await self._get_component_bytes(component)
|
||||||
|
if file_bytes:
|
||||||
|
await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Voice):
|
||||||
|
voice_bytes = await self._get_component_bytes(component)
|
||||||
|
if voice_bytes:
|
||||||
|
await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
if node.message_chain:
|
||||||
|
await self.send_message(target_type, target_id, node.message_chain)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'Failed to send component {type(component).__name__}: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
):
|
||||||
|
"""Reply to a received message."""
|
||||||
|
source_msg = message_source.source_platform_object
|
||||||
|
if isinstance(source_msg, WeixinMessage):
|
||||||
|
target_id = source_msg.from_user_id or ''
|
||||||
|
if target_id:
|
||||||
|
await self.send_message('friend', target_id, message)
|
||||||
|
|
||||||
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]:
|
||||||
|
"""Extract raw bytes from a File or Voice component."""
|
||||||
|
b64_val = getattr(component, 'base64', None)
|
||||||
|
url_val = getattr(component, 'url', None)
|
||||||
|
path_val = getattr(component, 'path', None)
|
||||||
|
|
||||||
|
if b64_val:
|
||||||
|
return base64.b64decode(b64_val)
|
||||||
|
elif url_val and url_val.startswith(('http://', 'https://')):
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url_val) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.read()
|
||||||
|
elif path_val:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
with open(path_val, 'rb') as f:
|
||||||
|
return await asyncio.to_thread(f.read)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners[event_type] = callback
|
||||||
|
|
||||||
|
def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
"""Start the adapter. If no token is configured, trigger QR code login first."""
|
||||||
|
base_url = self.config.get('base_url', DEFAULT_BASE_URL)
|
||||||
|
token = self.config.get('token', '')
|
||||||
|
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter starting...')
|
||||||
|
|
||||||
|
# QR code login flow when no token is provided
|
||||||
|
if not token:
|
||||||
|
await self.logger.info('No token configured, starting QR code login...')
|
||||||
|
try:
|
||||||
|
await self._do_login()
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'QR code login failed: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Rebuild client with the (possibly updated) config
|
||||||
|
self.client = OpenClawWeixinClient(
|
||||||
|
base_url=self.config.get('base_url', base_url),
|
||||||
|
token=self.config.get('token', token),
|
||||||
|
)
|
||||||
|
self.bot_account_id = self.config.get('account_id', 'openclaw-weixin')
|
||||||
|
self._polling = True
|
||||||
|
|
||||||
|
# Start the long-poll loop
|
||||||
|
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter running')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _poll_loop(self):
|
||||||
|
"""Long-poll loop: call getUpdates continuously.
|
||||||
|
|
||||||
|
Error handling follows the weixin-bot SDK pattern:
|
||||||
|
- Exponential backoff (1s -> 10s max) on failures
|
||||||
|
- Session expired (errcode -14) triggers automatic re-login
|
||||||
|
"""
|
||||||
|
get_updates_buf = ''
|
||||||
|
poll_timeout = float(self.config.get('poll_timeout', 35))
|
||||||
|
|
||||||
|
backoff_delay = 1.0
|
||||||
|
max_backoff = 10.0
|
||||||
|
|
||||||
|
while self._polling:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get_updates(
|
||||||
|
get_updates_buf=get_updates_buf,
|
||||||
|
timeout=poll_timeout + 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0:
|
||||||
|
poll_timeout = resp.longpolling_timeout_ms / 1000.0
|
||||||
|
|
||||||
|
is_api_error = (resp.ret is not None and resp.ret != 0) or (
|
||||||
|
resp.errcode is not None and resp.errcode != 0
|
||||||
|
)
|
||||||
|
if is_api_error:
|
||||||
|
is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE
|
||||||
|
|
||||||
|
if is_session_expired:
|
||||||
|
await self.logger.error('OpenClaw WeChat session expired, attempting re-login...')
|
||||||
|
try:
|
||||||
|
await self._do_login()
|
||||||
|
# Rebuild client with new credentials
|
||||||
|
self.client = OpenClawWeixinClient(
|
||||||
|
base_url=self.config.get('base_url', DEFAULT_BASE_URL),
|
||||||
|
token=self.config.get('token', ''),
|
||||||
|
)
|
||||||
|
self._context_tokens.clear()
|
||||||
|
get_updates_buf = ''
|
||||||
|
backoff_delay = 1.0
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Re-login failed: {traceback.format_exc()}')
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.logger.error(
|
||||||
|
f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff_delay)
|
||||||
|
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
|
backoff_delay = 1.0
|
||||||
|
|
||||||
|
if resp.get_updates_buf:
|
||||||
|
get_updates_buf = resp.get_updates_buf
|
||||||
|
|
||||||
|
for msg in resp.msgs:
|
||||||
|
try:
|
||||||
|
await self._handle_inbound_message(msg)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling message: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}')
|
||||||
|
await asyncio.sleep(backoff_delay)
|
||||||
|
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||||
|
|
||||||
|
async def _handle_inbound_message(self, msg: WeixinMessage):
|
||||||
|
"""Process a single inbound message from getUpdates."""
|
||||||
|
if msg.context_token and msg.from_user_id:
|
||||||
|
self._context_tokens[msg.from_user_id] = msg.context_token
|
||||||
|
|
||||||
|
# Download CDN media (files, images) before converting to LangBot events
|
||||||
|
await self._download_media_items(msg)
|
||||||
|
|
||||||
|
event = await OpenClawWeixinEventConverter.target2yiri(msg)
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if type(event) in self.listeners:
|
||||||
|
await self.listeners[type(event)](event, self)
|
||||||
|
|
||||||
|
async def _download_media_items(self, msg: WeixinMessage):
|
||||||
|
"""Download CDN media for image items in the message."""
|
||||||
|
if not msg.item_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in msg.item_list:
|
||||||
|
try:
|
||||||
|
if item.type == MessageItem.IMAGE and item.image_item:
|
||||||
|
if (
|
||||||
|
item.image_item.media
|
||||||
|
and item.image_item.media.encrypt_query_param
|
||||||
|
and item.image_item.media.aes_key
|
||||||
|
):
|
||||||
|
img_bytes = await self.client.download_media(item.image_item.media)
|
||||||
|
item.image_item._downloaded_bytes = img_bytes
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.FILE and item.file_item and item.file_item.media:
|
||||||
|
# if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key:
|
||||||
|
# file_bytes = await self.client.download_media(item.file_item.media)
|
||||||
|
# item.file_item._downloaded_bytes = file_bytes
|
||||||
|
#
|
||||||
|
# elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media:
|
||||||
|
# if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key:
|
||||||
|
# voice_bytes = await self.client.download_media(item.voice_item.media)
|
||||||
|
# item.voice_item._downloaded_bytes = voice_bytes
|
||||||
|
#
|
||||||
|
# elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media:
|
||||||
|
# if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key:
|
||||||
|
# video_bytes = await self.client.download_media(item.video_item.media)
|
||||||
|
# item.video_item._downloaded_bytes = video_bytes
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
"""Stop the adapter."""
|
||||||
|
self._polling = False
|
||||||
|
if self._poll_task and not self._poll_task.done():
|
||||||
|
self._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await self.client.close()
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter stopped')
|
||||||
|
return True
|
||||||
88
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
88
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: openclaw-weixin
|
||||||
|
label:
|
||||||
|
en_US: OpenClaw WeChat
|
||||||
|
zh_Hans: 个人微信机器人
|
||||||
|
zh_Hant: 個人微信機器人
|
||||||
|
description:
|
||||||
|
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||||
|
zh_Hans: 微信官方个人助手,扫码即可登录使用
|
||||||
|
zh_Hant: 微信官方個人助手,掃碼即可登入使用
|
||||||
|
icon: wechat.png
|
||||||
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||||
|
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||||
|
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||||
|
config:
|
||||||
|
- name: base_url
|
||||||
|
label:
|
||||||
|
en_US: API Base URL
|
||||||
|
zh_Hans: API 基础地址
|
||||||
|
zh_Hant: API 基礎地址
|
||||||
|
description:
|
||||||
|
en_US: The base URL of the OpenClaw WeChat backend API
|
||||||
|
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||||
|
zh_Hant: OpenClaw 微信後端 API 的基礎地址
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
|
- name: qr-login
|
||||||
|
label:
|
||||||
|
en_US: Scan QR Login
|
||||||
|
zh_Hans: 扫码登录
|
||||||
|
zh_Hant: 掃碼登入
|
||||||
|
ja_JP: QRコードでログイン
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
|
||||||
|
zh_Hans: 使用微信扫码授权,自动填写令牌
|
||||||
|
zh_Hant: 使用微信掃碼授權,自動填寫令牌
|
||||||
|
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: weixin
|
||||||
|
required: false
|
||||||
|
- name: token
|
||||||
|
label:
|
||||||
|
en_US: Token
|
||||||
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
description:
|
||||||
|
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||||
|
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
|
||||||
|
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: account_id
|
||||||
|
label:
|
||||||
|
en_US: Account ID
|
||||||
|
zh_Hans: 账号标识
|
||||||
|
zh_Hant: 帳號標識
|
||||||
|
description:
|
||||||
|
en_US: A label for this WeChat account (used for display purposes)
|
||||||
|
zh_Hans: 此微信账号的标识(用于显示)
|
||||||
|
zh_Hant: 此微信帳號的標識(用於顯示)
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "openclaw-weixin"
|
||||||
|
- name: poll_timeout
|
||||||
|
label:
|
||||||
|
en_US: Poll Timeout (seconds)
|
||||||
|
zh_Hans: 轮询超时(秒)
|
||||||
|
zh_Hant: 輪詢逾時(秒)
|
||||||
|
description:
|
||||||
|
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||||
|
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||||
|
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
default: 35
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./openclaw_weixin.py
|
||||||
|
attr: OpenClawWeixinAdapter
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -15,11 +17,25 @@ from ...utils import image
|
|||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
|
def _is_base64_data(value: str) -> bool:
|
||||||
|
"""Check if a string contains base64-encoded data rather than a URL."""
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
# data: URI scheme (e.g. data:image/png;base64,xxx)
|
||||||
|
if value.startswith('data:'):
|
||||||
|
return True
|
||||||
|
# Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars
|
||||||
|
if value.startswith(('http://', 'https://', '/', './', '../')):
|
||||||
|
return False
|
||||||
|
# Check if it looks like base64 (only valid chars, reasonable length)
|
||||||
|
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
|
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
|
||||||
content_list = []
|
content_list = []
|
||||||
# 只实现了发文字
|
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
content_list.append(
|
content_list.append(
|
||||||
@@ -28,6 +44,49 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
|||||||
'content': msg.text,
|
'content': msg.text,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif type(msg) is platform_message.Image:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'image',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif type(msg) is platform_message.Voice:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'voice',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif type(msg) is platform_message.File:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'file',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
'name': msg.name if hasattr(msg, 'name') else 'file',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@@ -129,12 +188,19 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
config: dict
|
config: dict
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
|
enable_webhook: bool = False
|
||||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
|
|
||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
app_id=config['appid'],
|
||||||
|
secret=config['secret'],
|
||||||
|
token=config['token'],
|
||||||
|
logger=logger,
|
||||||
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -144,6 +210,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot_account_id=config['appid'],
|
bot_account_id=config['appid'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.enable_webhook = enable_webhook
|
||||||
|
self._ws_task: asyncio.Task = None
|
||||||
|
self._stream_ctx: dict = {}
|
||||||
|
self._stream_ctx_ts: dict[str, float] = {}
|
||||||
|
self._fallback_text: dict[str, str] = {}
|
||||||
|
self._fallback_text_ts: dict[str, float] = {}
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -156,28 +229,18 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
|
|
||||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||||
|
|
||||||
# 私聊消息
|
# 确定 target_type 和 target_id
|
||||||
|
target_type = None
|
||||||
|
target_id = None
|
||||||
|
|
||||||
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
||||||
for content in content_list:
|
target_type = 'c2c'
|
||||||
if content['type'] == 'text':
|
target_id = qq_official_event.user_openid
|
||||||
await self.bot.send_private_text_msg(
|
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||||
qq_official_event.user_openid,
|
target_type = 'group'
|
||||||
content['content'],
|
target_id = qq_official_event.group_openid
|
||||||
qq_official_event.d_id,
|
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||||
)
|
# 频道群聊使用频道 API,暂不支持富媒体
|
||||||
|
|
||||||
# 群聊消息
|
|
||||||
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
|
||||||
for content in content_list:
|
|
||||||
if content['type'] == 'text':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
qq_official_event.group_openid,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 频道群聊
|
|
||||||
if qq_official_event.t == 'AT_MESSAGE_CREATE':
|
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_group_text_msg(
|
await self.bot.send_channle_group_text_msg(
|
||||||
@@ -185,9 +248,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
# 频道私聊
|
elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||||
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
# 频道私聊使用频道 API,暂不支持富媒体
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_private_text_msg(
|
await self.bot.send_channle_private_text_msg(
|
||||||
@@ -195,6 +258,63 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# C2C 和群聊:支持文字 + 富媒体
|
||||||
|
for content in content_list:
|
||||||
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
|
if content_type == 'text':
|
||||||
|
if target_type == 'c2c':
|
||||||
|
await self.bot.send_private_text_msg(
|
||||||
|
target_id,
|
||||||
|
content['content'],
|
||||||
|
qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
await self.bot.send_group_text_msg(
|
||||||
|
target_id,
|
||||||
|
content['content'],
|
||||||
|
qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'image':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'voice':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_voice_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'file':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
file_name = content.get('name', 'file')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_file_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
@@ -238,17 +358,196 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
if not self.enable_webhook:
|
||||||
# 保持运行但不启动独立端口
|
await self._run_websocket()
|
||||||
|
else:
|
||||||
|
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||||
|
async def keep_alive():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def keep_alive():
|
await keep_alive()
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
await keep_alive()
|
async def _run_websocket(self):
|
||||||
|
"""以 WebSocket 模式运行网关连接"""
|
||||||
|
await self.logger.info('QQ Official adapter starting in WebSocket mode')
|
||||||
|
|
||||||
|
async def on_ready():
|
||||||
|
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||||
|
|
||||||
|
async def on_event(event_type: str, event_data: dict):
|
||||||
|
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||||
|
message_event_types = {
|
||||||
|
'C2C_MESSAGE_CREATE',
|
||||||
|
'DIRECT_MESSAGE_CREATE',
|
||||||
|
'GROUP_AT_MESSAGE_CREATE',
|
||||||
|
'AT_MESSAGE_CREATE',
|
||||||
|
}
|
||||||
|
if event_type not in message_event_types:
|
||||||
|
return
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
|
||||||
|
return
|
||||||
|
await self.logger.info(f'Processing message event: {event_type}')
|
||||||
|
# 构造与 webhook 模式相同的 payload 结构
|
||||||
|
payload = {'t': event_type, 'd': event_data}
|
||||||
|
message_data = await self.bot.get_message(payload)
|
||||||
|
if message_data:
|
||||||
|
event = QQOfficialEvent.from_payload(message_data)
|
||||||
|
await self.bot._handle_message(event)
|
||||||
|
|
||||||
|
async def on_error(error: Exception):
|
||||||
|
await self.logger.error(f'WebSocket error: {error}')
|
||||||
|
await self.logger.error(f'QQ Official WebSocket error: {error}')
|
||||||
|
|
||||||
|
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
|
||||||
|
try:
|
||||||
|
await self._ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
if self._ws_task:
|
||||||
|
self._ws_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._ws_task = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --------------- 流式输出 ---------------
|
||||||
|
|
||||||
|
_STREAM_CTX_TTL = 300 # seconds
|
||||||
|
|
||||||
|
async def _cleanup_stale_streams(self):
|
||||||
|
"""Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds."""
|
||||||
|
now = time.time()
|
||||||
|
stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||||
|
for mid in stale_ids:
|
||||||
|
self._stream_ctx.pop(mid, None)
|
||||||
|
self._stream_ctx_ts.pop(mid, None)
|
||||||
|
stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||||
|
for mid in stale_fb:
|
||||||
|
self._fallback_text.pop(mid, None)
|
||||||
|
self._fallback_text_ts.pop(mid, None)
|
||||||
|
if stale_ids or stale_fb:
|
||||||
|
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return self.config.get('enable-stream-reply', False)
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
|
source = event.source_platform_object
|
||||||
|
# Streaming API only supports C2C private chat
|
||||||
|
if source.t != 'C2C_MESSAGE_CREATE':
|
||||||
|
return False
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
'user_openid': source.user_openid,
|
||||||
|
'msg_id': source.d_id,
|
||||||
|
'stream_msg_id': None,
|
||||||
|
'msg_seq': 1,
|
||||||
|
'index': 0,
|
||||||
|
'last_update_ts': 0,
|
||||||
|
'accumulated_text': '',
|
||||||
|
'sent_length': 0,
|
||||||
|
'session_started': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._stream_ctx[message_id] = ctx
|
||||||
|
self._stream_ctx_ts[message_id] = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message: dict,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
):
|
||||||
|
# Periodically clean up stale stream contexts
|
||||||
|
await self._cleanup_stale_streams()
|
||||||
|
# 提取纯文本内容(当前 chunk 的文本)
|
||||||
|
text_parts = []
|
||||||
|
for msg in message:
|
||||||
|
if type(msg) is platform_message.Plain:
|
||||||
|
text_parts.append(msg.text)
|
||||||
|
chunk_text = '\n\n'.join(text_parts)
|
||||||
|
|
||||||
|
message_id = (
|
||||||
|
bot_message.get('resp_message_id')
|
||||||
|
if isinstance(bot_message, dict)
|
||||||
|
else getattr(bot_message, 'resp_message_id', None)
|
||||||
|
)
|
||||||
|
if not message_id or message_id not in self._stream_ctx:
|
||||||
|
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
||||||
|
if chunk_text:
|
||||||
|
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
||||||
|
self._fallback_text_ts[message_id] = time.time()
|
||||||
|
if is_final:
|
||||||
|
full_text = self._fallback_text.pop(message_id, '')
|
||||||
|
if full_text:
|
||||||
|
fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)])
|
||||||
|
await self.reply_message(message_source, fallback_msg, quote_origin)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx = self._stream_ctx[message_id]
|
||||||
|
|
||||||
|
# 累积文本
|
||||||
|
if chunk_text:
|
||||||
|
ctx['accumulated_text'] += chunk_text
|
||||||
|
|
||||||
|
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||||
|
if not ctx['session_started']:
|
||||||
|
if not ctx['accumulated_text']:
|
||||||
|
return
|
||||||
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
|
ctx['session_started'] = True
|
||||||
|
|
||||||
|
# 发送内容 = 全量累积文本
|
||||||
|
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
||||||
|
# - 首次:发送全部文本,建立会话
|
||||||
|
# - 后续:只能发送新增部分(append 行为)
|
||||||
|
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||||
|
if not content_to_send and not is_final:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
|
# Rate limiting: skip non-final updates if last update was <0.5s ago
|
||||||
|
now = time.time()
|
||||||
|
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
||||||
|
return
|
||||||
|
ctx['last_update_ts'] = now
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.bot.send_stream_msg(
|
||||||
|
user_openid=ctx['user_openid'],
|
||||||
|
content=content_to_send,
|
||||||
|
event_id=ctx['msg_id'],
|
||||||
|
msg_id=ctx['msg_id'],
|
||||||
|
msg_seq=ctx['msg_seq'],
|
||||||
|
index=ctx['index'],
|
||||||
|
stream_msg_id=ctx['stream_msg_id'],
|
||||||
|
input_state=input_state,
|
||||||
|
)
|
||||||
|
if resp and isinstance(resp, dict):
|
||||||
|
new_stream_id = resp.get('id')
|
||||||
|
if new_stream_id:
|
||||||
|
ctx['stream_msg_id'] = new_stream_id
|
||||||
|
ctx['sent_length'] = len(ctx['accumulated_text'])
|
||||||
|
ctx['index'] += 1
|
||||||
|
await self.logger.debug(
|
||||||
|
f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, '
|
||||||
|
f'sent_len={ctx["sent_length"]}, is_final={is_final}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to send stream message: {e}')
|
||||||
|
|
||||||
|
if is_final:
|
||||||
|
self._stream_ctx.pop(message_id, None)
|
||||||
|
|
||||||
def unregister_listener(
|
def unregister_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: QQ Official API
|
en_US: QQ Official API
|
||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
|
zh_Hant: QQ 官方 API
|
||||||
description:
|
description:
|
||||||
en_US: QQ Official API (Webhook)
|
en_US: QQ Official API (Webhook / WebSocket)
|
||||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式
|
||||||
|
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
|
||||||
icon: qqofficial.svg
|
icon: qqofficial.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||||
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
config:
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,9 +39,50 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: enable-webhook
|
||||||
|
label:
|
||||||
|
en_US: Enable Webhook Mode
|
||||||
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode
|
||||||
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: enable-stream-reply
|
||||||
|
label:
|
||||||
|
en_US: Enable Stream Reply Mode
|
||||||
|
zh_Hans: 启用流式回复模式
|
||||||
|
zh_Hant: 啟用串流回覆模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
|
||||||
|
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
|
||||||
|
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./qqofficial.py
|
path: ./qqofficial.py
|
||||||
|
|||||||
@@ -5,36 +5,70 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori
|
en_US: Satori
|
||||||
zh_Hans: Satori
|
zh_Hans: Satori
|
||||||
|
zh_Hant: Satori
|
||||||
|
th_TH: Satori
|
||||||
|
vi_VN: Satori
|
||||||
|
es_ES: Satori
|
||||||
description:
|
description:
|
||||||
en_US: SatoriAdapter
|
en_US: SatoriAdapter
|
||||||
zh_Hans: 古明地觉协议适配器
|
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
|
||||||
|
zh_Hant: Satori 協定適配器,支援多種平台的接入,請查看文件了解使用方式
|
||||||
|
th_TH: อะแดปเตอร์โปรโตคอล Satori รองรับการเชื่อมต่อหลายแพลตฟอร์ม โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp giao thức Satori, hỗ trợ kết nối nhiều nền tảng, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador del protocolo Satori, soporta acceso a múltiples plataformas, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: satori.png
|
icon: satori.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/satori
|
||||||
|
en: https://link.langbot.app/en/platforms/satori
|
||||||
|
ja: https://link.langbot.app/ja/platforms/satori
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
en_US: Platform
|
en_US: Platform
|
||||||
zh_Hans: 平台名称
|
zh_Hans: 平台名称
|
||||||
|
zh_Hant: 平台名稱
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม
|
||||||
|
vi_VN: Tên nền tảng
|
||||||
|
es_ES: Nombre de la plataforma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "llonebot"
|
default: "llonebot"
|
||||||
description:
|
description:
|
||||||
en_US: The platform name (e.g., llonebot, discord, telegram)
|
en_US: The platform name (e.g., llonebot, discord, telegram)
|
||||||
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
||||||
|
zh_Hant: 平台名稱(如 llonebot、discord、telegram)
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม (เช่น llonebot, discord, telegram)
|
||||||
|
vi_VN: "Tên nền tảng (ví dụ: llonebot, discord, telegram)"
|
||||||
|
es_ES: El nombre de la plataforma (p. ej., llonebot, discord, telegram)
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机地址
|
zh_Hans: 主机地址
|
||||||
|
zh_Hant: 主機地址
|
||||||
|
th_TH: ที่อยู่โฮสต์
|
||||||
|
vi_VN: Địa chỉ máy chủ
|
||||||
|
es_ES: Dirección del host
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "127.0.0.1"
|
default: "127.0.0.1"
|
||||||
description:
|
description:
|
||||||
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
zh_Hant: LLOneBot Satori 伺服器的主機地址(如 127.0.0.1、localhost、192.168.1.100)
|
||||||
|
th_TH: ที่อยู่โฮสต์ของเซิร์ฟเวอร์ LLOneBot Satori (เช่น 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
vi_VN: "Địa chỉ máy chủ LLOneBot Satori (ví dụ: 127.0.0.1, localhost, 192.168.1.100)"
|
||||||
|
es_ES: La dirección del host del servidor LLOneBot Satori (p. ej., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
- name: port
|
- name: port
|
||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听端口
|
zh_Hans: 监听端口
|
||||||
|
zh_Hant: 監聽連接埠
|
||||||
|
th_TH: พอร์ต
|
||||||
|
vi_VN: Cổng
|
||||||
|
es_ES: Puerto
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 5600
|
default: 5600
|
||||||
@@ -42,6 +76,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori API Endpoint
|
en_US: Satori API Endpoint
|
||||||
zh_Hans: Satori API 终结点
|
zh_Hans: Satori API 终结点
|
||||||
|
zh_Hant: Satori API 端點
|
||||||
|
th_TH: จุดปลาย Satori API
|
||||||
|
vi_VN: Điểm cuối Satori API
|
||||||
|
es_ES: Punto de acceso de la API Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://localhost:5600/v1"
|
default: "http://localhost:5600/v1"
|
||||||
@@ -49,6 +87,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori WebSocket Endpoint
|
en_US: Satori WebSocket Endpoint
|
||||||
zh_Hans: Satori WebSocket 终结点
|
zh_Hans: Satori WebSocket 终结点
|
||||||
|
zh_Hant: Satori WebSocket 端點
|
||||||
|
th_TH: จุดปลาย Satori WebSocket
|
||||||
|
vi_VN: Điểm cuối Satori WebSocket
|
||||||
|
es_ES: Punto de acceso WebSocket de Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "ws://localhost:5600/v1/events"
|
default: "ws://localhost:5600/v1/events"
|
||||||
@@ -56,6 +98,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,58 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Slack
|
en_US: Slack
|
||||||
zh_Hans: Slack
|
zh_Hans: Slack
|
||||||
|
zh_Hant: Slack
|
||||||
|
ja_JP: Slack
|
||||||
|
th_TH: Slack
|
||||||
|
vi_VN: Slack
|
||||||
|
es_ES: Slack
|
||||||
description:
|
description:
|
||||||
en_US: Slack Adapter
|
en_US: Slack Adapter
|
||||||
zh_Hans: Slack 适配器,请查看文档了解使用方式
|
zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式
|
||||||
|
ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: slack.png
|
icon: slack.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/slack
|
||||||
|
en: https://link.langbot.app/en/platforms/slack
|
||||||
|
ja: https://link.langbot.app/ja/platforms/slack
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Slack app's event subscription configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中
|
||||||
|
ja_JP: この URL をコピーして Slack アプリのイベントサブスクリプション設定に貼り付けてください
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่าการสมัครรับเหตุการณ์ของแอป Slack ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình đăng ký sự kiện của ứng dụng Slack của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de suscripción de eventos de su aplicación Slack
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: bot_token
|
- name: bot_token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
|
ja_JP: ボットトークン
|
||||||
|
th_TH: โทเค็นบอท
|
||||||
|
vi_VN: Mã thông báo Bot
|
||||||
|
es_ES: Token del bot
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +64,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: signing_secret
|
en_US: signing_secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
|
ja_JP: 署名シークレット
|
||||||
|
th_TH: คีย์ลายเซ็น
|
||||||
|
vi_VN: Khóa ký
|
||||||
|
es_ES: Secreto de firma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
photo_bytes = f.read()
|
photo_bytes = f.read()
|
||||||
|
|
||||||
components.append({'type': 'photo', 'photo': photo_bytes})
|
components.append({'type': 'photo', 'photo': photo_bytes})
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = None
|
||||||
|
|
||||||
|
if component.base64:
|
||||||
|
# Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
file_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
|
||||||
|
file_name = getattr(component, 'name', None) or 'file'
|
||||||
|
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
|
||||||
elif isinstance(component, platform_message.Forward):
|
elif isinstance(component, platform_message.Forward):
|
||||||
for node in component.node_list:
|
for node in component.node_list:
|
||||||
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
||||||
@@ -104,6 +123,27 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if message.document:
|
||||||
|
if message.caption:
|
||||||
|
message_components.extend(parse_message_text(message.caption))
|
||||||
|
|
||||||
|
file = await message.document.get_file()
|
||||||
|
file_name = message.document.file_name or 'document'
|
||||||
|
file_size = message.document.file_size or 0
|
||||||
|
file_format = message.document.mime_type or 'application/octet-stream'
|
||||||
|
|
||||||
|
file_bytes = None
|
||||||
|
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
|
||||||
|
message_components.append(
|
||||||
|
platform_message.File(
|
||||||
|
name=file_name,
|
||||||
|
size=file_size,
|
||||||
|
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return platform_message.MessageChain(message_components)
|
return platform_message.MessageChain(message_components)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +219,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
application = ApplicationBuilder().token(config['token']).build()
|
application = ApplicationBuilder().token(config['token']).build()
|
||||||
bot = application.bot
|
bot = application.bot
|
||||||
application.add_handler(
|
application.add_handler(
|
||||||
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)
|
MessageHandler(
|
||||||
|
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
|
||||||
|
telegram_callback,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -218,6 +261,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
continue
|
continue
|
||||||
args['photo'] = telegram.InputFile(photo)
|
args['photo'] = telegram.InputFile(photo)
|
||||||
await self.bot.send_photo(**args)
|
await self.bot.send_photo(**args)
|
||||||
|
elif component_type == 'document':
|
||||||
|
doc = component.get('document')
|
||||||
|
if doc is None:
|
||||||
|
continue
|
||||||
|
filename = component.get('filename', 'file')
|
||||||
|
args['document'] = telegram.InputFile(doc, filename=filename)
|
||||||
|
await self.bot.send_document(**args)
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -5,23 +5,50 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Telegram
|
en_US: Telegram
|
||||||
zh_Hans: 电报
|
zh_Hans: 电报
|
||||||
|
zh_Hant: Telegram
|
||||||
|
ja_JP: Telegram
|
||||||
|
th_TH: Telegram
|
||||||
|
vi_VN: Telegram
|
||||||
|
es_ES: Telegram
|
||||||
description:
|
description:
|
||||||
en_US: Telegram Adapter
|
en_US: Telegram Adapter
|
||||||
zh_Hans: 电报适配器,请查看文档了解使用方式
|
zh_Hans: Telegram 适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: Telegram 適配器,請查看文件了解使用方式
|
||||||
|
ja_JP: Telegram アダプター。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Telegram โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Telegram, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: telegram.svg
|
icon: telegram.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/telegram
|
||||||
|
en: https://link.langbot.app/en/platforms/telegram
|
||||||
|
ja: https://link.langbot.app/ja/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: "token_from_botfather"
|
||||||
- name: markdown_card
|
- name: markdown_card
|
||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
|
ja_JP: Markdown カードを使用
|
||||||
|
th_TH: การ์ด Markdown
|
||||||
|
vi_VN: Thẻ Markdown
|
||||||
|
es_ES: Tarjeta Markdown
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -29,9 +56,19 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用电报流式回复模式
|
zh_Hans: 启用电报流式回复模式
|
||||||
|
zh_Hant: 啟用 Telegram 串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
|
th_TH: เปิดใช้งานโหมดตอบกลับแบบสตรีม
|
||||||
|
vi_VN: Bật chế độ trả lời trực tuyến
|
||||||
|
es_ES: Habilitar modo de respuesta en streaming
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
|
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||||
|
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||||
|
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user