mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
Compare commits
2 Commits
v4.9.6
...
feat/long-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662e6a4815 | ||
|
|
c92d3d7ad7 |
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://link.langbot.app/zh/docs/network
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
||||||
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://link.langbot.app/en/docs/network
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
|
||||||
title: "[Bug]: "
|
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
|
||||||
npx vite build
|
npm run build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/dist ./web
|
cp -r /tmp/langbot_build_web/web/out ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
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/dist
|
mkdir -p ../src/langbot/web/out
|
||||||
cp -r dist ../src/langbot/web/
|
cp -r out ../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
171
.github/workflows/test-migrations.yml
vendored
@@ -1,171 +0,0 @@
|
|||||||
name: Test Migrations
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- dev
|
|
||||||
paths:
|
|
||||||
- 'src/langbot/pkg/persistence/**'
|
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
paths:
|
|
||||||
- 'src/langbot/pkg/persistence/**'
|
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-migrations-sqlite:
|
|
||||||
name: Migrations (SQLite)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Test Alembic upgrade (SQLite)
|
|
||||||
run: |
|
|
||||||
uv run python -c "
|
|
||||||
import asyncio
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
|
|
||||||
|
|
||||||
# Create all tables (simulates existing DB)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
# Stamp baseline
|
|
||||||
await run_alembic_stamp(engine, '0001_baseline')
|
|
||||||
rev = await get_alembic_current(engine)
|
|
||||||
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
|
||||||
print(f'Stamped: {rev}')
|
|
||||||
|
|
||||||
# Upgrade to head
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
rev = await get_alembic_current(engine)
|
|
||||||
print(f'After upgrade: {rev}')
|
|
||||||
assert rev is not None, 'Expected a revision after upgrade'
|
|
||||||
|
|
||||||
# Verify idempotent
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
rev2 = await get_alembic_current(engine)
|
|
||||||
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
|
||||||
print(f'Idempotent check passed: {rev2}')
|
|
||||||
|
|
||||||
# Fresh DB: upgrade from scratch
|
|
||||||
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
|
|
||||||
async with engine2.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await run_alembic_upgrade(engine2, 'head')
|
|
||||||
rev3 = await get_alembic_current(engine2)
|
|
||||||
print(f'Fresh DB upgrade: {rev3}')
|
|
||||||
assert rev3 is not None
|
|
||||||
|
|
||||||
print('All SQLite migration tests passed!')
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
"
|
|
||||||
|
|
||||||
test-migrations-postgres:
|
|
||||||
name: Migrations (PostgreSQL)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: langbot
|
|
||||||
POSTGRES_PASSWORD: langbot
|
|
||||||
POSTGRES_DB: langbot_test
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd="pg_isready -U langbot"
|
|
||||||
--health-interval=5s
|
|
||||||
--health-timeout=5s
|
|
||||||
--health-retries=5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Test Alembic upgrade (PostgreSQL)
|
|
||||||
run: |
|
|
||||||
uv run python -c "
|
|
||||||
import asyncio
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
|
||||||
|
|
||||||
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
engine = create_async_engine(DB_URL)
|
|
||||||
|
|
||||||
# Create all tables
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
# Stamp baseline
|
|
||||||
await run_alembic_stamp(engine, '0001_baseline')
|
|
||||||
rev = await get_alembic_current(engine)
|
|
||||||
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
|
||||||
print(f'Stamped: {rev}')
|
|
||||||
|
|
||||||
# Upgrade to head
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
rev = await get_alembic_current(engine)
|
|
||||||
print(f'After upgrade: {rev}')
|
|
||||||
assert rev is not None
|
|
||||||
|
|
||||||
# Verify idempotent
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
rev2 = await get_alembic_current(engine)
|
|
||||||
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
|
||||||
print(f'Idempotent check passed: {rev2}')
|
|
||||||
|
|
||||||
# Fresh DB: drop all and upgrade from scratch
|
|
||||||
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
|
|
||||||
|
|
||||||
# Create fresh database
|
|
||||||
from sqlalchemy import text
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text('COMMIT'))
|
|
||||||
await conn.execute(text('CREATE DATABASE langbot_fresh'))
|
|
||||||
|
|
||||||
async with engine2.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await run_alembic_upgrade(engine2, 'head')
|
|
||||||
rev3 = await get_alembic_current(engine2)
|
|
||||||
print(f'Fresh DB upgrade: {rev3}')
|
|
||||||
assert rev3 is not None
|
|
||||||
|
|
||||||
print('All PostgreSQL migration tests passed!')
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,6 +52,3 @@ src/langbot/web/
|
|||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
# Next.js build cache (legacy)
|
|
||||||
web/.next/
|
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ repos:
|
|||||||
# Run the formatter of backend.
|
# Run the formatter of backend.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: local
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
rev: v3.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
name: prettier
|
|
||||||
entry: npx --prefix web prettier --write --ignore-unknown
|
|
||||||
language: system
|
|
||||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||||
|
additional_dependencies:
|
||||||
|
- prettier@3.1.0
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
- id: lint-staged
|
- id: lint-staged
|
||||||
name: lint-staged
|
name: lint-staged
|
||||||
entry: cd web && pnpm lint-staged
|
entry: cd web && pnpm lint-staged
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npx vite build
|
RUN cd web && npm install && npm run build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/dist ./web/dist
|
COPY --from=node /app/web/out ./web/out
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
12
README.md
12
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://link.langbot.app/en/docs/features">Features</a> |
|
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/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://link.langbot.app/en/docs/features)
|
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ docker compose up -d
|
|||||||
[](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://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://docs.langbot.app/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
16
README_CN.md
16
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://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/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,6 +34,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 什么是 LangBot?
|
||||||
|
|
||||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||||
|
|
||||||
### 核心能力
|
### 核心能力
|
||||||
@@ -41,11 +43,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
- **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://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +78,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://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ docker compose up -d
|
|||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
12
README_ES.md
12
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://link.langbot.app/en/docs/features">Características</a> |
|
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://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://link.langbot.app/en/docs/features)
|
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](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://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_FR.md
12
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://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://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://link.langbot.app/en/docs/features)
|
[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](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://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [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 | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_JP.md
12
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://link.langbot.app/ja/docs/features">機能</a> |
|
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
<a href="https://docs.langbot.app/ja/tags/readme.html">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://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_KO.md
12
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://link.langbot.app/en/docs/features">기능</a> |
|
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme.html">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://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_RU.md
12
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://link.langbot.app/en/docs/features">Возможности</a> |
|
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme.html">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://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,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 | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_TW.md
12
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://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://docs.langbot.app/zh/tags/readme.html">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://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ docker compose up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_VI.md
12
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://link.langbot.app/en/docs/features">Tính năng</a> |
|
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://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://link.langbot.app/en/docs/features)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](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://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ spec:
|
|||||||
### 参考资源
|
### 参考资源
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
- [LangBot 官方文档](https://docs.langbot.app)
|
||||||
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
- [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://link.langbot.app/zh/docs/docker)
|
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.6"
|
version = "4.8.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"]
|
||||||
@@ -39,7 +39,6 @@ dependencies = [
|
|||||||
"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",
|
||||||
@@ -62,10 +61,10 @@ dependencies = [
|
|||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=0.4.24",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.0.0b7",
|
||||||
"langbot-plugin==0.3.8",
|
"langbot-plugin==0.3.0rc1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -112,7 +111,7 @@ 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/dist/**", "pkg/persistence/alembic/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -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.6'
|
__version__ = '4.8.7'
|
||||||
|
|||||||
@@ -182,88 +182,6 @@ 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))
|
||||||
@@ -275,15 +193,6 @@ 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()
|
||||||
|
|
||||||
@@ -359,52 +268,19 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
raw_content = incoming_message.to_dict().get('content', {})
|
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
||||||
# 兼容处理:如果 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()
|
||||||
raw_data = incoming_message.to_dict()
|
if len(down_list) >= 2:
|
||||||
file_info = raw_data.get('content', {})
|
message_data['File'] = await self.get_file_url(down_list[0])
|
||||||
|
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'Failed to extract file info from message content: {file_info}')
|
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
||||||
message_data['File'] = None
|
message_data['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()
|
||||||
|
|||||||
@@ -47,22 +47,6 @@ 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]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
|
||||||
from .types import ApiError as ApiError
|
|
||||||
from .types import LoginResult as LoginResult
|
|
||||||
@@ -1,807 +0,0 @@
|
|||||||
"""Async HTTP client for the OpenClaw WeChat API.
|
|
||||||
|
|
||||||
Implements the iLink Bot API protocol.
|
|
||||||
Reference: https://github.com/epiral/weixin-bot
|
|
||||||
|
|
||||||
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from .types import (
|
|
||||||
ApiError,
|
|
||||||
CDNMedia,
|
|
||||||
FileItem,
|
|
||||||
GetConfigResponse,
|
|
||||||
GetUpdatesResponse,
|
|
||||||
GetUploadUrlResponse,
|
|
||||||
ImageItem,
|
|
||||||
LoginResult,
|
|
||||||
MessageItem,
|
|
||||||
QRCodeResponse,
|
|
||||||
QRStatusResponse,
|
|
||||||
RefMessage,
|
|
||||||
TextItem,
|
|
||||||
VideoItem,
|
|
||||||
VoiceItem,
|
|
||||||
WeixinMessage,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger('openclaw-weixin-sdk')
|
|
||||||
|
|
||||||
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
|
||||||
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
|
||||||
|
|
||||||
CHANNEL_VERSION = '1.0.0'
|
|
||||||
|
|
||||||
DEFAULT_API_TIMEOUT = 15
|
|
||||||
DEFAULT_LONG_POLL_TIMEOUT = 40
|
|
||||||
DEFAULT_CONFIG_TIMEOUT = 10
|
|
||||||
DEFAULT_QR_POLL_TIMEOUT = 35
|
|
||||||
|
|
||||||
SESSION_EXPIRED_ERRCODE = -14
|
|
||||||
|
|
||||||
DEFAULT_BOT_TYPE = '3'
|
|
||||||
|
|
||||||
# Maximum text length per message chunk (WeChat limit)
|
|
||||||
MAX_TEXT_CHUNK_SIZE = 2000
|
|
||||||
|
|
||||||
|
|
||||||
def _random_wechat_uin() -> str:
|
|
||||||
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
|
|
||||||
rand_bytes = os.urandom(4)
|
|
||||||
uint32_val = struct.unpack('>I', rand_bytes)[0]
|
|
||||||
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
def _build_base_info() -> dict:
|
|
||||||
"""Build the base_info payload included in every API request."""
|
|
||||||
return {'channel_version': CHANNEL_VERSION}
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
|
|
||||||
"""Split long text into chunks that fit within WeChat's message size limit."""
|
|
||||||
if len(text) <= max_size:
|
|
||||||
return [text]
|
|
||||||
chunks = []
|
|
||||||
while text:
|
|
||||||
chunks.append(text[:max_size])
|
|
||||||
text = text[max_size:]
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
class OpenClawWeixinClient:
|
|
||||||
"""Async client for the OpenClaw WeChat HTTP JSON API."""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, token: str):
|
|
||||||
self.base_url = base_url.rstrip('/')
|
|
||||||
self.token = token
|
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
|
||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
|
||||||
if self._session is None or self._session.closed:
|
|
||||||
self._session = aiohttp.ClientSession()
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
if self._session and not self._session.closed:
|
|
||||||
await self._session.close()
|
|
||||||
|
|
||||||
def _build_headers(self) -> dict[str, str]:
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'AuthorizationType': 'ilink_bot_token',
|
|
||||||
'X-WECHAT-UIN': _random_wechat_uin(),
|
|
||||||
}
|
|
||||||
if self.token:
|
|
||||||
headers['Authorization'] = f'Bearer {self.token}'
|
|
||||||
return headers
|
|
||||||
|
|
||||||
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
|
|
||||||
"""Make a POST request and return the JSON response.
|
|
||||||
|
|
||||||
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
|
|
||||||
"""
|
|
||||||
payload['base_info'] = _build_base_info()
|
|
||||||
|
|
||||||
session = await self._get_session()
|
|
||||||
url = f'{self.base_url}/{endpoint}'
|
|
||||||
headers = self._build_headers()
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise ApiError(
|
|
||||||
f'OpenClaw API error {resp.status}: {text}',
|
|
||||||
status=resp.status,
|
|
||||||
)
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
|
|
||||||
# Check for application-level errors in the response body
|
|
||||||
errcode = data.get('errcode') or data.get('ret')
|
|
||||||
if errcode and errcode != 0:
|
|
||||||
raise ApiError(
|
|
||||||
data.get('errmsg') or f'API errcode {errcode}',
|
|
||||||
status=200,
|
|
||||||
code=errcode,
|
|
||||||
payload=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def get_updates(
|
|
||||||
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
|
|
||||||
) -> GetUpdatesResponse:
|
|
||||||
"""Long-poll for new messages.
|
|
||||||
|
|
||||||
Note: This method does NOT raise ApiError for errcode responses —
|
|
||||||
it returns them in the GetUpdatesResponse so the caller can handle
|
|
||||||
session expiry and other errors with full context.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Bypass the errcode check in _post since get_updates needs
|
|
||||||
# to return error info (e.g. session expired) to the caller.
|
|
||||||
payload: dict = {'get_updates_buf': get_updates_buf}
|
|
||||||
payload['base_info'] = _build_base_info()
|
|
||||||
|
|
||||||
session = await self._get_session()
|
|
||||||
url = f'{self.base_url}/ilink/bot/getupdates'
|
|
||||||
headers = self._build_headers()
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise ApiError(
|
|
||||||
f'OpenClaw API error {resp.status}: {text}',
|
|
||||||
status=resp.status,
|
|
||||||
)
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
|
||||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
|
||||||
except ApiError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
if 'timeout' in str(e).lower():
|
|
||||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
|
||||||
raise
|
|
||||||
|
|
||||||
return _parse_get_updates_response(data)
|
|
||||||
|
|
||||||
async def send_message(
|
|
||||||
self,
|
|
||||||
to_user_id: str,
|
|
||||||
item_list: list[MessageItem],
|
|
||||||
context_token: str = '',
|
|
||||||
) -> None:
|
|
||||||
"""Send a message to a user."""
|
|
||||||
items_payload = [_message_item_to_dict(item) for item in item_list]
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'msg': {
|
|
||||||
'from_user_id': '',
|
|
||||||
'to_user_id': to_user_id,
|
|
||||||
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
|
|
||||||
'message_type': WeixinMessage.TYPE_BOT,
|
|
||||||
'message_state': WeixinMessage.STATE_FINISH,
|
|
||||||
'item_list': items_payload,
|
|
||||||
'context_token': context_token or None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self._post('ilink/bot/sendmessage', payload)
|
|
||||||
|
|
||||||
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
|
|
||||||
"""Send a plain text message, automatically chunking if too long."""
|
|
||||||
chunks = _chunk_text(text)
|
|
||||||
for chunk in chunks:
|
|
||||||
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
|
|
||||||
await self.send_message(to_user_id, [item], context_token)
|
|
||||||
|
|
||||||
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
|
|
||||||
"""Get bot config including typing_ticket."""
|
|
||||||
data = await self._post(
|
|
||||||
'ilink/bot/getconfig',
|
|
||||||
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
|
|
||||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
|
||||||
)
|
|
||||||
return GetConfigResponse(
|
|
||||||
ret=data.get('ret'),
|
|
||||||
errmsg=data.get('errmsg'),
|
|
||||||
typing_ticket=data.get('typing_ticket'),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
|
|
||||||
"""Send typing indicator. status: 1=typing, 2=cancel."""
|
|
||||||
await self._post(
|
|
||||||
'ilink/bot/sendtyping',
|
|
||||||
{
|
|
||||||
'ilink_user_id': ilink_user_id,
|
|
||||||
'typing_ticket': typing_ticket,
|
|
||||||
'status': status,
|
|
||||||
},
|
|
||||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
|
|
||||||
"""Cancel the typing indicator for a user."""
|
|
||||||
await self.send_typing(ilink_user_id, typing_ticket, status=2)
|
|
||||||
|
|
||||||
async def download_media(
|
|
||||||
self,
|
|
||||||
media: CDNMedia,
|
|
||||||
) -> bytes:
|
|
||||||
"""Download and decrypt a file from the WeChat CDN.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
media: CDNMedia object with encrypt_query_param and aes_key.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decrypted file bytes.
|
|
||||||
"""
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
from cryptography.hazmat.primitives.padding import PKCS7
|
|
||||||
|
|
||||||
if not media.encrypt_query_param:
|
|
||||||
raise ApiError('CDN media has no encrypt_query_param', status=0)
|
|
||||||
if not media.aes_key:
|
|
||||||
raise ApiError('CDN media has no aes_key', status=0)
|
|
||||||
|
|
||||||
# Derive 16-byte AES key
|
|
||||||
# aes_key is base64-encoded; the decoded content may be:
|
|
||||||
# - raw 16 bytes (direct AES key)
|
|
||||||
# - 32-char hex string (decode hex to get 16 bytes)
|
|
||||||
raw = base64.b64decode(media.aes_key)
|
|
||||||
if len(raw) == 16:
|
|
||||||
aes_key = raw
|
|
||||||
elif len(raw) == 32:
|
|
||||||
# Hex-encoded 16-byte key
|
|
||||||
aes_key = bytes.fromhex(raw.decode('utf-8'))
|
|
||||||
else:
|
|
||||||
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
|
|
||||||
|
|
||||||
# Download encrypted bytes from CDN
|
|
||||||
session = await self._get_session()
|
|
||||||
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
|
|
||||||
|
|
||||||
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
|
||||||
encrypted = await resp.read()
|
|
||||||
|
|
||||||
# Decrypt AES-128-ECB with PKCS7 padding
|
|
||||||
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
|
||||||
decryptor = cipher.decryptor()
|
|
||||||
padded = decryptor.update(encrypted) + decryptor.finalize()
|
|
||||||
|
|
||||||
unpadder = PKCS7(128).unpadder()
|
|
||||||
return unpadder.update(padded) + unpadder.finalize()
|
|
||||||
|
|
||||||
async def upload_media(
|
|
||||||
self,
|
|
||||||
file_bytes: bytes,
|
|
||||||
to_user_id: str,
|
|
||||||
media_type: int,
|
|
||||||
) -> CDNMedia:
|
|
||||||
"""Encrypt and upload media to WeChat CDN.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_bytes: Raw file bytes to upload.
|
|
||||||
to_user_id: Recipient user ID.
|
|
||||||
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
from cryptography.hazmat.primitives.padding import PKCS7
|
|
||||||
|
|
||||||
# 1. Generate random 16-byte AES key
|
|
||||||
raw_key = os.urandom(16)
|
|
||||||
aes_key_hex = raw_key.hex() # 32-char hex string
|
|
||||||
|
|
||||||
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
|
|
||||||
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
|
|
||||||
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
|
||||||
|
|
||||||
# 3. Encrypt file with AES-128-ECB + PKCS7
|
|
||||||
padder = PKCS7(128).padder()
|
|
||||||
padded = padder.update(file_bytes) + padder.finalize()
|
|
||||||
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
|
||||||
encryptor = cipher.encryptor()
|
|
||||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
|
||||||
|
|
||||||
# 4. Get upload URL
|
|
||||||
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
|
||||||
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
|
||||||
|
|
||||||
upload_resp = await self.get_upload_url(
|
|
||||||
filekey=filekey,
|
|
||||||
media_type=media_type,
|
|
||||||
to_user_id=to_user_id,
|
|
||||||
rawsize=len(file_bytes),
|
|
||||||
rawfilemd5=raw_md5,
|
|
||||||
filesize=len(encrypted),
|
|
||||||
aeskey=aes_key_hex, # hex string, as expected by the API
|
|
||||||
)
|
|
||||||
|
|
||||||
if not upload_resp.upload_param:
|
|
||||||
raise ApiError('Failed to get upload URL', status=0)
|
|
||||||
|
|
||||||
# 5. Upload to CDN
|
|
||||||
# upload_param is an opaque token from the server — pass it as-is
|
|
||||||
session = await self._get_session()
|
|
||||||
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
|
|
||||||
logger.debug(
|
|
||||||
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
|
|
||||||
cdn_url,
|
|
||||||
len(file_bytes),
|
|
||||||
len(encrypted),
|
|
||||||
raw_md5,
|
|
||||||
encoded_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
cdn_url,
|
|
||||||
data=encrypted,
|
|
||||||
headers={'Content-Type': 'application/octet-stream'},
|
|
||||||
timeout=aiohttp.ClientTimeout(total=120),
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
|
|
||||||
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
|
|
||||||
download_param = resp.headers.get('x-encrypted-param', '')
|
|
||||||
|
|
||||||
if not download_param:
|
|
||||||
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
|
|
||||||
|
|
||||||
return CDNMedia(
|
|
||||||
encrypt_query_param=download_param,
|
|
||||||
aes_key=encoded_key,
|
|
||||||
encrypt_type=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_image(
|
|
||||||
self,
|
|
||||||
to_user_id: str,
|
|
||||||
image_bytes: bytes,
|
|
||||||
context_token: str = '',
|
|
||||||
) -> None:
|
|
||||||
"""Upload an image to CDN and send it."""
|
|
||||||
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
|
|
||||||
item = MessageItem(
|
|
||||||
type=MessageItem.IMAGE,
|
|
||||||
image_item=ImageItem(
|
|
||||||
media=media,
|
|
||||||
aeskey=media.aes_key,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await self.send_message(to_user_id, [item], context_token)
|
|
||||||
|
|
||||||
async def send_file(
|
|
||||||
self,
|
|
||||||
to_user_id: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
file_name: str,
|
|
||||||
context_token: str = '',
|
|
||||||
) -> None:
|
|
||||||
"""Upload a file to CDN and send it."""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
|
|
||||||
item = MessageItem(
|
|
||||||
type=MessageItem.FILE,
|
|
||||||
file_item=FileItem(
|
|
||||||
media=media,
|
|
||||||
file_name=file_name,
|
|
||||||
md5=hashlib.md5(file_bytes).hexdigest(),
|
|
||||||
len=str(len(file_bytes)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await self.send_message(to_user_id, [item], context_token)
|
|
||||||
|
|
||||||
async def send_voice(
|
|
||||||
self,
|
|
||||||
to_user_id: str,
|
|
||||||
voice_bytes: bytes,
|
|
||||||
playtime: int = 0,
|
|
||||||
context_token: str = '',
|
|
||||||
) -> None:
|
|
||||||
"""Upload a voice message to CDN and send it."""
|
|
||||||
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
|
|
||||||
item = MessageItem(
|
|
||||||
type=MessageItem.VOICE,
|
|
||||||
voice_item=VoiceItem(
|
|
||||||
media=media,
|
|
||||||
playtime=playtime,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await self.send_message(to_user_id, [item], context_token)
|
|
||||||
|
|
||||||
async def get_upload_url(
|
|
||||||
self,
|
|
||||||
filekey: str,
|
|
||||||
media_type: int,
|
|
||||||
to_user_id: str,
|
|
||||||
rawsize: int,
|
|
||||||
rawfilemd5: str,
|
|
||||||
filesize: int,
|
|
||||||
thumb_rawsize: Optional[int] = None,
|
|
||||||
thumb_rawfilemd5: Optional[str] = None,
|
|
||||||
thumb_filesize: Optional[int] = None,
|
|
||||||
aeskey: Optional[str] = None,
|
|
||||||
) -> GetUploadUrlResponse:
|
|
||||||
"""Get a pre-signed CDN upload URL."""
|
|
||||||
payload: dict = {
|
|
||||||
'filekey': filekey,
|
|
||||||
'media_type': media_type,
|
|
||||||
'to_user_id': to_user_id,
|
|
||||||
'rawsize': rawsize,
|
|
||||||
'rawfilemd5': rawfilemd5,
|
|
||||||
'filesize': filesize,
|
|
||||||
'no_need_thumb': True,
|
|
||||||
}
|
|
||||||
if thumb_rawsize is not None:
|
|
||||||
payload['thumb_rawsize'] = thumb_rawsize
|
|
||||||
if thumb_rawfilemd5 is not None:
|
|
||||||
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
|
|
||||||
if thumb_filesize is not None:
|
|
||||||
payload['thumb_filesize'] = thumb_filesize
|
|
||||||
if aeskey is not None:
|
|
||||||
payload['aeskey'] = aeskey
|
|
||||||
|
|
||||||
data = await self._post('ilink/bot/getuploadurl', payload)
|
|
||||||
logger.debug('get_upload_url response: %s', data)
|
|
||||||
return GetUploadUrlResponse(
|
|
||||||
upload_param=data.get('upload_param'),
|
|
||||||
thumb_upload_param=data.get('thumb_upload_param'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# QR Code Login
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
|
|
||||||
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
|
|
||||||
session = await self._get_session()
|
|
||||||
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
|
|
||||||
|
|
||||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise ApiError(
|
|
||||||
f'Failed to fetch QR code: {resp.status} {text}',
|
|
||||||
status=resp.status,
|
|
||||||
)
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
|
||||||
)
|
|
||||||
|
|
||||||
return QRCodeResponse(
|
|
||||||
qrcode=data.get('qrcode'),
|
|
||||||
qrcode_img_content=data.get('qrcode_img_content'),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _fetch_qr_image_base64(self, url: str) -> str:
|
|
||||||
"""Generate a QR code image from the URL and return a data URI string.
|
|
||||||
|
|
||||||
The qrcode_img_content URL points to an HTML page (not a raw image),
|
|
||||||
so we generate the QR code locally using the qrcode library.
|
|
||||||
"""
|
|
||||||
import qrcode
|
|
||||||
|
|
||||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
|
||||||
qr.add_data(url)
|
|
||||||
qr.make(fit=True)
|
|
||||||
img = qr.make_image(fill_color='black', back_color='white')
|
|
||||||
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
|
||||||
return f'data:image/png;base64,{b64}'
|
|
||||||
|
|
||||||
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
|
|
||||||
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
|
|
||||||
session = await self._get_session()
|
|
||||||
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
|
|
||||||
headers = {'iLink-App-ClientVersion': '1'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with session.get(
|
|
||||||
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise ApiError(
|
|
||||||
f'Failed to poll QR status: {resp.status} {text}',
|
|
||||||
status=resp.status,
|
|
||||||
)
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
logger.debug('QR status poll response: %s', data)
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
|
||||||
return QRStatusResponse(status='wait')
|
|
||||||
|
|
||||||
return QRStatusResponse(
|
|
||||||
status=data.get('status'),
|
|
||||||
bot_token=data.get('bot_token'),
|
|
||||||
ilink_bot_id=data.get('ilink_bot_id'),
|
|
||||||
baseurl=data.get('baseurl'),
|
|
||||||
ilink_user_id=data.get('ilink_user_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def login(
|
|
||||||
self,
|
|
||||||
max_retries: int = 5,
|
|
||||||
poll_timeout_ms: int = 480_000,
|
|
||||||
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
|
|
||||||
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
|
|
||||||
) -> LoginResult:
|
|
||||||
"""Complete QR code login flow with auto-retry on expiry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_retries: Max number of QR code refreshes on expiry.
|
|
||||||
poll_timeout_ms: Timeout per QR code in milliseconds.
|
|
||||||
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
|
|
||||||
new QR code is fetched. Use this to display the QR code.
|
|
||||||
on_status: Callback(status_str) called on each status poll change.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LoginResult with token, base_url, and account_id.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ApiError: On unrecoverable API errors.
|
|
||||||
Exception: If all retries are exhausted.
|
|
||||||
"""
|
|
||||||
last_qr_base64: Optional[str] = None
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
qr_resp = await self.fetch_qrcode()
|
|
||||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
|
||||||
raise ApiError('Failed to get QR code from server', status=0)
|
|
||||||
|
|
||||||
# Convert QR image to base64 and notify caller
|
|
||||||
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
|
|
||||||
if on_qrcode:
|
|
||||||
try:
|
|
||||||
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
|
|
||||||
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
|
||||||
await result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning('on_qrcode callback error: %s', e)
|
|
||||||
|
|
||||||
# Poll until confirmed / expired / timeout
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
deadline = loop.time() + poll_timeout_ms / 1000.0
|
|
||||||
|
|
||||||
while loop.time() < deadline:
|
|
||||||
try:
|
|
||||||
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error polling QR status: %s', e)
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if on_status:
|
|
||||||
try:
|
|
||||||
cb_result = on_status(status_resp.status or 'unknown')
|
|
||||||
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
|
|
||||||
await cb_result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning('on_status callback error: %s', e)
|
|
||||||
|
|
||||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
|
||||||
new_base_url = status_resp.baseurl or self.base_url
|
|
||||||
# Update this client instance as well
|
|
||||||
self.token = status_resp.bot_token
|
|
||||||
self.base_url = new_base_url.rstrip('/')
|
|
||||||
return LoginResult(
|
|
||||||
token=status_resp.bot_token,
|
|
||||||
base_url=new_base_url,
|
|
||||||
account_id=status_resp.ilink_bot_id or '',
|
|
||||||
qr_image_base64=last_qr_base64,
|
|
||||||
)
|
|
||||||
|
|
||||||
if status_resp.status == 'expired':
|
|
||||||
break # retry with a new QR code
|
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
else:
|
|
||||||
# While-loop ended without break → poll timeout, treat as expired
|
|
||||||
pass
|
|
||||||
|
|
||||||
remaining = max_retries - attempt - 1
|
|
||||||
if remaining > 0:
|
|
||||||
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
|
|
||||||
else:
|
|
||||||
raise ApiError('QR code login failed: max retries exceeded', status=0)
|
|
||||||
|
|
||||||
# Should not reach here, but just in case
|
|
||||||
raise ApiError('QR code login failed', status=0)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Parsing helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
return CDNMedia(
|
|
||||||
encrypt_query_param=data.get('encrypt_query_param'),
|
|
||||||
aes_key=data.get('aes_key'),
|
|
||||||
encrypt_type=data.get('encrypt_type'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_message_item(data: dict) -> MessageItem:
|
|
||||||
item = MessageItem(
|
|
||||||
type=data.get('type'),
|
|
||||||
create_time_ms=data.get('create_time_ms'),
|
|
||||||
update_time_ms=data.get('update_time_ms'),
|
|
||||||
is_completed=data.get('is_completed'),
|
|
||||||
msg_id=data.get('msg_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('text_item'):
|
|
||||||
item.text_item = TextItem(text=data['text_item'].get('text'))
|
|
||||||
|
|
||||||
if data.get('image_item'):
|
|
||||||
img = data['image_item']
|
|
||||||
item.image_item = ImageItem(
|
|
||||||
media=_parse_cdn_media(img.get('media')),
|
|
||||||
thumb_media=_parse_cdn_media(img.get('thumb_media')),
|
|
||||||
aeskey=img.get('aeskey'),
|
|
||||||
url=img.get('url'),
|
|
||||||
mid_size=img.get('mid_size'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('voice_item'):
|
|
||||||
v = data['voice_item']
|
|
||||||
item.voice_item = VoiceItem(
|
|
||||||
media=_parse_cdn_media(v.get('media')),
|
|
||||||
encode_type=v.get('encode_type'),
|
|
||||||
playtime=v.get('playtime'),
|
|
||||||
text=v.get('text'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('file_item'):
|
|
||||||
f = data['file_item']
|
|
||||||
item.file_item = FileItem(
|
|
||||||
media=_parse_cdn_media(f.get('media')),
|
|
||||||
file_name=f.get('file_name'),
|
|
||||||
md5=f.get('md5'),
|
|
||||||
len=f.get('len'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('video_item'):
|
|
||||||
vid = data['video_item']
|
|
||||||
item.video_item = VideoItem(
|
|
||||||
media=_parse_cdn_media(vid.get('media')),
|
|
||||||
video_size=vid.get('video_size'),
|
|
||||||
play_length=vid.get('play_length'),
|
|
||||||
video_md5=vid.get('video_md5'),
|
|
||||||
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('ref_msg'):
|
|
||||||
ref = data['ref_msg']
|
|
||||||
item.ref_msg = RefMessage(
|
|
||||||
title=ref.get('title'),
|
|
||||||
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_weixin_message(data: dict) -> WeixinMessage:
|
|
||||||
msg = WeixinMessage(
|
|
||||||
seq=data.get('seq'),
|
|
||||||
message_id=data.get('message_id'),
|
|
||||||
from_user_id=data.get('from_user_id'),
|
|
||||||
to_user_id=data.get('to_user_id'),
|
|
||||||
client_id=data.get('client_id'),
|
|
||||||
create_time_ms=data.get('create_time_ms'),
|
|
||||||
session_id=data.get('session_id'),
|
|
||||||
group_id=data.get('group_id'),
|
|
||||||
message_type=data.get('message_type'),
|
|
||||||
message_state=data.get('message_state'),
|
|
||||||
context_token=data.get('context_token'),
|
|
||||||
)
|
|
||||||
if data.get('item_list'):
|
|
||||||
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
|
|
||||||
resp = GetUpdatesResponse(
|
|
||||||
ret=data.get('ret'),
|
|
||||||
errcode=data.get('errcode'),
|
|
||||||
errmsg=data.get('errmsg'),
|
|
||||||
get_updates_buf=data.get('get_updates_buf'),
|
|
||||||
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
|
|
||||||
)
|
|
||||||
if data.get('msgs'):
|
|
||||||
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
|
|
||||||
if not media:
|
|
||||||
return None
|
|
||||||
d: dict = {}
|
|
||||||
if media.encrypt_query_param is not None:
|
|
||||||
d['encrypt_query_param'] = media.encrypt_query_param
|
|
||||||
if media.aes_key is not None:
|
|
||||||
d['aes_key'] = media.aes_key
|
|
||||||
if media.encrypt_type is not None:
|
|
||||||
d['encrypt_type'] = media.encrypt_type
|
|
||||||
return d or None
|
|
||||||
|
|
||||||
|
|
||||||
def _message_item_to_dict(item: MessageItem) -> dict:
|
|
||||||
d: dict = {'type': item.type}
|
|
||||||
|
|
||||||
if item.text_item:
|
|
||||||
d['text_item'] = {'text': item.text_item.text}
|
|
||||||
|
|
||||||
if item.image_item:
|
|
||||||
img_d: dict = {}
|
|
||||||
if item.image_item.media:
|
|
||||||
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
|
|
||||||
if item.image_item.mid_size is not None:
|
|
||||||
img_d['mid_size'] = item.image_item.mid_size
|
|
||||||
d['image_item'] = img_d
|
|
||||||
|
|
||||||
if item.voice_item:
|
|
||||||
voice_d: dict = {}
|
|
||||||
if item.voice_item.media:
|
|
||||||
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
|
|
||||||
if item.voice_item.playtime is not None:
|
|
||||||
voice_d['playtime'] = item.voice_item.playtime
|
|
||||||
d['voice_item'] = voice_d
|
|
||||||
|
|
||||||
if item.file_item:
|
|
||||||
file_d: dict = {}
|
|
||||||
if item.file_item.media:
|
|
||||||
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
|
|
||||||
if item.file_item.file_name:
|
|
||||||
file_d['file_name'] = item.file_item.file_name
|
|
||||||
if item.file_item.len:
|
|
||||||
file_d['len'] = item.file_item.len
|
|
||||||
d['file_item'] = file_d
|
|
||||||
|
|
||||||
if item.video_item:
|
|
||||||
vid_d: dict = {}
|
|
||||||
if item.video_item.media:
|
|
||||||
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
|
|
||||||
if item.video_item.video_size is not None:
|
|
||||||
vid_d['video_size'] = item.video_item.video_size
|
|
||||||
d['video_item'] = vid_d
|
|
||||||
|
|
||||||
return d
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
SESSION_EXPIRED_ERRCODE = -14
|
|
||||||
|
|
||||||
|
|
||||||
class ApiError(Exception):
|
|
||||||
"""Structured error raised by the OpenClaw WeChat API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
status: int = 0,
|
|
||||||
code: int | None = None,
|
|
||||||
payload: Any = None,
|
|
||||||
):
|
|
||||||
super().__init__(message)
|
|
||||||
self.status = status
|
|
||||||
self.code = code
|
|
||||||
self.payload = payload
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_session_expired(self) -> bool:
|
|
||||||
return self.code == SESSION_EXPIRED_ERRCODE
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CDNMedia:
|
|
||||||
encrypt_query_param: Optional[str] = None
|
|
||||||
aes_key: Optional[str] = None
|
|
||||||
encrypt_type: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TextItem:
|
|
||||||
text: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ImageItem:
|
|
||||||
media: Optional[CDNMedia] = None
|
|
||||||
thumb_media: Optional[CDNMedia] = None
|
|
||||||
aeskey: Optional[str] = None
|
|
||||||
url: Optional[str] = None
|
|
||||||
mid_size: Optional[int] = None
|
|
||||||
thumb_size: Optional[int] = None
|
|
||||||
thumb_height: Optional[int] = None
|
|
||||||
thumb_width: Optional[int] = None
|
|
||||||
hd_size: Optional[int] = None
|
|
||||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class VoiceItem:
|
|
||||||
media: Optional[CDNMedia] = None
|
|
||||||
encode_type: Optional[int] = None
|
|
||||||
bits_per_sample: Optional[int] = None
|
|
||||||
sample_rate: Optional[int] = None
|
|
||||||
playtime: Optional[int] = None
|
|
||||||
text: Optional[str] = None
|
|
||||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FileItem:
|
|
||||||
media: Optional[CDNMedia] = None
|
|
||||||
file_name: Optional[str] = None
|
|
||||||
md5: Optional[str] = None
|
|
||||||
len: Optional[str] = None
|
|
||||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class VideoItem:
|
|
||||||
media: Optional[CDNMedia] = None
|
|
||||||
video_size: Optional[int] = None
|
|
||||||
play_length: Optional[int] = None
|
|
||||||
video_md5: Optional[str] = None
|
|
||||||
thumb_media: Optional[CDNMedia] = None
|
|
||||||
thumb_size: Optional[int] = None
|
|
||||||
thumb_height: Optional[int] = None
|
|
||||||
thumb_width: Optional[int] = None
|
|
||||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RefMessage:
|
|
||||||
message_item: Optional[MessageItem] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MessageItem:
|
|
||||||
"""A single content item inside a WeixinMessage."""
|
|
||||||
|
|
||||||
# Item types
|
|
||||||
NONE = 0
|
|
||||||
TEXT = 1
|
|
||||||
IMAGE = 2
|
|
||||||
VOICE = 3
|
|
||||||
FILE = 4
|
|
||||||
VIDEO = 5
|
|
||||||
|
|
||||||
type: Optional[int] = None
|
|
||||||
create_time_ms: Optional[int] = None
|
|
||||||
update_time_ms: Optional[int] = None
|
|
||||||
is_completed: Optional[bool] = None
|
|
||||||
msg_id: Optional[str] = None
|
|
||||||
ref_msg: Optional[RefMessage] = None
|
|
||||||
text_item: Optional[TextItem] = None
|
|
||||||
image_item: Optional[ImageItem] = None
|
|
||||||
voice_item: Optional[VoiceItem] = None
|
|
||||||
file_item: Optional[FileItem] = None
|
|
||||||
video_item: Optional[VideoItem] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WeixinMessage:
|
|
||||||
"""Unified message from getUpdates or for sendMessage."""
|
|
||||||
|
|
||||||
# Message types
|
|
||||||
TYPE_USER = 1
|
|
||||||
TYPE_BOT = 2
|
|
||||||
|
|
||||||
# Message states
|
|
||||||
STATE_NEW = 0
|
|
||||||
STATE_GENERATING = 1
|
|
||||||
STATE_FINISH = 2
|
|
||||||
|
|
||||||
seq: Optional[int] = None
|
|
||||||
message_id: Optional[int] = None
|
|
||||||
from_user_id: Optional[str] = None
|
|
||||||
to_user_id: Optional[str] = None
|
|
||||||
client_id: Optional[str] = None
|
|
||||||
create_time_ms: Optional[int] = None
|
|
||||||
update_time_ms: Optional[int] = None
|
|
||||||
delete_time_ms: Optional[int] = None
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
group_id: Optional[str] = None
|
|
||||||
message_type: Optional[int] = None
|
|
||||||
message_state: Optional[int] = None
|
|
||||||
item_list: Optional[list[MessageItem]] = None
|
|
||||||
context_token: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetUpdatesResponse:
|
|
||||||
ret: Optional[int] = None
|
|
||||||
errcode: Optional[int] = None
|
|
||||||
errmsg: Optional[str] = None
|
|
||||||
msgs: list[WeixinMessage] = field(default_factory=list)
|
|
||||||
get_updates_buf: Optional[str] = None
|
|
||||||
longpolling_timeout_ms: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetConfigResponse:
|
|
||||||
ret: Optional[int] = None
|
|
||||||
errmsg: Optional[str] = None
|
|
||||||
typing_ticket: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GetUploadUrlResponse:
|
|
||||||
upload_param: Optional[str] = None
|
|
||||||
thumb_upload_param: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QRCodeResponse:
|
|
||||||
"""Response from get_bot_qrcode endpoint."""
|
|
||||||
|
|
||||||
qrcode: Optional[str] = None
|
|
||||||
qrcode_img_content: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QRStatusResponse:
|
|
||||||
"""Response from get_qrcode_status endpoint."""
|
|
||||||
|
|
||||||
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
|
|
||||||
bot_token: Optional[str] = None
|
|
||||||
ilink_bot_id: Optional[str] = None
|
|
||||||
baseurl: Optional[str] = None
|
|
||||||
ilink_user_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LoginResult:
|
|
||||||
"""Result returned by the login flow."""
|
|
||||||
|
|
||||||
token: str
|
|
||||||
base_url: str
|
|
||||||
account_id: str
|
|
||||||
qr_image_base64: Optional[str] = None # data URI of the last QR code shown
|
|
||||||
@@ -6,8 +6,7 @@ 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
|
||||||
import re
|
from typing import Any, Callable, Optional
|
||||||
from typing import Any, Callable, Optional, Tuple
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -64,9 +63,6 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
# 反馈 ID,用于接收用户点赞/点踩反馈
|
|
||||||
feedback_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
@@ -77,7 +73,6 @@ class StreamSessionManager:
|
|||||||
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:
|
||||||
@@ -87,32 +82,6 @@ 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]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -228,488 +197,6 @@ 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:
|
||||||
@@ -749,27 +236,14 @@ 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(
|
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
||||||
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。
|
||||||
@@ -777,16 +251,13 @@ 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_payload,
|
'stream': {
|
||||||
|
'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]:
|
||||||
@@ -842,14 +313,9 @@ 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:
|
||||||
@@ -858,7 +324,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, feedback_id)
|
payload = self._build_stream_payload(session.stream_id, '', False)
|
||||||
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]:
|
||||||
@@ -983,83 +449,202 @@ 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 _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
|
||||||
"""处理企业微信用户反馈事件(点赞/点踩)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg_json: 解密后的企业微信反馈事件 JSON。
|
|
||||||
nonce: 企业微信回调参数 nonce。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple[Response, int]: Quart Response 及状态码。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
企业微信协议要求:反馈事件目前仅支持回复空包。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
|
||||||
feedback_id = feedback_event.get('id', '')
|
|
||||||
feedback_type = feedback_event.get('type', 0)
|
|
||||||
feedback_content = feedback_event.get('content', '')
|
|
||||||
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
|
||||||
|
|
||||||
await self.logger.info(
|
|
||||||
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
|
||||||
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
|
||||||
)
|
|
||||||
|
|
||||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
|
||||||
|
|
||||||
if session:
|
|
||||||
await self.logger.info(
|
|
||||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
|
|
||||||
|
|
||||||
# Dispatch feedback event regardless of session availability
|
|
||||||
for handler in self._message_handlers.get('feedback', []):
|
|
||||||
try:
|
|
||||||
await handler(
|
|
||||||
feedback_id=feedback_id,
|
|
||||||
feedback_type=feedback_type,
|
|
||||||
feedback_content=feedback_content,
|
|
||||||
inaccurate_reasons=inaccurate_reasons,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
if self._feedback_callback:
|
|
||||||
try:
|
|
||||||
await self._feedback_callback(
|
|
||||||
feedback_id=feedback_id,
|
|
||||||
feedback_type=feedback_type,
|
|
||||||
feedback_content=feedback_content,
|
|
||||||
inaccurate_reasons=inaccurate_reasons,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
return await self._encrypt_and_reply({}, nonce)
|
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def get_message(self, msg_json):
|
||||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
message_data = {}
|
||||||
|
|
||||||
|
msg_type = msg_json.get('msgtype', '')
|
||||||
|
if msg_type:
|
||||||
|
message_data['msgtype'] = msg_type
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'single':
|
||||||
|
message_data['type'] = 'single'
|
||||||
|
elif msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['type'] = 'group'
|
||||||
|
|
||||||
|
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
||||||
|
|
||||||
|
async def _safe_download(url: str):
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
return await self.download_url_to_base64(url, self.EnCodingAESKey)
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||||
|
elif msg_type == 'markdown':
|
||||||
|
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||||
|
'content', ''
|
||||||
|
)
|
||||||
|
elif msg_type == 'image':
|
||||||
|
picurl = msg_json.get('image', {}).get('url', '')
|
||||||
|
base64_data = await _safe_download(picurl)
|
||||||
|
if base64_data:
|
||||||
|
message_data['picurl'] = base64_data
|
||||||
|
message_data['images'] = [base64_data]
|
||||||
|
elif msg_type == 'voice':
|
||||||
|
voice_info = msg_json.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
message_data['voice'] = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
|
||||||
|
if voice_info.get('content'):
|
||||||
|
message_data['content'] = voice_info.get('content')
|
||||||
|
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
voice_base64 = await _safe_download(download_url)
|
||||||
|
if voice_base64:
|
||||||
|
message_data['voice']['base64'] = voice_base64
|
||||||
|
elif msg_type == 'video':
|
||||||
|
video_info = msg_json.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
video_base64 = await _safe_download(download_url)
|
||||||
|
if video_base64:
|
||||||
|
video_data['base64'] = video_base64
|
||||||
|
message_data['video'] = video_data
|
||||||
|
elif msg_type == 'file':
|
||||||
|
file_info = msg_json.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
file_base64 = await _safe_download(download_url)
|
||||||
|
if file_base64:
|
||||||
|
file_data['base64'] = file_base64
|
||||||
|
message_data['file'] = file_data
|
||||||
|
elif msg_type == 'link':
|
||||||
|
message_data['link'] = msg_json.get('link', {})
|
||||||
|
if not message_data.get('content'):
|
||||||
|
title = message_data['link'].get('title', '')
|
||||||
|
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
||||||
|
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif msg_type == 'mixed':
|
||||||
|
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
voices = []
|
||||||
|
videos = []
|
||||||
|
links = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_url = item.get('image', {}).get('url')
|
||||||
|
base64_data = await _safe_download(img_url)
|
||||||
|
if base64_data:
|
||||||
|
images.append(base64_data)
|
||||||
|
elif item_type == 'file':
|
||||||
|
file_info = item.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
file_base64 = await _safe_download(download_url)
|
||||||
|
if file_base64:
|
||||||
|
file_data['base64'] = file_base64
|
||||||
|
files.append(file_data)
|
||||||
|
elif item_type == 'voice':
|
||||||
|
voice_info = item.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
voice_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
texts.append(voice_info.get('content'))
|
||||||
|
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
voice_base64 = await _safe_download(download_url)
|
||||||
|
if voice_base64:
|
||||||
|
voice_data['base64'] = voice_base64
|
||||||
|
voices.append(voice_data)
|
||||||
|
elif item_type == 'video':
|
||||||
|
video_info = item.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
video_base64 = await _safe_download(download_url)
|
||||||
|
if video_base64:
|
||||||
|
video_data['base64'] = video_base64
|
||||||
|
videos.append(video_data)
|
||||||
|
elif item_type == 'link':
|
||||||
|
links.append(item.get('link', {}))
|
||||||
|
|
||||||
|
if texts:
|
||||||
|
message_data['content'] = ' '.join(texts) # 拼接所有 text
|
||||||
|
if images:
|
||||||
|
message_data['images'] = images
|
||||||
|
message_data['picurl'] = images[0] # 只保留第一个 image
|
||||||
|
if files:
|
||||||
|
message_data['files'] = files
|
||||||
|
message_data['file'] = files[0]
|
||||||
|
if voices:
|
||||||
|
message_data['voices'] = voices
|
||||||
|
message_data['voice'] = voices[0]
|
||||||
|
if videos:
|
||||||
|
message_data['videos'] = videos
|
||||||
|
message_data['video'] = videos[0]
|
||||||
|
if links:
|
||||||
|
message_data['link'] = links[0]
|
||||||
|
if items:
|
||||||
|
message_data['attachments'] = items
|
||||||
|
else:
|
||||||
|
message_data['raw_msg'] = msg_json
|
||||||
|
|
||||||
|
# Extract user information
|
||||||
|
from_info = msg_json.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = (
|
||||||
|
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract chat/group information
|
||||||
|
if msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['chatid'] = msg_json.get('chatid', '')
|
||||||
|
# Try to get group name if available
|
||||||
|
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||||
|
|
||||||
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
|
if msg_json.get('aibotid'):
|
||||||
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
return message_data
|
||||||
|
|
||||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""
|
"""
|
||||||
@@ -1126,20 +711,40 @@ 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):
|
||||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
async with httpx.AsyncClient() as client:
|
||||||
if data:
|
response = await client.get(download_url)
|
||||||
return _bytes_to_data_uri(data)
|
if response.status_code != 200:
|
||||||
return None
|
await self.logger.error(f'failed to get file: {response.text}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_bytes = response.content
|
||||||
|
|
||||||
|
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
|
||||||
|
iv = aes_key[:16]
|
||||||
|
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||||
|
decrypted = cipher.decrypt(encrypted_bytes)
|
||||||
|
|
||||||
|
pad_len = decrypted[-1]
|
||||||
|
decrypted = decrypted[:-pad_len]
|
||||||
|
|
||||||
|
if decrypted.startswith(b'\xff\xd8'): # JPEG
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
|
elif decrypted.startswith(b'\x89PNG'): # PNG
|
||||||
|
mime_type = 'image/png'
|
||||||
|
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
|
||||||
|
mime_type = 'image/gif'
|
||||||
|
elif decrypted.startswith(b'BM'): # BMP
|
||||||
|
mime_type = 'image/bmp'
|
||||||
|
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
|
||||||
|
mime_type = 'image/tiff'
|
||||||
|
else:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
# 转 base64
|
||||||
|
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
||||||
|
return f'data:{mime_type};base64,{base64_str}'
|
||||||
|
|
||||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -133,24 +133,3 @@ 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', {})
|
|
||||||
|
|||||||
@@ -1,683 +0,0 @@
|
|||||||
"""WeChat Work AI Bot WebSocket long connection client.
|
|
||||||
|
|
||||||
Implements the WebSocket protocol for receiving messages and sending replies
|
|
||||||
via a persistent connection to wss://openws.work.weixin.qq.com, as an
|
|
||||||
alternative to the HTTP callback (webhook) mode.
|
|
||||||
|
|
||||||
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
|
|
||||||
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from typing import 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()
|
|
||||||
@@ -4,7 +4,6 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
from urllib.parse import quote
|
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -68,31 +67,6 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
raise Exception(f'未获取access token: {data}')
|
||||||
|
|
||||||
async def get_user_info(self, userid: str) -> dict:
|
|
||||||
"""
|
|
||||||
Get user information by user ID using the application secret.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userid: The user ID to look up.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: User information including 'name' field.
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
|
|
||||||
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(url)
|
|
||||||
data = response.json()
|
|
||||||
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
return await self.get_user_info(userid)
|
|
||||||
if data.get('errcode', 0) != 0:
|
|
||||||
await self.logger.error(f'获取用户信息失败:{data}')
|
|
||||||
return {}
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def get_users(self):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Callable
|
|||||||
from .wecomcsevent import WecomCSEvent
|
from .wecomcsevent import WecomCSEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
@@ -35,10 +34,6 @@ class WecomCSClient:
|
|||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
|
||||||
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
|
||||||
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
|
||||||
|
|
||||||
# 只有在非统一模式下才注册独立路由
|
# 只有在非统一模式下才注册独立路由
|
||||||
if not self.unified_mode:
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
@@ -383,53 +378,3 @@ class WecomCSClient:
|
|||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, image: platform_message.Image):
|
||||||
media_id = await self.upload_to_work(image=image)
|
media_id = await self.upload_to_work(image=image)
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
async def get_customer_info(self, external_userid: str) -> dict | None:
|
|
||||||
"""
|
|
||||||
Get customer information by external_userid with caching.
|
|
||||||
|
|
||||||
Uses a 1-minute cache to avoid repeated API calls for the same user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
external_userid: The external user ID of the customer.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
|
|
||||||
"""
|
|
||||||
# Check cache first
|
|
||||||
current_time = time.time()
|
|
||||||
if external_userid in self._customer_cache:
|
|
||||||
cached_info, cached_time = self._customer_cache[external_userid]
|
|
||||||
if current_time - cached_time < self._cache_ttl:
|
|
||||||
return cached_info
|
|
||||||
|
|
||||||
# Cache miss or expired, fetch from API
|
|
||||||
if not await self.check_access_token():
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
|
|
||||||
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'external_userid_list': [external_userid],
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data.get('errcode') in [40014, 42001]:
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
return await self.get_customer_info(external_userid)
|
|
||||||
|
|
||||||
if data.get('errcode', 0) != 0:
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.warning(f'Failed to get customer info: {data}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
customer_list = data.get('customer_list', [])
|
|
||||||
if customer_list:
|
|
||||||
customer_info = customer_list[0]
|
|
||||||
# Store in cache
|
|
||||||
self._customer_cache[external_userid] = (customer_info, current_time)
|
|
||||||
return customer_info
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -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/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _(image_key: str) -> quart.Response:
|
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):
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import quart
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
from ......core import taskmgr
|
|
||||||
from ......entity.persistence import metadata as persistence_metadata
|
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
|
||||||
|
|
||||||
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
|
|
||||||
LANGRAG_PLUGIN_NAME = 'LangRAG'
|
|
||||||
LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'
|
|
||||||
DEFAULT_SPACE_URL = 'https://space.langbot.app'
|
|
||||||
|
|
||||||
# Old Retriever plugin_name -> New Connector plugin_name
|
|
||||||
EXTERNAL_PLUGIN_NAME_MAPPING = {
|
|
||||||
'DifyDatasetsRetriever': 'DifyDatasetsConnector',
|
|
||||||
'RAGFlowRetriever': 'RAGFlowConnector',
|
|
||||||
'FastGPTRetriever': 'FastGPTConnector',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Per-plugin: which old retriever_config fields belong to creation_settings.
|
|
||||||
# Remaining fields go to retrieval_settings.
|
|
||||||
# None means ALL fields go to creation_settings (no retrieval_schema).
|
|
||||||
EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
|
|
||||||
'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},
|
|
||||||
'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},
|
|
||||||
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
|
|
||||||
class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
|
||||||
async def _get_migration_flag(self) -> bool:
|
|
||||||
"""Check if rag_plugin_migration_needed flag is set."""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_metadata.Metadata).where(
|
|
||||||
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
return row is not None and row.value == 'true'
|
|
||||||
|
|
||||||
async def _set_migration_flag(self, value: str):
|
|
||||||
"""Set rag_plugin_migration_needed flag."""
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.update(persistence_metadata.Metadata)
|
|
||||||
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
|
|
||||||
.values(value=value)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _table_exists(self, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists."""
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
|
||||||
).bindparams(table_name=table_name)
|
|
||||||
)
|
|
||||||
return result.scalar()
|
|
||||||
else:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
|
||||||
table_name=table_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.first() is not None
|
|
||||||
|
|
||||||
async def _install_plugin_from_marketplace(
|
|
||||||
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
|
|
||||||
) -> None:
|
|
||||||
"""Install a single plugin from the marketplace."""
|
|
||||||
p_author, p_name = plugin_id.split('/', 1)
|
|
||||||
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
|
|
||||||
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
|
|
||||||
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
|
|
||||||
resp.raise_for_status()
|
|
||||||
p_data = resp.json().get('data', {}).get('plugin', {})
|
|
||||||
p_version = p_data.get('latest_version')
|
|
||||||
if not p_version:
|
|
||||||
raise Exception(f'Could not determine latest version for {plugin_id}')
|
|
||||||
|
|
||||||
await self.ap.plugin_connector.install_plugin(
|
|
||||||
PluginInstallSource.MARKETPLACE,
|
|
||||||
{
|
|
||||||
'plugin_author': p_author,
|
|
||||||
'plugin_name': p_name,
|
|
||||||
'plugin_version': p_version,
|
|
||||||
},
|
|
||||||
task_context=task_context,
|
|
||||||
)
|
|
||||||
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
|
|
||||||
|
|
||||||
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
|
|
||||||
"""Execute RAG migration: install required plugins and restore backup data."""
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
|
|
||||||
needed_plugins: dict[str, str] = {
|
|
||||||
LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,
|
|
||||||
}
|
|
||||||
|
|
||||||
has_external = await self._table_exists('external_knowledge_bases')
|
|
||||||
if has_external:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
|
|
||||||
)
|
|
||||||
for row in result.fetchall():
|
|
||||||
plugin_author = row[0] or ''
|
|
||||||
plugin_name = row[1] or ''
|
|
||||||
mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
|
||||||
plugin_id = f'{plugin_author}/{mapped_name}'
|
|
||||||
if plugin_id not in needed_plugins:
|
|
||||||
needed_plugins[plugin_id] = mapped_name
|
|
||||||
|
|
||||||
self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')
|
|
||||||
|
|
||||||
if install_plugin:
|
|
||||||
# Step 1: Install all required plugins from marketplace
|
|
||||||
task_context.trace('Installing required plugins...', action='install-plugin')
|
|
||||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')
|
|
||||||
|
|
||||||
for plugin_id in needed_plugins:
|
|
||||||
try:
|
|
||||||
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
|
|
||||||
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
|
|
||||||
|
|
||||||
# Step 2: Wait for all plugins to become available as knowledge engines
|
|
||||||
task_context.trace(
|
|
||||||
f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',
|
|
||||||
action='wait-plugin',
|
|
||||||
)
|
|
||||||
max_retries = 30
|
|
||||||
engine_id_set: set[str] = set()
|
|
||||||
for i in range(max_retries):
|
|
||||||
try:
|
|
||||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
|
||||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if all(pid in engine_id_set for pid in needed_plugins):
|
|
||||||
self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')
|
|
||||||
task_context.trace('All required plugins are ready.')
|
|
||||||
break
|
|
||||||
if i == max_retries - 1:
|
|
||||||
still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]
|
|
||||||
warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'
|
|
||||||
self.ap.logger.warning(f'RAG migration: {warning}')
|
|
||||||
warnings.append(warning)
|
|
||||||
task_context.trace(warning)
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
|
||||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
|
||||||
except Exception:
|
|
||||||
engine_id_set = set()
|
|
||||||
|
|
||||||
# Step 3: Restore internal knowledge bases from backup
|
|
||||||
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
|
|
||||||
if await self._table_exists('knowledge_bases_backup'):
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
columns = result.keys()
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
row_dict = dict(zip(columns, row))
|
|
||||||
kb_uuid = row_dict.get('uuid')
|
|
||||||
name = row_dict.get('name', 'Untitled')
|
|
||||||
description = row_dict.get('description', '')
|
|
||||||
emoji = row_dict.get('emoji', '\U0001f4da')
|
|
||||||
embedding_model_uuid = row_dict.get('embedding_model_uuid', '')
|
|
||||||
top_k = row_dict.get('top_k', 5)
|
|
||||||
created_at = row_dict.get('created_at')
|
|
||||||
updated_at = row_dict.get('updated_at')
|
|
||||||
|
|
||||||
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
|
|
||||||
retrieval_settings = json.dumps({'top_k': top_k})
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'INSERT INTO knowledge_bases '
|
|
||||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
|
||||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
|
||||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
|
||||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
|
||||||
).bindparams(
|
|
||||||
uuid=kb_uuid,
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
emoji=emoji,
|
|
||||||
created_at=created_at,
|
|
||||||
updated_at=updated_at,
|
|
||||||
plugin_id=LANGRAG_PLUGIN_ID,
|
|
||||||
collection_id=kb_uuid,
|
|
||||||
creation_settings=creation_settings,
|
|
||||||
retrieval_settings=retrieval_settings,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = {'embedding_model_uuid': embedding_model_uuid}
|
|
||||||
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
|
|
||||||
task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')
|
|
||||||
except Exception as e:
|
|
||||||
warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'
|
|
||||||
warnings.append(warning)
|
|
||||||
task_context.trace(warning)
|
|
||||||
|
|
||||||
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
|
||||||
|
|
||||||
# Step 4: Restore external knowledge bases
|
|
||||||
task_context.trace('Restoring external knowledge bases...', action='restore-external')
|
|
||||||
if has_external:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
columns = result.keys()
|
|
||||||
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'
|
|
||||||
)
|
|
||||||
task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
row_dict = dict(zip(columns, row))
|
|
||||||
kb_uuid = row_dict.get('uuid')
|
|
||||||
name = row_dict.get('name', 'Untitled')
|
|
||||||
description = row_dict.get('description', '')
|
|
||||||
emoji = row_dict.get('emoji', '\U0001f517')
|
|
||||||
plugin_author = row_dict.get('plugin_author', '')
|
|
||||||
plugin_name = row_dict.get('plugin_name', '')
|
|
||||||
retriever_config = row_dict.get('retriever_config', {})
|
|
||||||
created_at = row_dict.get('created_at')
|
|
||||||
|
|
||||||
mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
|
||||||
external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'
|
|
||||||
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'RAG migration: processing external KB "{name}" ({kb_uuid}), '
|
|
||||||
f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(retriever_config, str):
|
|
||||||
try:
|
|
||||||
retriever_config = json.loads(retriever_config)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
retriever_config = {}
|
|
||||||
|
|
||||||
creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)
|
|
||||||
if creation_fields is None:
|
|
||||||
creation_settings_dict = retriever_config
|
|
||||||
retrieval_settings_dict = {}
|
|
||||||
else:
|
|
||||||
creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}
|
|
||||||
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'INSERT INTO knowledge_bases '
|
|
||||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
|
||||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
|
||||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
|
||||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
|
||||||
).bindparams(
|
|
||||||
uuid=kb_uuid,
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
emoji=emoji,
|
|
||||||
created_at=created_at,
|
|
||||||
updated_at=created_at,
|
|
||||||
plugin_id=external_plugin_id,
|
|
||||||
collection_id=kb_uuid,
|
|
||||||
creation_settings=json.dumps(creation_settings_dict),
|
|
||||||
retrieval_settings=json.dumps(retrieval_settings_dict),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if external_plugin_id not in engine_id_set:
|
|
||||||
warning = (
|
|
||||||
f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} '
|
|
||||||
f'is not installed yet. Install the connector plugin to use it.'
|
|
||||||
)
|
|
||||||
warnings.append(warning)
|
|
||||||
task_context.trace(warning)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await self.ap.plugin_connector.rag_on_kb_create(
|
|
||||||
external_plugin_id, kb_uuid, creation_settings_dict
|
|
||||||
)
|
|
||||||
task_context.trace(f'Restored external KB: {name} ({kb_uuid})')
|
|
||||||
except Exception as e:
|
|
||||||
warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'
|
|
||||||
warnings.append(warning)
|
|
||||||
task_context.trace(warning)
|
|
||||||
|
|
||||||
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
|
||||||
|
|
||||||
# Step 5: Clear migration flag
|
|
||||||
await self._set_migration_flag('false')
|
|
||||||
task_context.trace('RAG migration completed.', action='done')
|
|
||||||
|
|
||||||
if warnings:
|
|
||||||
task_context.trace(f'Completed with {len(warnings)} warning(s).')
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
needed = await self._get_migration_flag()
|
|
||||||
|
|
||||||
internal_kb_count = 0
|
|
||||||
external_kb_count = 0
|
|
||||||
|
|
||||||
if needed:
|
|
||||||
if await self._table_exists('knowledge_bases_backup'):
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
|
|
||||||
)
|
|
||||||
internal_kb_count = result.scalar() or 0
|
|
||||||
|
|
||||||
if await self._table_exists('external_knowledge_bases'):
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
|
||||||
)
|
|
||||||
external_kb_count = result.scalar() or 0
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'needed': needed,
|
|
||||||
'internal_kb_count': internal_kb_count,
|
|
||||||
'external_kb_count': external_kb_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
needed = await self._get_migration_flag()
|
|
||||||
if not needed:
|
|
||||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
|
||||||
|
|
||||||
data = await quart.request.get_json(silent=True) or {}
|
|
||||||
install_plugin = data.get('install_plugin', True)
|
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
|
||||||
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
|
|
||||||
kind='rag-migration',
|
|
||||||
name='rag-migration-execute',
|
|
||||||
label='Migrating knowledge bases to plugin architecture',
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(data={'task_id': wrapper.id})
|
|
||||||
|
|
||||||
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
needed = await self._get_migration_flag()
|
|
||||||
if not needed:
|
|
||||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
|
||||||
|
|
||||||
await self._set_migration_flag('false')
|
|
||||||
return self.success()
|
|
||||||
@@ -456,31 +456,6 @@ 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)
|
||||||
|
|
||||||
@@ -511,63 +486,3 @@ 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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -265,8 +265,6 @@ 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,
|
||||||
@@ -297,17 +295,12 @@ 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 {plugin_author}/{plugin_name}',
|
label=f'Installing plugin from marketplace ...{data}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -330,13 +323,11 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('tools', '/api/v1/tools')
|
|
||||||
class ToolsRouterGroup(group.RouterGroup):
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
"""获取所有可用工具列表"""
|
|
||||||
tools = await self.ap.tool_mgr.get_all_tools()
|
|
||||||
|
|
||||||
tool_list = []
|
|
||||||
for tool in tools:
|
|
||||||
tool_list.append(
|
|
||||||
{
|
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(data={'tools': tool_list})
|
|
||||||
|
|
||||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _(tool_name: str) -> str:
|
|
||||||
"""获取特定工具详情"""
|
|
||||||
tools = await self.ap.tool_mgr.get_all_tools()
|
|
||||||
|
|
||||||
for tool in tools:
|
|
||||||
if tool.name == tool_name:
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'tool': {
|
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
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')
|
||||||
@@ -13,24 +9,6 @@ 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,
|
||||||
@@ -49,83 +27,17 @@ 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, task_kind))
|
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
||||||
|
|
||||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@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:
|
||||||
|
|||||||
@@ -105,29 +105,6 @@ 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,17 +70,12 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -105,16 +105,11 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
||||||
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
pipeline_config = pipeline.config
|
||||||
if not model_config.get('primary', ''):
|
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
||||||
pipeline_config = pipeline.config
|
pipeline_data = {'config': pipeline_config}
|
||||||
pipeline_config['ai']['local-agent']['model'] = {
|
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||||
'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']
|
||||||
|
|
||||||
|
|||||||
@@ -16,57 +16,6 @@ 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) -> dict[str, int]:
|
|
||||||
"""Delete monitoring records older than the specified retention period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
retention_days: Number of days to retain records.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A dict mapping table name to the number of deleted rows.
|
|
||||||
"""
|
|
||||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
|
||||||
days=retention_days
|
|
||||||
)
|
|
||||||
|
|
||||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
|
||||||
(
|
|
||||||
'monitoring_messages',
|
|
||||||
persistence_monitoring.MonitoringMessage,
|
|
||||||
persistence_monitoring.MonitoringMessage.timestamp,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'monitoring_llm_calls',
|
|
||||||
persistence_monitoring.MonitoringLLMCall,
|
|
||||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'monitoring_embedding_calls',
|
|
||||||
persistence_monitoring.MonitoringEmbeddingCall,
|
|
||||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'monitoring_errors',
|
|
||||||
persistence_monitoring.MonitoringError,
|
|
||||||
persistence_monitoring.MonitoringError.timestamp,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'monitoring_sessions',
|
|
||||||
persistence_monitoring.MonitoringSession,
|
|
||||||
persistence_monitoring.MonitoringSession.last_activity,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
deleted_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
for table_name, model_cls, ts_column in tables_and_columns:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
|
||||||
deleted_counts[table_name] = result.rowcount
|
|
||||||
|
|
||||||
return deleted_counts
|
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -81,7 +30,6 @@ class MonitoringService:
|
|||||||
level: str = 'info',
|
level: str = 'info',
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
user_name: str | None = None,
|
|
||||||
runner_name: str | None = None,
|
runner_name: str | None = None,
|
||||||
variables: str | None = None,
|
variables: str | None = None,
|
||||||
role: str = 'user',
|
role: str = 'user',
|
||||||
@@ -101,7 +49,6 @@ class MonitoringService:
|
|||||||
'level': level,
|
'level': level,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
'user_name': user_name,
|
|
||||||
'runner_name': runner_name,
|
'runner_name': runner_name,
|
||||||
'variables': variables,
|
'variables': variables,
|
||||||
'role': role,
|
'role': role,
|
||||||
@@ -205,7 +152,6 @@ class MonitoringService:
|
|||||||
pipeline_name: str,
|
pipeline_name: str,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
user_name: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record a new session"""
|
"""Record a new session"""
|
||||||
session_data = {
|
session_data = {
|
||||||
@@ -220,7 +166,6 @@ class MonitoringService:
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
'user_name': user_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -1183,314 +1128,3 @@ 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
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this user has a local password set
|
# Check if this is a Space account
|
||||||
if not user_obj.password:
|
if user_obj.account_type == 'space':
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,8 +108,9 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
if not user_obj.password:
|
# Space accounts cannot change password locally
|
||||||
raise ValueError('No local password set, please set a password first')
|
if user_obj.account_type == 'space':
|
||||||
|
raise ValueError('Space account cannot change password locally')
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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
|
||||||
@@ -31,7 +30,6 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import 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 ..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
|
||||||
@@ -188,34 +186,6 @@ 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 = auto_cleanup_cfg.get('retention_days', 30)
|
|
||||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
|
|||||||
@@ -74,30 +74,20 @@ 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):
|
if not isinstance(current, dict) or key not in current:
|
||||||
break
|
break
|
||||||
|
|
||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key - check if it's a scalar value
|
||||||
if key in current:
|
if isinstance(current[key], (dict, list)):
|
||||||
if isinstance(current[key], list):
|
# Skip dict and list types
|
||||||
# Convert comma-separated string to list
|
pass
|
||||||
# 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:
|
||||||
# Key doesn't exist yet - create it as string
|
# Valid scalar value - convert and set it
|
||||||
current[key] = env_value
|
converted_value = convert_value(env_value, current[key])
|
||||||
|
current[key] = converted_value
|
||||||
else:
|
else:
|
||||||
# Navigate deeper - create intermediate dict if needed
|
# Navigate deeper
|
||||||
if key not in current:
|
|
||||||
current[key] = {}
|
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
@@ -156,50 +146,16 @@ 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
|
||||||
# Priority:
|
ap.instance_id = await config.load_json_config(
|
||||||
# 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
|
'data/labels/instance_id.json',
|
||||||
# 2. data/labels/instance_id.json (if file exists)
|
template_data={
|
||||||
# 3. Generate new and save to file
|
'instance_id': f'instance_{str(uuid.uuid4())}',
|
||||||
config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
|
'instance_create_ts': int(time.time()),
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
|
||||||
if config_instance_id:
|
constants.instance_id = ap.instance_id.data['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}')
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ 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'
|
||||||
@@ -42,7 +38,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, 'metadata': self.metadata}
|
return {'current_action': self.current_action, 'log': self.log}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new() -> TaskContext:
|
def new() -> TaskContext:
|
||||||
@@ -215,14 +211,9 @@ 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': [
|
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
||||||
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,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,23 +17,11 @@ 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 = {}
|
||||||
@@ -41,16 +29,8 @@ 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,7 +16,6 @@ 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,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class MonitoringMessage(Base):
|
|||||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
|
||||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||||
@@ -65,7 +64,6 @@ class MonitoringSession(Base):
|
|||||||
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
|
||||||
|
|
||||||
|
|
||||||
class MonitoringError(Base):
|
class MonitoringError(Base):
|
||||||
@@ -106,26 +104,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Alembic environment for LangBot.
|
|
||||||
|
|
||||||
This env.py is designed to be called programmatically (not via CLI).
|
|
||||||
It supports both SQLite and PostgreSQL.
|
|
||||||
|
|
||||||
The sync connection is passed via config attributes by the runner.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
|
||||||
url = context.config.get_main_option('sqlalchemy.url')
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={'paramstyle': 'named'},
|
|
||||||
)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations with a live sync connection passed via config attributes."""
|
|
||||||
connection: Connection = context.config.attributes.get('connection')
|
|
||||||
if connection is None:
|
|
||||||
raise RuntimeError('connection not provided in alembic config attributes')
|
|
||||||
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
|
||||||
render_as_batch=True,
|
|
||||||
)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Alembic script.py.mako — template for auto-generated revisions
|
|
||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""baseline: stamp existing schema (db version 25)
|
|
||||||
|
|
||||||
This is a no-op migration that marks the starting point for Alembic.
|
|
||||||
All tables already exist via create_all() + legacy DBMigration system.
|
|
||||||
|
|
||||||
Revision ID: 0001_baseline
|
|
||||||
Revises: None
|
|
||||||
Create Date: 2026-04-08
|
|
||||||
"""
|
|
||||||
|
|
||||||
revision = '0001_baseline'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# No-op: existing schema is already at database_version=25
|
|
||||||
# This revision serves as the Alembic baseline.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""example: sample migration demonstrating Alembic patterns
|
|
||||||
|
|
||||||
This is a SAMPLE showing how to write migrations that work
|
|
||||||
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
|
||||||
|
|
||||||
Revision ID: 0002_sample
|
|
||||||
Revises: 0001_baseline
|
|
||||||
Create Date: 2026-04-08
|
|
||||||
|
|
||||||
Patterns demonstrated:
|
|
||||||
1. Schema change (add column) — works on both DBs via render_as_batch
|
|
||||||
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
|
||||||
"""
|
|
||||||
|
|
||||||
revision = '0002_sample'
|
|
||||||
down_revision = '0001_baseline'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""
|
|
||||||
EXAMPLE: Uncomment to use. This shows the patterns.
|
|
||||||
|
|
||||||
# --- Pattern 1: Schema change (add/drop column) ---
|
|
||||||
# render_as_batch=True in env.py makes this work on SQLite too.
|
|
||||||
#
|
|
||||||
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
|
||||||
|
|
||||||
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
|
||||||
# No if/else for sqlite vs postgres needed!
|
|
||||||
#
|
|
||||||
# conn = op.get_bind()
|
|
||||||
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
|
||||||
# for row in rows:
|
|
||||||
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
|
||||||
# # Modify the config
|
|
||||||
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
|
||||||
# conn.execute(
|
|
||||||
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
|
||||||
# {"cfg": json.dumps(config), "uuid": row[0]}
|
|
||||||
# )
|
|
||||||
|
|
||||||
# --- Pattern 3: Create a new table ---
|
|
||||||
#
|
|
||||||
# op.create_table(
|
|
||||||
# 'audit_log',
|
|
||||||
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
|
||||||
# sa.Column('action', sa.String(255), nullable=False),
|
|
||||||
# sa.Column('detail', sa.Text),
|
|
||||||
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
|
||||||
# )
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""
|
|
||||||
# op.drop_column('pipelines', 'description')
|
|
||||||
# op.drop_table('audit_log')
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Programmatic Alembic runner for LangBot.
|
|
||||||
|
|
||||||
Usage from async code:
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
|
|
||||||
await run_alembic_upgrade(async_engine)
|
|
||||||
|
|
||||||
CLI usage (autogenerate):
|
|
||||||
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
|
|
||||||
python -m langbot.pkg.persistence.alembic_runner upgrade
|
|
||||||
python -m langbot.pkg.persistence.alembic_runner current
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from alembic.config import Config
|
|
||||||
from alembic import command
|
|
||||||
from alembic.runtime.migration import MigrationContext
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
|
|
||||||
|
|
||||||
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
|
|
||||||
|
|
||||||
|
|
||||||
def _build_config(connection: Connection) -> Config:
|
|
||||||
"""Build an Alembic Config with sync connection attached."""
|
|
||||||
cfg = Config()
|
|
||||||
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
|
||||||
cfg.attributes['connection'] = connection
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
|
|
||||||
"""Synchronous upgrade — runs inside run_sync."""
|
|
||||||
cfg = _build_config(connection)
|
|
||||||
command.upgrade(cfg, revision)
|
|
||||||
|
|
||||||
|
|
||||||
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
|
|
||||||
"""Synchronous stamp — runs inside run_sync."""
|
|
||||||
cfg = _build_config(connection)
|
|
||||||
command.stamp(cfg, revision)
|
|
||||||
|
|
||||||
|
|
||||||
def _do_get_current(connection: Connection) -> str | None:
|
|
||||||
"""Get current alembic revision synchronously."""
|
|
||||||
ctx = MigrationContext.configure(connection)
|
|
||||||
return ctx.get_current_revision()
|
|
||||||
|
|
||||||
|
|
||||||
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
|
||||||
"""Synchronous autogenerate — runs inside run_sync."""
|
|
||||||
cfg = _build_config(connection)
|
|
||||||
command.revision(cfg, message=message, autogenerate=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
|
||||||
"""Run Alembic upgrade to the given revision."""
|
|
||||||
async with async_engine.connect() as conn:
|
|
||||||
await conn.run_sync(_do_upgrade, revision)
|
|
||||||
await conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
|
||||||
"""Stamp the database with a revision without running migrations."""
|
|
||||||
async with async_engine.connect() as conn:
|
|
||||||
await conn.run_sync(_do_stamp, revision)
|
|
||||||
await conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
|
|
||||||
"""Get current alembic revision, or None if not stamped."""
|
|
||||||
async with async_engine.connect() as conn:
|
|
||||||
return await conn.run_sync(_do_get_current)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
|
|
||||||
"""Compare ORM models against DB schema and generate a migration script."""
|
|
||||||
async with async_engine.connect() as conn:
|
|
||||||
await conn.run_sync(_do_autogenerate, message)
|
|
||||||
|
|
||||||
|
|
||||||
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
def _get_engine():
|
|
||||||
"""Create engine from data/config.yaml or default SQLite."""
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
try:
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
with open('data/config.yaml') as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
db_cfg = config.get('database', {})
|
|
||||||
db_type = db_cfg.get('use', 'sqlite')
|
|
||||||
if db_type == 'postgresql':
|
|
||||||
pg = db_cfg.get('postgresql', {})
|
|
||||||
url = (
|
|
||||||
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
|
|
||||||
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
|
|
||||||
url = f'sqlite+aiosqlite:///{path}'
|
|
||||||
except Exception:
|
|
||||||
url = 'sqlite+aiosqlite:///data/langbot.db'
|
|
||||||
|
|
||||||
return create_async_engine(url)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
|
|
||||||
print('Commands:')
|
|
||||||
print(' autogenerate "message" — Generate migration from ORM model diff')
|
|
||||||
print(' upgrade [revision] — Upgrade database (default: head)')
|
|
||||||
print(' stamp [revision] — Stamp revision without running (default: head)')
|
|
||||||
print(' current — Show current revision')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
cmd = sys.argv[1]
|
|
||||||
engine = _get_engine()
|
|
||||||
|
|
||||||
if cmd == 'autogenerate':
|
|
||||||
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
|
|
||||||
asyncio.run(run_alembic_autogenerate(engine, msg))
|
|
||||||
print(f'Migration generated: {msg}')
|
|
||||||
elif cmd == 'upgrade':
|
|
||||||
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
|
||||||
asyncio.run(run_alembic_upgrade(engine, rev))
|
|
||||||
print(f'Upgraded to: {rev}')
|
|
||||||
elif cmd == 'stamp':
|
|
||||||
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
|
||||||
asyncio.run(run_alembic_stamp(engine, rev))
|
|
||||||
print(f'Stamped: {rev}')
|
|
||||||
elif cmd == 'current':
|
|
||||||
rev = asyncio.run(get_alembic_current(engine))
|
|
||||||
print(f'Current revision: {rev}')
|
|
||||||
else:
|
|
||||||
print(f'Unknown command: {cmd}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -2,16 +2,18 @@ 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, metadata, model as persistence_model
|
from ..entity.persistence import base, pipeline, 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)
|
||||||
@@ -76,9 +78,7 @@ 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}.')
|
||||||
|
|
||||||
# Run Alembic migrations (new migration system)
|
await self.write_default_pipeline()
|
||||||
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,6 +101,29 @@ 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'
|
||||||
@@ -138,28 +161,6 @@ class PersistenceManager:
|
|||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
|
|
||||||
async def _run_alembic_migrations(self):
|
|
||||||
"""Run Alembic-based migrations after legacy migrations complete."""
|
|
||||||
from . import alembic_runner
|
|
||||||
|
|
||||||
engine = self.get_db_engine()
|
|
||||||
|
|
||||||
try:
|
|
||||||
current_rev = await alembic_runner.get_alembic_current(engine)
|
|
||||||
|
|
||||||
if current_rev is None:
|
|
||||||
# First time: stamp baseline so Alembic knows existing schema is up-to-date
|
|
||||||
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
|
|
||||||
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
|
|
||||||
current_rev = '0001_baseline'
|
|
||||||
|
|
||||||
# Upgrade to head
|
|
||||||
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
|
||||||
self.ap.logger.info('Alembic migrations completed.')
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
async 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)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from .. import migration
|
from .. import migration
|
||||||
|
|
||||||
@@ -7,22 +9,20 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
"""Migrate to unified Knowledge Engine plugin architecture.
|
"""Migrate to unified Knowledge Engine plugin architecture.
|
||||||
|
|
||||||
Changes:
|
Changes:
|
||||||
- Backup existing knowledge_bases data to knowledge_bases_backup
|
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases
|
||||||
- Clear knowledge_bases table and add new plugin architecture columns
|
- Migrate existing top_k values into retrieval_settings JSON
|
||||||
- Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
|
- Migrate existing embedding_model_uuid into creation_settings JSON
|
||||||
- Preserve external_knowledge_bases table as-is for future migration
|
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped)
|
||||||
- Set rag_plugin_migration_needed flag in metadata if old data exists
|
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def upgrade(self):
|
async def upgrade(self):
|
||||||
"""Upgrade"""
|
"""Upgrade"""
|
||||||
has_internal_data = await self._backup_knowledge_bases()
|
|
||||||
has_external_data = await self._check_external_knowledge_bases()
|
|
||||||
await self._clear_knowledge_bases()
|
|
||||||
await self._add_columns_to_knowledge_bases()
|
await self._add_columns_to_knowledge_bases()
|
||||||
|
await self._migrate_top_k_to_retrieval_settings()
|
||||||
|
await self._migrate_embedding_model_uuid_to_creation_settings()
|
||||||
await self._drop_old_columns()
|
await self._drop_old_columns()
|
||||||
if has_internal_data or has_external_data:
|
await self._drop_external_knowledge_bases_table()
|
||||||
await self._set_migration_flag()
|
|
||||||
|
|
||||||
async def _get_table_columns(self, table_name: str) -> list[str]:
|
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||||
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
||||||
@@ -57,50 +57,6 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
)
|
)
|
||||||
return result.first() is not None
|
return result.first() is not None
|
||||||
|
|
||||||
async def _backup_knowledge_bases(self) -> bool:
|
|
||||||
"""Backup knowledge_bases data. Returns True if data was backed up."""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))
|
|
||||||
count = result.scalar()
|
|
||||||
if count == 0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Drop backup table if it already exists (from a previous failed migration)
|
|
||||||
if await self._table_exists('knowledge_bases_backup'):
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')
|
|
||||||
)
|
|
||||||
self.ap.logger.info(
|
|
||||||
'Backed up %d knowledge base(s) to knowledge_bases_backup table.',
|
|
||||||
count,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _check_external_knowledge_bases(self) -> bool:
|
|
||||||
"""Check if external_knowledge_bases table exists and has data.
|
|
||||||
|
|
||||||
The table is preserved as-is (not dropped) for future migration.
|
|
||||||
"""
|
|
||||||
if not await self._table_exists('external_knowledge_bases'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
|
||||||
)
|
|
||||||
count = result.scalar()
|
|
||||||
if count > 0:
|
|
||||||
self.ap.logger.info(
|
|
||||||
'Found %d external knowledge base(s) in external_knowledge_bases table. '
|
|
||||||
'Table preserved for future migration.',
|
|
||||||
count,
|
|
||||||
)
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def _clear_knowledge_bases(self):
|
|
||||||
"""Clear all rows from knowledge_bases table (preserve table structure)."""
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))
|
|
||||||
|
|
||||||
async def _add_columns_to_knowledge_bases(self):
|
async def _add_columns_to_knowledge_bases(self):
|
||||||
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
||||||
columns = await self._get_table_columns('knowledge_bases')
|
columns = await self._get_table_columns('knowledge_bases')
|
||||||
@@ -118,6 +74,73 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
|
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For existing knowledge bases without knowledge_engine_plugin_id,
|
||||||
|
# set collection_id = uuid (same default as new KBs)
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;')
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _migrate_top_k_to_retrieval_settings(self):
|
||||||
|
"""Migrate existing top_k values into retrieval_settings JSON."""
|
||||||
|
columns = await self._get_table_columns('knowledge_bases')
|
||||||
|
if 'top_k' not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
kb_uuid = row[0]
|
||||||
|
top_k = row[1]
|
||||||
|
retrieval_settings = json.dumps({'top_k': top_k})
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams(
|
||||||
|
rs=retrieval_settings, uuid=kb_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _migrate_embedding_model_uuid_to_creation_settings(self):
|
||||||
|
"""Migrate existing embedding_model_uuid into creation_settings JSON."""
|
||||||
|
columns = await self._get_table_columns('knowledge_bases')
|
||||||
|
if 'embedding_model_uuid' not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases '
|
||||||
|
"WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
kb_uuid = row[0]
|
||||||
|
emb_uuid = row[1]
|
||||||
|
existing_settings = row[2]
|
||||||
|
|
||||||
|
if existing_settings and isinstance(existing_settings, str):
|
||||||
|
try:
|
||||||
|
settings = json.loads(existing_settings)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
settings = {}
|
||||||
|
elif isinstance(existing_settings, dict):
|
||||||
|
settings = existing_settings
|
||||||
|
else:
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
if 'embedding_model_uuid' not in settings:
|
||||||
|
settings['embedding_model_uuid'] = emb_uuid
|
||||||
|
new_settings = json.dumps(settings)
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;'
|
||||||
|
).bindparams(cs=new_settings, uuid=kb_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
async def _drop_old_columns(self):
|
async def _drop_old_columns(self):
|
||||||
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
||||||
|
|
||||||
@@ -139,22 +162,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _set_migration_flag(self):
|
async def _drop_external_knowledge_bases_table(self):
|
||||||
"""Set rag_plugin_migration_needed flag in metadata table."""
|
"""Drop the external_knowledge_bases table if it exists."""
|
||||||
# Check if the key already exists
|
if await self._table_exists('external_knowledge_bases'):
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
# Log existing external KBs before dropping, so users are aware of data loss
|
||||||
sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
|
rows = await self.ap.persistence_mgr.execute_async(
|
||||||
)
|
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
||||||
row = result.first()
|
|
||||||
if row is not None:
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';")
|
|
||||||
)
|
)
|
||||||
else:
|
existing = rows.fetchall()
|
||||||
await self.ap.persistence_mgr.execute_async(
|
if existing:
|
||||||
sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
|
self.ap.logger.warning(
|
||||||
)
|
'Dropping external_knowledge_bases table with %d existing record(s). '
|
||||||
self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
|
'These external KB configurations will be removed: %s',
|
||||||
|
len(existing),
|
||||||
|
[dict(row._mapping) for row in existing],
|
||||||
|
)
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;'))
|
||||||
|
|
||||||
async def downgrade(self):
|
async def downgrade(self):
|
||||||
"""Downgrade"""
|
"""Downgrade"""
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
from .. import migration
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(21)
|
|
||||||
class DBMigrateMergeExceptionHandling(migration.DBMigration):
|
|
||||||
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
|
|
||||||
and add failure-hint field.
|
|
||||||
|
|
||||||
Conversion logic:
|
|
||||||
- block-failed-request-output=true -> exception-handling: hide
|
|
||||||
- hide-exception=true -> exception-handling: show-hint
|
|
||||||
- hide-exception=false -> exception-handling: show-error
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
"""Upgrade"""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
|
||||||
)
|
|
||||||
pipelines = result.fetchall()
|
|
||||||
|
|
||||||
current_version = self.ap.ver_mgr.get_current_version()
|
|
||||||
|
|
||||||
for pipeline_row in pipelines:
|
|
||||||
uuid = pipeline_row[0]
|
|
||||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
|
||||||
|
|
||||||
if 'output' not in config:
|
|
||||||
config['output'] = {}
|
|
||||||
if 'misc' not in config['output']:
|
|
||||||
config['output']['misc'] = {}
|
|
||||||
|
|
||||||
misc = config['output']['misc']
|
|
||||||
|
|
||||||
# Determine new exception-handling value from legacy fields
|
|
||||||
hide_exception = misc.get('hide-exception', True)
|
|
||||||
block_failed = misc.get('block-failed-request-output', False)
|
|
||||||
|
|
||||||
if block_failed:
|
|
||||||
exception_handling = 'hide'
|
|
||||||
elif hide_exception:
|
|
||||||
exception_handling = 'show-hint'
|
|
||||||
else:
|
|
||||||
exception_handling = 'show-error'
|
|
||||||
|
|
||||||
misc['exception-handling'] = exception_handling
|
|
||||||
|
|
||||||
# Add failure-hint with default value
|
|
||||||
misc['failure-hint'] = 'Request failed.'
|
|
||||||
|
|
||||||
# Remove legacy fields
|
|
||||||
misc.pop('hide-exception', None)
|
|
||||||
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
"""Downgrade"""
|
|
||||||
pass
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import sqlalchemy
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(22)
|
|
||||||
class DBMigrateMonitoringUserId(migration.DBMigration):
|
|
||||||
"""Add user_id and user_name columns to monitoring_sessions table
|
|
||||||
|
|
||||||
This migration adds the missing user_id column and also ensures user_name
|
|
||||||
column exists (in case migration 21 failed or was skipped).
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def _table_exists(self, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
|
||||||
).bindparams(table_name=table_name)
|
|
||||||
)
|
|
||||||
return bool(result.scalar())
|
|
||||||
else:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
|
||||||
table_name=table_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.first() is not None
|
|
||||||
|
|
||||||
async def _get_table_columns(self, table_name: str) -> list[str]:
|
|
||||||
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'
|
|
||||||
).bindparams(table_name=table_name)
|
|
||||||
)
|
|
||||||
return [row[0] for row in result.fetchall()]
|
|
||||||
else:
|
|
||||||
if not table_name.isidentifier():
|
|
||||||
raise ValueError(f'Invalid table name: {table_name}')
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
|
||||||
return [row[1] for row in result.fetchall()]
|
|
||||||
|
|
||||||
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
|
|
||||||
"""Add a column to a table if it does not already exist."""
|
|
||||||
columns = await self._get_table_columns(table_name)
|
|
||||||
if column_name in columns:
|
|
||||||
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
|
|
||||||
return
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
|
|
||||||
)
|
|
||||||
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
# Check if monitoring_sessions table exists
|
|
||||||
if not await self._table_exists('monitoring_sessions'):
|
|
||||||
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add user_id column to monitoring_sessions table
|
|
||||||
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
|
|
||||||
|
|
||||||
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
|
|
||||||
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
|
|
||||||
|
|
||||||
# Add user_name column to monitoring_messages table (in case migration 21 failed)
|
|
||||||
if await self._table_exists('monitoring_messages'):
|
|
||||||
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
pass
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from .. import migration
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(23)
|
|
||||||
class DBMigrateModelFallbackConfig(migration.DBMigration):
|
|
||||||
"""Convert model field from plain UUID string to object with primary/fallbacks"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
"""Upgrade"""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
|
||||||
)
|
|
||||||
pipelines = result.fetchall()
|
|
||||||
|
|
||||||
current_version = self.ap.ver_mgr.get_current_version()
|
|
||||||
|
|
||||||
for pipeline_row in pipelines:
|
|
||||||
uuid = pipeline_row[0]
|
|
||||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
|
||||||
|
|
||||||
if 'ai' not in config or 'local-agent' not in config['ai']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
local_agent = config['ai']['local-agent']
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
# Convert model from string to object
|
|
||||||
model_value = local_agent.get('model', '')
|
|
||||||
if isinstance(model_value, str):
|
|
||||||
local_agent['model'] = {
|
|
||||||
'primary': model_value,
|
|
||||||
'fallbacks': [],
|
|
||||||
}
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
# Remove leftover fallback-models field if present
|
|
||||||
if 'fallback-models' in local_agent:
|
|
||||||
del local_agent['fallback-models']
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if not changed:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
"""Downgrade"""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
|
||||||
)
|
|
||||||
pipelines = result.fetchall()
|
|
||||||
|
|
||||||
current_version = self.ap.ver_mgr.get_current_version()
|
|
||||||
|
|
||||||
for pipeline_row in pipelines:
|
|
||||||
uuid = pipeline_row[0]
|
|
||||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
|
||||||
|
|
||||||
if 'ai' not in config or 'local-agent' not in config['ai']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
local_agent = config['ai']['local-agent']
|
|
||||||
|
|
||||||
# Convert model from object back to string
|
|
||||||
model_value = local_agent.get('model', '')
|
|
||||||
if isinstance(model_value, dict):
|
|
||||||
local_agent['model'] = model_value.get('primary', '')
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
|
||||||
),
|
|
||||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from .. import migration
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(24)
|
|
||||||
class DBMigrateWecomBotWebSocketMode(migration.DBMigration):
|
|
||||||
"""Add enable-webhook field to existing wecombot adapter configs.
|
|
||||||
|
|
||||||
Existing wecombot bots were all using webhook mode, so we set
|
|
||||||
enable-webhook=true to preserve their behavior after the new
|
|
||||||
WebSocket long connection mode is introduced as default.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
"""Upgrade"""
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'")
|
|
||||||
)
|
|
||||||
bots = result.fetchall()
|
|
||||||
|
|
||||||
for bot_row in bots:
|
|
||||||
bot_uuid = bot_row[0]
|
|
||||||
adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]
|
|
||||||
|
|
||||||
if 'enable-webhook' in adapter_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine mode based on existing config: if webhook fields are present, keep webhook mode
|
|
||||||
has_webhook_config = bool(
|
|
||||||
adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')
|
|
||||||
)
|
|
||||||
adapter_config['enable-webhook'] = has_webhook_config
|
|
||||||
|
|
||||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),
|
|
||||||
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),
|
|
||||||
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
"""Downgrade"""
|
|
||||||
pass
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import sqlalchemy
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(25)
|
|
||||||
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
|
||||||
"""Add pipeline_routing_rules column to bots table"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
|
||||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
|
||||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
|
||||||
@@ -37,7 +37,6 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +125,6 @@ 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
|
||||||
|
|
||||||
@@ -147,7 +145,6 @@ 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
|
||||||
|
|
||||||
@@ -162,7 +159,6 @@ 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
|
||||||
@@ -221,7 +217,6 @@ 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
|
||||||
|
|
||||||
@@ -236,7 +231,6 @@ 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:
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# metadata type -> coercion function
|
|
||||||
_COERCE_MAP = {
|
|
||||||
'integer': lambda v: int(v),
|
|
||||||
'number': lambda v: float(v),
|
|
||||||
'float': lambda v: float(v),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_bool(v):
|
|
||||||
if isinstance(v, bool):
|
|
||||||
return v
|
|
||||||
if isinstance(v, str):
|
|
||||||
if v.lower() == 'true':
|
|
||||||
return True
|
|
||||||
if v.lower() == 'false':
|
|
||||||
return False
|
|
||||||
raise ValueError(f'Cannot convert string {v!r} to bool')
|
|
||||||
return bool(v)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_value(value, expected_type: str):
|
|
||||||
"""Convert a single value to the expected type.
|
|
||||||
|
|
||||||
Returns the converted value, or the original value if no conversion needed.
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return value
|
|
||||||
|
|
||||||
if expected_type == 'boolean':
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
return _coerce_bool(value)
|
|
||||||
|
|
||||||
coerce_fn = _COERCE_MAP.get(expected_type)
|
|
||||||
if coerce_fn is None:
|
|
||||||
return value
|
|
||||||
|
|
||||||
# Already the correct type
|
|
||||||
if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
return coerce_fn(value)
|
|
||||||
|
|
||||||
|
|
||||||
def coerce_pipeline_config(
|
|
||||||
config: dict,
|
|
||||||
*metadata_list: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Coerce pipeline config values according to metadata type definitions.
|
|
||||||
|
|
||||||
Walks each metadata dict (trigger, safety, ai, output) and converts
|
|
||||||
config values in-place so that strings coming from the JSON column are
|
|
||||||
cast to their declared types (integer, number/float, boolean).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: The pipeline config dict to modify in-place.
|
|
||||||
*metadata_list: Metadata dicts loaded from the YAML templates.
|
|
||||||
"""
|
|
||||||
for meta in metadata_list:
|
|
||||||
section_name = meta.get('name')
|
|
||||||
if not section_name or section_name not in config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
section = config[section_name]
|
|
||||||
if not isinstance(section, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for stage_def in meta.get('stages', []):
|
|
||||||
stage_name = stage_def.get('name')
|
|
||||||
if not stage_name or stage_name not in section:
|
|
||||||
continue
|
|
||||||
|
|
||||||
stage_config = section[stage_name]
|
|
||||||
if not isinstance(stage_config, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for field_def in stage_def.get('config', []):
|
|
||||||
field_name = field_def.get('name')
|
|
||||||
field_type = field_def.get('type')
|
|
||||||
if not field_name or not field_type or field_name not in stage_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_value = stage_config[field_name]
|
|
||||||
try:
|
|
||||||
new_value = _coerce_value(old_value, field_type)
|
|
||||||
if new_value is not old_value:
|
|
||||||
stage_config[field_name] = new_value
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logger.warning(
|
|
||||||
'Failed to coerce config %s.%s.%s (%r) to %s: %s',
|
|
||||||
section_name,
|
|
||||||
stage_name,
|
|
||||||
field_name,
|
|
||||||
old_value,
|
|
||||||
field_type,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
@@ -63,14 +63,6 @@ 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()
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
strategy_impl: strategy.LongTextStrategy | None
|
strategy_impl: strategy.LongTextStrategy | None
|
||||||
|
is_split: bool
|
||||||
|
|
||||||
async def initialize(self, pipeline_config: dict):
|
async def initialize(self, pipeline_config: dict):
|
||||||
config = pipeline_config['output']['long-text-processing']
|
config = pipeline_config['output']['long-text-processing']
|
||||||
|
|
||||||
|
self.is_split = config['strategy'] == 'split'
|
||||||
|
|
||||||
if config['strategy'] == 'none':
|
if config['strategy'] == 'none':
|
||||||
self.strategy_impl = None
|
self.strategy_impl = None
|
||||||
return
|
return
|
||||||
@@ -90,8 +93,23 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
len(str(query.resp_message_chain[-1]))
|
len(str(query.resp_message_chain[-1]))
|
||||||
> query.pipeline_config['output']['long-text-processing']['threshold']
|
> query.pipeline_config['output']['long-text-processing']['threshold']
|
||||||
):
|
):
|
||||||
query.resp_message_chain[-1] = platform_message.MessageChain(
|
if self.is_split:
|
||||||
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
|
original_text = str(query.resp_message_chain[-1])
|
||||||
)
|
threshold = query.pipeline_config['output']['long-text-processing']['threshold']
|
||||||
|
segments = self.strategy_impl.split_text(original_text, threshold)
|
||||||
|
# Replace the last chain with the first segment, store extra segments separately
|
||||||
|
# to avoid interfering with existing multi-chain scenarios (e.g. agent tool calls)
|
||||||
|
query.resp_message_chain[-1] = platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=segments[0])]
|
||||||
|
)
|
||||||
|
if len(segments) > 1:
|
||||||
|
query.set_variable(
|
||||||
|
'_longtext_split_extra_chains',
|
||||||
|
[platform_message.MessageChain([platform_message.Plain(text=seg)]) for seg in segments[1:]],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query.resp_message_chain[-1] = platform_message.MessageChain(
|
||||||
|
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
|
||||||
|
)
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
224
src/langbot/pkg/pipeline/longtext/strategies/split.py
Normal file
224
src/langbot/pkg/pipeline/longtext/strategies/split.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .. import strategy as strategy_model
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
|
@strategy_model.strategy_class('split')
|
||||||
|
class SplitStrategy(strategy_model.LongTextStrategy):
|
||||||
|
"""Split long text into multiple message segments with Markdown awareness."""
|
||||||
|
|
||||||
|
async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:
|
||||||
|
segments = self.split_text(
|
||||||
|
message,
|
||||||
|
query.pipeline_config['output']['long-text-processing']['threshold'],
|
||||||
|
)
|
||||||
|
return [platform_message.Plain(text=segments[0])] if segments else []
|
||||||
|
|
||||||
|
def split_text(self, text: str, max_length: int) -> list[str]:
|
||||||
|
"""Split text into segments respecting Markdown structure.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Markdown structural boundaries (headings, code blocks, horizontal rules)
|
||||||
|
2. Paragraph breaks (blank lines)
|
||||||
|
3. List item boundaries
|
||||||
|
4. Line breaks
|
||||||
|
5. Hard cut (fallback)
|
||||||
|
"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
blocks = self._parse_markdown_blocks(text)
|
||||||
|
return self._merge_blocks(blocks, max_length)
|
||||||
|
|
||||||
|
def _parse_markdown_blocks(self, text: str) -> list[str]:
|
||||||
|
"""Parse text into Markdown-aware blocks.
|
||||||
|
|
||||||
|
Keeps code blocks intact and splits the rest by structural elements.
|
||||||
|
"""
|
||||||
|
blocks: list[str] = []
|
||||||
|
lines = text.split('\n')
|
||||||
|
current_block: list[str] = []
|
||||||
|
in_code_block = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Toggle fenced code block state
|
||||||
|
if stripped.startswith('```'):
|
||||||
|
if in_code_block:
|
||||||
|
# End of code block - close it as one block
|
||||||
|
current_block.append(line)
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
current_block = []
|
||||||
|
in_code_block = False
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Start of code block - flush current block first
|
||||||
|
if current_block:
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
current_block = []
|
||||||
|
current_block.append(line)
|
||||||
|
in_code_block = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_code_block:
|
||||||
|
current_block.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Heading (# ...) - start a new block
|
||||||
|
if re.match(r'^#{1,6}\s', stripped):
|
||||||
|
if current_block:
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
current_block = []
|
||||||
|
current_block.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Horizontal rule (---, ***, ___) - start a new block
|
||||||
|
if re.match(r'^(-{3,}|\*{3,}|_{3,})\s*$', stripped):
|
||||||
|
if current_block:
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
current_block = []
|
||||||
|
blocks.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Blank line - paragraph boundary
|
||||||
|
if stripped == '':
|
||||||
|
if current_block:
|
||||||
|
current_block.append(line)
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
current_block = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_block.append(line)
|
||||||
|
|
||||||
|
# Flush remaining (including unclosed code blocks)
|
||||||
|
if current_block:
|
||||||
|
blocks.append('\n'.join(current_block))
|
||||||
|
|
||||||
|
return [b for b in blocks if b.strip()]
|
||||||
|
|
||||||
|
def _merge_blocks(self, blocks: list[str], max_length: int) -> list[str]:
|
||||||
|
"""Merge small blocks greedily until approaching max_length.
|
||||||
|
|
||||||
|
If a single block exceeds max_length, split it by lines as fallback.
|
||||||
|
"""
|
||||||
|
segments: list[str] = []
|
||||||
|
current = ''
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
candidate = (current + '\n\n' + block) if current else block
|
||||||
|
|
||||||
|
if len(candidate) <= max_length:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
# Flush current segment
|
||||||
|
if current:
|
||||||
|
segments.append(current)
|
||||||
|
|
||||||
|
# Check if this single block fits
|
||||||
|
if len(block) <= max_length:
|
||||||
|
current = block
|
||||||
|
else:
|
||||||
|
# Block too large - split it by lines
|
||||||
|
for part in self._split_large_block(block, max_length):
|
||||||
|
segments.append(part)
|
||||||
|
current = ''
|
||||||
|
|
||||||
|
if current:
|
||||||
|
segments.append(current)
|
||||||
|
|
||||||
|
return [s for s in segments if s.strip()]
|
||||||
|
|
||||||
|
def _split_large_block(self, block: str, max_length: int) -> list[str]:
|
||||||
|
"""Split an oversized block by lines, preserving code block fences.
|
||||||
|
|
||||||
|
For single-line plain text (no newlines), falls back to splitting at
|
||||||
|
natural language boundaries (spaces, punctuation).
|
||||||
|
"""
|
||||||
|
lines = block.split('\n')
|
||||||
|
|
||||||
|
# Single long line with no newlines - use plain text splitting
|
||||||
|
if len(lines) == 1:
|
||||||
|
return self._split_plain_text(block, max_length)
|
||||||
|
|
||||||
|
is_code_block = lines[0].strip().startswith('```')
|
||||||
|
|
||||||
|
segments: list[str] = []
|
||||||
|
current_lines: list[str] = []
|
||||||
|
current_len = 0
|
||||||
|
|
||||||
|
# For code blocks, track the opening fence to re-apply on continuations
|
||||||
|
code_fence = lines[0] if is_code_block else ''
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line_len = len(line) + 1 # +1 for newline
|
||||||
|
|
||||||
|
# Single line exceeds limit on its own - split it first
|
||||||
|
if line_len > max_length:
|
||||||
|
if current_lines:
|
||||||
|
seg = '\n'.join(current_lines)
|
||||||
|
if is_code_block and not seg.rstrip().endswith('```'):
|
||||||
|
seg += '\n```'
|
||||||
|
segments.append(seg)
|
||||||
|
current_lines = []
|
||||||
|
current_len = 0
|
||||||
|
|
||||||
|
for part in self._split_plain_text(line, max_length):
|
||||||
|
segments.append(part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_len + line_len > max_length and current_lines:
|
||||||
|
segment = '\n'.join(current_lines)
|
||||||
|
# Close code block fence if splitting mid-code-block
|
||||||
|
if is_code_block and not segment.rstrip().endswith('```'):
|
||||||
|
segment += '\n```'
|
||||||
|
segments.append(segment)
|
||||||
|
|
||||||
|
current_lines = []
|
||||||
|
current_len = 0
|
||||||
|
# Re-open code block fence for continuation
|
||||||
|
if is_code_block and i < len(lines) - 1 and not line.strip().startswith('```'):
|
||||||
|
current_lines.append(code_fence)
|
||||||
|
current_len = len(code_fence) + 1
|
||||||
|
|
||||||
|
current_lines.append(line)
|
||||||
|
current_len += line_len
|
||||||
|
|
||||||
|
if current_lines:
|
||||||
|
segments.append('\n'.join(current_lines))
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def _split_plain_text(self, text: str, max_length: int) -> list[str]:
|
||||||
|
"""Split a long plain text string (no newlines) at word/space boundaries."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
segments: list[str] = []
|
||||||
|
remaining = text
|
||||||
|
|
||||||
|
while remaining:
|
||||||
|
if len(remaining) <= max_length:
|
||||||
|
segments.append(remaining)
|
||||||
|
break
|
||||||
|
|
||||||
|
chunk = remaining[:max_length]
|
||||||
|
min_pos = int(max_length * 0.3)
|
||||||
|
|
||||||
|
# Try to find a space to split at
|
||||||
|
pos = chunk.rfind(' ')
|
||||||
|
if pos >= min_pos:
|
||||||
|
split_pos = pos
|
||||||
|
else:
|
||||||
|
# Hard cut as last resort
|
||||||
|
split_pos = max_length
|
||||||
|
|
||||||
|
segments.append(remaining[:split_pos].rstrip())
|
||||||
|
remaining = remaining[split_pos:].lstrip()
|
||||||
|
|
||||||
|
return [s for s in segments if s]
|
||||||
@@ -34,15 +34,6 @@ class MonitoringHelper:
|
|||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
|
||||||
sender_name = None
|
|
||||||
if hasattr(query, 'message_event'):
|
|
||||||
if hasattr(query.message_event, 'sender'):
|
|
||||||
if hasattr(query.message_event.sender, 'nickname'):
|
|
||||||
sender_name = query.message_event.sender.nickname
|
|
||||||
elif hasattr(query.message_event.sender, 'member_name'):
|
|
||||||
sender_name = query.message_event.sender.member_name
|
|
||||||
|
|
||||||
# Try to record message
|
# Try to record message
|
||||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||||
@@ -66,7 +57,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
variables=None, # Will be updated in record_query_success
|
variables=None, # Will be updated in record_query_success
|
||||||
)
|
)
|
||||||
@@ -90,7 +80,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_id
|
return message_id
|
||||||
@@ -139,15 +128,6 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
|
||||||
sender_name = None
|
|
||||||
if hasattr(query, 'message_event'):
|
|
||||||
if hasattr(query.message_event, 'sender'):
|
|
||||||
if hasattr(query.message_event.sender, 'nickname'):
|
|
||||||
sender_name = query.message_event.sender.nickname
|
|
||||||
elif hasattr(query.message_event.sender, 'member_name'):
|
|
||||||
sender_name = query.message_event.sender.member_name
|
|
||||||
|
|
||||||
# Extract response content from resp_message_chain
|
# Extract response content from resp_message_chain
|
||||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||||
# Serialize the last response message chain
|
# Serialize the last response message chain
|
||||||
@@ -182,7 +162,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
)
|
)
|
||||||
@@ -204,15 +183,6 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
|
||||||
sender_name = None
|
|
||||||
if hasattr(query, 'message_event'):
|
|
||||||
if hasattr(query.message_event, 'sender'):
|
|
||||||
if hasattr(query.message_event.sender, 'nickname'):
|
|
||||||
sender_name = query.message_event.sender.nickname
|
|
||||||
elif hasattr(query.message_event.sender, 'member_name'):
|
|
||||||
sender_name = query.message_event.sender.member_name
|
|
||||||
|
|
||||||
# Record error message
|
# Record error message
|
||||||
message_id = await ap.monitoring_service.record_message(
|
message_id = await ap.monitoring_service.record_message(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
@@ -227,7 +197,6 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
user_name=sender_name,
|
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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.events as platform_events
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
from .config_coercion import coerce_pipeline_config
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -323,9 +322,6 @@ 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}')
|
||||||
@@ -424,14 +420,6 @@ class PipelineManager:
|
|||||||
elif isinstance(pipeline_entity, dict):
|
elif isinstance(pipeline_entity, dict):
|
||||||
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
||||||
|
|
||||||
coerce_pipeline_config(
|
|
||||||
pipeline_entity.config,
|
|
||||||
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
|
|
||||||
getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
|
|
||||||
getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
|
|
||||||
getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# initialize stage containers according to pipeline_entity.stages
|
# initialize stage containers according to pipeline_entity.stages
|
||||||
stage_containers: list[StageInstContainer] = []
|
stage_containers: list[StageInstContainer] = []
|
||||||
for stage_name in pipeline_entity.stages:
|
for stage_name in pipeline_entity.stages:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ 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
|
||||||
@@ -53,7 +52,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={'_routed_by_rule': routed_by_rule},
|
variables={},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -36,36 +36,17 @@ 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
|
||||||
llm_model = None
|
try:
|
||||||
if selected_runner == 'local-agent':
|
llm_model = (
|
||||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
||||||
# but handle legacy plain string for backward compatibility
|
if selected_runner == 'local-agent'
|
||||||
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
else None
|
||||||
if isinstance(model_config, str):
|
)
|
||||||
# Legacy format: plain UUID string
|
except ValueError:
|
||||||
primary_uuid = model_config
|
self.ap.logger.warning(
|
||||||
fallback_uuids = []
|
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
||||||
else:
|
)
|
||||||
primary_uuid = model_config.get('primary', '')
|
llm_model = None
|
||||||
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,
|
||||||
@@ -80,28 +61,20 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
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':
|
if selected_runner == 'local-agent' and llm_model:
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
if llm_model:
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
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):
|
||||||
@@ -160,6 +133,7 @@ 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:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
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:
|
||||||
@@ -171,29 +145,10 @@ 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):
|
|
||||||
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,9 +61,6 @@ 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:
|
||||||
@@ -152,19 +149,12 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||||
|
|
||||||
if exception_handling == 'show-error':
|
|
||||||
user_notice = f'{e}'
|
|
||||||
elif exception_handling == 'show-hint':
|
|
||||||
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
|
||||||
else: # hide
|
|
||||||
user_notice = None
|
|
||||||
|
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
result_type=entities.ResultType.INTERRUPT,
|
result_type=entities.ResultType.INTERRUPT,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
user_notice=user_notice,
|
user_notice='请求失败' if hide_exception_info else f'{e}',
|
||||||
error_notice=f'{e}',
|
error_notice=f'{e}',
|
||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
@@ -208,7 +198,6 @@ 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(),
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
quote_origin=quote_origin,
|
quote_origin=quote_origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Send extra chains produced by long text split strategy
|
||||||
|
extra_chains = query.get_variable('_longtext_split_extra_chains')
|
||||||
|
if extra_chains:
|
||||||
|
for chain in extra_chains:
|
||||||
|
await query.adapter.reply_message(
|
||||||
|
message_source=query.message_event,
|
||||||
|
message=chain,
|
||||||
|
quote_origin=False,
|
||||||
|
)
|
||||||
|
query.set_variable('_longtext_split_extra_chains', None)
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ 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,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -11,7 +9,6 @@ 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
|
||||||
|
|
||||||
@@ -54,148 +51,6 @@ 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,
|
||||||
@@ -227,23 +82,6 @@ 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,
|
||||||
@@ -252,8 +90,7 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=self.bot_entity.use_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')
|
||||||
@@ -288,23 +125,6 @@ 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,
|
||||||
@@ -313,8 +133,7 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=self.bot_entity.use_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')
|
||||||
@@ -322,50 +141,6 @@ 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:
|
||||||
@@ -421,20 +196,12 @@ 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)
|
||||||
@@ -515,8 +282,6 @@ class PlatformManager:
|
|||||||
return runtime_bot
|
return runtime_bot
|
||||||
|
|
||||||
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
||||||
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
|
|
||||||
return self.websocket_proxy_bot
|
|
||||||
for bot in self.bots:
|
for bot in self.bots:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
return bot
|
return bot
|
||||||
|
|||||||
@@ -5,29 +5,19 @@ 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, used for QQ bots
|
en_US: OneBot v11 Adapter
|
||||||
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
||||||
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
|
||||||
@@ -35,11 +25,9 @@ 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
|
||||||
@@ -47,11 +35,9 @@ 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,8 +71,7 @@ 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:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
if event.content:
|
||||||
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:
|
||||||
@@ -82,38 +81,7 @@ 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:
|
||||||
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
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,25 +5,16 @@ 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: 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: ""
|
||||||
@@ -31,7 +22,6 @@ 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: ""
|
||||||
@@ -39,7 +29,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -47,7 +36,6 @@ 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: ""
|
||||||
@@ -55,7 +43,6 @@ 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
|
||||||
@@ -63,11 +50,9 @@ 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
|
||||||
@@ -75,7 +60,6 @@ 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
|
||||||
@@ -83,7 +67,6 @@ 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,38 +5,16 @@ 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 适配器,需要可连接 Discord 服务器的网络环境
|
zh_Hans: 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: ""
|
||||||
@@ -44,11 +22,6 @@ 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,25 +5,16 @@ 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,127 +575,6 @@ 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,
|
||||||
@@ -708,31 +587,6 @@ 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(
|
||||||
@@ -805,65 +659,8 @@ 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
|
|
||||||
|
|
||||||
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,
|
|
||||||
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('', '')
|
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
||||||
.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']
|
||||||
@@ -973,32 +770,6 @@ 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']
|
||||||
@@ -1153,7 +924,6 @@ 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',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1177,7 +947,6 @@ 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',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1539,52 +1308,6 @@ 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
|
|
||||||
|
|
||||||
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,
|
|
||||||
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,30 +5,16 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Lark
|
en_US: Lark
|
||||||
zh_Hans: 飞书
|
zh_Hans: 飞书
|
||||||
zh_Hant: 飛書
|
|
||||||
ja_JP: Lark
|
|
||||||
description:
|
description:
|
||||||
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
en_US: Lark Adapter
|
||||||
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
||||||
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: 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: ""
|
||||||
@@ -36,8 +22,6 @@ 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: ""
|
||||||
@@ -45,13 +29,9 @@ 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: ""
|
||||||
@@ -59,63 +39,29 @@ 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
|
||||||
@@ -123,40 +69,28 @@ 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,56 +5,20 @@ 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, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
en_US: LINE Adapter
|
||||||
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
zh_Hans: LINE适配器,请查看文档了解使用方式
|
||||||
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
|
ja_JP: LINEアダプター、ドキュメントを参照してください
|
||||||
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
||||||
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: ""
|
||||||
@@ -63,18 +27,12 @@ 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: ""
|
||||||
|
|||||||
@@ -5,44 +5,23 @@ 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: 令牌
|
||||||
zh_Hant: 令牌
|
type: string
|
||||||
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: ""
|
||||||
@@ -50,7 +29,6 @@ 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: ""
|
||||||
@@ -58,7 +36,6 @@ 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: ""
|
||||||
@@ -66,7 +43,6 @@ 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"
|
||||||
@@ -74,7 +50,6 @@ 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正在思考中,请发送任意内容获取回复。"
|
||||||
@@ -82,11 +57,9 @@ 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"
|
||||||
|
|||||||
@@ -1,577 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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: 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
|
|
||||||
@@ -5,37 +5,16 @@ 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)
|
||||||
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
||||||
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
|
||||||
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: 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: ""
|
|
||||||
- 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: ""
|
||||||
@@ -43,7 +22,6 @@ 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: ""
|
||||||
@@ -51,7 +29,6 @@ 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: ""
|
||||||
|
|||||||
@@ -5,70 +5,36 @@ 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: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
|
zh_Hans: 古明地觉协议适配器
|
||||||
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
|
||||||
@@ -76,10 +42,6 @@ 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"
|
||||||
@@ -87,10 +49,6 @@ 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"
|
||||||
@@ -98,10 +56,6 @@ 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,58 +5,16 @@ 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 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
|
zh_Hans: 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: ""
|
||||||
@@ -64,11 +22,6 @@ 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,25 +42,6 @@ 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))
|
||||||
@@ -123,27 +104,6 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -219,10 +179,7 @@ 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(
|
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)
|
||||||
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
|
|
||||||
telegram_callback,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -261,13 +218,6 @@ 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,50 +5,23 @@ 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: Telegram 适配器,请查看文档了解使用方式
|
zh_Hans: 电报适配器,请查看文档了解使用方式
|
||||||
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: "token_from_botfather"
|
default: ""
|
||||||
- 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
|
||||||
@@ -56,19 +29,9 @@ 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
|
||||||
|
|||||||
@@ -5,21 +5,11 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: "WebSocket Chat"
|
en_US: "WebSocket Chat"
|
||||||
zh_Hans: "WebSocket 聊天"
|
zh_Hans: "WebSocket 聊天"
|
||||||
zh_Hant: "WebSocket 聊天"
|
|
||||||
th_TH: "แชท WebSocket"
|
|
||||||
vi_VN: "Trò chuyện WebSocket"
|
|
||||||
es_ES: "Chat WebSocket"
|
|
||||||
description:
|
description:
|
||||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||||
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||||
zh_Hant: "用於雙向即時通訊的 WebSocket 適配器"
|
|
||||||
th_TH: "อะแดปเตอร์ WebSocket สำหรับการสื่อสารแบบเรียลไทม์สองทิศทาง"
|
|
||||||
vi_VN: "Bộ điều hợp WebSocket cho giao tiếp thời gian thực hai chiều"
|
|
||||||
es_ES: "Adaptador WebSocket para comunicación bidireccional en tiempo real"
|
|
||||||
icon: ""
|
icon: ""
|
||||||
spec:
|
spec:
|
||||||
categories:
|
|
||||||
- protocol
|
|
||||||
config: []
|
config: []
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -37,24 +37,16 @@ class WebSocketSession:
|
|||||||
id: str
|
id: str
|
||||||
message_lists: dict[str, list[WebSocketMessage]] = {}
|
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||||
"""消息列表 {pipeline_uuid: [messages]}"""
|
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||||
stream_message_indexes: dict[str, dict[str, int]] = {}
|
|
||||||
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
|
|
||||||
|
|
||||||
def __init__(self, id: str):
|
def __init__(self, id: str):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.message_lists = {}
|
self.message_lists = {}
|
||||||
self.stream_message_indexes = {}
|
|
||||||
|
|
||||||
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||||
if pipeline_uuid not in self.message_lists:
|
if pipeline_uuid not in self.message_lists:
|
||||||
self.message_lists[pipeline_uuid] = []
|
self.message_lists[pipeline_uuid] = []
|
||||||
return self.message_lists[pipeline_uuid]
|
return self.message_lists[pipeline_uuid]
|
||||||
|
|
||||||
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
|
|
||||||
if pipeline_uuid not in self.stream_message_indexes:
|
|
||||||
self.stream_message_indexes[pipeline_uuid] = {}
|
|
||||||
return self.stream_message_indexes[pipeline_uuid]
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
"""WebSocket适配器 - 支持双向实时通信"""
|
"""WebSocket适配器 - 支持双向实时通信"""
|
||||||
@@ -97,46 +89,20 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""发送消息 - 这里用于主动推送消息到前端
|
"""发送消息 - 这里用于主动推送消息到前端"""
|
||||||
|
message_data = {
|
||||||
|
'type': 'bot_message',
|
||||||
|
'target_type': target_type,
|
||||||
|
'target_id': target_id,
|
||||||
|
'content': str(message),
|
||||||
|
'message_chain': [component.__dict__ for component in message],
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
对于 WebSocket 适配器,我们需要将消息广播到正确的 pipeline 连接。
|
# 推送到所有相关连接
|
||||||
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
await self.outbound_message_queue.put(message_data)
|
||||||
我们需要尝试两种方式来确保消息能够送达。
|
|
||||||
"""
|
|
||||||
# 获取当前的 pipeline_uuid
|
|
||||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
|
||||||
session_type = 'group' if target_type == 'group' else 'person'
|
|
||||||
|
|
||||||
# 选择会话
|
return message_data
|
||||||
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
|
||||||
|
|
||||||
# 生成唯一消息ID
|
|
||||||
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
|
||||||
|
|
||||||
message_data = WebSocketMessage(
|
|
||||||
id=msg_id,
|
|
||||||
role='assistant',
|
|
||||||
content=str(message),
|
|
||||||
message_chain=[component.__dict__ for component in message],
|
|
||||||
timestamp=datetime.now().isoformat(),
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 保存到历史记录
|
|
||||||
session.get_message_list(pipeline_uuid).append(message_data)
|
|
||||||
|
|
||||||
# 直接广播到当前pipeline的连接
|
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
|
||||||
pipeline_uuid,
|
|
||||||
{
|
|
||||||
'type': 'response',
|
|
||||||
'session_type': session_type,
|
|
||||||
'data': message_data.model_dump(),
|
|
||||||
},
|
|
||||||
session_type=session_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
return message_data.model_dump()
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -203,16 +169,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
message_list = session.get_message_list(pipeline_uuid)
|
message_list = session.get_message_list(pipeline_uuid)
|
||||||
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
|
|
||||||
|
|
||||||
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
# 检查是否是新的流式消息(通过bot_message对象判断)
|
||||||
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
||||||
resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
|
if not message_list or message_list[-1].is_final:
|
||||||
existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None
|
|
||||||
|
|
||||||
message_is_final = is_final and bot_message.tool_calls is None
|
|
||||||
|
|
||||||
if existing_index is None or existing_index >= len(message_list):
|
|
||||||
# 创建新消息
|
# 创建新消息
|
||||||
msg_id = len(message_list) + 1
|
msg_id = len(message_list) + 1
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
@@ -221,31 +181,27 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
is_final=message_is_final,
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
|
# 只有在is_final时才保存到历史记录
|
||||||
message_list.append(message_data)
|
if is_final and bot_message.tool_calls is None:
|
||||||
if resp_message_id:
|
message_list.append(message_data)
|
||||||
stream_message_indexes[resp_message_id] = len(message_list) - 1
|
|
||||||
else:
|
else:
|
||||||
# 更新同一条流式消息
|
# 更新最后一条消息
|
||||||
old_message = message_list[existing_index]
|
msg_id = message_list[-1].id
|
||||||
msg_id = old_message.id
|
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=old_message.timestamp, # 保持原始时间戳
|
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
||||||
is_final=message_is_final,
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 更新历史记录中的对应消息
|
# 如果是final,更新历史记录中的最后一条
|
||||||
message_list[existing_index] = message_data
|
if is_final and bot_message.tool_calls is None:
|
||||||
|
message_list[-1] = message_data
|
||||||
if message_is_final and resp_message_id:
|
|
||||||
stream_message_indexes.pop(resp_message_id, None)
|
|
||||||
|
|
||||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
@@ -454,10 +410,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if session_type == 'person':
|
if session_type == 'person':
|
||||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||||
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
|
|
||||||
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
|
|
||||||
else:
|
else:
|
||||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||||
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
|
|
||||||
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 466 KiB |
@@ -4,26 +4,17 @@ metadata:
|
|||||||
name: wechatpad
|
name: wechatpad
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad
|
en_US: WeChatPad
|
||||||
zh_Hans: WeChatPad(个人微信ipad)
|
zh_CN: WeChatPad(个人微信ipad)
|
||||||
zh_Hant: WeChatPad(個人微信iPad)
|
|
||||||
description:
|
description:
|
||||||
en_US: WeChatPad Adapter
|
en_US: WeChatPad Adapter
|
||||||
zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式
|
zh_CN: WeChatPad 适配器
|
||||||
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
|
|
||||||
icon: wechatpad.png
|
icon: wechatpad.png
|
||||||
spec:
|
spec:
|
||||||
categories:
|
|
||||||
- china
|
|
||||||
help_links:
|
|
||||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
|
||||||
en: https://link.langbot.app/en/platforms/wechatpad
|
|
||||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad ERL
|
en_US: WeChatPad ERL
|
||||||
zh_CN: WeChatPad URL
|
zh_CN: WeChatPad URL
|
||||||
zh_Hant: WeChatPad URL
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -31,7 +22,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: WeChatPad_Ws
|
en_US: WeChatPad_Ws
|
||||||
zh_CN: WeChatPad_Ws
|
zh_CN: WeChatPad_Ws
|
||||||
zh_Hant: WeChatPad_Ws
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -39,7 +29,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Admin_Key
|
en_US: Admin_Key
|
||||||
zh_CN: 管理员密匙
|
zh_CN: 管理员密匙
|
||||||
zh_Hant: 管理員密鑰
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -47,7 +36,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_CN: 令牌
|
zh_CN: 令牌
|
||||||
zh_Hant: 令牌
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -55,7 +43,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: wxid
|
en_US: wxid
|
||||||
zh_CN: wxid
|
zh_CN: wxid
|
||||||
zh_Hant: wxid
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -148,54 +148,51 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if type(event) is platform_events.FriendMessage:
|
if type(event) is platform_events.FriendMessage:
|
||||||
return event.source_platform_object
|
payload = {
|
||||||
|
'MsgType': 'text',
|
||||||
|
'Content': '',
|
||||||
|
'FromUserName': event.sender.id,
|
||||||
|
'ToUserName': bot_account_id,
|
||||||
|
'CreateTime': int(datetime.datetime.now().timestamp()),
|
||||||
|
'AgentID': event.sender.nickname,
|
||||||
|
}
|
||||||
|
wecom_event = WecomEvent.from_payload(payload=payload)
|
||||||
|
if not wecom_event:
|
||||||
|
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
|
||||||
|
|
||||||
|
return wecom_event
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
async def target2yiri(event: WecomEvent):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信事件。
|
event (WecomEvent): 企业微信事件。
|
||||||
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
# Try to get the user's real name from the WeCom API
|
|
||||||
nickname = str(event.user_id)
|
|
||||||
if bot and event.user_id:
|
|
||||||
try:
|
|
||||||
user_info = await bot.get_user_info(event.user_id)
|
|
||||||
if user_info and user_info.get('name'):
|
|
||||||
nickname = user_info.get('name')
|
|
||||||
except Exception:
|
|
||||||
pass # Fall back to user_id as nickname
|
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
@@ -213,6 +210,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'secret',
|
'secret',
|
||||||
'token',
|
'token',
|
||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
|
'contacts_secret',
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
@@ -225,7 +223,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
contacts_secret=config['contacts_secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
unified_mode=True,
|
||||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||||
@@ -250,17 +248,18 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
# user_id is the original FromUserName from WecomEvent
|
fixed_user_id = Wecom_event.user_id
|
||||||
user_id = Wecom_event.user_id
|
# 删掉开头的u
|
||||||
|
fixed_user_id = fixed_user_id[1:]
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
|
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||||
elif content['type'] == 'image':
|
elif content['type'] == 'image':
|
||||||
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||||
elif content['type'] == 'voice':
|
elif content['type'] == 'voice':
|
||||||
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||||
elif content['type'] == 'file':
|
elif content['type'] == 'file':
|
||||||
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_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):
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
@@ -288,7 +287,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomEvent):
|
async def on_message(event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
return await callback(await self.event_converter.target2yiri(event), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -5,38 +5,16 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeCom
|
en_US: WeCom
|
||||||
zh_Hans: 企业微信
|
zh_Hans: 企业微信
|
||||||
zh_Hant: 企業微信
|
|
||||||
description:
|
description:
|
||||||
en_US: WeCom Adapter
|
en_US: WeCom Adapter
|
||||||
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
|
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
||||||
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
|
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
categories:
|
|
||||||
- popular
|
|
||||||
- china
|
|
||||||
help_links:
|
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
|
||||||
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 WeCom app's webhook configuration
|
|
||||||
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
|
|
||||||
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
|
|
||||||
type: webhook-url
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
zh_Hant: 企業ID
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -44,7 +22,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥 (Secret)
|
zh_Hans: 密钥 (Secret)
|
||||||
zh_Hant: 密鑰 (Secret)
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -52,7 +29,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
zh_Hant: 令牌 (Token)
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -60,7 +36,13 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: contacts_secret
|
||||||
|
label:
|
||||||
|
en_US: Contacts Secret
|
||||||
|
zh_Hans: 通讯录密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -68,11 +50,9 @@ 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 WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service 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://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
|||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
|
||||||
|
|
||||||
|
|
||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -24,18 +23,14 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
async def target2yiri(event: WecomBotEvent):
|
||||||
yiri_msg_list = []
|
yiri_msg_list = []
|
||||||
if event.type == 'group':
|
if event.type == 'group':
|
||||||
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
||||||
|
|
||||||
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
|
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
|
||||||
|
|
||||||
if event.content:
|
if event.content:
|
||||||
content = event.content
|
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||||
if bot_name:
|
|
||||||
content = content.replace(f'@{bot_name}', '').strip()
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text=content))
|
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
if event.images:
|
if event.images:
|
||||||
@@ -126,107 +121,6 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if summary:
|
if summary:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=summary))
|
yiri_msg_list.append(platform_message.Plain(text=summary))
|
||||||
|
|
||||||
# Handle quoted message (引用消息) - important for group chat file references
|
|
||||||
# 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_info = event.quote or {}
|
|
||||||
if quote_info:
|
|
||||||
# Process quote text content - add as Plain for context
|
|
||||||
if quote_info.get('content'):
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
|
|
||||||
|
|
||||||
# Process quote images - add as top-level Image components
|
|
||||||
quote_images = quote_info.get('images', [])
|
|
||||||
if not quote_images and quote_info.get('picurl'):
|
|
||||||
quote_images = [quote_info.get('picurl')]
|
|
||||||
for img_data in quote_images:
|
|
||||||
if img_data:
|
|
||||||
yiri_msg_list.append(platform_message.Image(base64=img_data))
|
|
||||||
|
|
||||||
# Process quote file - add as top-level File component (same as private chat)
|
|
||||||
quote_file = quote_info.get('file') or {}
|
|
||||||
if quote_file:
|
|
||||||
file_url = (
|
|
||||||
quote_file.get('base64')
|
|
||||||
or quote_file.get('download_url')
|
|
||||||
or quote_file.get('url')
|
|
||||||
or quote_file.get('fileurl')
|
|
||||||
)
|
|
||||||
file_name = quote_file.get('filename') or quote_file.get('name')
|
|
||||||
file_size = quote_file.get('filesize') or quote_file.get('size')
|
|
||||||
if file_url or file_name:
|
|
||||||
file_kwargs = {}
|
|
||||||
if file_url:
|
|
||||||
file_kwargs['url'] = file_url
|
|
||||||
if file_name:
|
|
||||||
file_kwargs['name'] = file_name
|
|
||||||
if file_size is not None:
|
|
||||||
file_kwargs['size'] = file_size
|
|
||||||
try:
|
|
||||||
yiri_msg_list.append(platform_message.File(**file_kwargs))
|
|
||||||
except Exception:
|
|
||||||
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
|
|
||||||
|
|
||||||
# Process quote voice - add as top-level Voice/File component
|
|
||||||
quote_voice = quote_info.get('voice') or {}
|
|
||||||
if quote_voice:
|
|
||||||
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
|
|
||||||
if voice_payload:
|
|
||||||
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
|
|
||||||
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
|
|
||||||
try:
|
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
voice_kwargs = {'url': voice_payload}
|
|
||||||
voice_name = quote_voice.get('filename') or quote_voice.get('name')
|
|
||||||
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
|
|
||||||
if voice_name:
|
|
||||||
voice_kwargs['name'] = voice_name
|
|
||||||
if voice_size is not None:
|
|
||||||
voice_kwargs['size'] = voice_size
|
|
||||||
yiri_msg_list.append(platform_message.File(**voice_kwargs))
|
|
||||||
except Exception:
|
|
||||||
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
|
|
||||||
|
|
||||||
# Process quote video - add as top-level File component
|
|
||||||
quote_video = quote_info.get('video') or {}
|
|
||||||
if quote_video:
|
|
||||||
video_payload = (
|
|
||||||
quote_video.get('base64')
|
|
||||||
or quote_video.get('url')
|
|
||||||
or quote_video.get('download_url')
|
|
||||||
or quote_video.get('fileurl')
|
|
||||||
)
|
|
||||||
if video_payload:
|
|
||||||
video_kwargs = {'url': video_payload}
|
|
||||||
video_name = quote_video.get('filename') or quote_video.get('name')
|
|
||||||
video_size = quote_video.get('filesize') or quote_video.get('size')
|
|
||||||
if video_name:
|
|
||||||
video_kwargs['name'] = video_name
|
|
||||||
if video_size is not None:
|
|
||||||
video_kwargs['size'] = video_size
|
|
||||||
try:
|
|
||||||
yiri_msg_list.append(platform_message.File(**video_kwargs))
|
|
||||||
except Exception:
|
|
||||||
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
|
|
||||||
|
|
||||||
# Process quote link - add as Plain text
|
|
||||||
quote_link = quote_info.get('link') or {}
|
|
||||||
if quote_link:
|
|
||||||
link_summary = '\n'.join(
|
|
||||||
filter(
|
|
||||||
None,
|
|
||||||
[
|
|
||||||
quote_link.get('title', ''),
|
|
||||||
quote_link.get('description') or quote_link.get('digest', ''),
|
|
||||||
quote_link.get('url', ''),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if link_summary:
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
|
|
||||||
|
|
||||||
has_content_element = any(
|
has_content_element = any(
|
||||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||||
)
|
)
|
||||||
@@ -239,15 +133,13 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
def __init__(self, bot_name: str = ''):
|
|
||||||
self.bot_name = bot_name
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(event: platform_events.MessageEvent):
|
async def yiri2target(event: platform_events.MessageEvent):
|
||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
async def target2yiri(self, event: WecomBotEvent):
|
@staticmethod
|
||||||
message_chain = await WecomBotMessageConverter.target2yiri(event, bot_name=self.bot_name)
|
async def target2yiri(event: WecomBotEvent):
|
||||||
|
message_chain = await WecomBotMessageConverter.target2yiri(event)
|
||||||
if event.type == 'single':
|
if event.type == 'single':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
@@ -284,53 +176,34 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: typing.Union[WecomBotClient, WecomBotWsClient]
|
bot: WecomBotClient
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||||
event_converter: WecomBotEventConverter
|
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
_ws_mode: bool = False
|
|
||||||
bot_name: str = ''
|
|
||||||
listeners: dict = {}
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
||||||
bot_name = config.get('robot_name', '')
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
|
if missing_keys:
|
||||||
|
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
||||||
|
|
||||||
if not enable_webhook:
|
bot = WecomBotClient(
|
||||||
bot = WecomBotWsClient(
|
Token=config['Token'],
|
||||||
bot_id=config['BotId'],
|
EnCodingAESKey=config['EncodingAESKey'],
|
||||||
secret=config['Secret'],
|
Corpid=config['Corpid'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
encoding_aes_key=config.get('EncodingAESKey', ''),
|
unified_mode=True,
|
||||||
)
|
)
|
||||||
else:
|
bot_account_id = config['BotId']
|
||||||
# Webhook callback mode
|
|
||||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
|
|
||||||
missing_keys = [key for key in required_keys if key not in config or not config[key]]
|
|
||||||
if missing_keys:
|
|
||||||
raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')
|
|
||||||
|
|
||||||
bot = WecomBotClient(
|
|
||||||
Token=config['Token'],
|
|
||||||
EnCodingAESKey=config['EncodingAESKey'],
|
|
||||||
Corpid=config['Corpid'],
|
|
||||||
logger=logger,
|
|
||||||
unified_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
bot_account_id = config.get('BotId', '')
|
|
||||||
event_converter = WecomBotEventConverter(bot_name=bot_name)
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
bot_name=bot_name,
|
|
||||||
event_converter=event_converter,
|
|
||||||
)
|
)
|
||||||
self.listeners = {}
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -339,17 +212,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||||
|
|
||||||
if _ws_mode:
|
|
||||||
event = message_source.source_platform_object
|
|
||||||
req_id = event.get('req_id', '')
|
|
||||||
if req_id:
|
|
||||||
await self.bot.reply_text(req_id, content)
|
|
||||||
else:
|
|
||||||
await self.bot.set_message(event.message_id, content)
|
|
||||||
else:
|
|
||||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -359,44 +222,44 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
|
"""将流水线增量输出写入企业微信 stream 会话。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_source: 流水线提供的原始消息事件。
|
||||||
|
bot_message: 当前片段对应的模型元信息(未使用)。
|
||||||
|
message: 需要回复的消息链。
|
||||||
|
quote_origin: 是否引用原消息(企业微信暂不支持)。
|
||||||
|
is_final: 标记当前片段是否为最终回复。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含 `stream` 键,标识写入是否成功。
|
||||||
|
|
||||||
|
Example:
|
||||||
|
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
|
||||||
|
"""
|
||||||
|
# 转换为纯文本(智能机器人当前协议仅支持文本流)
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
msg_id = message_source.source_platform_object.message_id
|
msg_id = message_source.source_platform_object.message_id
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
|
||||||
|
|
||||||
if _ws_mode:
|
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
event = message_source.source_platform_object
|
# 未命中流式队列时使用旧有 set_message 兜底
|
||||||
req_id = event.get('req_id', '')
|
await self.bot.set_message(msg_id, content)
|
||||||
if req_id:
|
return {'stream': success}
|
||||||
await self.bot.reply_text(req_id, content)
|
|
||||||
return {'stream': success}
|
|
||||||
else:
|
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
|
||||||
if not success and is_final:
|
|
||||||
await self.bot.set_message(msg_id, content)
|
|
||||||
return {'stream': success}
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
"""Whether streaming output is enabled for this bot instance."""
|
"""智能机器人侧默认开启流式能力。
|
||||||
return self.config.get('enable-stream-reply', True)
|
|
||||||
|
Returns:
|
||||||
|
bool: 恒定返回 True。
|
||||||
|
|
||||||
|
Example:
|
||||||
|
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
||||||
|
return True
|
||||||
|
|
||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
pass
|
||||||
if _ws_mode:
|
|
||||||
content = await self.message_converter.yiri2target(message)
|
|
||||||
await self.bot.send_message(target_id, content)
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def on_message(self, event: WecomBotEvent):
|
|
||||||
try:
|
|
||||||
lb_event = await self.event_converter.target2yiri(event)
|
|
||||||
if lb_event:
|
|
||||||
await self.listeners[type(lb_event)](lb_event, self)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
|
|
||||||
print(traceback.format_exc())
|
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
@@ -405,16 +268,18 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
self.listeners[event_type] = callback
|
async def on_message(event: WecomBotEvent):
|
||||||
|
try:
|
||||||
|
return await callback(await self.event_converter.target2yiri(event), self)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
|
||||||
|
print(traceback.format_exc())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if event_type == platform_events.FriendMessage:
|
if event_type == platform_events.FriendMessage:
|
||||||
self.bot.on_message('single')(self.on_message)
|
self.bot.on_message('single')(on_message)
|
||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('group')(self.on_message)
|
self.bot.on_message('group')(on_message)
|
||||||
elif event_type == platform_events.FeedbackEvent:
|
|
||||||
if hasattr(self.bot, 'on_feedback'):
|
|
||||||
self.bot.on_feedback()(self._on_feedback)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
|
|
||||||
@@ -422,76 +287,30 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
self.bot_uuid = bot_uuid
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
async def _on_feedback(self, **kwargs):
|
|
||||||
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
|
||||||
try:
|
|
||||||
feedback_id = kwargs.get('feedback_id', '')
|
|
||||||
feedback_type = kwargs.get('feedback_type', 0)
|
|
||||||
feedback_content = kwargs.get('feedback_content', '') or None
|
|
||||||
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
|
|
||||||
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
|
|
||||||
if inaccurate_reasons:
|
|
||||||
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
|
|
||||||
session = kwargs.get('session')
|
|
||||||
|
|
||||||
session_id = None
|
|
||||||
user_id = None
|
|
||||||
message_id = None
|
|
||||||
stream_id = None
|
|
||||||
if session:
|
|
||||||
if session.chat_id:
|
|
||||||
session_id = f'group_{session.chat_id}'
|
|
||||||
elif session.user_id:
|
|
||||||
session_id = f'person_{session.user_id}'
|
|
||||||
user_id = session.user_id
|
|
||||||
message_id = session.msg_id
|
|
||||||
stream_id = session.stream_id
|
|
||||||
|
|
||||||
await self.logger.info(
|
|
||||||
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
|
||||||
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
|
||||||
)
|
|
||||||
|
|
||||||
event = platform_events.FeedbackEvent(
|
|
||||||
feedback_id=feedback_id,
|
|
||||||
feedback_type=feedback_type,
|
|
||||||
feedback_content=feedback_content,
|
|
||||||
inaccurate_reasons=inaccurate_reasons,
|
|
||||||
user_id=user_id,
|
|
||||||
session_id=session_id,
|
|
||||||
message_id=message_id,
|
|
||||||
stream_id=stream_id,
|
|
||||||
source_platform_object=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if platform_events.FeedbackEvent in self.listeners:
|
|
||||||
await self.listeners[platform_events.FeedbackEvent](event, self)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error in wecombot feedback callback: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
"""处理统一 webhook 请求。
|
||||||
if _ws_mode:
|
|
||||||
return None
|
Args:
|
||||||
|
bot_uuid: Bot 的 UUID
|
||||||
|
path: 子路径(如果有的话)
|
||||||
|
request: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
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):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||||
if _ws_mode:
|
# 保持运行但不启动独立端口
|
||||||
await self.bot.connect()
|
|
||||||
else:
|
|
||||||
|
|
||||||
async def keep_alive():
|
async def keep_alive():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
|
||||||
if _ws_mode:
|
|
||||||
await self.bot.disconnect()
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def unregister_listener(
|
async def unregister_listener(
|
||||||
|
|||||||
@@ -5,124 +5,40 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComBot
|
en_US: WeComBot
|
||||||
zh_Hans: 企业微信智能机器人
|
zh_Hans: 企业微信智能机器人
|
||||||
zh_Hant: 企業微信智慧機器人
|
|
||||||
description:
|
description:
|
||||||
en_US: WeComBot Adapter
|
en_US: WeComBot Adapter
|
||||||
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
||||||
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
|
||||||
icon: wecombot.png
|
icon: wecombot.png
|
||||||
spec:
|
spec:
|
||||||
categories:
|
|
||||||
- china
|
|
||||||
help_links:
|
|
||||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
|
||||||
config:
|
config:
|
||||||
- name: BotId
|
|
||||||
label:
|
|
||||||
en_US: BotId
|
|
||||||
zh_Hans: 机器人ID (BotId)
|
|
||||||
zh_Hant: 機器人ID (BotId)
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
- name: robot_name
|
|
||||||
label:
|
|
||||||
en_US: Robot Name
|
|
||||||
zh_Hans: 机器人名称
|
|
||||||
zh_Hant: 機器人名稱
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
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 WS long connection mode
|
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
|
||||||
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
|
||||||
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 WeComBot webhook configuration
|
|
||||||
zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中
|
|
||||||
zh_Hant: 複製此地址並貼到企業微信智慧機器人的 Webhook 設定中
|
|
||||||
type: webhook-url
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
show_if:
|
|
||||||
field: enable-webhook
|
|
||||||
operator: eq
|
|
||||||
value: true
|
|
||||||
- name: Secret
|
|
||||||
label:
|
|
||||||
en_US: Secret
|
|
||||||
zh_Hans: 机器人密钥 (Secret)
|
|
||||||
zh_Hant: 機器人密鑰 (Secret)
|
|
||||||
description:
|
|
||||||
en_US: Required for WebSocket long connection mode
|
|
||||||
zh_Hans: 使用 WS 长连接模式时必填
|
|
||||||
zh_Hant: 使用 WS 長連線模式時必填
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: Corpid
|
- name: Corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
zh_Hant: 企業ID
|
|
||||||
description:
|
|
||||||
en_US: Required for Webhook mode
|
|
||||||
zh_Hans: 使用 Webhook 模式时必填
|
|
||||||
zh_Hant: 使用 Webhook 模式時必填
|
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: Token
|
- name: Token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
zh_Hant: 令牌 (Token)
|
|
||||||
description:
|
|
||||||
en_US: Required for Webhook mode
|
|
||||||
zh_Hans: 使用 Webhook 模式时必填
|
|
||||||
zh_Hant: 使用 Webhook 模式時必填
|
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
type: string
|
||||||
description:
|
required: true
|
||||||
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
default: ""
|
||||||
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
- name: BotId
|
||||||
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
|
label:
|
||||||
|
en_US: BotId
|
||||||
|
zh_Hans: 机器人ID
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: enable-stream-reply
|
|
||||||
label:
|
|
||||||
en_US: Enable Stream Reply
|
|
||||||
zh_Hans: 启用流式回复
|
|
||||||
zh_Hant: 啟用串流回覆
|
|
||||||
description:
|
|
||||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
|
||||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
|
||||||
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./wecombot.py
|
path: ./wecombot.py
|
||||||
|
|||||||
@@ -81,33 +81,22 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
async def target2yiri(event: WecomCSEvent):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信客服事件。
|
event (WecomEvent): 企业微信客服事件。
|
||||||
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
# Try to get customer nickname from WeChat API
|
|
||||||
nickname = str(event.user_id)
|
|
||||||
if bot and event.user_id:
|
|
||||||
try:
|
|
||||||
customer_info = await bot.get_customer_info(event.user_id)
|
|
||||||
if customer_info and customer_info.get('nickname'):
|
|
||||||
nickname = customer_info.get('nickname')
|
|
||||||
except Exception:
|
|
||||||
pass # Fall back to user_id as nickname
|
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.user_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,7 +106,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.user_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,7 +187,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomCSEvent):
|
async def on_message(event: WecomCSEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
return await callback(await self.event_converter.target2yiri(event), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -5,37 +5,16 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComCustomerService
|
en_US: WeComCustomerService
|
||||||
zh_Hans: 企业微信客服
|
zh_Hans: 企业微信客服
|
||||||
zh_Hant: 企業微信客服
|
|
||||||
description:
|
description:
|
||||||
en_US: WeComCSAdapter
|
en_US: WeComCSAdapter
|
||||||
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
|
zh_Hans: 企业微信客服适配器
|
||||||
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
categories:
|
|
||||||
- china
|
|
||||||
help_links:
|
|
||||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
|
||||||
en: https://link.langbot.app/en/platforms/wecomcs
|
|
||||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
|
||||||
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 WeCom Customer Service webhook configuration
|
|
||||||
zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中
|
|
||||||
zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中
|
|
||||||
type: webhook-url
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
zh_Hant: 企業ID
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,7 +22,6 @@ 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: ""
|
||||||
@@ -51,7 +29,6 @@ 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: ""
|
||||||
@@ -59,7 +36,6 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
zh_Hant: 訊息加解密密鑰
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -67,11 +43,9 @@ 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 WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service 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://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user