mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-17 03:04:20 +00:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b55f073e62 | |||
| e6cfee541f | |||
| f10ea82418 | |||
| a32c4d152f | |||
| 83b0d26e99 | |||
| e08b5db625 | |||
| d0f65b17ec | |||
| 2b533c4a00 | |||
| f663d87a60 | |||
| 60e5b873ee | |||
| b96f209b98 | |||
| 894709d577 | |||
| 6823069103 | |||
| 699545a196 | |||
| f0061817ea | |||
| 688202e7d1 | |||
| d46b762d03 | |||
| 0963fd5443 | |||
| 6471770737 | |||
| 314b7d15bb | |||
| c758908745 | |||
| 767137aaa0 | |||
| acb2ce6a40 | |||
| 67784708d6 | |||
| 1bd9c334aa | |||
| 17bbc8bf10 | |||
| 4a4c0921a4 | |||
| e425cf079a | |||
| 245e798b79 | |||
| 27fdccce16 | |||
| 484643c0ee | |||
| ec61459619 | |||
| 66ef744447 | |||
| 10d3a9cc92 | |||
| 885320e9ae | |||
| ed02ac4710 | |||
| e4841edbaf | |||
| ef7a06b0db | |||
| 6fe20c1812 | |||
| 9e8c8f79df | |||
| 01d06898fb | |||
| 0a669c7016 | |||
| b251fc4b89 | |||
| 075c85e2bc | |||
| 62b63ca2ca | |||
| 3680a80248 | |||
| 6713b57d01 | |||
| ea13ef87f2 | |||
| 59bd581e88 | |||
| cba83a62e8 | |||
| f412127fb0 | |||
| 5273bbb23f | |||
| 0ceab3f6a5 | |||
| aedc097188 | |||
| 18b27dd9ef | |||
| 3f50a56623 | |||
| 1fcdbd472f | |||
| 547006cb4a | |||
| 92bf9a7ea5 | |||
| 832efb4069 | |||
| 8f1847d480 | |||
| fe619e415f | |||
| 0154ea6cd3 | |||
| 8db55267d8 | |||
| b9662250a6 | |||
| d9378c3a88 | |||
| 86a4d1bf0b | |||
| ce6e79db8e | |||
| d53e2cb9a0 | |||
| c1168745b7 | |||
| 69b87a0d8a | |||
| 6637b153f1 | |||
| e768fc6116 | |||
| 2442d3bf52 | |||
| 42d78817f4 | |||
| 4b9f25a05d | |||
| d1f0e07cc0 | |||
| 78e55509ae | |||
| 2c28635a39 | |||
| 5f3cecfbe2 | |||
| 12df9d6ee9 | |||
| 195f6efeff | |||
| 564d829e25 | |||
| 58c1916712 | |||
| a8fba46040 | |||
| 3115d6f6dd | |||
| 323481d69b | |||
| 5a5c4295b1 | |||
| 88111d87ac | |||
| 4e5a6ee79a | |||
| 05c684d757 | |||
| 2838020580 | |||
| 9b34ae2db4 | |||
| f8010a20eb | |||
| 917edb3413 | |||
| 10425ede34 | |||
| e4b40a8fa0 | |||
| 0b8ab4b54b | |||
| 49239e0e08 | |||
| aec2a30445 | |||
| c8915ca964 | |||
| a715eddd06 | |||
| 2f9c235b41 | |||
| cc4d8838eb | |||
| fa0a77f09f | |||
| fd6a7b73d4 | |||
| bf0848d60b | |||
| e06fac2bb7 | |||
| bec61427a0 | |||
| 5fae7b2eb0 | |||
| 2eebdfe16a | |||
| 9cd3544d59 | |||
| de4d14fee3 | |||
| f29c568381 | |||
| af3f557055 | |||
| b894842736 | |||
| e190029e1f | |||
| e4940a8050 | |||
| 617c95ebc4 | |||
| 1cdd428bcc | |||
| 71ac719aee | |||
| 4621e6cc9f | |||
| 66087f83e1 | |||
| 25f9330491 | |||
| 14b1e0d33b | |||
| 83ccb33fd3 | |||
| 05bcf543ba | |||
| 7cd063bb5d | |||
| 8f1317b39e | |||
| 77a0de5ef0 | |||
| 875227a2fe | |||
| 2317392ee5 | |||
| c7efa4dd7f | |||
| e701daa8e0 | |||
| 1ae99199b2 | |||
| 7c067a1cb3 | |||
| 478bc62576 | |||
| a740eb8ee9 |
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Check i18n Keys
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-i18n:
|
||||||
|
name: Check i18n Key Consistency
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Check i18n keys against en-US reference
|
||||||
|
run: node web/scripts/check-i18n.mjs
|
||||||
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
|
- 'scripts/test-*.sh'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
paths:
|
paths:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
|
- 'scripts/test-*.sh'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -39,28 +43,13 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
uses: astral-sh/setup-uv@v4
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --dev
|
||||||
uv sync --dev
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit + smoke tests
|
||||||
run: |
|
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
||||||
bash run_tests.sh
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
if: matrix.python-version == '3.12'
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: ./coverage.xml
|
|
||||||
flags: unit-tests
|
|
||||||
name: unit-tests-coverage
|
|
||||||
fail_ci_if_error: false
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Test Summary
|
- name: Test Summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -69,3 +58,79 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Fast Integration Tests
|
||||||
|
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: Run fast integration tests
|
||||||
|
run: uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
- name: Integration Test Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Integration Tests Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: Coverage Gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, integration]
|
||||||
|
|
||||||
|
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: Run coverage (unit + smoke)
|
||||||
|
run: |
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-fail-under=18 \
|
||||||
|
-q --tb=short
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
files: ./coverage.xml
|
||||||
|
flags: unit-tests
|
||||||
|
name: coverage-report
|
||||||
|
fail_ci_if_error: false
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
- name: Coverage Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: Test Migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/persistence/**'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/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: Run SQLite migration tests
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
||||||
|
|
||||||
|
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: Run PostgreSQL migration tests
|
||||||
|
env:
|
||||||
|
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
||||||
@@ -47,8 +47,12 @@ plugins.bak
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
|
testsdk/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
|
|||||||
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
||||||
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
||||||
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||||
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
|
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
|
||||||
|
|
||||||
## Some Principles
|
## Some Principles
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# LangBot Makefile
|
||||||
|
# Quick developer commands
|
||||||
|
|
||||||
|
.PHONY: test test-quick test-integration-fast test-coverage test-all-local lint
|
||||||
|
|
||||||
|
# Run all tests (full suite with coverage)
|
||||||
|
test:
|
||||||
|
bash run_tests.sh
|
||||||
|
|
||||||
|
# Quick self-test for developers (lint + unit + smoke, no real credentials needed)
|
||||||
|
test-quick:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
|
||||||
|
# Fast integration tests (SQLite/API/Pipeline, no external services)
|
||||||
|
test-integration-fast:
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
|
||||||
|
# Coverage gate (all tests, enforces minimum threshold)
|
||||||
|
test-coverage:
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Full local quality gate (quick + integration + coverage)
|
||||||
|
test-all-local:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Run linting only
|
||||||
|
lint:
|
||||||
|
ruff check src/langbot/ tests/
|
||||||
|
ruff format --check src/langbot/ tests/
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
lint-fix:
|
||||||
|
ruff check --fix src/langbot/ tests/
|
||||||
|
ruff format src/langbot/ tests/
|
||||||
@@ -47,6 +47,8 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
|
|
||||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -84,45 +86,48 @@ docker compose up -d
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Official |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Official |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Official |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Official |
|
||||||
| QQ | ✅ | Personal & Official API |
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Official |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Official |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Official |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Supported LLMs & Integrations
|
## Supported LLMs & Integrations
|
||||||
|
|
||||||
| Provider | Type | Status |
|
| Provider | Type | Status |
|
||||||
|----------|------|--------|
|
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
@@ -130,22 +135,23 @@ docker compose up -d
|
|||||||
|
|
||||||
## Why LangBot?
|
## Why LangBot?
|
||||||
|
|
||||||
| Use Case | How LangBot Helps |
|
| Use Case | How LangBot Helps |
|
||||||
|----------|-------------------|
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||||
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||||
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||||
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
## Live Demo
|
||||||
|
|
||||||
**Try it now:** https://demo.langbot.dev/
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
- Email: `demo@langbot.app`
|
- Email: `demo@langbot.app`
|
||||||
- Password: `langbot123456`
|
- Password: `langbot123456`
|
||||||
|
|
||||||
*Note: Public demo environment. Do not enter sensitive information.*
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -47,6 +47,8 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -87,13 +89,16 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | |
|
| 飞书 | ✅ | 官方 |
|
||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| KOOK | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +129,7 @@ docker compose up -d
|
|||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -46,6 +46,8 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Inicio Rápido
|
## Inicio Rápido
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Oficial |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Oficial |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Oficial |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Oficial |
|
||||||
| QQ | ✅ | Personal y API Oficial |
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Oficial |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Oficial |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Oficial |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -46,6 +46,8 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Démarrage Rapide
|
## Démarrage Rapide
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Officiel |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Officiel |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Officiel |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Officiel |
|
||||||
| QQ | ✅ | Personnel & API Officielle |
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Officiel |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Officiel |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Officiel |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+14
-9
@@ -46,6 +46,8 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## クイックスタート
|
## クイックスタート
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| QQ | ✅ | 個人 & 公式API |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -46,6 +46,8 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 공식 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 공식 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 공식 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 공식 |
|
||||||
| QQ | ✅ | 개인 및 공식 API |
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 공식 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 공식 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 공식 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -46,6 +46,8 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Официальный |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Официальный |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Официальный |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Официальный |
|
||||||
| QQ | ✅ | Личный и официальный API |
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Официальный |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Официальный |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Официальный |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -48,6 +48,8 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
@@ -85,17 +87,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 飛書 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 釘釘 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| KOOK | ✅ | |
|
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -46,6 +46,8 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
|
|
||||||
[→ 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://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bắt đầu nhanh
|
## Bắt đầu nhanh
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Chính thức |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Chính thức |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Chính thức |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Chính thức |
|
||||||
| QQ | ✅ | Cá nhân & API chính thức |
|
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||||
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Chính thức |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Chính thức |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Chính thức |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.7"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocqhttp>=1.4.4",
|
"aiocqhttp>=1.4.4",
|
||||||
"aiofiles>=24.1.0",
|
"aiofiles>=24.1.0",
|
||||||
"aiohttp>=3.11.18",
|
"aiohttp>=3.13.4",
|
||||||
"aioshutil>=1.5",
|
"aioshutil>=1.5",
|
||||||
"aiosqlite>=0.21.0",
|
"aiosqlite>=0.21.0",
|
||||||
"anthropic>=0.51.0",
|
"anthropic>=0.51.0",
|
||||||
@@ -16,18 +16,18 @@ dependencies = [
|
|||||||
"async-lru>=2.0.5",
|
"async-lru>=2.0.5",
|
||||||
"certifi>=2025.4.26",
|
"certifi>=2025.4.26",
|
||||||
"colorlog~=6.6.0",
|
"colorlog~=6.6.0",
|
||||||
"cryptography>=44.0.3",
|
"cryptography>=46.0.7",
|
||||||
"dashscope>=1.25.10",
|
"dashscope>=1.25.10",
|
||||||
"dingtalk-stream>=0.24.0",
|
"dingtalk-stream>=0.24.0",
|
||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
"openai>1.0.0",
|
"openai>1.0.0",
|
||||||
"pillow>=11.2.1",
|
"pillow>=12.2.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pycryptodome>=3.22.0",
|
"pycryptodome>=3.22.0",
|
||||||
"pydantic>2.0",
|
"pydantic>2.0",
|
||||||
@@ -35,10 +35,12 @@ dependencies = [
|
|||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -49,7 +51,7 @@ dependencies = [
|
|||||||
"pip>=25.1.1",
|
"pip>=25.1.1",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"uv>=0.7.11",
|
"uv>=0.11.6",
|
||||||
"mypy>=1.16.0",
|
"mypy>=1.16.0",
|
||||||
"PyPDF2>=3.0.1",
|
"PyPDF2>=3.0.1",
|
||||||
"python-docx>=1.1.0",
|
"python-docx>=1.1.0",
|
||||||
@@ -60,13 +62,18 @@ dependencies = [
|
|||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-core>=1.2.28",
|
||||||
|
"langsmith>=0.7.31",
|
||||||
|
"python-multipart>=0.0.26",
|
||||||
|
"Mako>=1.3.11",
|
||||||
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.6",
|
"langbot-plugin==0.3.11",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
@@ -111,12 +118,13 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"moto>=5.2.1",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=8.4.1",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ python_files = test_*.py
|
|||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|
||||||
|
# Python path for imports
|
||||||
|
pythonpath = . tests
|
||||||
|
|
||||||
# Test paths
|
# Test paths
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ markers =
|
|||||||
asyncio: mark test as async
|
asyncio: mark test as async
|
||||||
unit: mark test as unit test
|
unit: mark test as unit test
|
||||||
integration: mark test as integration test
|
integration: mark test as integration test
|
||||||
|
smoke: mark test as smoke test
|
||||||
slow: mark test as slow running
|
slow: mark test as slow running
|
||||||
|
e2e: mark test as end-to-end test (requires real LangBot process)
|
||||||
|
|
||||||
# Coverage options (when using pytest-cov)
|
# Coverage options (when using pytest-cov)
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
"""Generate the DingTalk human-input card template JSON.
|
||||||
|
|
||||||
|
The output is wrapped in the {editorData, widgetInfo, type, mode} envelope
|
||||||
|
the DingTalk card builder expects on import. editorData is itself a JSON
|
||||||
|
string (NOT a nested object), matching real exports from the builder.
|
||||||
|
|
||||||
|
Run from the repo root: python scripts/build_dingtalk_card_template.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUTPUT = Path('src/langbot/templates/dingtalk_human_input_card.json')
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_block(node_id, variable='content'):
|
||||||
|
"""A MarkdownBlock whose content is bound to a global variable.
|
||||||
|
|
||||||
|
Unlike BaseText (whose `text` field requires editor-side manual binding),
|
||||||
|
MarkdownBlock's `content` accepts `variableValue` binding at JSON load
|
||||||
|
time — the imported template renders the variable straight away.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'componentName': 'MarkdownBlock',
|
||||||
|
'id': node_id,
|
||||||
|
'props': {
|
||||||
|
'mdVer': 0,
|
||||||
|
'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'},
|
||||||
|
'content': {'variable': variable, 'variableType': 'global', 'type': 'variableValue'},
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'isStreaming': True,
|
||||||
|
'enableLinkStatPoint': False,
|
||||||
|
'linkStatPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
||||||
|
'linkStatPointParams': [],
|
||||||
|
'marginTop': 6,
|
||||||
|
'marginBottom': 6,
|
||||||
|
'marginLeft': 12,
|
||||||
|
'marginRight': 12,
|
||||||
|
},
|
||||||
|
'title': 'AI 流式富文本',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def text_block(
|
||||||
|
node_id,
|
||||||
|
text,
|
||||||
|
*,
|
||||||
|
bold=False,
|
||||||
|
gravity='left',
|
||||||
|
font_size=14,
|
||||||
|
line_height=22,
|
||||||
|
max_lines=20,
|
||||||
|
ml=12,
|
||||||
|
mr=12,
|
||||||
|
mt=4,
|
||||||
|
mb=4,
|
||||||
|
color_token='common_level1_base_color',
|
||||||
|
style_token='common_body_text_style',
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
'componentName': 'BaseText',
|
||||||
|
'id': node_id,
|
||||||
|
'props': {
|
||||||
|
'text': {'i18n': False, 'type': 'dynamicString', 'content': text},
|
||||||
|
'hoverText': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
||||||
|
'iconType': 'iconCode',
|
||||||
|
'iconFont': {'type': 'icon', 'icon': '', 'iconType': 'ddIcon'},
|
||||||
|
'icon': {
|
||||||
|
'type': 'dynamicLink',
|
||||||
|
'value': '',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'darkIcon': {
|
||||||
|
'type': 'dynamicLink',
|
||||||
|
'value': '',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'autoWidth': False,
|
||||||
|
'maxWidth': {
|
||||||
|
'type': 'dynamicNumber',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': 0,
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'fixedWidth': {
|
||||||
|
'type': 'dynamicNumber',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': 0,
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'marginLeft': ml,
|
||||||
|
'marginRight': mr,
|
||||||
|
'marginTop': mt,
|
||||||
|
'marginBottom': mb,
|
||||||
|
'fontColorType': 'Standard',
|
||||||
|
'enableHighlight': False,
|
||||||
|
'maxLine': {
|
||||||
|
'type': 'dynamicNumber',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': max_lines,
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'color': {
|
||||||
|
'type': 'dynamicColor',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': color_token,
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'customLightColor': {
|
||||||
|
'type': 'dynamicColor',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': '#35404b',
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'customDarkColor': {
|
||||||
|
'type': 'dynamicColor',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': '#f6f6f6',
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'gravity': gravity,
|
||||||
|
'fontSizeType': 'Standard',
|
||||||
|
'styleType': 'custom',
|
||||||
|
'styleToken': style_token,
|
||||||
|
'size': 'middle',
|
||||||
|
'customFontSize': font_size,
|
||||||
|
'customFontLineHeight': line_height,
|
||||||
|
'bold': bold,
|
||||||
|
'italic': False,
|
||||||
|
'strikeThrough': False,
|
||||||
|
'lineHeight': 'normal',
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'autoMaxWidth': False,
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableIcon': False,
|
||||||
|
'widthMode': 'match_parent',
|
||||||
|
'margin': -2,
|
||||||
|
},
|
||||||
|
'title': '基础文本',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def button_group(node_id):
|
||||||
|
return {
|
||||||
|
'componentName': 'ButtonGroup',
|
||||||
|
'id': node_id,
|
||||||
|
'props': {
|
||||||
|
'dynamicButtons': {'type': 'variableValue', 'variableType': 'global', 'variable': 'btns'},
|
||||||
|
'marginLeft': 12,
|
||||||
|
'marginRight': 12,
|
||||||
|
'marginTop': 6,
|
||||||
|
'marginBottom': 12,
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'responsiveLayoutWidth': 350,
|
||||||
|
'buttonsSource': 'variable',
|
||||||
|
'fixedButtonIds': [],
|
||||||
|
'fixedButtons': [],
|
||||||
|
'enableResponsiveLayout': False,
|
||||||
|
'matchContent': False,
|
||||||
|
'buttonSpacing': 8,
|
||||||
|
'margin': -2,
|
||||||
|
'innerOffset': 0,
|
||||||
|
},
|
||||||
|
'title': '按钮组',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_data():
|
||||||
|
component_names = [
|
||||||
|
'AIPending',
|
||||||
|
'AICardStatusContainer',
|
||||||
|
'BaseText',
|
||||||
|
'AICardContent',
|
||||||
|
'AICardContainer',
|
||||||
|
'ButtonGroup',
|
||||||
|
'MarkdownBlock',
|
||||||
|
]
|
||||||
|
components_map = [
|
||||||
|
{
|
||||||
|
'package': '@ali/dxComponent',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'exportName': n,
|
||||||
|
'main': './src/index.tsx',
|
||||||
|
'destructuring': False,
|
||||||
|
'subName': '',
|
||||||
|
'componentName': n,
|
||||||
|
}
|
||||||
|
for n in component_names
|
||||||
|
]
|
||||||
|
|
||||||
|
pending_state = {
|
||||||
|
'componentName': 'AICardStatusContainer',
|
||||||
|
'id': 'node_status_pending',
|
||||||
|
'props': {
|
||||||
|
'status': 1,
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'enableExtend': False,
|
||||||
|
'autoFoldConfig': {
|
||||||
|
'needFold': True,
|
||||||
|
'heightLimit': 480,
|
||||||
|
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableCollapse': False,
|
||||||
|
'margin': -2,
|
||||||
|
},
|
||||||
|
'title': '处理中状态',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'componentName': 'AIPending',
|
||||||
|
'id': 'node_pending_inner',
|
||||||
|
'props': {
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'pendingTip': {'type': 'dynamicString', 'content': '处理中...', 'i18n': False},
|
||||||
|
'style': 'embed',
|
||||||
|
'hideIcon': False,
|
||||||
|
},
|
||||||
|
'hidden': False,
|
||||||
|
'title': '',
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
done_state = {
|
||||||
|
'componentName': 'AICardStatusContainer',
|
||||||
|
'id': 'node_status_done',
|
||||||
|
'props': {
|
||||||
|
'status': 3,
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'enableExtend': False,
|
||||||
|
'autoFoldConfig': {
|
||||||
|
'needFold': True,
|
||||||
|
'heightLimit': 480,
|
||||||
|
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableCollapse': False,
|
||||||
|
'margin': -2,
|
||||||
|
},
|
||||||
|
'title': '完成状态',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'componentName': 'AICardContent',
|
||||||
|
'id': 'node_done_content',
|
||||||
|
'props': {
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'disabledWhileForward': False,
|
||||||
|
'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
||||||
|
'statPointParams': [
|
||||||
|
{'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'}
|
||||||
|
],
|
||||||
|
'margin': -2,
|
||||||
|
'transformToEventChain': False,
|
||||||
|
'enableStatPoint': False,
|
||||||
|
},
|
||||||
|
'hidden': False,
|
||||||
|
'title': '',
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
markdown_block('node_text_content', variable='content'),
|
||||||
|
button_group('node_btn_group'),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
failed_state = {
|
||||||
|
'componentName': 'AICardStatusContainer',
|
||||||
|
'id': 'node_status_failed',
|
||||||
|
'props': {
|
||||||
|
'status': 5,
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'enableExtend': False,
|
||||||
|
'autoFoldConfig': {
|
||||||
|
'needFold': True,
|
||||||
|
'heightLimit': 480,
|
||||||
|
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableCollapse': False,
|
||||||
|
'margin': -2,
|
||||||
|
},
|
||||||
|
'title': '失败状态',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'componentName': 'AICardContent',
|
||||||
|
'id': 'node_failed_content',
|
||||||
|
'props': {
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'innerOffset': 0,
|
||||||
|
'disabledWhileForward': False,
|
||||||
|
'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
||||||
|
'statPointParams': [
|
||||||
|
{'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'}
|
||||||
|
],
|
||||||
|
'margin': -2,
|
||||||
|
'transformToEventChain': False,
|
||||||
|
'enableStatPoint': False,
|
||||||
|
},
|
||||||
|
'hidden': False,
|
||||||
|
'title': '',
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
text_block(
|
||||||
|
'node_failed_text',
|
||||||
|
'操作失败,请稍后重试。',
|
||||||
|
gravity='center',
|
||||||
|
mt=10,
|
||||||
|
mb=10,
|
||||||
|
ml=10,
|
||||||
|
mr=10,
|
||||||
|
max_lines=2,
|
||||||
|
font_size=15,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
root = {
|
||||||
|
'componentName': 'AICardContainer',
|
||||||
|
'id': 'node_root',
|
||||||
|
'props': {
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'enablePending': True,
|
||||||
|
'enableWriting': False,
|
||||||
|
'enableDoing': False,
|
||||||
|
'enableFailed': True,
|
||||||
|
'summaryContent': {'type': 'variableValue', 'variableType': 'global', 'variable': ''},
|
||||||
|
'enableTitle': False,
|
||||||
|
'flowStatusVar': {'type': 'variableValue', 'variableType': 'global', 'variable': 'flowStatus'},
|
||||||
|
'operationPenalType': 'custom',
|
||||||
|
'enableFlowAbort': True,
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableGradientBorder': True,
|
||||||
|
'cardSizeMode': 'adaptive',
|
||||||
|
'cardSizeHeightMode': 'adaptive',
|
||||||
|
'cardSizeWidthMode': 'adaptive',
|
||||||
|
'cardSizeHeight': {
|
||||||
|
'type': 'dynamicNumber',
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'value': 226,
|
||||||
|
'variable': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
},
|
||||||
|
'hasBackground': False,
|
||||||
|
'backgroundType': 'Standard',
|
||||||
|
'standardBackgroundColor': 'gray',
|
||||||
|
'backgroundColor': '#F6F6F6',
|
||||||
|
'darkModeBackgroundColor': '#3C3C3C',
|
||||||
|
'enableEngineUpgrade': False,
|
||||||
|
'enableExposeStatPoint': False,
|
||||||
|
'enableDebugTool': False,
|
||||||
|
},
|
||||||
|
'hidden': False,
|
||||||
|
'title': '',
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [pending_state, done_state, failed_state],
|
||||||
|
}
|
||||||
|
|
||||||
|
btns_var = {
|
||||||
|
'name': 'btns',
|
||||||
|
'private': False,
|
||||||
|
'type': 'buttonGroup',
|
||||||
|
'id': 'btns',
|
||||||
|
'description': '动态按钮列表(Dify actions)',
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': False,
|
||||||
|
'schema': [
|
||||||
|
{
|
||||||
|
'id': 'btns.text',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'text',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '按钮文案',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.color',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'color',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '按钮颜色',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.status',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'status',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '按钮状态',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.event',
|
||||||
|
'type': 'dynamicEvent',
|
||||||
|
'name': 'event',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '按钮点击事件',
|
||||||
|
'schema': [
|
||||||
|
{
|
||||||
|
'id': 'btns.type',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'type',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '事件类型:openLink / sendCardRequest',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.params',
|
||||||
|
'type': 'object',
|
||||||
|
'name': 'params',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '事件参数',
|
||||||
|
'schema': [
|
||||||
|
{
|
||||||
|
'id': 'btns.url',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'url',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '点击跳转链接(type=openLink)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.actionId',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'actionId',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '回传请求 id(type=sendCardRequest)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'btns.params',
|
||||||
|
'type': 'object',
|
||||||
|
'name': 'params',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'description': '回传请求参数(type=sendCardRequest)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'schemaVersion': '3.0.0',
|
||||||
|
'schema': {
|
||||||
|
'config': {'update_multi': True, 'streaming_mode': True},
|
||||||
|
'componentsMap': components_map,
|
||||||
|
'componentsTree': [root],
|
||||||
|
'i18n': {},
|
||||||
|
'version': '1.0.0',
|
||||||
|
},
|
||||||
|
'mockData': {
|
||||||
|
'cardData': {
|
||||||
|
'flowStatus': 3,
|
||||||
|
'content': '请审核以下报销申请:\n\n- 申请人:张三\n- 金额:¥1,200\n- 类别:差旅',
|
||||||
|
'btns': [
|
||||||
|
{
|
||||||
|
'text': '通过',
|
||||||
|
'color': 'blue',
|
||||||
|
'status': 'normal',
|
||||||
|
'event': {
|
||||||
|
'type': 'sendCardRequest',
|
||||||
|
'params': {'actionId': 'approve', 'params': {'action_id': 'approve'}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'text': '驳回',
|
||||||
|
'color': 'gray',
|
||||||
|
'status': 'normal',
|
||||||
|
'event': {
|
||||||
|
'type': 'sendCardRequest',
|
||||||
|
'params': {'actionId': 'reject', 'params': {'action_id': 'reject'}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'text': '补充资料',
|
||||||
|
'color': 'gray',
|
||||||
|
'status': 'normal',
|
||||||
|
'event': {
|
||||||
|
'type': 'sendCardRequest',
|
||||||
|
'params': {'actionId': 'more_info', 'params': {'action_id': 'more_info'}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'cardPrivateData': {},
|
||||||
|
'localData': {'flowStatus': '', '_cardFoldStatusLocalDataKey': ''},
|
||||||
|
'richTextData': {},
|
||||||
|
},
|
||||||
|
'renderContext': {'regenerateEnabled': '1', 'regenerateIndex': '2', 'regenerateTotal': '5'},
|
||||||
|
'editVersion': 0,
|
||||||
|
'customWidgetInfo': '',
|
||||||
|
'useCustomWidgetInfo': False,
|
||||||
|
'variableList': [
|
||||||
|
{
|
||||||
|
'id': 'content',
|
||||||
|
'type': 'markdown',
|
||||||
|
'name': 'content',
|
||||||
|
'description': '人工输入提示词(Dify form_content 含可选 node_title 前缀)',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'flowStatus',
|
||||||
|
'type': 'string',
|
||||||
|
'name': 'flowStatus',
|
||||||
|
'description': 'AI卡片状态:pending(1)、writing(2)、done(3)、failed(5)',
|
||||||
|
'private': False,
|
||||||
|
'editorVarType': 'variables',
|
||||||
|
'disabled': True,
|
||||||
|
'visible': False,
|
||||||
|
},
|
||||||
|
btns_var,
|
||||||
|
],
|
||||||
|
'formList': [],
|
||||||
|
'customContextList': [],
|
||||||
|
'expList': [],
|
||||||
|
'localList': [],
|
||||||
|
'hsfList': [],
|
||||||
|
'lwpList': [],
|
||||||
|
'pageData': {},
|
||||||
|
'extension': {'extendType': 'AI', 'aiStatusList': [3, 1, 5], 'fileTypeList': []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
editor_data = build_editor_data()
|
||||||
|
wrapper = {
|
||||||
|
'editorData': json.dumps(editor_data, ensure_ascii=False, separators=(',', ':')),
|
||||||
|
'widgetInfo': '',
|
||||||
|
'type': 'im',
|
||||||
|
'mode': 'card',
|
||||||
|
}
|
||||||
|
OUTPUT.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
print(f'wrote {OUTPUT}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Coverage gate script
|
||||||
|
# Runs all tests with coverage, enforcing minimum coverage threshold
|
||||||
|
# Uses separate pytest invocations to avoid sys.modules pollution between test types
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Coverage Gate ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Coverage threshold (baseline from current coverage, conservative buffer)
|
||||||
|
# Current: ~22.14%, threshold: 18%
|
||||||
|
COVERAGE_THRESHOLD=18
|
||||||
|
|
||||||
|
# Create temporary directory for coverage files
|
||||||
|
COV_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $COV_DIR" EXIT
|
||||||
|
|
||||||
|
echo "[1/3] Running unit + smoke tests with coverage..."
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/unit.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[2/3] Running fast integration tests with coverage..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/integration.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[3/3] Combining coverage reports..."
|
||||||
|
# Use coverage combine if available, otherwise just report total
|
||||||
|
if command -v coverage &> /dev/null; then
|
||||||
|
# Combine JSON reports
|
||||||
|
coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \
|
||||||
|
--data-file=$COV_DIR/combined.data 2>/dev/null || true
|
||||||
|
|
||||||
|
coverage report --data-file=$COV_DIR/combined.data || true
|
||||||
|
else
|
||||||
|
echo "Note: coverage combine not available, showing individual reports above"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate final XML report for CI (from last run)
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml:coverage.xml \
|
||||||
|
--cov-report=term \
|
||||||
|
--cov-fail-under=$COVERAGE_THRESHOLD \
|
||||||
|
-q 2>/dev/null || {
|
||||||
|
# If threshold check fails on combined, check unit+smoke baseline
|
||||||
|
echo ""
|
||||||
|
echo "Coverage threshold: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Note: Full coverage requires running all test types separately"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Coverage Gate Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Coverage baseline: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Coverage report saved to coverage.xml"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Fast integration tests
|
||||||
|
# Runs integration tests excluding slow ones (PostgreSQL, external services)
|
||||||
|
# Uses fake runner/provider, no real credentials needed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Fast Integration Tests ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Running integration tests (excluding slow)..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Fast Integration Tests Complete ==="
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick developer self-test command
|
||||||
|
# Runs linting, unit tests, and smoke tests without requiring real provider keys
|
||||||
|
# Suitable for local branch validation
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Quick Self-Test ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Ruff check
|
||||||
|
echo "[1/3] Running ruff check..."
|
||||||
|
uv run ruff check src/langbot/ tests/ --output-format=concise || {
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Ruff check found issues. Run 'uv run ruff check --fix' to auto-fix."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✓ Ruff check passed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Unit tests
|
||||||
|
echo "[2/3] Running unit tests..."
|
||||||
|
uv run pytest tests/unit_tests/ -q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Smoke tests (if exists)
|
||||||
|
echo "[3/3] Running smoke tests..."
|
||||||
|
if [ -d "tests/smoke" ]; then
|
||||||
|
uv run pytest tests/smoke/ -q --tb=short
|
||||||
|
else
|
||||||
|
echo "No smoke tests found, skipping"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Quick Self-Test Complete ==="
|
||||||
@@ -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.5'
|
__version__ = '4.9.7'
|
||||||
|
|||||||
@@ -109,6 +109,61 @@ class AsyncDifyServiceClient:
|
|||||||
if chunk.startswith('data:'):
|
if chunk.startswith('data:'):
|
||||||
yield json.loads(chunk[5:])
|
yield json.loads(chunk[5:])
|
||||||
|
|
||||||
|
async def workflow_submit(
|
||||||
|
self,
|
||||||
|
form_token: str,
|
||||||
|
workflow_run_id: str,
|
||||||
|
inputs: dict[str, typing.Any],
|
||||||
|
user: str,
|
||||||
|
action: str = '',
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""Submit human input to resume a paused workflow, then stream events.
|
||||||
|
|
||||||
|
1. POST /form/human_input/{form_token} to submit the form
|
||||||
|
2. GET /workflow/{task_id}/events to stream the resumed workflow events
|
||||||
|
"""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
# Step 1: Submit the form
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'inputs': inputs if isinstance(inputs, dict) else {},
|
||||||
|
'user': user,
|
||||||
|
'action': action,
|
||||||
|
}
|
||||||
|
|
||||||
|
submit_resp = await client.post(
|
||||||
|
f'/form/human_input/{form_token}',
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if submit_resp.status_code != 200:
|
||||||
|
raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
|
||||||
|
|
||||||
|
# Step 2: Stream resumed workflow events
|
||||||
|
async with client.stream(
|
||||||
|
'GET',
|
||||||
|
f'/workflow/{workflow_run_id}/events',
|
||||||
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
|
params={'user': user},
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise DifyAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
yield json.loads(chunk[5:])
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self,
|
self,
|
||||||
file: httpx._types.FileTypes,
|
file: httpx._types.FileTypes,
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Callable
|
from typing import Awaitable, Callable, Optional
|
||||||
import dingtalk_stream # type: ignore
|
import dingtalk_stream # type: ignore
|
||||||
import websockets
|
import websockets
|
||||||
from .EchoHandler import EchoTextHandler
|
from .EchoHandler import EchoTextHandler
|
||||||
|
from .card_callback import DingTalkCardActionHandler
|
||||||
from .dingtalkevent import DingTalkEvent
|
from .dingtalkevent import DingTalkEvent
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
||||||
|
|
||||||
|
|
||||||
|
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
||||||
|
|
||||||
|
|
||||||
class DingTalkClient:
|
class DingTalkClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -21,6 +30,7 @@ class DingTalkClient:
|
|||||||
robot_code: str,
|
robot_code: str,
|
||||||
markdown_card: bool,
|
markdown_card: bool,
|
||||||
logger: None,
|
logger: None,
|
||||||
|
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
|
||||||
):
|
):
|
||||||
"""初始化 WebSocket 连接并自动启动"""
|
"""初始化 WebSocket 连接并自动启动"""
|
||||||
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
||||||
@@ -30,6 +40,14 @@ class DingTalkClient:
|
|||||||
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
||||||
self.EchoTextHandler = EchoTextHandler(self)
|
self.EchoTextHandler = EchoTextHandler(self)
|
||||||
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
|
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
|
||||||
|
# STREAM-mode card action button click handler. Forwards parsed payload
|
||||||
|
# to the adapter so it can resume paused Dify workflows.
|
||||||
|
self.card_action_callback = card_action_callback
|
||||||
|
self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action)
|
||||||
|
self.client.register_callback_handler(
|
||||||
|
dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK,
|
||||||
|
self.card_action_handler,
|
||||||
|
)
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -41,6 +59,16 @@ class DingTalkClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._stopped = False # Flag to control the event loop
|
self._stopped = False # Flag to control the event loop
|
||||||
|
|
||||||
|
async def _on_card_action(self, payload: dict) -> None:
|
||||||
|
"""Dispatch a parsed card-action payload to the adapter callback."""
|
||||||
|
if self.card_action_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.card_action_callback(payload)
|
||||||
|
except Exception:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def get_access_token(self):
|
async def get_access_token(self):
|
||||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
@@ -182,6 +210,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
|
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||||||
|
"""Parse the quoted/replied message and extract its content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
replied_msg: The repliedMsg object from DingTalk message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing the quoted message info with keys:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
quote_info = {
|
||||||
|
'message_id': replied_msg.get('msgId', ''),
|
||||||
|
'msg_type': replied_msg.get('msgType', ''),
|
||||||
|
'sender_id': replied_msg.get('senderId', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = replied_msg.get('msgType', '')
|
||||||
|
content = replied_msg.get('content', {})
|
||||||
|
|
||||||
|
# Handle content as string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
# Text message
|
||||||
|
if isinstance(content, dict):
|
||||||
|
quote_info['content'] = content.get('content', '')
|
||||||
|
else:
|
||||||
|
quote_info['content'] = str(content)
|
||||||
|
|
||||||
|
elif msg_type == 'file':
|
||||||
|
# File message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
file_name = content.get('fileName')
|
||||||
|
if download_code and file_name:
|
||||||
|
try:
|
||||||
|
quote_info['file_url'] = await self.get_file_url(download_code)
|
||||||
|
quote_info['file_name'] = file_name
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'picture':
|
||||||
|
# Picture message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['picture'] = await self.download_image(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to download quoted image: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'audio':
|
||||||
|
# Audio message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['audio'] = await self.get_audio_url(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'richText':
|
||||||
|
# Rich text message - extract text content
|
||||||
|
rich_text = content.get('richText', [])
|
||||||
|
texts = []
|
||||||
|
for item in rich_text:
|
||||||
|
if 'text' in item and item['text'] != '\n':
|
||||||
|
texts.append(item['text'])
|
||||||
|
quote_info['content'] = '\n'.join(texts)
|
||||||
|
|
||||||
|
return quote_info
|
||||||
|
|
||||||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +303,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
message_data['conversation_type'] = 'GroupMessage'
|
||||||
|
|
||||||
|
# Check for quoted/replied message
|
||||||
|
raw_data = incoming_message.to_dict()
|
||||||
|
text_data = raw_data.get('text', {})
|
||||||
|
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||||||
|
replied_msg = text_data.get('repliedMsg', {})
|
||||||
|
if replied_msg:
|
||||||
|
quote_info = await self._parse_quoted_message(replied_msg)
|
||||||
|
message_data['QuotedMessage'] = quote_info
|
||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,7 +387,25 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
raw_content = incoming_message.to_dict().get('content', {})
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
try:
|
||||||
|
raw_content = json.loads(raw_content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw_content = {}
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||||||
|
recognition = raw_content.get('recognition', '')
|
||||||
|
if recognition:
|
||||||
|
message_data['Content'] = recognition
|
||||||
|
|
||||||
|
download_code = raw_content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
message_data['Audio'] = await self.get_audio_url(download_code)
|
||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
@@ -320,18 +457,35 @@ class DingTalkClient:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# For enterprise-internal robots, robotCode == AppKey (client_id).
|
||||||
|
# The dedicated robot_code field is only required for scenario-group
|
||||||
|
# robots or third-party robots; fall back to client_id when empty so
|
||||||
|
# the common single-bot setup keeps working without manual config.
|
||||||
|
robot_code = self.robot_code or self.key
|
||||||
data = {
|
data = {
|
||||||
'robotCode': self.robot_code,
|
'robotCode': robot_code,
|
||||||
'userIds': [target_id],
|
'userIds': [target_id],
|
||||||
'msgKey': 'sampleText',
|
'msgKey': 'sampleText',
|
||||||
'msgParam': json.dumps({'content': content}),
|
'msgParam': json.dumps({'content': content}),
|
||||||
}
|
}
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
|
||||||
|
robot_code,
|
||||||
|
target_id,
|
||||||
|
len(content),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
|
||||||
|
response.status_code,
|
||||||
|
response.text[:500],
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_stdout_logger.exception('DingTalk send_proactive_message_to_one error')
|
||||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
|
|
||||||
@@ -347,7 +501,7 @@ class DingTalkClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'robotCode': self.robot_code,
|
'robotCode': self.robot_code or self.key,
|
||||||
'openConversationId': target_id,
|
'openConversationId': target_id,
|
||||||
'msgKey': 'sampleText',
|
'msgKey': 'sampleText',
|
||||||
'msgParam': json.dumps({'content': content}),
|
'msgParam': json.dumps({'content': content}),
|
||||||
@@ -368,41 +522,244 @@ class DingTalkClient:
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
card_auto_layout: bool = False,
|
card_auto_layout: bool = False,
|
||||||
):
|
):
|
||||||
card_data = {}
|
"""Create + deliver the streaming chat card for a chatbot reply.
|
||||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
|
||||||
card_data['content'] = ''
|
|
||||||
|
|
||||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
|
||||||
# print(card_instance)
|
`(None, out_track_id)` to keep call sites compatible with the
|
||||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
previous `(card_instance, card_instance_id)` shape — the first slot
|
||||||
card_instance_id = await card_instance.async_create_and_deliver_card(
|
is unused now that everything is driven by out_track_id.
|
||||||
temp_card_id,
|
"""
|
||||||
card_data,
|
out_track_id = uuid.uuid4().hex
|
||||||
|
is_group = str(incoming_message.conversation_type) == '2'
|
||||||
|
if is_group:
|
||||||
|
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
|
||||||
|
else:
|
||||||
|
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
|
||||||
|
|
||||||
|
card_param_map = {'content': ''}
|
||||||
|
if incoming_message.message_type == 'text':
|
||||||
|
card_param_map['query'] = incoming_message.get_text_list()[0]
|
||||||
|
else:
|
||||||
|
card_param_map['query'] = '...'
|
||||||
|
|
||||||
|
await self.create_and_deliver_card(
|
||||||
|
card_template_id=temp_card_id,
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
open_space_id=open_space_id,
|
||||||
|
is_group=is_group,
|
||||||
|
card_param_map=card_param_map,
|
||||||
|
card_data_config={'autoLayout': card_auto_layout},
|
||||||
)
|
)
|
||||||
return card_instance, card_instance_id
|
return None, out_track_id
|
||||||
|
|
||||||
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
|
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
|
||||||
content_key = 'content'
|
"""Stream a single chunk into an existing card's `content` field."""
|
||||||
try:
|
try:
|
||||||
await card_instance.async_streaming(
|
await self.streaming_update_card(
|
||||||
card_instance_id,
|
out_track_id=card_instance_id,
|
||||||
content_key=content_key,
|
content_key='content',
|
||||||
content_value=content,
|
content_value=content,
|
||||||
append=False,
|
append=False,
|
||||||
finished=is_final,
|
finished=is_final,
|
||||||
failed=False,
|
failed=False,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(e)
|
if self.logger:
|
||||||
await card_instance.async_streaming(
|
self.logger.exception(e)
|
||||||
card_instance_id,
|
await self.streaming_update_card(
|
||||||
content_key=content_key,
|
out_track_id=card_instance_id,
|
||||||
|
content_key='content',
|
||||||
content_value='',
|
content_value='',
|
||||||
append=False,
|
append=False,
|
||||||
finished=is_final,
|
finished=is_final,
|
||||||
failed=True,
|
failed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_and_deliver_card(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
card_template_id: str,
|
||||||
|
out_track_id: str,
|
||||||
|
open_space_id: str,
|
||||||
|
is_group: bool,
|
||||||
|
card_param_map: Optional[dict] = None,
|
||||||
|
callback_type: str = 'STREAM',
|
||||||
|
callback_route_key: Optional[str] = None,
|
||||||
|
support_forward: bool = True,
|
||||||
|
dynamic_data_source_configs: Optional[list] = None,
|
||||||
|
card_data_config: Optional[dict] = None,
|
||||||
|
at_user_ids: Optional[dict] = None,
|
||||||
|
recipients: Optional[list] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""POST /v1.0/card/instances/createAndDeliver.
|
||||||
|
|
||||||
|
Mirrors the SDK's `async_create_and_deliver_card` shape but exposes
|
||||||
|
the dynamic-data-source config slot so we can register a pull URL
|
||||||
|
for variable-length button lists.
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
cardData: dict = {'cardParamMap': card_param_map or {}}
|
||||||
|
if card_data_config is not None:
|
||||||
|
cardData['config'] = json.dumps(card_data_config)
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
'cardTemplateId': card_template_id,
|
||||||
|
'outTrackId': out_track_id,
|
||||||
|
'cardData': cardData,
|
||||||
|
'callbackType': callback_type,
|
||||||
|
'openSpaceId': open_space_id,
|
||||||
|
'imGroupOpenSpaceModel': {'supportForward': support_forward},
|
||||||
|
'imRobotOpenSpaceModel': {'supportForward': support_forward},
|
||||||
|
}
|
||||||
|
if callback_type == 'HTTP' and callback_route_key:
|
||||||
|
body['callbackRouteKey'] = callback_route_key
|
||||||
|
|
||||||
|
if is_group:
|
||||||
|
deliver: dict = {'robotCode': self.robot_code or self.key}
|
||||||
|
if at_user_ids:
|
||||||
|
deliver['atUserIds'] = at_user_ids
|
||||||
|
if recipients is not None:
|
||||||
|
deliver['recipients'] = recipients
|
||||||
|
body['imGroupOpenDeliverModel'] = deliver
|
||||||
|
else:
|
||||||
|
body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
|
||||||
|
|
||||||
|
if dynamic_data_source_configs:
|
||||||
|
body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
|
||||||
|
|
||||||
|
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
|
||||||
|
headers = {
|
||||||
|
'x-acs-dingtalk-access-token': self.access_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk createAndDeliver request body: %s',
|
||||||
|
json.dumps(body, ensure_ascii=False)[:1500],
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk createAndDeliver response: %s',
|
||||||
|
response.text[:500],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
_stdout_logger.error(
|
||||||
|
'DingTalk createAndDeliver failed: status=%s body=%s',
|
||||||
|
response.status_code,
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(
|
||||||
|
f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
_stdout_logger.exception('DingTalk createAndDeliver error')
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def streaming_update_card(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
out_track_id: str,
|
||||||
|
content_key: str,
|
||||||
|
content_value: str,
|
||||||
|
append: bool,
|
||||||
|
finished: bool,
|
||||||
|
failed: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""PUT /v1.0/card/streaming.
|
||||||
|
|
||||||
|
Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body
|
||||||
|
shape (outTrackId / guid / key / content / isFull / isFinalize /
|
||||||
|
isError) per the SDK source.
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'outTrackId': out_track_id,
|
||||||
|
'guid': uuid.uuid4().hex,
|
||||||
|
'key': content_key,
|
||||||
|
'content': content_value,
|
||||||
|
'isFull': not append,
|
||||||
|
'isFinalize': finished,
|
||||||
|
'isError': failed,
|
||||||
|
}
|
||||||
|
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
|
||||||
|
headers = {
|
||||||
|
'x-acs-dingtalk-access-token': self.access_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(
|
||||||
|
f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_card_data(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
out_track_id: str,
|
||||||
|
card_param_map: Optional[dict] = None,
|
||||||
|
private_data: Optional[dict] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""PUT /v1.0/card/instances — non-streaming card content update."""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
'outTrackId': out_track_id,
|
||||||
|
'cardData': {'cardParamMap': card_param_map or {}},
|
||||||
|
}
|
||||||
|
if private_data:
|
||||||
|
body['privateData'] = private_data
|
||||||
|
|
||||||
|
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances'
|
||||||
|
headers = {
|
||||||
|
'x-acs-dingtalk-access-token': self.access_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk update_card_data request: out_track_id=%s body=%s',
|
||||||
|
out_track_id,
|
||||||
|
json.dumps(body, ensure_ascii=False)[:500],
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
_stdout_logger.info(
|
||||||
|
'DingTalk update_card_data response: status=%d body=%s',
|
||||||
|
response.status_code,
|
||||||
|
response.text[:300],
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(
|
||||||
|
f'DingTalk update card failed: status={response.status_code} body={response.text}'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
_stdout_logger.exception('DingTalk update_card_data error')
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""启动 WebSocket 连接,监听消息"""
|
"""启动 WebSocket 连接,监听消息"""
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""STREAM-mode handler for DingTalk card action button clicks.
|
||||||
|
|
||||||
|
DingTalk delivers card-action callbacks over the same WebSocket stream used
|
||||||
|
for chatbot messages, under the topic `/v1.0/card/instances/callback`. This
|
||||||
|
module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed
|
||||||
|
payload to a coroutine the adapter registers, so the resume-paused-workflow
|
||||||
|
logic stays in the platform adapter where it belongs.
|
||||||
|
|
||||||
|
The `CardCallbackMessage` returned by `from_dict` exposes:
|
||||||
|
|
||||||
|
* `card_instance_id` (from `outTrackId`) — the card whose button was clicked
|
||||||
|
* `user_id` — the clicker's userId
|
||||||
|
* `content` — parsed JSON; the click params live here. Where exactly inside
|
||||||
|
`content` they sit depends on the template binding. We probe
|
||||||
|
the common paths.
|
||||||
|
* `extension` — parsed JSON; any extra data we set when delivering the card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
import dingtalk_stream # type: ignore
|
||||||
|
from dingtalk_stream import AckMessage
|
||||||
|
from dingtalk_stream.card_callback import CardCallbackMessage
|
||||||
|
|
||||||
|
|
||||||
|
_PARAM_PATHS = (
|
||||||
|
('params',),
|
||||||
|
('cardPrivateData', 'params'),
|
||||||
|
('userPrivateData', 'params'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_params(content: dict) -> dict:
|
||||||
|
"""Return the action params dict regardless of where the template put it."""
|
||||||
|
for path in _PARAM_PATHS:
|
||||||
|
node = content
|
||||||
|
for key in path:
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
node = None
|
||||||
|
break
|
||||||
|
node = node.get(key)
|
||||||
|
if node is None:
|
||||||
|
break
|
||||||
|
if isinstance(node, dict) and node:
|
||||||
|
return node
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dingtalk_stream_client,
|
||||||
|
on_action: Optional[Callable[[dict], Awaitable[None]]] = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.dingtalk_client = dingtalk_stream_client
|
||||||
|
self.on_action = on_action
|
||||||
|
|
||||||
|
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
||||||
|
try:
|
||||||
|
message = CardCallbackMessage.from_dict(callback.data)
|
||||||
|
params = _extract_params(message.content if isinstance(message.content, dict) else {})
|
||||||
|
|
||||||
|
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
|
||||||
|
# top-level field that ButtonGroup's sendCardRequest event puts
|
||||||
|
# there). Pull it from the raw callback.data instead.
|
||||||
|
raw = callback.data if isinstance(callback.data, dict) else {}
|
||||||
|
action_id = raw.get('actionId') or ''
|
||||||
|
if not action_id:
|
||||||
|
# Some templates nest it under actionData / cardPrivateData.
|
||||||
|
action_data = raw.get('actionData') or {}
|
||||||
|
if isinstance(action_data, dict):
|
||||||
|
action_id = action_data.get('actionId') or action_id
|
||||||
|
if not action_id:
|
||||||
|
cpd = action_data.get('cardPrivateData') or {}
|
||||||
|
if isinstance(cpd, dict):
|
||||||
|
ids = cpd.get('actionIds')
|
||||||
|
if isinstance(ids, list) and ids:
|
||||||
|
action_id = str(ids[0])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'out_track_id': message.card_instance_id,
|
||||||
|
'user_id': message.user_id,
|
||||||
|
'corp_id': message.corp_id,
|
||||||
|
'action_id': action_id,
|
||||||
|
'params': params,
|
||||||
|
'raw_content': message.content,
|
||||||
|
'extension': message.extension if isinstance(message.extension, dict) else {},
|
||||||
|
}
|
||||||
|
if self.on_action is not None:
|
||||||
|
await self.on_action(payload)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f'DingTalkCardActionHandler.process error: {e}')
|
||||||
|
return AckMessage.STATUS_OK, 'OK'
|
||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
return self.get('conversation_type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_message(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the quoted/replied message info if this is a reply message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
return self.get('QuotedMessage')
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> Optional[Any]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
from .qqofficialevent import QQOfficialEvent
|
from .qqofficialevent import QQOfficialEvent
|
||||||
import json
|
import json
|
||||||
@@ -10,6 +12,70 @@ import traceback
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
|
||||||
|
def build_keyboard_from_form(form_data: dict, *, buttons_per_row: int = 2) -> dict:
|
||||||
|
"""Build a QQ keyboard JSON payload from a Dify human-input form_data.
|
||||||
|
|
||||||
|
Each Dify ``action`` becomes a callback button (``action.type=1``)
|
||||||
|
whose ``data`` is set directly to the Dify ``action_id``. The
|
||||||
|
INTERACTION_CREATE event carries this back as
|
||||||
|
``data.resolved.button_data`` so the adapter can match the click to
|
||||||
|
the originating form.
|
||||||
|
|
||||||
|
Layout limits per spec: max 5 rows, max 5 buttons per row. We default
|
||||||
|
to 2 buttons per row for legibility; oversized button lists wrap
|
||||||
|
onto additional rows and overflow gets dropped (max 25 visible).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_data: Dify ``{"actions": [{"id", "title", "button_style"}, ...]}``.
|
||||||
|
buttons_per_row: 1..5. Mobile UI looks best at 2.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"content": {"rows": [{"buttons": [...]}]}}``.
|
||||||
|
"""
|
||||||
|
actions = list(form_data.get('actions') or [])[:25] # 5×5 hard cap
|
||||||
|
buttons_per_row = max(1, min(5, buttons_per_row))
|
||||||
|
|
||||||
|
def _button(idx: int, action: dict) -> dict:
|
||||||
|
action_id = str(action.get('id') or '')
|
||||||
|
label = str(action.get('title') or action_id or f'选项 {idx + 1}')
|
||||||
|
style_raw = (action.get('button_style') or '').lower()
|
||||||
|
# QQ: 0 灰色线框, 1 蓝色线框. Highlight the primary / first action.
|
||||||
|
if style_raw == 'primary' or (style_raw == '' and idx == 0):
|
||||||
|
style = 1
|
||||||
|
else:
|
||||||
|
style = 0
|
||||||
|
return {
|
||||||
|
'id': str(idx + 1),
|
||||||
|
'render_data': {
|
||||||
|
'label': label,
|
||||||
|
# Shown after the user clicks — gives local "已选择" feedback
|
||||||
|
# without a follow-up message. Style mimics DingTalk/Lark's
|
||||||
|
# in-card selection state.
|
||||||
|
'visited_label': f'✓ {label}',
|
||||||
|
'style': style,
|
||||||
|
},
|
||||||
|
'action': {
|
||||||
|
'type': 1, # callback button
|
||||||
|
'permission': {'type': 2}, # everyone can click
|
||||||
|
'data': action_id,
|
||||||
|
'unsupport_tips': '当前客户端版本不支持此按钮,请升级 QQ',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for row_start in range(0, len(actions), buttons_per_row):
|
||||||
|
row_actions = actions[row_start : row_start + buttons_per_row]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
'buttons': [_button(row_start + j, a) for j, a in enumerate(row_actions)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(rows) >= 5:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {'content': {'rows': rows}}
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialClient:
|
class QQOfficialClient:
|
||||||
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
@@ -28,10 +94,16 @@ class QQOfficialClient:
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
self._message_handlers = {}
|
self._message_handlers = {}
|
||||||
|
# Single optional handler for INTERACTION_CREATE (button click). We
|
||||||
|
# don't multiplex like message handlers — only the adapter cares,
|
||||||
|
# and the click<->resume path needs a single source of truth.
|
||||||
|
self._interaction_handler: Optional[Callable[[Dict[str, Any], Optional[str]], Any]] = None
|
||||||
self.base_url = 'https://api.sgroup.qq.com'
|
self.base_url = 'https://api.sgroup.qq.com'
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._msg_seq_counter = 0
|
||||||
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
"""检查access_token是否存在"""
|
"""检查access_token是否存在"""
|
||||||
@@ -50,18 +122,18 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
try:
|
response = await client.post(url, json=params, headers=headers)
|
||||||
response = await client.post(url, json=params, headers=headers)
|
if response.status_code != 200:
|
||||||
if response.status_code == 200:
|
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
access_token = response_data.get('access_token')
|
access_token = response_data.get('access_token')
|
||||||
expires_in = int(response_data.get('expires_in', 7200))
|
expires_in = int(response_data.get('expires_in', 7200))
|
||||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||||
if access_token:
|
if access_token:
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
except Exception as e:
|
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
else:
|
||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception('Failed to get access_token: no access_token in response')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
@@ -87,10 +159,10 @@ class QQOfficialClient:
|
|||||||
try:
|
try:
|
||||||
body = await req.get_data()
|
body = await req.get_data()
|
||||||
|
|
||||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||||
|
|
||||||
if not body or len(body) == 0:
|
if not body or len(body) == 0:
|
||||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
await self.logger.info('Received empty body, might be health check or GET request')
|
||||||
return {'code': 0, 'message': 'ok'}, 200
|
return {'code': 0, 'message': 'ok'}, 200
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
@@ -103,6 +175,23 @@ class QQOfficialClient:
|
|||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
if payload.get('op') == 0:
|
if payload.get('op') == 0:
|
||||||
|
# INTERACTION_CREATE (button click) skips ``get_message`` —
|
||||||
|
# that helper only flattens message-event fields and would
|
||||||
|
# drop ``data.resolved.button_data`` / ``data.button_id``.
|
||||||
|
if payload.get('t') == 'INTERACTION_CREATE':
|
||||||
|
if self._interaction_handler:
|
||||||
|
try:
|
||||||
|
d = payload.get('d') or {}
|
||||||
|
# Top-level ``id`` is the ws/event id used as
|
||||||
|
# ``event_id`` for passive replies. ``d.id``
|
||||||
|
# is the interaction id used for ACK. Do not
|
||||||
|
# confuse the two — QQ rejects misuse with
|
||||||
|
# 40034025.
|
||||||
|
ws_event_id = payload.get('id')
|
||||||
|
await self._interaction_handler(d, ws_event_id)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in interaction handler: {traceback.format_exc()}')
|
||||||
|
return {'code': 0, 'message': 'success'}
|
||||||
message_data = await self.get_message(payload)
|
message_data = await self.get_message(payload)
|
||||||
if message_data:
|
if message_data:
|
||||||
event = QQOfficialEvent.from_payload(message_data)
|
event = QQOfficialEvent.from_payload(message_data)
|
||||||
@@ -111,7 +200,6 @@ class QQOfficialClient:
|
|||||||
return {'code': 0, 'message': 'success'}
|
return {'code': 0, 'message': 'success'}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
|
|
||||||
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||||
return {'error': str(e)}, 400
|
return {'error': str(e)}, 400
|
||||||
|
|
||||||
@@ -130,6 +218,21 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_interaction(self):
|
||||||
|
"""Register a single handler for INTERACTION_CREATE events.
|
||||||
|
|
||||||
|
The handler receives ``(data_dict, interaction_id)`` — the raw
|
||||||
|
``d`` payload plus the top-level ``id`` field (the interaction
|
||||||
|
id, needed for the PUT /interactions/{id} ack and for reuse as
|
||||||
|
an ``event_id`` on the resumed reply within 30 minutes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable[[Dict[str, Any], Optional[str]], Any]):
|
||||||
|
self._interaction_handler = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def _handle_message(self, event: QQOfficialEvent):
|
async def _handle_message(self, event: QQOfficialEvent):
|
||||||
"""处理消息事件"""
|
"""处理消息事件"""
|
||||||
msg_type = event.t
|
msg_type = event.t
|
||||||
@@ -139,21 +242,24 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||||
"""获取消息"""
|
"""获取消息"""
|
||||||
|
d = msg.get('d', {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return {}
|
||||||
message_data = {
|
message_data = {
|
||||||
't': msg.get('t', {}),
|
't': msg.get('t', {}),
|
||||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
'timestamp': d.get('timestamp', {}),
|
||||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
'd_author_id': d.get('author', {}).get('id', {}),
|
||||||
'content': msg.get('d', {}).get('content', {}),
|
'content': d.get('content', {}),
|
||||||
'd_id': msg.get('d', {}).get('id', {}),
|
'd_id': d.get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
'channel_id': d.get('channel_id', {}),
|
||||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
'username': d.get('author', {}).get('username', {}),
|
||||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
'guild_id': d.get('guild_id', {}),
|
||||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
'member_openid': d.get('author', {}).get('openid', {}),
|
||||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
'group_openid': d.get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = msg.get('d', {}).get('attachments', [])
|
attachments = d.get('attachments', [])
|
||||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||||
image_attachments_type = [
|
image_attachments_type = [
|
||||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||||
@@ -171,8 +277,20 @@ class QQOfficialClient:
|
|||||||
content_type = attachment.get('content_type', '')
|
content_type = attachment.get('content_type', '')
|
||||||
return content_type.startswith('image/')
|
return content_type.startswith('image/')
|
||||||
|
|
||||||
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
|
async def send_private_text_msg(
|
||||||
"""发送私聊消息"""
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
):
|
||||||
|
"""Send a c2c text message.
|
||||||
|
|
||||||
|
Either ``msg_id`` (inbound user msg, free passive reply) or
|
||||||
|
``event_id`` (e.g. INTERACTION_CREATE id, valid 30 min) is
|
||||||
|
required. Without either, the call costs the proactive-send quota.
|
||||||
|
"""
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
@@ -182,21 +300,36 @@ class QQOfficialClient:
|
|||||||
'Authorization': f'QQBot {self.access_token}',
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
data = {
|
data: dict[str, Any] = {
|
||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_id': msg_id,
|
'msg_seq': msg_seq,
|
||||||
}
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
async def send_group_text_msg(
|
||||||
"""发送群聊消息"""
|
self,
|
||||||
|
group_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
):
|
||||||
|
"""Send a group text message.
|
||||||
|
|
||||||
|
Either ``msg_id`` or ``event_id`` is required (see
|
||||||
|
:meth:`send_private_text_msg` for the distinction).
|
||||||
|
"""
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
@@ -206,16 +339,20 @@ class QQOfficialClient:
|
|||||||
'Authorization': f'QQBot {self.access_token}',
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
data = {
|
data: dict[str, Any] = {
|
||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_id': msg_id,
|
'msg_seq': msg_seq,
|
||||||
}
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||||
raise Exception(response.read().decode())
|
raise Exception(response.read().decode())
|
||||||
|
|
||||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||||
@@ -238,7 +375,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||||
@@ -261,9 +398,324 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
|
# ---- 富媒体消息 ----
|
||||||
|
|
||||||
|
# 媒体文件类型
|
||||||
|
MEDIA_TYPE_IMAGE = 1
|
||||||
|
MEDIA_TYPE_VIDEO = 2
|
||||||
|
MEDIA_TYPE_VOICE = 3
|
||||||
|
MEDIA_TYPE_FILE = 4
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_type: int,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""上传媒体文件,返回 file_info。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_type: 'c2c' | 'group'
|
||||||
|
target_id: 用户 openid 或群 openid
|
||||||
|
file_type: 1=图片, 2=视频, 3=语音, 4=文件
|
||||||
|
file_url: 在线 URL(与 file_data 二选一)
|
||||||
|
file_data: base64 编码的文件数据或 data URL(与 file_url 二选一)
|
||||||
|
file_name: 文件名(file_type=4 时必填)
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/files'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/files'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'file_type': file_type,
|
||||||
|
'srv_send_msg': False,
|
||||||
|
}
|
||||||
|
if file_url:
|
||||||
|
body['url'] = file_url
|
||||||
|
elif file_data:
|
||||||
|
# 处理 data URL 格式: data:image/png;base64,xxxxx
|
||||||
|
if file_data.startswith('data:'):
|
||||||
|
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
body['file_data'] = match.group(1)
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
raise ValueError('file_url or file_data is required')
|
||||||
|
|
||||||
|
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||||
|
body['file_name'] = file_name
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
file_info = data.get('file_info', '')
|
||||||
|
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||||
|
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _send_media_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_info: str,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送富媒体消息(msg_type=7)"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
self._msg_seq_counter += 1
|
||||||
|
msg_seq = self._msg_seq_counter
|
||||||
|
body = {
|
||||||
|
'msg_type': 7,
|
||||||
|
'media': {'file_info': file_info},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if content:
|
||||||
|
body['content'] = content
|
||||||
|
if msg_id:
|
||||||
|
body['msg_id'] = msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def send_image_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送图片消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_IMAGE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
|
||||||
|
|
||||||
|
async def send_voice_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送语音消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_VOICE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_file_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送文件消息(含视频)"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_FILE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_stream_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
event_id: str,
|
||||||
|
msg_id: str,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
index: int = 0,
|
||||||
|
stream_msg_id: str = None,
|
||||||
|
input_state: int = 1,
|
||||||
|
):
|
||||||
|
"""发送流式消息(C2C 私聊)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_state: 1=生成中, 10=生成结束
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
|
||||||
|
body = {
|
||||||
|
'input_mode': 'replace',
|
||||||
|
'input_state': input_state,
|
||||||
|
'content_type': 'markdown',
|
||||||
|
'content_raw': content,
|
||||||
|
'event_id': event_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
'index': index,
|
||||||
|
}
|
||||||
|
if stream_msg_id:
|
||||||
|
body['stream_msg_id'] = stream_msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def send_markdown_keyboard(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
markdown_content: str,
|
||||||
|
keyboard: dict,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a ``msg_type=2`` (markdown) message carrying a keyboard.
|
||||||
|
|
||||||
|
The keyboard ride-along is the only documented way to attach
|
||||||
|
buttons in QQ official; pure keyboard-only messages are not
|
||||||
|
accepted by the server (markdown content is required).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_type: 'c2c' (single chat), 'group', 'channel' (text
|
||||||
|
channel — uses POST /channels/{id}/messages instead of v2).
|
||||||
|
target_id: openid for c2c/group, channel_id for channel.
|
||||||
|
markdown_content: Plain markdown text shown above the buttons.
|
||||||
|
keyboard: ``{'content': {'rows': [{'buttons': [...]}]}}`` per
|
||||||
|
the official spec. Use :func:`build_keyboard_from_form`
|
||||||
|
to construct from Dify form_data.
|
||||||
|
msg_id: Inbound user message id; turns this into a passive
|
||||||
|
reply (preferred — no monthly quota cost).
|
||||||
|
event_id: Use ``INTERACTION_CREATE`` event id from a prior
|
||||||
|
button click to keep within the 30-minute passive window
|
||||||
|
without an inbound msg_id.
|
||||||
|
msg_seq: De-dup counter when reusing msg_id.
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
elif target_type == 'channel':
|
||||||
|
url = f'{self.base_url}/channels/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
'msg_type': 2,
|
||||||
|
'markdown': {'content': markdown_content},
|
||||||
|
'keyboard': keyboard,
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if msg_id:
|
||||||
|
body['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
body['event_id'] = event_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
await self.logger.error(
|
||||||
|
f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}'
|
||||||
|
)
|
||||||
|
raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def ack_interaction(self, interaction_id: str, code: int = 0) -> None:
|
||||||
|
"""Acknowledge a button-click INTERACTION_CREATE event.
|
||||||
|
|
||||||
|
QQ keeps the client in a loading spinner until this ack is
|
||||||
|
received. Should be called as soon as the click is parsed, before
|
||||||
|
any heavier downstream work (the actual workflow resume can run
|
||||||
|
async).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction_id: The ``id`` field from the INTERACTION_CREATE event.
|
||||||
|
code: 0=success, 1=fail, 2=rate-limited, 3=duplicate, 4=no
|
||||||
|
permission, 5=admin only. Default 0.
|
||||||
|
"""
|
||||||
|
if not interaction_id:
|
||||||
|
return
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/interactions/{interaction_id}'
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await client.put(url, headers=headers, json={'code': code})
|
||||||
|
if response.status_code >= 400:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'ack_interaction non-success: HTTP {response.status_code} {response.text}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.warning(f'ack_interaction error (non-fatal): {e}')
|
||||||
|
|
||||||
async def is_token_expired(self):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
@@ -292,3 +744,346 @@ class QQOfficialClient:
|
|||||||
'signature': signature,
|
'signature': signature,
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# ---- WebSocket Gateway ----
|
||||||
|
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||||
|
|
||||||
|
INTENT_GUILDS = 1 << 0
|
||||||
|
INTENT_GUILD_MEMBERS = 1 << 1
|
||||||
|
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
|
||||||
|
INTENT_DIRECT_MESSAGE = 1 << 12
|
||||||
|
INTENT_GROUP_AND_C2C = 1 << 25
|
||||||
|
INTENT_INTERACTION = 1 << 26
|
||||||
|
|
||||||
|
FULL_INTENTS = (
|
||||||
|
INTENT_GUILDS
|
||||||
|
| INTENT_GUILD_MEMBERS
|
||||||
|
| INTENT_PUBLIC_GUILD_MESSAGES
|
||||||
|
| INTENT_DIRECT_MESSAGE
|
||||||
|
| INTENT_GROUP_AND_C2C
|
||||||
|
| INTENT_INTERACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gateway_url(self) -> str:
|
||||||
|
"""获取 WebSocket 网关地址"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/gateway'
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
}
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
ws_url = data.get('url', '')
|
||||||
|
if not ws_url:
|
||||||
|
raise Exception('Gateway URL is empty')
|
||||||
|
return ws_url
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _background_token_refresh(self):
|
||||||
|
"""在 token 到期前主动刷新"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if self.access_token_expiry_time:
|
||||||
|
remain = self.access_token_expiry_time - time.time()
|
||||||
|
if remain > 120:
|
||||||
|
await asyncio.sleep(remain - 60)
|
||||||
|
continue
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
if await self.check_access_token():
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
else:
|
||||||
|
await self.get_access_token()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
|
||||||
|
on_ready: 连接就绪 (收到 READY) 时的回调
|
||||||
|
on_error: 发生错误时的回调
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
reconnect_attempts = 0
|
||||||
|
max_reconnect_attempts = 100
|
||||||
|
backoff_delays = [1, 2, 5, 10, 30, 60]
|
||||||
|
rate_limit_delay = 60
|
||||||
|
|
||||||
|
# Cancel previous token refresh task if any
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = None
|
||||||
|
|
||||||
|
while reconnect_attempts <= max_reconnect_attempts:
|
||||||
|
heartbeat_interval = 45000
|
||||||
|
should_refresh_token = False
|
||||||
|
ws = None
|
||||||
|
heartbeat_task = None
|
||||||
|
|
||||||
|
# Refresh token if needed
|
||||||
|
if should_refresh_token:
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws_url = await self.get_gateway_url()
|
||||||
|
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
await self.logger.error(f'Failed to get gateway URL: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
|
||||||
|
delay = rate_limit_delay
|
||||||
|
else:
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.logger.info('Connecting to WebSocket gateway...')
|
||||||
|
ws = await websockets.connect(ws_url)
|
||||||
|
await self.logger.info('WebSocket connected')
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw_msg in ws:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_msg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = payload.get('op')
|
||||||
|
d = payload.get('d', {})
|
||||||
|
s = payload.get('s')
|
||||||
|
t = payload.get('t')
|
||||||
|
# Top-level event id, distinct from `d.id`. Per QQ
|
||||||
|
# spec this is the only value accepted as ``event_id``
|
||||||
|
# in subsequent passive-reply send-message calls
|
||||||
|
# (``d.id`` for INTERACTION_CREATE is the interaction
|
||||||
|
# id, used solely for PUT /interactions/{id} ack).
|
||||||
|
ws_event_id = payload.get('id')
|
||||||
|
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
d = {}
|
||||||
|
|
||||||
|
if op == 10: # Hello
|
||||||
|
heartbeat_interval = d.get('heartbeat_interval', 45000)
|
||||||
|
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
|
||||||
|
|
||||||
|
# Send Identify or Resume
|
||||||
|
if session_id and last_seq > 0:
|
||||||
|
resume_payload = {
|
||||||
|
'op': 6,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'session_id': session_id,
|
||||||
|
'seq': last_seq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(resume_payload))
|
||||||
|
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
|
||||||
|
else:
|
||||||
|
identify_payload = {
|
||||||
|
'op': 2,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'intents': self.FULL_INTENTS,
|
||||||
|
'shard': [0, 1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(identify_payload))
|
||||||
|
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
async def _heartbeat_loop(conn, interval_ms):
|
||||||
|
interval_sec = interval_ms / 1000.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_sec)
|
||||||
|
try:
|
||||||
|
hb_payload = {'op': 1, 'd': last_seq}
|
||||||
|
await conn.send(json.dumps(hb_payload))
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
|
||||||
|
|
||||||
|
elif op == 0: # Dispatch
|
||||||
|
if s is not None:
|
||||||
|
last_seq = s
|
||||||
|
|
||||||
|
if t == 'READY':
|
||||||
|
session_id = d.get('session_id', '')
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info(f'READY, session_id={session_id}')
|
||||||
|
if on_ready:
|
||||||
|
try:
|
||||||
|
result = on_ready()
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Track token refresh task to avoid leaks
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
|
||||||
|
|
||||||
|
elif t == 'RESUMED':
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info('RESUMED')
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
|
# INTERACTION_CREATE bypasses the regular
|
||||||
|
# on_event dispatcher so the adapter sees the
|
||||||
|
# top-level ws_event_id (needed as event_id
|
||||||
|
# for the resumed reply) — same shape as the
|
||||||
|
# webhook handler.
|
||||||
|
if t == 'INTERACTION_CREATE':
|
||||||
|
if self._interaction_handler:
|
||||||
|
try:
|
||||||
|
result = self._interaction_handler(d, ws_event_id)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'Error in interaction handler (ws): {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
elif on_event:
|
||||||
|
try:
|
||||||
|
result = on_event(t, d)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
elif op == 11: # Heartbeat ACK
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == 7: # Reconnect
|
||||||
|
await self.logger.info('Received Reconnect directive')
|
||||||
|
break
|
||||||
|
|
||||||
|
elif op == 9: # Invalid Session
|
||||||
|
can_resume = d.get('can_resume', False)
|
||||||
|
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
|
||||||
|
if not can_resume:
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Connection closed normally (end of async for)
|
||||||
|
try:
|
||||||
|
close_code = ws.close_code
|
||||||
|
close_reason = ws.close_reason or ''
|
||||||
|
except Exception:
|
||||||
|
close_code = None
|
||||||
|
close_reason = ''
|
||||||
|
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
|
||||||
|
|
||||||
|
if close_code == 4004:
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code in (4006, 4007, 4009):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code == 4008:
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = rate_limit_delay
|
||||||
|
await self.logger.info(
|
||||||
|
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
elif close_code in (4914, 4915):
|
||||||
|
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, err)
|
||||||
|
return
|
||||||
|
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
|
||||||
|
if close_code == 1000:
|
||||||
|
return
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
|
||||||
|
finally:
|
||||||
|
if heartbeat_task:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if ws:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we reach here, we need to reconnect
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if reconnect_attempts > max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
|
||||||
|
return
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def _safe_callback(self, callback, *args):
|
||||||
|
"""Safely invoke a callback, handling both sync and async functions."""
|
||||||
|
try:
|
||||||
|
result = callback(*args)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway_loop(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""持续重连的网关循环。"""
|
||||||
|
await self.connect_gateway(on_event, on_ready, on_error)
|
||||||
|
|||||||
@@ -67,10 +67,25 @@ class StreamSession:
|
|||||||
# 反馈 ID,用于接收用户点赞/点踩反馈
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
feedback_id: Optional[str] = None
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
# Dify 人工输入暂停态:runner 把 _form_data 传过来时填充。
|
||||||
|
# 一旦设置,下次企微 followup 请求时返回 button_interaction 模板卡
|
||||||
|
# 替代 stream chunk。点击按钮会回调 template_card_event,EventKey
|
||||||
|
# 就是 Dify 的 action_id。
|
||||||
|
pending_form: Optional[dict] = None
|
||||||
|
|
||||||
|
# template_card task_id(企微要求 button_interaction 必填且不可重复)。
|
||||||
|
# 创建 pending_form 时生成;按钮点击回调里用来反查 session。
|
||||||
|
pending_form_task_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
|
|
||||||
|
# Sessions with registered feedback_ids use a longer TTL to survive the
|
||||||
|
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||||
|
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||||
|
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
@@ -78,6 +93,9 @@ class StreamSessionManager:
|
|||||||
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 映射
|
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||||
|
# task_id (button_interaction template_card 的) -> stream_id 映射,
|
||||||
|
# 用于按钮点击回调里反查 pending_form。
|
||||||
|
self._task_index: dict[str, str] = {}
|
||||||
|
|
||||||
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:
|
||||||
@@ -113,6 +131,40 @@ class StreamSessionManager:
|
|||||||
if feedback_id and stream_id:
|
if feedback_id and stream_id:
|
||||||
self._feedback_index[feedback_id] = stream_id
|
self._feedback_index[feedback_id] = stream_id
|
||||||
|
|
||||||
|
def set_pending_form(self, stream_id: str, form_data: dict, task_id: str) -> None:
|
||||||
|
"""把 Dify 人工输入暂停态绑定到 stream session。
|
||||||
|
|
||||||
|
下一次企微 followup 请求时,adapter 检测到 pending_form,
|
||||||
|
返回 button_interaction 模板卡而不是 stream chunk。
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(stream_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
session.pending_form = form_data
|
||||||
|
session.pending_form_task_id = task_id
|
||||||
|
if task_id:
|
||||||
|
self._task_index[task_id] = stream_id
|
||||||
|
|
||||||
|
def get_session_by_task_id(self, task_id: str) -> Optional[StreamSession]:
|
||||||
|
"""按按钮点击回调里的 TaskId 反查 session。"""
|
||||||
|
if not task_id:
|
||||||
|
return None
|
||||||
|
stream_id = self._task_index.get(task_id)
|
||||||
|
if not stream_id:
|
||||||
|
return None
|
||||||
|
return self._sessions.get(stream_id)
|
||||||
|
|
||||||
|
def clear_pending_form(self, stream_id: str) -> None:
|
||||||
|
"""按钮点击消费完后清掉 pending_form,避免重复弹卡。"""
|
||||||
|
session = self._sessions.get(stream_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
task_id = session.pending_form_task_id
|
||||||
|
session.pending_form = None
|
||||||
|
session.pending_form_task_id = None
|
||||||
|
if task_id:
|
||||||
|
self._task_index.pop(task_id, None)
|
||||||
|
|
||||||
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]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -214,11 +266,17 @@ class StreamSessionManager:
|
|||||||
session.last_access = time.time()
|
session.last_access = time.time()
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||||
|
|
||||||
|
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||||
|
不会因为 session 被提前清除而丢失上下文信息。
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired: list[str] = []
|
expired: list[str] = []
|
||||||
for stream_id, session in self._sessions.items():
|
for stream_id, session in self._sessions.items():
|
||||||
if now - session.last_access > self.ttl:
|
# Sessions with registered feedback_ids use a longer TTL
|
||||||
|
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
|
||||||
|
if now - session.last_access > effective_ttl:
|
||||||
expired.append(stream_id)
|
expired.append(stream_id)
|
||||||
|
|
||||||
for stream_id in expired:
|
for stream_id in expired:
|
||||||
@@ -228,6 +286,9 @@ 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:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
@@ -434,10 +495,10 @@ async def parse_wecom_bot_message(
|
|||||||
}
|
}
|
||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
message_data['content'] = voice_info.get('content')
|
message_data['content'] = voice_info.get('content')
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
# 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)
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if voice_base64:
|
# if voice_base64:
|
||||||
message_data['voice']['base64'] = voice_base64
|
# message_data['voice']['base64'] = voice_base64
|
||||||
elif msg_type == 'video':
|
elif msg_type == 'video':
|
||||||
video_info = msg_json.get('video', {}) or {}
|
video_info = msg_json.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
@@ -449,10 +510,12 @@ async def parse_wecom_bot_message(
|
|||||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if video_base64:
|
# if video_base64:
|
||||||
video_data['base64'] = video_base64
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['video'] = video_data
|
message_data['video'] = video_data
|
||||||
elif msg_type == 'file':
|
elif msg_type == 'file':
|
||||||
file_info = msg_json.get('file', {}) or {}
|
file_info = msg_json.get('file', {}) or {}
|
||||||
@@ -466,12 +529,15 @@ async def parse_wecom_bot_message(
|
|||||||
'download_url': download_url,
|
'download_url': download_url,
|
||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
if file_bytes:
|
# if file_bytes:
|
||||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
if dl_filename and not file_data.get('filename'):
|
# if dl_filename and not file_data.get('filename'):
|
||||||
file_data['filename'] = dl_filename
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['file'] = file_data
|
message_data['file'] = file_data
|
||||||
elif msg_type == 'link':
|
elif msg_type == 'link':
|
||||||
message_data['link'] = msg_json.get('link', {})
|
message_data['link'] = msg_json.get('link', {})
|
||||||
@@ -587,9 +653,196 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = 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
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
|
def build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str, Any]:
|
||||||
|
"""Build a `template_card` (button_interaction) WeCom payload.
|
||||||
|
|
||||||
|
Shared by both the webhook-mode client (returns the payload as the
|
||||||
|
response to a stream-followup callback) and the ws_client (sends it
|
||||||
|
as a reply frame). Output shape is `{"msgtype": "template_card",
|
||||||
|
"template_card": {...}}` per the WeCom spec.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_data: Dify human-input form data with keys ``actions`` (list of
|
||||||
|
``{id, title, button_style}``), ``node_title``, ``form_content``.
|
||||||
|
task_id: Unique per-card identifier. WeCom requires this for
|
||||||
|
button_interaction. The click callback returns it as TaskId so we
|
||||||
|
can find the originating session.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
* ``button.key`` is set directly to the Dify ``action_id``. The click
|
||||||
|
callback's ``EventKey`` carries this back unchanged (1024-byte limit
|
||||||
|
per the spec, far more than we ever need).
|
||||||
|
* WeCom caps the button list at 6. Extra actions are appended to
|
||||||
|
``sub_title_text`` so users can still reply with the id as text.
|
||||||
|
* Styles map ``primary``→1 (blue), ``danger``→2 (red), default→0
|
||||||
|
(gray). First button is auto-promoted to primary when no style.
|
||||||
|
"""
|
||||||
|
actions = list(form_data.get('actions') or [])
|
||||||
|
node_title = (form_data.get('node_title') or '').strip() or '人工介入'
|
||||||
|
form_content = (form_data.get('form_content') or '').strip()
|
||||||
|
|
||||||
|
visible_actions = actions[:6]
|
||||||
|
overflow = actions[6:]
|
||||||
|
|
||||||
|
sub_title_parts: list[str] = []
|
||||||
|
if form_content:
|
||||||
|
sub_title_parts.append(form_content)
|
||||||
|
if overflow:
|
||||||
|
extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow]
|
||||||
|
sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines))
|
||||||
|
sub_title_text = '\n\n'.join(sub_title_parts) or '请选择一个操作以继续。'
|
||||||
|
|
||||||
|
button_list = []
|
||||||
|
for idx, action in enumerate(visible_actions):
|
||||||
|
action_id = str(action.get('id') or '')
|
||||||
|
title = str(action.get('title') or action_id or f'选项 {idx + 1}')
|
||||||
|
style_raw = (action.get('button_style') or '').lower()
|
||||||
|
if style_raw == 'primary' or (style_raw == '' and idx == 0):
|
||||||
|
style = 1
|
||||||
|
elif style_raw == 'danger':
|
||||||
|
style = 2
|
||||||
|
else:
|
||||||
|
style = 0
|
||||||
|
button_list.append(
|
||||||
|
{
|
||||||
|
'text': title,
|
||||||
|
'style': style,
|
||||||
|
'key': action_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = {
|
||||||
|
'card_type': 'button_interaction',
|
||||||
|
'main_title': {
|
||||||
|
'title': node_title,
|
||||||
|
},
|
||||||
|
'sub_title_text': sub_title_text,
|
||||||
|
'button_list': button_list,
|
||||||
|
'task_id': task_id,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'msgtype': 'template_card',
|
||||||
|
'template_card': card,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WecomBotClient:
|
class WecomBotClient:
|
||||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
||||||
"""企业微信智能机器人客户端。
|
"""企业微信智能机器人客户端。
|
||||||
@@ -628,6 +881,7 @@ class WecomBotClient:
|
|||||||
self.stream_poll_timeout = 0.5
|
self.stream_poll_timeout = 0.5
|
||||||
|
|
||||||
self._feedback_callback: Optional[Callable] = None
|
self._feedback_callback: Optional[Callable] = None
|
||||||
|
self._card_action_callback: Optional[Callable] = None
|
||||||
|
|
||||||
def set_feedback_callback(self, callback: Callable) -> None:
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
"""设置反馈回调函数。
|
"""设置反馈回调函数。
|
||||||
@@ -637,6 +891,19 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
self._feedback_callback = callback
|
self._feedback_callback = callback
|
||||||
|
|
||||||
|
def set_card_action_callback(self, callback: Callable) -> None:
|
||||||
|
"""设置按钮卡片点击回调函数。
|
||||||
|
|
||||||
|
Signature: ``async def callback(session, action_id, task_id, raw_event) -> None``
|
||||||
|
|
||||||
|
``session`` is the StreamSession the card was attached to;
|
||||||
|
``action_id`` is the Dify action_id reflected back via the
|
||||||
|
button's ``key`` field; ``task_id`` is the card's task_id
|
||||||
|
(matches ``session.pending_form_task_id``); ``raw_event`` is the
|
||||||
|
decoded callback JSON for any extra fields the adapter wants.
|
||||||
|
"""
|
||||||
|
self._card_action_callback = callback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_stream_payload(
|
def _build_stream_payload(
|
||||||
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
@@ -667,6 +934,12 @@ class WecomBotClient:
|
|||||||
'stream': stream_payload,
|
'stream': stream_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str, Any]:
|
||||||
|
"""Class-level shim — delegates to module-level builder so ws_client
|
||||||
|
can reuse the exact same payload shape without importing the class."""
|
||||||
|
return build_button_interaction_payload(form_data, task_id)
|
||||||
|
|
||||||
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]:
|
||||||
"""对响应进行加密封装并返回给企业微信。
|
"""对响应进行加密封装并返回给企业微信。
|
||||||
|
|
||||||
@@ -759,6 +1032,22 @@ class WecomBotClient:
|
|||||||
return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce)
|
return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce)
|
||||||
|
|
||||||
session = self.stream_sessions.get_session(stream_id)
|
session = self.stream_sessions.get_session(stream_id)
|
||||||
|
|
||||||
|
# If a Dify human-input pause arrived during this stream, switch
|
||||||
|
# the response from `msgtype: stream` to `msgtype: template_card`
|
||||||
|
# (button_interaction). The session's stream is also marked
|
||||||
|
# finished so future followups aren't expected (assuming the
|
||||||
|
# WeCom client treats template_card as the terminal response —
|
||||||
|
# we'll know from the next callback whether it kept polling).
|
||||||
|
if session and session.pending_form and session.pending_form_task_id:
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot: returning button_interaction for stream_id={stream_id} '
|
||||||
|
f'task_id={session.pending_form_task_id} actions={len(session.pending_form.get("actions") or [])}'
|
||||||
|
)
|
||||||
|
card_payload = self._build_button_interaction_payload(session.pending_form, session.pending_form_task_id)
|
||||||
|
self.stream_sessions.mark_finished(stream_id)
|
||||||
|
return await self._encrypt_and_reply(card_payload, nonce)
|
||||||
|
|
||||||
chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout)
|
chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout)
|
||||||
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -867,11 +1156,50 @@ class WecomBotClient:
|
|||||||
if event_type == 'feedback_event':
|
if event_type == 'feedback_event':
|
||||||
return await self._handle_feedback_event(msg_json, nonce)
|
return await self._handle_feedback_event(msg_json, nonce)
|
||||||
|
|
||||||
|
# Button click on a button_interaction template_card. The WeCom doc
|
||||||
|
# calls this `template_card_event`; some routes wrap the button
|
||||||
|
# event payload inside `event.template_card_event`.
|
||||||
|
if event_type == 'template_card_event':
|
||||||
|
return await self._handle_template_card_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_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
|
"""Handle a button click on a button_interaction template_card.
|
||||||
|
|
||||||
|
WeCom carries the click info in ``event.template_card_event`` with
|
||||||
|
``TaskId`` matching the card we created and ``EventKey`` carrying
|
||||||
|
the button's ``key`` (which we set to the Dify ``action_id``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tce = msg_json.get('event', {}).get('template_card_event', {})
|
||||||
|
task_id = tce.get('TaskId') or tce.get('task_id') or ''
|
||||||
|
event_key = tce.get('EventKey') or tce.get('event_key') or ''
|
||||||
|
card_type = tce.get('CardType') or tce.get('card_type') or ''
|
||||||
|
|
||||||
|
await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}')
|
||||||
|
|
||||||
|
session = self.stream_sessions.get_session_by_task_id(task_id)
|
||||||
|
if session is None:
|
||||||
|
await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃')
|
||||||
|
else:
|
||||||
|
if self._card_action_callback is not None:
|
||||||
|
try:
|
||||||
|
await self._card_action_callback(session, event_key, task_id, msg_json)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'card action callback raised: {traceback.format_exc()}')
|
||||||
|
# Drop the form so a fresh chunk/followup doesn't re-render
|
||||||
|
# the same card (and so the task_id can be GC'd).
|
||||||
|
self.stream_sessions.clear_pending_form(session.stream_id)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'_handle_template_card_event error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# WeCom expects an empty success ack for event callbacks.
|
||||||
|
return await self._encrypt_and_reply({}, nonce)
|
||||||
|
|
||||||
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
"""处理企业微信用户反馈事件(点赞/点踩)。
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
@@ -898,35 +1226,38 @@ class WecomBotClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
await self.logger.info(
|
await self.logger.info(
|
||||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_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(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())
|
|
||||||
else:
|
else:
|
||||||
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
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:
|
except Exception:
|
||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
@@ -978,6 +1309,29 @@ class WecomBotClient:
|
|||||||
self.stream_sessions.mark_finished(stream_id)
|
self.stream_sessions.mark_finished(stream_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def push_form_pause(
|
||||||
|
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
|
||||||
|
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""Attach a Dify human-input pause to the active stream session.
|
||||||
|
|
||||||
|
On the next WeCom followup poll, the response switches from
|
||||||
|
``msgtype: stream`` to ``msgtype: template_card`` (button_interaction)
|
||||||
|
carrying the buttons. ``task_id`` is auto-generated if not provided
|
||||||
|
and is what the button-click callback uses to look the session back up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(ok, stream_id, task_id)``. ``ok`` is False if the
|
||||||
|
adapter's msg_id maps to no stream session (e.g. non-stream mode).
|
||||||
|
"""
|
||||||
|
stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id)
|
||||||
|
if not stream_id:
|
||||||
|
return False, None, None
|
||||||
|
if not task_id:
|
||||||
|
# WeCom requires task_id [A-Za-z0-9_-@], <= 128 bytes, unique per bot.
|
||||||
|
task_id = f'dify-{uuid.uuid4().hex[:24]}'
|
||||||
|
self.stream_sessions.set_pending_form(stream_id, form_data, task_id)
|
||||||
|
return True, stream_id, task_id
|
||||||
|
|
||||||
async def set_message(self, msg_id: str, content: str):
|
async def set_message(self, msg_id: str, content: str):
|
||||||
"""兼容旧逻辑:若无法流式返回则缓存最终结果。
|
"""兼容旧逻辑:若无法流式返回则缓存最终结果。
|
||||||
|
|
||||||
|
|||||||
@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
|
|||||||
流式消息 ID
|
流式消息 ID
|
||||||
"""
|
"""
|
||||||
return self.get('stream_id', '')
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
from langbot.libs.wecom_ai_bot_api.api import (
|
||||||
|
parse_wecom_bot_message,
|
||||||
|
StreamSession,
|
||||||
|
build_button_interaction_payload,
|
||||||
|
)
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -96,6 +100,24 @@ class WecomBotWsClient:
|
|||||||
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
# Dedup: skip sending when content hasn't changed
|
# Dedup: skip sending when content hasn't changed
|
||||||
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
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
|
||||||
|
|
||||||
|
# Dify human-input pause state for ws mode. Keys are task_id (echoed
|
||||||
|
# back in template_card_event.TaskId so we can rebuild the session
|
||||||
|
# context on click).
|
||||||
|
# task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
self._pending_forms_by_task: dict[str, dict] = {}
|
||||||
|
# Reverse: msg_id -> task_id (for cleanup when stream finishes).
|
||||||
|
self._task_id_by_msg: dict[str, str] = {}
|
||||||
|
# Optional card-action callback registered by the adapter.
|
||||||
|
# Signature mirrors the http-mode WecomBotClient:
|
||||||
|
# async def callback(session, action_id, task_id, raw_event) -> None
|
||||||
|
self._card_action_callback: Optional[Callable] = None
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +186,27 @@ class WecomBotWsClient:
|
|||||||
|
|
||||||
return decorator
|
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(
|
async def reply_stream(
|
||||||
self,
|
self,
|
||||||
req_id: str,
|
req_id: str,
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
finish: bool = False,
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Send a streaming reply frame.
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
@@ -178,17 +215,22 @@ class WecomBotWsClient:
|
|||||||
stream_id: The stream ID for this streaming session.
|
stream_id: The stream ID for this streaming session.
|
||||||
content: The content to send (supports Markdown).
|
content: The content to send (supports Markdown).
|
||||||
finish: Whether this is the final chunk.
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ACK frame dict, or None on failure.
|
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 = {
|
body = {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
@@ -210,6 +252,83 @@ class WecomBotWsClient:
|
|||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def reply_template_card(self, req_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
|
||||||
|
"""Send a template_card (button_interaction etc.) reply.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame.
|
||||||
|
card_payload: Body produced by ``build_button_interaction_payload``;
|
||||||
|
must contain ``msgtype`` and ``template_card`` keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
return await self._send_reply(req_id, card_payload)
|
||||||
|
|
||||||
|
def set_card_action_callback(self, callback: Callable) -> None:
|
||||||
|
"""Register the button-click handler.
|
||||||
|
|
||||||
|
``async def callback(session, action_id, task_id, raw_event) -> None``
|
||||||
|
— same signature as the http-mode WecomBotClient version so the
|
||||||
|
adapter can hand both off to the same coroutine.
|
||||||
|
"""
|
||||||
|
self._card_action_callback = callback
|
||||||
|
|
||||||
|
async def push_form_pause(
|
||||||
|
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
|
||||||
|
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""Attach a Dify human-input pause to the active stream and send
|
||||||
|
the button_interaction card immediately.
|
||||||
|
|
||||||
|
ws mode has no notion of polled "followup" responses — each reply
|
||||||
|
is a one-shot frame send. So unlike the http path (which defers
|
||||||
|
card delivery to the next followup), here we just craft the card
|
||||||
|
and reply with it on the original req_id. The corresponding stream
|
||||||
|
session is then torn down so subsequent chunks don't re-send.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(ok, stream_id, task_id)``. ``ok=False`` if no active stream
|
||||||
|
for this msg_id (e.g. message arrived in non-stream mode).
|
||||||
|
"""
|
||||||
|
key = self._stream_ids.get(msg_id)
|
||||||
|
if not key:
|
||||||
|
return False, None, None
|
||||||
|
req_id, stream_id = key.split('|', 1)
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
task_id = f'dify-{secrets.token_hex(12)}'
|
||||||
|
|
||||||
|
session_info = self._stream_sessions.get(msg_id) or {}
|
||||||
|
self._pending_forms_by_task[task_id] = {
|
||||||
|
'form_data': form_data,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': session_info.get('user_id', ''),
|
||||||
|
'chat_id': session_info.get('chat_id', ''),
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'req_id': req_id,
|
||||||
|
}
|
||||||
|
self._task_id_by_msg[msg_id] = task_id
|
||||||
|
|
||||||
|
card_payload = build_button_interaction_payload(form_data, task_id)
|
||||||
|
try:
|
||||||
|
await self.reply_template_card(req_id, card_payload)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to send button_interaction card: {traceback.format_exc()}')
|
||||||
|
# Roll back the bookkeeping so the next attempt isn't blocked.
|
||||||
|
self._pending_forms_by_task.pop(task_id, None)
|
||||||
|
self._task_id_by_msg.pop(msg_id, None)
|
||||||
|
return False, stream_id, None
|
||||||
|
|
||||||
|
# Tear down the stream — WeCom expects either stream chunks OR a
|
||||||
|
# template_card, not both on the same req_id. Subsequent
|
||||||
|
# push_stream_chunk calls for this msg_id become no-ops.
|
||||||
|
self._stream_ids.pop(msg_id, None)
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
# Keep _stream_sessions so the button callback can still resolve
|
||||||
|
# user/chat context; it gets cleaned up when the click fires.
|
||||||
|
|
||||||
|
return True, stream_id, task_id
|
||||||
|
|
||||||
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
||||||
"""Proactively send a message to a specified chat.
|
"""Proactively send a message to a specified chat.
|
||||||
|
|
||||||
@@ -232,6 +351,23 @@ class WecomBotWsClient:
|
|||||||
body['text'] = {'content': content}
|
body['text'] = {'content': content}
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
async def send_template_card(self, chat_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
|
||||||
|
"""Proactively push a template_card to a chat.
|
||||||
|
|
||||||
|
Used for the resumed-workflow path (button click → new query):
|
||||||
|
synthetic events have no inbound req_id to reply against, so we
|
||||||
|
fall back to proactive ``aibot_send_msg`` instead of reply mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: userid (single chat) or chatid (group chat).
|
||||||
|
card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
|
||||||
|
as produced by :func:`build_button_interaction_payload`.
|
||||||
|
"""
|
||||||
|
req_id = _generate_req_id(CMD_SEND_MSG)
|
||||||
|
body = dict(card_payload)
|
||||||
|
body['chatid'] = chat_id
|
||||||
|
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:
|
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.
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
@@ -253,11 +389,23 @@ class WecomBotWsClient:
|
|||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# 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):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
return True
|
||||||
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
|
||||||
|
# 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
|
self._stream_last_content[msg_id] = content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
@@ -445,6 +593,15 @@ class WecomBotWsClient:
|
|||||||
msg_id = message_data.get('msgid', '')
|
msg_id = message_data.get('msgid', '')
|
||||||
if msg_id:
|
if msg_id:
|
||||||
self._stream_ids[msg_id] = f'{req_id}|{stream_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['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
@@ -454,7 +611,7 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_event_callback(self, frame: dict):
|
async def _handle_event_callback(self, frame: dict):
|
||||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
try:
|
try:
|
||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
@@ -479,14 +636,86 @@ class WecomBotWsClient:
|
|||||||
if body.get('chatid'):
|
if body.get('chatid'):
|
||||||
message_data['chatid'] = 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
|
||||||
|
|
||||||
|
if event_type == 'template_card_event':
|
||||||
|
tce = event_info.get('template_card_event', {})
|
||||||
|
task_id = tce.get('TaskId') or tce.get('task_id') or ''
|
||||||
|
event_key = tce.get('EventKey') or tce.get('event_key') or ''
|
||||||
|
card_type = tce.get('CardType') or tce.get('card_type') or ''
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到按钮点击 (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}'
|
||||||
|
)
|
||||||
|
pending = self._pending_forms_by_task.get(task_id)
|
||||||
|
if pending is None:
|
||||||
|
await self.logger.warning(f'未找到 task_id={task_id} 对应的 pending_form (ws),按钮点击被丢弃')
|
||||||
|
elif self._card_action_callback is not None:
|
||||||
|
try:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=pending.get('stream_id', ''),
|
||||||
|
msg_id=pending.get('msg_id', ''),
|
||||||
|
chat_id=pending.get('chat_id') or None,
|
||||||
|
user_id=pending.get('user_id') or None,
|
||||||
|
)
|
||||||
|
session.pending_form = pending.get('form_data')
|
||||||
|
session.pending_form_task_id = task_id
|
||||||
|
await self._card_action_callback(session, event_key, task_id, body)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}')
|
||||||
|
# Consume — drop bookkeeping so a stale click can't re-fire.
|
||||||
|
self._pending_forms_by_task.pop(task_id, None)
|
||||||
|
msg_id = pending.get('msg_id', '')
|
||||||
|
if msg_id:
|
||||||
|
self._task_id_by_msg.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
# Dispatch to event-specific handlers
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[event_type]:
|
for handler in self._message_handlers[event_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
# Also dispatch to generic 'event' handlers
|
|
||||||
if 'event' in self._message_handlers:
|
if 'event' in self._message_handlers:
|
||||||
for handler in self._message_handlers['event']:
|
for handler in self._message_handlers['event']:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""Embed widget routes - serve embeddable chat widget for external websites.
|
||||||
|
|
||||||
|
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
||||||
|
internal pipeline identifiers are never exposed to end-users. Each handler
|
||||||
|
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
||||||
|
the bound pipeline_uuid for internal routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......utils import paths
|
||||||
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache the widget template content
|
||||||
|
_widget_template_cache: str | None = None
|
||||||
|
_logo_bytes_cache: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_uuid(s: str) -> bool:
|
||||||
|
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_widget_template() -> str:
|
||||||
|
"""Load and cache the widget JS template."""
|
||||||
|
global _widget_template_cache
|
||||||
|
if _widget_template_cache is None:
|
||||||
|
template_path = paths.get_resource_path('templates/embed/widget.js')
|
||||||
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
|
_widget_template_cache = f.read()
|
||||||
|
return _widget_template_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _get_logo_bytes() -> bytes:
|
||||||
|
"""Load and cache the logo image."""
|
||||||
|
global _logo_bytes_cache
|
||||||
|
if _logo_bytes_cache is None:
|
||||||
|
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
||||||
|
with open(logo_path, 'rb') as f:
|
||||||
|
_logo_bytes_cache = f.read()
|
||||||
|
return _logo_bytes_cache
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('embed', '/api/v1/embed')
|
||||||
|
class EmbedRouterGroup(group.RouterGroup):
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_bot(self, bot_uuid: str):
|
||||||
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||||
|
|
||||||
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||||
|
``web_page_bot``, is disabled, or has no pipeline bound.
|
||||||
|
"""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if (
|
||||||
|
bot.bot_entity.uuid == bot_uuid
|
||||||
|
and bot.bot_entity.adapter == 'web_page_bot'
|
||||||
|
and bot.bot_entity.enable
|
||||||
|
and bot.bot_entity.use_pipeline_uuid
|
||||||
|
):
|
||||||
|
return bot, bot.bot_entity.use_pipeline_uuid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
||||||
|
return bot.bot_entity.adapter_config
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
return True
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
return False
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
ts_str, mac = token.split('.', 1)
|
||||||
|
ts = float(ts_str)
|
||||||
|
if time.time() - ts > 86400:
|
||||||
|
return False
|
||||||
|
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(mac, expected_mac)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- routes --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def verify_turnstile(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
token = data.get('token')
|
||||||
|
if not token:
|
||||||
|
return self.http_status(400, -1, 'Token is required')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
ts = time.time()
|
||||||
|
return self.success(data={'token': f'{ts}.dummy'})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data={'secret': secret, 'response': token},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
return self.http_status(403, -1, 'Turnstile verification failed')
|
||||||
|
|
||||||
|
ts = time.time()
|
||||||
|
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
session_token = f'{ts}.{mac}'
|
||||||
|
|
||||||
|
return self.success(data={'token': session_token})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_widget(bot_uuid: str) -> quart.Response:
|
||||||
|
"""Serve the embed widget JavaScript with injected configuration."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return quart.Response(
|
||||||
|
'// Bot not found or not available', status=404, content_type='application/javascript'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
template = _get_widget_template()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
||||||
|
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||||
|
if webhook_prefix:
|
||||||
|
base_url = webhook_prefix.rstrip('/')
|
||||||
|
|
||||||
|
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
site_key = config.get('turnstile_site_key', '')
|
||||||
|
locale = config.get('language', 'en_US') or 'en_US'
|
||||||
|
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
||||||
|
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
||||||
|
|
||||||
|
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=300'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_logo() -> quart.Response:
|
||||||
|
"""Serve the LangBot logo for the embed widget."""
|
||||||
|
try:
|
||||||
|
logo_data = _get_logo_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('', status=404)
|
||||||
|
|
||||||
|
response = quart.Response(logo_data, content_type='image/webp')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def submit_feedback(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
message_id = data.get('message_id', '')
|
||||||
|
feedback_type = data.get('feedback_type')
|
||||||
|
|
||||||
|
if feedback_type not in (1, 2, 3):
|
||||||
|
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
||||||
|
|
||||||
|
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
bot_id=runtime_bot.bot_entity.uuid,
|
||||||
|
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
||||||
|
pipeline_id=pipeline_uuid,
|
||||||
|
message_id=str(message_id),
|
||||||
|
platform='web_page_bot',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'feedback_id': feedback_id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
# -- Embed WebSocket endpoint ----------------------------------------
|
||||||
|
|
||||||
|
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||||
|
async def embed_websocket_connect(bot_uuid: str):
|
||||||
|
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = await ws_connection_manager.add_connection(
|
||||||
|
websocket=quart.websocket._get_current_object(),
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
|
)
|
||||||
|
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'type': 'connected',
|
||||||
|
'connection_id': connection.connection_id,
|
||||||
|
'bot_uuid': bot_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'timestamp': connection.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'Embed WebSocket connected: {connection.connection_id} '
|
||||||
|
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
||||||
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(receive_task, send_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket task error: {e}')
|
||||||
|
finally:
|
||||||
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||||
|
try:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- WebSocket receive/send helpers --------------------------------------
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
message = await quart.websocket.receive()
|
||||||
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
await connection.send_queue.put(
|
||||||
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
elif message_type == 'message':
|
||||||
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
elif message_type == 'disconnect':
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
async def _handle_send(self, connection):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
|
await quart.websocket.send(json.dumps(message))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
@@ -43,6 +43,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
||||||
|
owner_bot = self._find_owner_bot(pipeline_uuid)
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
websocket=quart.websocket._get_current_object(),
|
websocket=quart.websocket._get_current_object(),
|
||||||
@@ -70,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -178,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
def _find_owner_bot(self, pipeline_uuid: str):
|
||||||
|
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
||||||
|
return bot
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -203,7 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import asyncio
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
||||||
|
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
|
||||||
|
|
||||||
|
The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`.
|
||||||
|
`key` is the 32-byte AES-256 key locally generated when the bind task
|
||||||
|
was created and submitted as `key` to `q.qq.com/lite/create_bind_task`.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(encrypted_b64)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError('Malformed encrypted credential') from exc
|
||||||
|
if len(key) != 32 or len(raw) <= 28:
|
||||||
|
raise ValueError('Invalid encrypted credential layout')
|
||||||
|
nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:]
|
||||||
|
try:
|
||||||
|
return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8')
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError('Failed to decrypt credential') from exc
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('adapters', '/api/v1/platform/adapters')
|
@group.group_class('adapters', '/api/v1/platform/adapters')
|
||||||
class AdaptersRouterGroup(group.RouterGroup):
|
class AdaptersRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -35,3 +59,834 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In-memory session store for active registrations
|
||||||
|
_create_app_sessions: dict = {}
|
||||||
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
def _cleanup_expired_sessions():
|
||||||
|
"""Remove sessions that have exceeded their TTL."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
|
||||||
|
for sid in expired:
|
||||||
|
session = _create_app_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/lark/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import lark_oapi as lark
|
||||||
|
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
|
||||||
|
|
||||||
|
_cleanup_expired_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'app_id': None,
|
||||||
|
'app_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_create_app_sessions[session_id] = session
|
||||||
|
|
||||||
|
def on_qr_code(info):
|
||||||
|
# May be called from a background thread by the SDK;
|
||||||
|
# use call_soon_threadsafe to safely update session state.
|
||||||
|
def _update():
|
||||||
|
session['qr_url'] = info['url']
|
||||||
|
session['expire_at'] = time.time() + 600 # 10 minutes
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
async def run_registration():
|
||||||
|
try:
|
||||||
|
result = await lark.aregister_app(
|
||||||
|
on_qr_code=on_qr_code,
|
||||||
|
source='langbot',
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['app_id'] = result['client_id']
|
||||||
|
session['app_secret'] = result['client_secret']
|
||||||
|
except AppAccessDeniedError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'User denied authorization'
|
||||||
|
except AppExpiredError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_registration())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll registration status."""
|
||||||
|
session = _create_app_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['app_id'] = session['app_id']
|
||||||
|
data['app_secret'] = session['app_secret']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a registration session."""
|
||||||
|
session = _create_app_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeChat QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_weixin_login_sessions: dict = {}
|
||||||
|
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
|
||||||
|
|
||||||
|
def _cleanup_expired_weixin_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _weixin_login_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/weixin/login', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
_cleanup_expired_weixin_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_data_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'token': None,
|
||||||
|
'base_url': None,
|
||||||
|
'account_id': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_weixin_login_sessions[session_id] = session
|
||||||
|
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=DEFAULT_BASE_URL,
|
||||||
|
token='',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_login():
|
||||||
|
try:
|
||||||
|
|
||||||
|
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||||
|
def _update():
|
||||||
|
session['qr_data_url'] = qr_data_url
|
||||||
|
session['expire_at'] = time.time() + 180
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
result = await client.login(
|
||||||
|
max_retries=1,
|
||||||
|
poll_timeout_ms=180_000,
|
||||||
|
on_qrcode=on_qrcode,
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['token'] = result.token
|
||||||
|
session['base_url'] = result.base_url
|
||||||
|
session['account_id'] = result.account_id
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = error_message
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_login())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_data_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_data_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeChat login status."""
|
||||||
|
session = _weixin_login_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'status': session['status'],
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['token'] = session['token']
|
||||||
|
data['base_url'] = session['base_url']
|
||||||
|
data['account_id'] = session['account_id']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'expired':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeChat login session."""
|
||||||
|
session = _weixin_login_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# DingTalk Device Flow QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_dingtalk_sessions: dict = {}
|
||||||
|
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_dingtalk_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _dingtalk_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
|
||||||
|
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'client_id': None,
|
||||||
|
'client_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'device_code': None,
|
||||||
|
'interval': 5,
|
||||||
|
}
|
||||||
|
_dingtalk_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_device_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Init — get nonce
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/init',
|
||||||
|
json={'source': 'langbot'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to init')
|
||||||
|
return
|
||||||
|
nonce = data['nonce']
|
||||||
|
|
||||||
|
# Step 2: Begin — get device_code + QR URL
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/begin',
|
||||||
|
json={'nonce': nonce},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to begin authorization')
|
||||||
|
return
|
||||||
|
|
||||||
|
device_code = data['device_code']
|
||||||
|
verification_uri_complete = data.get('verification_uri_complete', '')
|
||||||
|
expires_in = data.get('expires_in', 7200)
|
||||||
|
interval = data.get('interval', 5)
|
||||||
|
|
||||||
|
session['device_code'] = device_code
|
||||||
|
session['interval'] = interval
|
||||||
|
session['qr_url'] = verification_uri_complete
|
||||||
|
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 3: Poll for authorization result
|
||||||
|
deadline = time.time() + expires_in
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/poll',
|
||||||
|
json={'device_code': device_code},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if poll_data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('errmsg', 'Poll failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
status = poll_data.get('status', '')
|
||||||
|
|
||||||
|
if status == 'SUCCESS':
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['client_id'] = poll_data.get('client_id', '')
|
||||||
|
session['client_secret'] = poll_data.get('client_secret', '')
|
||||||
|
return
|
||||||
|
elif status == 'FAIL':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
|
||||||
|
return
|
||||||
|
elif status == 'EXPIRED':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status == 'WAITING': continue polling
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_device_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll DingTalk Device Flow status."""
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
session = _dingtalk_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['client_id'] = session['client_id']
|
||||||
|
data['client_secret'] = session['client_secret']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||||
|
session = _dingtalk_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeComBot QR Code One-Click Create
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_wecombot_sessions: dict = {}
|
||||||
|
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_wecombot_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _wecombot_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
|
||||||
|
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
|
||||||
|
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'botid': None,
|
||||||
|
'secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'scode': None,
|
||||||
|
'task': None,
|
||||||
|
}
|
||||||
|
_wecombot_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Generate QR code
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from WeCom service'
|
||||||
|
return
|
||||||
|
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to generate QR code')
|
||||||
|
return
|
||||||
|
|
||||||
|
scode = data['data']['scode']
|
||||||
|
auth_url = data['data']['auth_url']
|
||||||
|
|
||||||
|
session['scode'] = scode
|
||||||
|
session['qr_url'] = auth_url
|
||||||
|
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: Poll for scan result
|
||||||
|
deadline = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = poll_data.get('data', {}).get('status', '')
|
||||||
|
if status == 'success':
|
||||||
|
bot_info = poll_data.get('data', {}).get('bot_info', {})
|
||||||
|
if bot_info.get('botid') and bot_info.get('secret'):
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['botid'] = bot_info['botid']
|
||||||
|
session['secret'] = bot_info['secret']
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Scan succeeded but bot info is incomplete'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_qr_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeComBot creation status."""
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
session = _wecombot_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['botid'] = session['botid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeComBot creation session."""
|
||||||
|
session = _wecombot_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# QQ Official QR Binding
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_qqofficial_sessions: dict = {}
|
||||||
|
_QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_qqofficial_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _qqofficial_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/qqofficial/bind', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start QQ Official QR binding. Returns session_id + QR URL.
|
||||||
|
|
||||||
|
Flow: generate a local AES-256 key, register it with
|
||||||
|
`q.qq.com/lite/create_bind_task`, then poll
|
||||||
|
`q.qq.com/lite/poll_bind_result` until the user authorizes the
|
||||||
|
bind inside the QQ Bot Assistant on mobile QQ. The encrypted
|
||||||
|
AppSecret returned by the poll endpoint is decrypted with the
|
||||||
|
same key. The key never leaves this process.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
QQ_BIND_BASE = 'https://q.qq.com'
|
||||||
|
_cleanup_expired_qqofficial_sessions()
|
||||||
|
|
||||||
|
bind_key_bytes = secrets.token_bytes(32)
|
||||||
|
bind_key = base64.b64encode(bind_key_bytes).decode('ascii')
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'appid': None,
|
||||||
|
'secret': None,
|
||||||
|
'user_openid': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'task_id': None,
|
||||||
|
'bind_key_bytes': bind_key_bytes,
|
||||||
|
'interval': 2,
|
||||||
|
}
|
||||||
|
_qqofficial_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_binding():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: create_bind_task — register our AES key, get task_id
|
||||||
|
async with http.post(
|
||||||
|
f'{QQ_BIND_BASE}/lite/create_bind_task',
|
||||||
|
json={'key': bind_key},
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from QQ bind service'
|
||||||
|
return
|
||||||
|
if int(data.get('retcode', -1)) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = (
|
||||||
|
data.get('msg') or data.get('message') or 'Failed to create bind task'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
task_id = str((data.get('data') or {}).get('task_id') or '').strip()
|
||||||
|
if not task_id:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Missing task_id in QQ response'
|
||||||
|
return
|
||||||
|
|
||||||
|
# The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant.
|
||||||
|
# `source=langbot` is a courtesy attribution parameter so Tencent
|
||||||
|
# can see LangBot adoption metrics, matching the convention used by
|
||||||
|
# other third-party integrations (e.g. hermes-agent uses `source=hermes`).
|
||||||
|
qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot'
|
||||||
|
session['task_id'] = task_id
|
||||||
|
session['qr_url'] = qr_url
|
||||||
|
session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: poll_bind_result until completed (status=2) or expired (3).
|
||||||
|
deadline = time.time() + _QQOFFICIAL_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(session['interval'])
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{QQ_BIND_BASE}/lite/poll_bind_result',
|
||||||
|
json={'task_id': task_id},
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json(content_type=None)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if int(poll_data.get('retcode', -1)) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed'
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = poll_data.get('data') or {}
|
||||||
|
try:
|
||||||
|
raw_status = int(payload.get('status', 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw_status = 0
|
||||||
|
|
||||||
|
if raw_status == 2:
|
||||||
|
appid = str(payload.get('bot_appid') or '').strip()
|
||||||
|
encrypted = str(payload.get('bot_encrypt_secret') or '').strip()
|
||||||
|
if not appid or not encrypted:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Incomplete credential payload'
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
session['secret'] = _decrypt_qqofficial_secret(
|
||||||
|
encrypted,
|
||||||
|
bind_key_bytes,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(exc)
|
||||||
|
return
|
||||||
|
session['appid'] = appid
|
||||||
|
# The scanner's OpenID is returned alongside the credentials —
|
||||||
|
# surfaced to the dashboard for audit / "bound by" display.
|
||||||
|
session['user_openid'] = str(payload.get('user_openid') or '').strip() or None
|
||||||
|
session['status'] = 'success'
|
||||||
|
return
|
||||||
|
|
||||||
|
if raw_status == 3:
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status 0 / 1: still pending, continue polling
|
||||||
|
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_qr_binding())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait up to 10s for the QR URL to be ready before responding.
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll QQ Official QR binding status."""
|
||||||
|
_cleanup_expired_qqofficial_sessions()
|
||||||
|
session = _qqofficial_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['appid'] = session['appid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
if session.get('user_openid'):
|
||||||
|
data['user_openid'] = session['user_openid']
|
||||||
|
_qqofficial_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] in ('error', 'expired'):
|
||||||
|
data['error'] = session['error']
|
||||||
|
_qqofficial_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a QQ Official QR binding session."""
|
||||||
|
session = _qqofficial_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|||||||
@@ -6,11 +6,50 @@ import re
|
|||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
|
from .....entity.persistence import plugin as persistence_plugin
|
||||||
from .. import group
|
from .. import group
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
|
# Resolve the built-in page SDK JS from the langbot_plugin package
|
||||||
|
_PAGE_SDK_PATH = None
|
||||||
|
try:
|
||||||
|
import langbot_plugin.assets as _assets_pkg
|
||||||
|
|
||||||
|
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
|
||||||
|
if os.path.exists(_candidate):
|
||||||
|
_PAGE_SDK_PATH = _candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
||||||
|
filepath = filepath.replace('\\', '/')
|
||||||
|
if filepath.startswith('/'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(filepath)
|
||||||
|
if normalized == '.' or normalized.startswith('../') or normalized == '..':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized.startswith('components/pages/'):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
return f'assets/{normalized}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_origin() -> str:
|
||||||
|
"""Return the public request origin, respecting reverse-proxy headers."""
|
||||||
|
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
|
||||||
|
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
|
||||||
|
|
||||||
|
scheme = forwarded_proto or quart.request.scheme
|
||||||
|
host = forwarded_host or quart.request.host
|
||||||
|
return f'{scheme}://{host}'
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
@@ -27,6 +66,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
"""Serve the built-in LangBot page SDK JavaScript."""
|
||||||
|
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
|
||||||
|
with open(_PAGE_SDK_PATH, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
return quart.Response(content, mimetype='application/javascript')
|
||||||
|
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
|
||||||
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
plugins = await self.ap.plugin_connector.list_plugins()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
@@ -102,7 +150,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
|
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
return self.success(data={'config': plugin['plugin_config']})
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
||||||
|
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
||||||
|
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||||
|
)
|
||||||
|
persisted_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
config = persisted_config if persisted_config is not None else plugin['plugin_config']
|
||||||
|
return self.success(data={'config': config})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
@@ -135,15 +191,62 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(icon_data, mimetype=mime_type)
|
return quart.Response(icon_data, mimetype=mime_type)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/assets/<filepath>',
|
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.NONE,
|
auth_type=group.AuthType.NONE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
asset_path = _normalize_plugin_asset_path(filepath)
|
||||||
|
if asset_path is None:
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
|
|
||||||
|
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||||
|
if not asset_data.get('asset_base64'):
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||||
mime_type = asset_data['mime_type']
|
mime_type = asset_data['mime_type']
|
||||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
resp = quart.Response(asset_bytes, mimetype=mime_type)
|
||||||
|
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||||
|
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||||
|
if mime_type and mime_type.startswith('text/html'):
|
||||||
|
origin = _get_request_origin()
|
||||||
|
resp.headers['Content-Security-Policy'] = (
|
||||||
|
f'default-src {origin}; '
|
||||||
|
f"script-src {origin} 'unsafe-inline'; "
|
||||||
|
f"style-src {origin} 'unsafe-inline'; "
|
||||||
|
f'img-src {origin} data:; '
|
||||||
|
f'connect-src {origin}; '
|
||||||
|
"frame-src 'none'; "
|
||||||
|
"object-src 'none'"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/page-api',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(author: str, plugin_name: str) -> str:
|
||||||
|
"""Forward a page API request to the plugin."""
|
||||||
|
data = await quart.request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
|
|
||||||
|
page_id = data.get('page_id', '')
|
||||||
|
endpoint = data.get('endpoint', '')
|
||||||
|
method = data.get('method', 'POST')
|
||||||
|
body = data.get('body')
|
||||||
|
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
|
||||||
|
return self.http_status(400, -1, 'invalid page api request')
|
||||||
|
if not endpoint.startswith('/') or '..' in endpoint:
|
||||||
|
return self.http_status(400, -1, 'invalid endpoint')
|
||||||
|
|
||||||
|
result = await self.ap.plugin_connector.handle_page_api(
|
||||||
|
author, plugin_name, page_id, endpoint, method.upper(), body
|
||||||
|
)
|
||||||
|
if result.get('error'):
|
||||||
|
return self.http_status(400, -1, result['error'])
|
||||||
|
return self.success(data=result.get('data'))
|
||||||
|
|
||||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
|
|||||||
@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
|
||||||
|
class RerankModelsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
|
if provider_uuid:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
|
||||||
|
return self.success(data={'uuid': model_uuid})
|
||||||
|
|
||||||
|
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
return self.http_status(404, -1, 'model not found')
|
||||||
|
|
||||||
|
return self.success(data={'model': model})
|
||||||
|
elif quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
elif quart.request.method == 'DELETE':
|
||||||
|
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'providers': providers})
|
return self.success(data={'providers': providers})
|
||||||
elif quart.request.method == 'POST':
|
elif quart.request.method == 'POST':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'provider': provider})
|
return self.success(data={'provider': provider})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -43,3 +45,12 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return self.http_status(400, -1, str(e))
|
return self.http_status(400, -1, str(e))
|
||||||
|
|
||||||
|
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(provider_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
model_type = quart.request.args.get('type')
|
||||||
|
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(400, -1, str(e))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('tools', '/api/v1/tools')
|
||||||
|
class ToolsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""获取所有可用工具列表"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
tool_list = []
|
||||||
|
for tool in tools:
|
||||||
|
tool_list.append(
|
||||||
|
{
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'tools': tool_list})
|
||||||
|
|
||||||
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(tool_name: str) -> str:
|
||||||
|
"""获取特定工具详情"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if tool.name == tool_name:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'tool': {
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
||||||
@@ -136,16 +136,9 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=task.to_dict())
|
return self.success(data=task.to_dict())
|
||||||
|
|
||||||
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
if not constants.debug_mode:
|
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||||
return self.http_status(403, 403, 'Forbidden')
|
|
||||||
|
|
||||||
py_code = await quart.request.data
|
|
||||||
|
|
||||||
ap = self.ap
|
|
||||||
|
|
||||||
return self.success(data=exec(py_code, {'ap': ap}))
|
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/debug/plugin/action',
|
'/debug/plugin/action',
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(3, str(e))
|
return self.fail(3, str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
||||||
return self.fail(1, str(e))
|
return self.fail(1, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -105,23 +105,24 @@ 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 path.startswith('home/'):
|
elif not path.startswith('api/'):
|
||||||
# SPA fallback for /home/* sub-routes.
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
# so the pre-rendered list page is served directly via path + '.html'.
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
# This fallback handles any remaining unmatched sub-paths.
|
if path.startswith('home/'):
|
||||||
segments = path.rstrip('/').split('/')
|
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
|
||||||
|
|
||||||
# Walk up parent segments looking for matching .html files
|
# Fallback to index.html for SPA client-side routing
|
||||||
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
|
|
||||||
# Final fallback to index.html for /home/* routes
|
|
||||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
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['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class ApiKeyService:
|
|||||||
|
|
||||||
async def verify_api_key(self, key: str) -> bool:
|
async def verify_api_key(self, key: str) -> bool:
|
||||||
"""Verify if an API key is valid"""
|
"""Verify if an API key is valid"""
|
||||||
|
if not isinstance(key, str) or not key.startswith('lbk_'):
|
||||||
|
return False
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# checkout the default pipeline
|
# bind the most recently updated pipeline if any exist
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
@@ -120,24 +120,26 @@ class BotService:
|
|||||||
|
|
||||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||||
"""Update bot"""
|
"""Update bot"""
|
||||||
if 'uuid' in bot_data:
|
update_data = bot_data.copy()
|
||||||
del bot_data['uuid']
|
|
||||||
|
if 'uuid' in update_data:
|
||||||
|
del update_data['uuid']
|
||||||
|
|
||||||
# set use_pipeline_name
|
# set use_pipeline_name
|
||||||
if 'use_pipeline_uuid' in bot_data:
|
if 'use_pipeline_uuid' in update_data:
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
update_data['use_pipeline_name'] = pipeline.name
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
raise Exception('Pipeline not found')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,126 @@ class KnowledgeService:
|
|||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
raise ValueError('knowledge_engine_plugin_id is required')
|
raise ValueError('knowledge_engine_plugin_id is required')
|
||||||
|
|
||||||
|
creation_settings = kb_data.get('creation_settings', {})
|
||||||
|
retrieval_settings = kb_data.get('retrieval_settings', {})
|
||||||
|
|
||||||
|
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
||||||
|
await self._validate_schema_required_fields(
|
||||||
|
knowledge_engine_plugin_id,
|
||||||
|
creation_settings,
|
||||||
|
retrieval_settings,
|
||||||
|
)
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||||
name=kb_data.get('name', 'Untitled'),
|
name=kb_data.get('name', 'Untitled'),
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||||
creation_settings=kb_data.get('creation_settings', {}),
|
creation_settings=creation_settings,
|
||||||
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
retrieval_settings=retrieval_settings,
|
||||||
description=kb_data.get('description', ''),
|
description=kb_data.get('description', ''),
|
||||||
)
|
)
|
||||||
return kb.uuid
|
return kb.uuid
|
||||||
|
|
||||||
|
async def _validate_schema_required_fields(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
creation_settings: dict,
|
||||||
|
retrieval_settings: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
|
||||||
|
|
||||||
|
This is a business-agnostic validation that checks all fields marked as
|
||||||
|
required in the plugin's schema, regardless of field type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Knowledge Engine plugin ID.
|
||||||
|
creation_settings: User-provided creation settings.
|
||||||
|
retrieval_settings: User-provided retrieval settings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any required field is missing or empty.
|
||||||
|
"""
|
||||||
|
# Validate creation_schema
|
||||||
|
try:
|
||||||
|
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||||
|
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
||||||
|
|
||||||
|
# Validate retrieval_schema
|
||||||
|
try:
|
||||||
|
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||||
|
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
|
||||||
|
|
||||||
|
def _check_required_fields(
|
||||||
|
self,
|
||||||
|
schema: dict | list,
|
||||||
|
settings: dict,
|
||||||
|
context: str,
|
||||||
|
) -> None:
|
||||||
|
"""Check required fields in schema against provided settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Plugin-defined schema (can be list or dict with 'schema' key).
|
||||||
|
settings: User-provided settings values.
|
||||||
|
context: Context name for error messages (e.g., 'creation_settings').
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a required field is missing or empty.
|
||||||
|
"""
|
||||||
|
if not schema:
|
||||||
|
return
|
||||||
|
|
||||||
|
# schema can be a list directly, or a dict with 'schema' key
|
||||||
|
items = schema if isinstance(schema, list) else schema.get('schema', [])
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
field_name = item.get('name')
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_required = item.get('required', False)
|
||||||
|
if not is_required:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check show_if condition - if field is conditionally shown, only validate when condition is met
|
||||||
|
show_if = item.get('show_if')
|
||||||
|
if show_if:
|
||||||
|
depend_field = show_if.get('field')
|
||||||
|
operator = show_if.get('operator')
|
||||||
|
expected_value = show_if.get('value')
|
||||||
|
|
||||||
|
if depend_field and operator:
|
||||||
|
depend_value = settings.get(depend_field)
|
||||||
|
# If show_if condition is not met, skip validation for this field
|
||||||
|
if operator == 'eq' and depend_value != expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'neq' and depend_value == expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = settings.get(field_name)
|
||||||
|
|
||||||
|
# Validate required field has a non-empty value
|
||||||
|
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||||
|
# Get field label for friendly error message
|
||||||
|
label = item.get('label', {})
|
||||||
|
field_label = (
|
||||||
|
label.get('en_US', field_name)
|
||||||
|
or label.get('zh_Hans', field_name)
|
||||||
|
or label.get('zh_Hant', field_name)
|
||||||
|
or field_name
|
||||||
|
)
|
||||||
|
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
||||||
|
|
||||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
# Filter to only mutable fields
|
# Filter to only mutable fields
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....entity.persistence import bstorage as persistence_bstorage
|
||||||
|
from ....entity.persistence import monitoring as persistence_monitoring
|
||||||
|
|
||||||
|
|
||||||
|
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceService:
|
||||||
|
"""Storage maintenance and diagnostics."""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def cleanup_expired_files(self) -> dict[str, int]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
||||||
|
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_storage_analysis(self) -> dict[str, Any]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
database_cfg = self.ap.instance_config.data.get('database', {})
|
||||||
|
database_type = database_cfg.get('use', 'sqlite')
|
||||||
|
database_path = (
|
||||||
|
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
||||||
|
)
|
||||||
|
roots: list[tuple[str, Path | None]] = [
|
||||||
|
('database', database_path),
|
||||||
|
('logs', Path('data/logs')),
|
||||||
|
('storage', Path('data/storage')),
|
||||||
|
('vector_store', Path('data/chroma')),
|
||||||
|
('plugins', Path('data/plugins')),
|
||||||
|
('mcp', Path('data/mcp')),
|
||||||
|
('temp', Path('data/temp')),
|
||||||
|
]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for key, path in roots:
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'path': str(path) if path else '',
|
||||||
|
'exists': path.exists() if path else False,
|
||||||
|
'size_bytes': self._path_size(path) if path else 0,
|
||||||
|
'file_count': self._file_count(path) if path else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monitoring_counts = await self._monitoring_counts()
|
||||||
|
binary_storage = await self._binary_storage_stats()
|
||||||
|
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
||||||
|
log_candidates = self._expired_log_candidates(log_retention_days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
'cleanup_policy': {
|
||||||
|
'uploaded_file_retention_days': upload_retention_days,
|
||||||
|
'log_retention_days': log_retention_days,
|
||||||
|
},
|
||||||
|
'sections': sections,
|
||||||
|
'database': {
|
||||||
|
'type': database_type,
|
||||||
|
'monitoring_counts': monitoring_counts,
|
||||||
|
'binary_storage': binary_storage,
|
||||||
|
},
|
||||||
|
'cleanup_candidates': {
|
||||||
|
'uploaded_files': upload_candidates,
|
||||||
|
'log_files': log_candidates,
|
||||||
|
},
|
||||||
|
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
provider_name = provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
return self._expired_local_upload_candidates(retention_days)
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
candidates = await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
await provider.delete(item['key'])
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
||||||
|
candidates = []
|
||||||
|
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
||||||
|
|
||||||
|
for page in paginator.paginate(Bucket=provider.bucket_name):
|
||||||
|
for obj in page.get('Contents', []):
|
||||||
|
key = obj.get('Key', '')
|
||||||
|
last_modified = obj.get('LastModified')
|
||||||
|
if not self._is_uploaded_file_key(key):
|
||||||
|
continue
|
||||||
|
if last_modified and last_modified < cutoff:
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'size_bytes': obj.get('Size', 0),
|
||||||
|
'modified_at': last_modified.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _cleanup_expired_log_files(self, retention_days: int) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for item in self._expired_log_candidates(retention_days, include_paths=True):
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _expired_local_upload_candidates(
|
||||||
|
self, retention_days: int, include_paths: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
storage_root = Path('data/storage')
|
||||||
|
if not storage_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
||||||
|
candidates = []
|
||||||
|
for entry in storage_root.iterdir():
|
||||||
|
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
if stat.st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
'key': entry.name,
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
||||||
|
log_root = Path('data/logs')
|
||||||
|
if not log_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
|
||||||
|
candidates = []
|
||||||
|
for entry in log_root.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
match = LOG_FILE_PATTERN.match(entry.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_date = datetime.date.fromisoformat(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if file_date >= cutoff_date:
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
item = {
|
||||||
|
'name': entry.name,
|
||||||
|
'date': file_date.isoformat(),
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _is_uploaded_file_key(self, key: str) -> bool:
|
||||||
|
return '/' not in key and not key.startswith('plugin_config_')
|
||||||
|
|
||||||
|
async def _monitoring_counts(self) -> dict[str, int]:
|
||||||
|
tables = {
|
||||||
|
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||||
|
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||||
|
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
|
'errors': persistence_monitoring.MonitoringError.id,
|
||||||
|
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
||||||
|
}
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for key, column in tables.items():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
||||||
|
counts[key] = result.scalar() or 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
async def _binary_storage_stats(self) -> dict[str, Any]:
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
||||||
|
)
|
||||||
|
size_bytes = None
|
||||||
|
try:
|
||||||
|
size_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
||||||
|
)
|
||||||
|
size_bytes = size_result.scalar() or 0
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'count': count_result.scalar() or 0,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _path_size(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return path.stat().st_size
|
||||||
|
total = 0
|
||||||
|
for root, _, files in os.walk(path):
|
||||||
|
for file_name in files:
|
||||||
|
file_path = Path(root) / file_name
|
||||||
|
try:
|
||||||
|
total += file_path.stat().st_size
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _file_count(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return 1
|
||||||
|
count = 0
|
||||||
|
for _, _, files in os.walk(path):
|
||||||
|
count += len(files)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
@@ -23,6 +23,17 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
|||||||
return provider_dict
|
return provider_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||||
|
"""Return model data for rebuilding runtime models after an update.
|
||||||
|
|
||||||
|
Update payloads intentionally omit uuid before writing to the database.
|
||||||
|
Runtime model entities still need the stable uuid so pipeline configs can
|
||||||
|
resolve the in-memory model immediately after an edit, without requiring a
|
||||||
|
process restart.
|
||||||
|
"""
|
||||||
|
return {**model_data, 'uuid': model_uuid}
|
||||||
|
|
||||||
|
|
||||||
class LLMModelsService:
|
class LLMModelsService:
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
@@ -173,7 +184,7 @@ class LLMModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
persistence_model.LLMModel(**model_data),
|
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
@@ -334,7 +345,7 @@ class EmbeddingModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||||
persistence_model.EmbeddingModel(**model_data),
|
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||||
@@ -367,3 +378,162 @@ class EmbeddingModelsService:
|
|||||||
input_text=['Hello, world!'],
|
input_text=['Hello, world!'],
|
||||||
extra_args={},
|
extra_args={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModelsService:
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def get_rerank_models(self) -> list[dict]:
|
||||||
|
"""Get all rerank models with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||||
|
models = result.all()
|
||||||
|
|
||||||
|
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.ModelProvider)
|
||||||
|
)
|
||||||
|
providers = {p.uuid: p for p in providers_result.all()}
|
||||||
|
|
||||||
|
models_list = []
|
||||||
|
for model in models:
|
||||||
|
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||||
|
provider = providers.get(model.provider_uuid)
|
||||||
|
if provider:
|
||||||
|
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||||
|
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||||
|
models_list.append(model_dict)
|
||||||
|
|
||||||
|
return models_list
|
||||||
|
|
||||||
|
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||||
|
"""Get rerank models by provider UUID"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
models = result.all()
|
||||||
|
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
|
|
||||||
|
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
|
"""Create a new rerank model"""
|
||||||
|
if not preserve_uuid:
|
||||||
|
model_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if 'provider' in model_data:
|
||||||
|
provider_data = model_data.pop('provider')
|
||||||
|
if provider_data.get('uuid'):
|
||||||
|
model_data['provider_uuid'] = provider_data['uuid']
|
||||||
|
else:
|
||||||
|
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||||
|
requester=provider_data.get('requester', ''),
|
||||||
|
base_url=provider_data.get('base_url', ''),
|
||||||
|
api_keys=provider_data.get('api_keys', []),
|
||||||
|
)
|
||||||
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||||
|
if runtime_provider is None:
|
||||||
|
raise Exception('provider not found')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**model_data),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
return model_data['uuid']
|
||||||
|
|
||||||
|
async def get_rerank_model(self, model_uuid: str) -> dict | None:
|
||||||
|
"""Get a single rerank model with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
model = result.first()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||||
|
|
||||||
|
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
|
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = provider_result.first()
|
||||||
|
if provider:
|
||||||
|
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||||
|
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||||
|
|
||||||
|
return model_dict
|
||||||
|
|
||||||
|
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Update an existing rerank model"""
|
||||||
|
if 'uuid' in model_data:
|
||||||
|
del model_data['uuid']
|
||||||
|
|
||||||
|
if 'provider' in model_data:
|
||||||
|
provider_data = model_data.pop('provider')
|
||||||
|
if provider_data.get('uuid'):
|
||||||
|
model_data['provider_uuid'] = provider_data['uuid']
|
||||||
|
else:
|
||||||
|
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||||
|
requester=provider_data.get('requester', ''),
|
||||||
|
base_url=provider_data.get('base_url', ''),
|
||||||
|
api_keys=provider_data.get('api_keys', []),
|
||||||
|
)
|
||||||
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_model.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
.values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||||
|
if runtime_provider is None:
|
||||||
|
raise Exception('provider not found')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
async def delete_rerank_model(self, model_uuid: str) -> None:
|
||||||
|
"""Delete a rerank model"""
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Test a rerank model"""
|
||||||
|
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
|
||||||
|
|
||||||
|
if model_uuid != '_':
|
||||||
|
for model in self.ap.model_mgr.rerank_models:
|
||||||
|
if model.model_entity.uuid == model_uuid:
|
||||||
|
runtime_rerank_model = model
|
||||||
|
break
|
||||||
|
if runtime_rerank_model is None:
|
||||||
|
raise Exception('model not found')
|
||||||
|
else:
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
|
||||||
|
|
||||||
|
await runtime_rerank_model.provider.invoke_rerank(
|
||||||
|
model=runtime_rerank_model,
|
||||||
|
query='What is artificial intelligence?',
|
||||||
|
documents=[
|
||||||
|
'Artificial intelligence is a branch of computer science.',
|
||||||
|
'The weather is nice today.',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,55 +18,119 @@ class MonitoringService:
|
|||||||
|
|
||||||
# ========== Cleanup Methods ==========
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
||||||
"""Delete monitoring records older than the specified retention period.
|
"""Delete monitoring records older than the specified retention period.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
retention_days: Number of days to retain records.
|
retention_days: Number of days to retain records.
|
||||||
|
batch_size: Maximum rows to delete per table batch.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict mapping table name to the number of deleted rows.
|
A dict mapping table name to the number of deleted rows.
|
||||||
"""
|
"""
|
||||||
|
if retention_days < 1:
|
||||||
|
raise ValueError('retention_days must be >= 1')
|
||||||
|
if batch_size < 1:
|
||||||
|
raise ValueError('batch_size must be >= 1')
|
||||||
|
|
||||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||||
days=retention_days
|
days=retention_days
|
||||||
)
|
)
|
||||||
|
|
||||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
||||||
(
|
(
|
||||||
'monitoring_messages',
|
'monitoring_messages',
|
||||||
persistence_monitoring.MonitoringMessage,
|
persistence_monitoring.MonitoringMessage,
|
||||||
persistence_monitoring.MonitoringMessage.timestamp,
|
persistence_monitoring.MonitoringMessage.timestamp,
|
||||||
|
persistence_monitoring.MonitoringMessage.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_llm_calls',
|
'monitoring_llm_calls',
|
||||||
persistence_monitoring.MonitoringLLMCall,
|
persistence_monitoring.MonitoringLLMCall,
|
||||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringLLMCall.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_embedding_calls',
|
'monitoring_embedding_calls',
|
||||||
persistence_monitoring.MonitoringEmbeddingCall,
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_errors',
|
'monitoring_errors',
|
||||||
persistence_monitoring.MonitoringError,
|
persistence_monitoring.MonitoringError,
|
||||||
persistence_monitoring.MonitoringError.timestamp,
|
persistence_monitoring.MonitoringError.timestamp,
|
||||||
|
persistence_monitoring.MonitoringError.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_sessions',
|
'monitoring_sessions',
|
||||||
persistence_monitoring.MonitoringSession,
|
persistence_monitoring.MonitoringSession,
|
||||||
persistence_monitoring.MonitoringSession.last_activity,
|
persistence_monitoring.MonitoringSession.last_activity,
|
||||||
|
persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_feedback',
|
||||||
|
persistence_monitoring.MonitoringFeedback,
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp,
|
||||||
|
persistence_monitoring.MonitoringFeedback.id,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
deleted_counts: dict[str, int] = {}
|
deleted_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for table_name, model_cls, ts_column in tables_and_columns:
|
for table_name, model_cls, ts_column, pk_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] = await self._delete_expired_in_batches(
|
||||||
deleted_counts[table_name] = result.rowcount
|
model_cls=model_cls,
|
||||||
|
ts_column=ts_column,
|
||||||
|
pk_column=pk_column,
|
||||||
|
cutoff=cutoff,
|
||||||
|
batch_size=batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sum(deleted_counts.values()) > 0:
|
||||||
|
await self._release_sqlite_space()
|
||||||
|
|
||||||
return deleted_counts
|
return deleted_counts
|
||||||
|
|
||||||
|
async def _delete_expired_in_batches(
|
||||||
|
self,
|
||||||
|
model_cls: type,
|
||||||
|
ts_column: sqlalchemy.Column,
|
||||||
|
pk_column: sqlalchemy.Column,
|
||||||
|
cutoff: datetime.datetime,
|
||||||
|
batch_size: int,
|
||||||
|
) -> int:
|
||||||
|
deleted_total = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
select_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
|
||||||
|
)
|
||||||
|
pk_values = list(select_result.scalars().all())
|
||||||
|
if not pk_values:
|
||||||
|
break
|
||||||
|
|
||||||
|
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
|
||||||
|
)
|
||||||
|
deleted = delete_result.rowcount or 0
|
||||||
|
deleted_total += deleted
|
||||||
|
|
||||||
|
if len(pk_values) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
return deleted_total
|
||||||
|
|
||||||
|
async def _release_sqlite_space(self) -> None:
|
||||||
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||||
|
if database_type != 'sqlite':
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
|
||||||
|
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -1224,30 +1288,83 @@ class MonitoringService:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_id = str(uuid.uuid4())
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
record_data = {
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
'id': record_id,
|
|
||||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
||||||
'feedback_id': feedback_id,
|
|
||||||
'feedback_type': feedback_type,
|
|
||||||
'feedback_content': feedback_content,
|
|
||||||
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
|
|
||||||
'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,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
return record_id
|
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(
|
async def get_feedback_stats(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -113,14 +113,9 @@ class PipelineService:
|
|||||||
return pipeline_data['uuid']
|
return pipeline_data['uuid']
|
||||||
|
|
||||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||||
if 'uuid' in pipeline_data:
|
pipeline_data = pipeline_data.copy()
|
||||||
del pipeline_data['uuid']
|
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||||
if 'for_version' in pipeline_data:
|
pipeline_data.pop(protected_field, None)
|
||||||
del pipeline_data['for_version']
|
|
||||||
if 'stages' in pipeline_data:
|
|
||||||
del pipeline_data['stages']
|
|
||||||
if 'is_default' in pipeline_data:
|
|
||||||
del pipeline_data['is_default']
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import traceback
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -16,6 +17,24 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if api_keys is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
|
||||||
|
normalized_keys = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
for raw_key in raw_keys:
|
||||||
|
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
|
||||||
|
if not normalized_key or normalized_key in seen_keys:
|
||||||
|
continue
|
||||||
|
normalized_keys.append(normalized_key)
|
||||||
|
seen_keys.add(normalized_key)
|
||||||
|
|
||||||
|
return normalized_keys
|
||||||
|
|
||||||
async def get_providers(self) -> list[dict]:
|
async def get_providers(self) -> list[dict]:
|
||||||
"""Get all providers"""
|
"""Get all providers"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||||
@@ -58,6 +77,7 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, provider_data: dict) -> str:
|
async def create_provider(self, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -71,6 +91,8 @@ class ModelProviderService:
|
|||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
if 'uuid' in provider_data:
|
if 'uuid' in provider_data:
|
||||||
del provider_data['uuid']
|
del provider_data['uuid']
|
||||||
|
if 'api_keys' in provider_data:
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
@@ -97,6 +119,14 @@ class ModelProviderService:
|
|||||||
if embedding_result.first() is not None:
|
if embedding_result.first() is not None:
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||||
|
|
||||||
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if rerank_result.first() is not None:
|
||||||
|
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
@@ -121,10 +151,19 @@ class ModelProviderService:
|
|||||||
)
|
)
|
||||||
embedding_count = embedding_result.scalar() or 0
|
embedding_count = embedding_result.scalar() or 0
|
||||||
|
|
||||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count())
|
||||||
|
.select_from(persistence_model.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
|
||||||
|
)
|
||||||
|
rerank_count = rerank_result.scalar() or 0
|
||||||
|
|
||||||
|
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
|
||||||
|
|
||||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
|
api_keys = self._normalize_api_keys(api_keys)
|
||||||
|
|
||||||
# Try to find existing provider with same config
|
# Try to find existing provider with same config
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
@@ -152,7 +191,7 @@ class ModelProviderService:
|
|||||||
'name': provider_name,
|
'name': provider_name,
|
||||||
'requester': requester,
|
'requester': requester,
|
||||||
'base_url': base_url,
|
'base_url': base_url,
|
||||||
'api_keys': api_keys or [],
|
'api_keys': api_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,6 +200,69 @@ class ModelProviderService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||||
.values(api_keys=[api_key])
|
.values(api_keys=self._normalize_api_keys(api_key))
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
|
||||||
|
provider = await self.get_provider(provider_uuid)
|
||||||
|
if provider is None:
|
||||||
|
raise ValueError('provider not found')
|
||||||
|
|
||||||
|
runtime_provider = await self.ap.model_mgr.load_provider(provider)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scan_result = await runtime_provider.requester.scan_models(
|
||||||
|
runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
raise ValueError('current provider does not support model scanning')
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
|
||||||
|
if isinstance(scan_result, dict):
|
||||||
|
scanned_models = scan_result.get('models', [])
|
||||||
|
debug_info = scan_result.get('debug')
|
||||||
|
else:
|
||||||
|
scanned_models = scan_result
|
||||||
|
debug_info = None
|
||||||
|
|
||||||
|
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
|
||||||
|
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
|
||||||
|
existing_llm_names = {model['name'] for model in llm_models}
|
||||||
|
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||||
|
|
||||||
|
filtered_models = []
|
||||||
|
for model in scanned_models:
|
||||||
|
scanned_type = model.get('type', 'llm')
|
||||||
|
if model_type and scanned_type != model_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_name = model.get('name') or model.get('id')
|
||||||
|
if not model_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_models.append(
|
||||||
|
{
|
||||||
|
'id': model.get('id', model_name),
|
||||||
|
'name': model_name,
|
||||||
|
'type': scanned_type,
|
||||||
|
'abilities': model.get('abilities', []),
|
||||||
|
'display_name': model.get('display_name'),
|
||||||
|
'description': model.get('description'),
|
||||||
|
'context_length': model.get('context_length'),
|
||||||
|
'owned_by': model.get('owned_by'),
|
||||||
|
'input_modalities': model.get('input_modalities', []),
|
||||||
|
'output_modalities': model.get('output_modalities', []),
|
||||||
|
'already_added': (
|
||||||
|
model_name in existing_embedding_names
|
||||||
|
if scanned_type == 'embedding'
|
||||||
|
else model_name in existing_llm_names
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'models': filtered_models, 'debug': debug_info}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class SpaceService:
|
|||||||
space_url = space_config['url']
|
space_url = space_config['url']
|
||||||
|
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
|
from ..api.http.service import maintenance as maintenance_service
|
||||||
|
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
@@ -133,6 +134,8 @@ class Application:
|
|||||||
|
|
||||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||||
|
|
||||||
|
rerank_models_service: model_service.RerankModelsService = None
|
||||||
|
|
||||||
provider_service: provider_service.ModelProviderService = None
|
provider_service: provider_service.ModelProviderService = None
|
||||||
|
|
||||||
pipeline_service: pipeline_service.PipelineService = None
|
pipeline_service: pipeline_service.PipelineService = None
|
||||||
@@ -153,6 +156,8 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
|
maintenance_service: maintenance_service.MaintenanceService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -192,14 +197,30 @@ class Application:
|
|||||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||||
if auto_cleanup_cfg.get('enabled', True):
|
if auto_cleanup_cfg.get('enabled', True):
|
||||||
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
retention_days = self._get_positive_int_config(
|
||||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
auto_cleanup_cfg.get('retention_days', 30),
|
||||||
|
default=30,
|
||||||
|
name='monitoring.auto_cleanup.retention_days',
|
||||||
|
)
|
||||||
|
delete_batch_size = self._get_positive_int_config(
|
||||||
|
auto_cleanup_cfg.get('delete_batch_size', 1000),
|
||||||
|
default=1000,
|
||||||
|
name='monitoring.auto_cleanup.delete_batch_size',
|
||||||
|
)
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
auto_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='monitoring.auto_cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
async def monitoring_cleanup_loop():
|
async def monitoring_cleanup_loop():
|
||||||
check_interval_seconds = check_interval_hours * 3600
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||||
|
retention_days,
|
||||||
|
batch_size=delete_batch_size,
|
||||||
|
)
|
||||||
total_deleted = sum(deleted.values())
|
total_deleted = sum(deleted.values())
|
||||||
if total_deleted > 0:
|
if total_deleted > 0:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -216,6 +237,33 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start storage/log maintenance task if enabled
|
||||||
|
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
storage_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='storage.cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def storage_cleanup_loop():
|
||||||
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await self.maintenance_service.cleanup_expired_files()
|
||||||
|
total_deleted = sum(deleted.values())
|
||||||
|
if total_deleted > 0:
|
||||||
|
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Storage maintenance error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
storage_cleanup_loop(),
|
||||||
|
name='storage-maintenance',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
@@ -230,6 +278,28 @@ class Application:
|
|||||||
self.logger.error(f'Application runtime fatal exception: {e}')
|
self.logger.error(f'Application runtime fatal exception: {e}')
|
||||||
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _get_positive_int_config(self, value, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _get_positive_float_config(self, value, default: float, name: str) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed <= 0:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
def dispose(self):
|
def dispose(self):
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
|||||||
|
|
||||||
|
|
||||||
async def main(loop: asyncio.AbstractEventLoop):
|
async def main(loop: asyncio.AbstractEventLoop):
|
||||||
|
app_inst: app.Application | None = None
|
||||||
try:
|
try:
|
||||||
# Hang system signal processing
|
# Hang system signal processing
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
app_inst.dispose()
|
if app_inst is not None:
|
||||||
|
app_inst.dispose()
|
||||||
print('[Signal] Program exit.')
|
print('[Signal] Program exit.')
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_service
|
from ...api.http.service import monitoring as monitoring_service
|
||||||
|
from ...api.http.service import maintenance as maintenance_service
|
||||||
from ...discover import engine as discover_engine
|
from ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -61,6 +62,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||||
ap.embedding_models_service = embedding_models_service_inst
|
ap.embedding_models_service = embedding_models_service_inst
|
||||||
|
|
||||||
|
rerank_models_service_inst = model_service.RerankModelsService(ap)
|
||||||
|
ap.rerank_models_service = rerank_models_service_inst
|
||||||
|
|
||||||
provider_service_inst = provider_service.ModelProviderService(ap)
|
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||||
ap.provider_service = provider_service_inst
|
ap.provider_service = provider_service_inst
|
||||||
|
|
||||||
@@ -164,6 +168,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
ap.monitoring_service = monitoring_service_inst
|
||||||
|
|
||||||
|
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
|
||||||
|
ap.maintenance_service = maintenance_service_inst
|
||||||
|
|
||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
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
|
||||||
|
# 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
|
pass
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Valid scalar value - convert and set it
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
from . import entities as core_entities
|
from . import entities as core_entities
|
||||||
@@ -119,6 +120,7 @@ class TaskWrapper:
|
|||||||
self.label = label if label != '' else name
|
self.label = label if label != '' else name
|
||||||
self.task.set_name(name)
|
self.task.set_name(name)
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
def assume_exception(self):
|
def assume_exception(self):
|
||||||
try:
|
try:
|
||||||
@@ -154,6 +156,7 @@ class TaskWrapper:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'label': self.label,
|
'label': self.label,
|
||||||
'scopes': [scope.value for scope in self.scopes],
|
'scopes': [scope.value for scope in self.scopes],
|
||||||
|
'created_at': self.created_at,
|
||||||
'task_context': self.task_context.to_dict(),
|
'task_context': self.task_context.to_dict(),
|
||||||
'runtime': {
|
'runtime': {
|
||||||
'done': self.task.done(),
|
'done': self.task.done(),
|
||||||
@@ -193,6 +196,8 @@ class AsyncTaskManager:
|
|||||||
) -> TaskWrapper:
|
) -> TaskWrapper:
|
||||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||||
self.tasks.append(wrapper)
|
self.tasks.append(wrapper)
|
||||||
|
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||||
|
self._prune_completed_tasks()
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def create_user_task(
|
def create_user_task(
|
||||||
@@ -226,6 +231,15 @@ class AsyncTaskManager:
|
|||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
completed = sum(1 for t in self.tasks if t.task.done())
|
||||||
|
return {
|
||||||
|
'total': len(self.tasks),
|
||||||
|
'running': len(self.tasks) - completed,
|
||||||
|
'completed': completed,
|
||||||
|
'id_index': TaskWrapper._id_index,
|
||||||
|
}
|
||||||
|
|
||||||
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
||||||
for t in self.tasks:
|
for t in self.tasks:
|
||||||
if t.id == id:
|
if t.id == id:
|
||||||
@@ -243,3 +257,27 @@ class AsyncTaskManager:
|
|||||||
if not wrapper.task.done():
|
if not wrapper.task.done():
|
||||||
wrapper.task.cancel()
|
wrapper.task.cancel()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _prune_completed_tasks(self):
|
||||||
|
completed_limit = (
|
||||||
|
self.ap.instance_config.data.get('system', {})
|
||||||
|
.get('task_retention', {})
|
||||||
|
.get(
|
||||||
|
'completed_limit',
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed_limit = int(completed_limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
completed_limit = 200
|
||||||
|
if completed_limit < 1:
|
||||||
|
completed_limit = 1
|
||||||
|
|
||||||
|
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
|
||||||
|
overflow = len(completed_tasks) - completed_limit
|
||||||
|
if overflow <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
|
||||||
|
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
|
|||||||
server_default=sqlalchemy.func.now(),
|
server_default=sqlalchemy.func.now(),
|
||||||
onupdate=sqlalchemy.func.now(),
|
onupdate=sqlalchemy.func.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModel(Base):
|
||||||
|
"""Rerank model"""
|
||||||
|
|
||||||
|
__tablename__ = 'rerank_models'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
updated_at = sqlalchemy.Column(
|
||||||
|
sqlalchemy.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.func.now(),
|
||||||
|
onupdate=sqlalchemy.func.now(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Alembic environment for LangBot.
|
||||||
|
|
||||||
|
This env.py is designed to be called programmatically (not via CLI).
|
||||||
|
It supports both SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
The sync connection is passed via config attributes by the runner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
||||||
|
url = context.config.get_main_option('sqlalchemy.url')
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={'paramstyle': 'named'},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations with a live sync connection passed via config attributes."""
|
||||||
|
connection: Connection = context.config.attributes.get('connection')
|
||||||
|
if connection is None:
|
||||||
|
raise RuntimeError('connection not provided in alembic config attributes')
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Alembic script.py.mako — template for auto-generated revisions
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""baseline: stamp existing schema (db version 25)
|
||||||
|
|
||||||
|
This is a no-op migration that marks the starting point for Alembic.
|
||||||
|
All tables already exist via create_all() + legacy DBMigration system.
|
||||||
|
|
||||||
|
Revision ID: 0001_baseline
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0001_baseline'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# No-op: existing schema is already at database_version=25
|
||||||
|
# This revision serves as the Alembic baseline.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""example: sample migration demonstrating Alembic patterns
|
||||||
|
|
||||||
|
This is a SAMPLE showing how to write migrations that work
|
||||||
|
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
||||||
|
|
||||||
|
Revision ID: 0002_sample
|
||||||
|
Revises: 0001_baseline
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
|
||||||
|
Patterns demonstrated:
|
||||||
|
1. Schema change (add column) — works on both DBs via render_as_batch
|
||||||
|
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0002_sample'
|
||||||
|
down_revision = '0001_baseline'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
EXAMPLE: Uncomment to use. This shows the patterns.
|
||||||
|
|
||||||
|
# --- Pattern 1: Schema change (add/drop column) ---
|
||||||
|
# render_as_batch=True in env.py makes this work on SQLite too.
|
||||||
|
#
|
||||||
|
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
||||||
|
|
||||||
|
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
||||||
|
# No if/else for sqlite vs postgres needed!
|
||||||
|
#
|
||||||
|
# conn = op.get_bind()
|
||||||
|
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
||||||
|
# for row in rows:
|
||||||
|
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||||
|
# # Modify the config
|
||||||
|
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
||||||
|
# conn.execute(
|
||||||
|
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
||||||
|
# {"cfg": json.dumps(config), "uuid": row[0]}
|
||||||
|
# )
|
||||||
|
|
||||||
|
# --- Pattern 3: Create a new table ---
|
||||||
|
#
|
||||||
|
# op.create_table(
|
||||||
|
# 'audit_log',
|
||||||
|
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
# sa.Column('action', sa.String(255), nullable=False),
|
||||||
|
# sa.Column('detail', sa.Text),
|
||||||
|
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
||||||
|
# )
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
# op.drop_column('pipelines', 'description')
|
||||||
|
# op.drop_table('audit_log')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add rerank_models table
|
||||||
|
|
||||||
|
Revision ID: 0003_add_rerank_models
|
||||||
|
Revises: 0002_sample
|
||||||
|
Create Date: 2026-04-19
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0003_add_rerank_models'
|
||||||
|
down_revision = '0002_sample'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if table already exists (may have been created by create_all())
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'rerank_models' not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'rerank_models',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
|
||||||
|
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('rerank_models')
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Programmatic Alembic runner for LangBot.
|
||||||
|
|
||||||
|
Usage from async code:
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
|
||||||
|
await run_alembic_upgrade(async_engine)
|
||||||
|
|
||||||
|
CLI usage (autogenerate):
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner upgrade
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner current
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config(connection: Connection) -> Config:
|
||||||
|
"""Build an Alembic Config with sync connection attached."""
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||||
|
cfg.attributes['connection'] = connection
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous upgrade — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.upgrade(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous stamp — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.stamp(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_get_current(connection: Connection) -> str | None:
|
||||||
|
"""Get current alembic revision synchronously."""
|
||||||
|
ctx = MigrationContext.configure(connection)
|
||||||
|
return ctx.get_current_revision()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||||
|
"""Synchronous autogenerate — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.revision(cfg, message=message, autogenerate=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Run Alembic upgrade to the given revision."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_upgrade, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Stamp the database with a revision without running migrations."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_stamp, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
|
||||||
|
"""Get current alembic revision, or None if not stamped."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
return await conn.run_sync(_do_get_current)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
|
||||||
|
"""Compare ORM models against DB schema and generate a migration script."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_autogenerate, message)
|
||||||
|
|
||||||
|
|
||||||
|
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
"""Create engine from data/config.yaml or default SQLite."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open('data/config.yaml') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
db_cfg = config.get('database', {})
|
||||||
|
db_type = db_cfg.get('use', 'sqlite')
|
||||||
|
if db_type == 'postgresql':
|
||||||
|
pg = db_cfg.get('postgresql', {})
|
||||||
|
url = (
|
||||||
|
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
|
||||||
|
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
|
||||||
|
url = f'sqlite+aiosqlite:///{path}'
|
||||||
|
except Exception:
|
||||||
|
url = 'sqlite+aiosqlite:///data/langbot.db'
|
||||||
|
|
||||||
|
return create_async_engine(url)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
|
||||||
|
print('Commands:')
|
||||||
|
print(' autogenerate "message" — Generate migration from ORM model diff')
|
||||||
|
print(' upgrade [revision] — Upgrade database (default: head)')
|
||||||
|
print(' stamp [revision] — Stamp revision without running (default: head)')
|
||||||
|
print(' current — Show current revision')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
engine = _get_engine()
|
||||||
|
|
||||||
|
if cmd == 'autogenerate':
|
||||||
|
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
|
||||||
|
asyncio.run(run_alembic_autogenerate(engine, msg))
|
||||||
|
print(f'Migration generated: {msg}')
|
||||||
|
elif cmd == 'upgrade':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_upgrade(engine, rev))
|
||||||
|
print(f'Upgraded to: {rev}')
|
||||||
|
elif cmd == 'stamp':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_stamp(engine, rev))
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
elif cmd == 'current':
|
||||||
|
rev = asyncio.run(get_alembic_current(engine))
|
||||||
|
print(f'Current revision: {rev}')
|
||||||
|
else:
|
||||||
|
print(f'Unknown command: {cmd}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -76,6 +76,9 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
|
# Run Alembic migrations (new migration system)
|
||||||
|
await self._run_alembic_migrations()
|
||||||
|
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -135,6 +138,28 @@ class PersistenceManager:
|
|||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
|
|
||||||
|
async def _run_alembic_migrations(self):
|
||||||
|
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||||
|
from . import alembic_runner
|
||||||
|
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_rev = await alembic_runner.get_alembic_current(engine)
|
||||||
|
|
||||||
|
if current_rev is None:
|
||||||
|
# First time: stamp baseline so Alembic knows existing schema is up-to-date
|
||||||
|
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
current_rev = '0001_baseline'
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||||
|
self.ap.logger.info('Alembic migrations completed.')
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(25)
|
||||||
|
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
||||||
|
"""Add pipeline_routing_rules column to bots table"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
|||||||
message_chain: platform_message.MessageChain
|
message_chain: platform_message.MessageChain
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
pipeline_uuid: typing.Optional[str]
|
pipeline_uuid: typing.Optional[str]
|
||||||
|
routed_by_rule: bool = False
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a message to the aggregation buffer
|
"""Add a message to the aggregation buffer
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
force_flush = False
|
force_flush = False
|
||||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
|||||||
message_chain=msg.message_chain,
|
message_chain=msg.message_chain,
|
||||||
adapter=msg.adapter,
|
adapter=msg.adapter,
|
||||||
pipeline_uuid=msg.pipeline_uuid,
|
pipeline_uuid=msg.pipeline_uuid,
|
||||||
|
routed_by_rule=msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_msg.message_chain,
|
message_chain=merged_msg.message_chain,
|
||||||
adapter=merged_msg.adapter,
|
adapter=merged_msg.adapter,
|
||||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=merged_msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||||
@@ -269,6 +275,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_chain,
|
message_chain=merged_chain,
|
||||||
adapter=base_msg.adapter,
|
adapter=base_msg.adapter,
|
||||||
pipeline_uuid=base_msg.pipeline_uuid,
|
pipeline_uuid=base_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=any(msg.routed_by_rule for msg in messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def flush_all(self) -> None:
|
async def flush_all(self) -> None:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Controller:
|
|||||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||||
if pipeline:
|
if pipeline:
|
||||||
await pipeline.run(selected_query)
|
await pipeline.run(selected_query)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
|
||||||
async with self.ap.query_pool:
|
async with self.ap.query_pool:
|
||||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
if not query.resp_message_chain:
|
||||||
|
self.ap.logger.debug('Response message chain is empty, skip long message processing.')
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
# 检查是否包含非 Plain 组件
|
# 检查是否包含非 Plain 组件
|
||||||
contains_non_plain = False
|
contains_non_plain = False
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class RuntimePipeline:
|
|||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
message=result.user_notice,
|
message=result.user_notice,
|
||||||
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
||||||
is_final=[msg.is_final for msg in query.resp_messages][0],
|
is_final=[msg.is_final for msg in query.resp_messages][-1],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await query.adapter.reply_message(
|
await query.adapter.reply_message(
|
||||||
@@ -297,6 +297,9 @@ class RuntimePipeline:
|
|||||||
)
|
)
|
||||||
# Store message_id in query variables for LLM call monitoring
|
# Store message_id in query variables for LLM call monitoring
|
||||||
query.variables['_monitoring_message_id'] = message_id
|
query.variables['_monitoring_message_id'] = message_id
|
||||||
|
# Notify adapter so it can map platform-specific IDs to monitoring message ID
|
||||||
|
if hasattr(query.adapter, 'on_monitoring_message_created'):
|
||||||
|
await query.adapter.on_monitoring_message_created(query, message_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||||
|
|
||||||
@@ -323,6 +326,9 @@ class RuntimePipeline:
|
|||||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ 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,
|
||||||
|
variables: typing.Optional[dict[str, typing.Any]] = None,
|
||||||
) -> 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
|
||||||
|
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
|
||||||
|
if variables:
|
||||||
|
initial_variables.update(variables)
|
||||||
query = pipeline_query.Query(
|
query = pipeline_query.Query(
|
||||||
bot_uuid=bot_uuid,
|
bot_uuid=bot_uuid,
|
||||||
query_id=query_id,
|
query_id=query_id,
|
||||||
@@ -52,7 +57,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={},
|
variables=initial_variables,
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
@@ -62,6 +67,7 @@ class QueryPool:
|
|||||||
self.cached_queries[query_id] = query
|
self.cached_queries[query_id] = query
|
||||||
self.query_id_counter += 1
|
self.query_id_counter += 1
|
||||||
self.condition.notify_all()
|
self.condition.notify_all()
|
||||||
|
return query
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.pool_lock.acquire()
|
await self.pool_lock.acquire()
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.bot_uuid,
|
query.bot_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Expire externally managed conversation ids after the conversation has
|
||||||
|
# been idle for longer than the configured conversation expire time.
|
||||||
|
# The idle window is measured from the last preprocess/update time, not
|
||||||
|
# from the conversation creation time.
|
||||||
|
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
if conversation_expire_time is not None and conversation_expire_time > 0:
|
||||||
|
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||||
|
if last_update_time is not None:
|
||||||
|
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
|
||||||
|
if conversation_idle_time > conversation_expire_time:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
|
||||||
|
)
|
||||||
|
conversation.uuid = None
|
||||||
|
|
||||||
|
# Treat every preprocess pass as a conversation activity update. This
|
||||||
|
# makes future expiry checks use the latest incoming message/preprocess
|
||||||
|
# time instead of the first message/creation time.
|
||||||
|
conversation.update_time = now
|
||||||
|
|
||||||
# 设置query
|
# 设置query
|
||||||
query.session = session
|
query.session = session
|
||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
@@ -160,8 +181,10 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
elif isinstance(me, platform_message.File):
|
||||||
# if me.url is not None:
|
if me.base64:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
|
||||||
|
elif me.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
if isinstance(msg, platform_message.Plain):
|
if isinstance(msg, platform_message.Plain):
|
||||||
@@ -172,6 +195,18 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
):
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
|
elif isinstance(msg, platform_message.File):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
|
elif isinstance(msg, platform_message.Voice):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(
|
||||||
|
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
|
||||||
|
)
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
|
||||||
|
|
||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
else:
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||||
|
)
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||||
else:
|
else:
|
||||||
if event_ctx.event.user_message_alter is not None:
|
if event_ctx.event.user_message_alter is not None:
|
||||||
@@ -205,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
|
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
|
||||||
# TODO 命令与流式的兼容性问题
|
# TODO 命令与流式的兼容性问题
|
||||||
if await query.adapter.is_stream_output_supported() and has_chunks:
|
if await query.adapter.is_stream_output_supported() and has_chunks:
|
||||||
is_final = [msg.is_final for msg in query.resp_messages][0]
|
is_final = [msg.is_final for msg in query.resp_messages][-1]
|
||||||
await query.adapter.reply_message_chunk(
|
await query.adapter.reply_message_chunk(
|
||||||
message_source=query.message_event,
|
message_source=query.message_event,
|
||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
|||||||
if query.launcher_type.value != 'group': # 只处理群消息
|
if query.launcher_type.value != 'group': # 只处理群消息
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||||
|
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||||
|
|
||||||
use_rule = rules
|
use_rule = rules
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -52,6 +54,148 @@ class RuntimeBot:
|
|||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||||
|
"""Evaluate a single operator condition."""
|
||||||
|
if operator == 'eq':
|
||||||
|
return actual == expected
|
||||||
|
elif operator == 'neq':
|
||||||
|
return actual != expected
|
||||||
|
elif operator == 'contains':
|
||||||
|
return expected in actual
|
||||||
|
elif operator == 'not_contains':
|
||||||
|
return expected not in actual
|
||||||
|
elif operator == 'starts_with':
|
||||||
|
return actual.startswith(expected)
|
||||||
|
elif operator == 'regex':
|
||||||
|
try:
|
||||||
|
return bool(re.search(expected, actual))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
PIPELINE_DISCARD = '__discard__'
|
||||||
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
|
def resolve_pipeline_uuid(
|
||||||
|
self,
|
||||||
|
launcher_type: str,
|
||||||
|
launcher_id: str,
|
||||||
|
message_text: str,
|
||||||
|
message_element_types: list[str] | None = None,
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
|
Rules are evaluated in order; first match wins.
|
||||||
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
|
|
||||||
|
Rule types:
|
||||||
|
- launcher_type: session type ("person" / "group")
|
||||||
|
- launcher_id: session / group id
|
||||||
|
- message_content: message text content
|
||||||
|
- message_has_element: message contains element of given type
|
||||||
|
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||||
|
Operators: eq (has), neq (doesn't have)
|
||||||
|
|
||||||
|
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||||
|
|
||||||
|
When pipeline_uuid is ``__discard__``, the message should be
|
||||||
|
silently dropped by the caller.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
|
when a routing rule matched, False when falling back to default.
|
||||||
|
"""
|
||||||
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
rule_value = rule.get('value', '')
|
||||||
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule_type == 'launcher_type':
|
||||||
|
if self._match_operator(launcher_type, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_content':
|
||||||
|
if self._match_operator(message_text, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_has_element':
|
||||||
|
has_element = rule_value in element_type_set
|
||||||
|
if operator == 'eq' and has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
elif operator == 'neq' and not has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
|
||||||
|
return self.bot_entity.use_pipeline_uuid, False
|
||||||
|
|
||||||
|
async def _record_discarded_message(
|
||||||
|
self,
|
||||||
|
launcher_type: provider_session.LauncherTypes,
|
||||||
|
launcher_id: str | int,
|
||||||
|
sender_id: str | int,
|
||||||
|
message_event: platform_events.MessageEvent,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Record a discarded message in the monitoring system."""
|
||||||
|
try:
|
||||||
|
if hasattr(message_chain, 'model_dump'):
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
message_content = str(message_chain)
|
||||||
|
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(message_event, 'sender'):
|
||||||
|
if hasattr(message_event.sender, 'nickname'):
|
||||||
|
sender_name = message_event.sender.nickname
|
||||||
|
elif hasattr(message_event.sender, 'member_name'):
|
||||||
|
sender_name = message_event.sender.member_name
|
||||||
|
|
||||||
|
# Use the same session_id format as monitoring_helper.py
|
||||||
|
session_id = f'{launcher_type}_{launcher_id}'
|
||||||
|
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='discarded',
|
||||||
|
level='info',
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the session exists so the message appears in the session monitor.
|
||||||
|
# Don't overwrite pipeline info — a session may have messages from
|
||||||
|
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||||
|
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if not session_updated:
|
||||||
|
# No session yet (first message for this launcher was discarded).
|
||||||
|
await self.ap.monitoring_service.record_session_start(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
@@ -83,6 +227,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'person', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Person message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -91,7 +252,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||||
@@ -126,6 +288,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'group', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Group message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -134,7 +313,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||||
@@ -241,12 +421,20 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||||
|
|
||||||
|
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||||
|
|
||||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
if component.metadata.name in disabled_adapters:
|
||||||
|
continue
|
||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
|
# Filter out disabled adapters from components list (for API responses)
|
||||||
|
if disabled_adapters:
|
||||||
|
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||||
|
|
||||||
# initialize websocket adapter
|
# initialize websocket adapter
|
||||||
websocket_adapter_class = self.adapter_dict['websocket']
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
@@ -313,6 +501,8 @@ class PlatformManager:
|
|||||||
bot_entity.adapter_config,
|
bot_entity.adapter_config,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
if hasattr(adapter_inst, 'ap'):
|
||||||
|
adapter_inst.ap = self.ap
|
||||||
|
|
||||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||||
@@ -335,7 +525,7 @@ class PlatformManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def remove_bot(self, bot_uuid: str):
|
async def remove_bot(self, bot_uuid: str):
|
||||||
for bot in self.bots:
|
for bot in self.bots[:]:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
if bot.enable:
|
if bot.enable:
|
||||||
await bot.shutdown()
|
await bot.shutdown()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -293,6 +294,29 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
elif msg.type == 'dice':
|
elif msg.type == 'dice':
|
||||||
face_id = msg.data['result']
|
face_id = msg.data['result']
|
||||||
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
||||||
|
elif msg.type == 'json':
|
||||||
|
try:
|
||||||
|
raw = msg.data.get('data', {})
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = json.loads(raw)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
_meta = raw.get('meta', {}) or {}
|
||||||
|
if isinstance(_meta, dict):
|
||||||
|
_detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {}
|
||||||
|
else:
|
||||||
|
_detail = {}
|
||||||
|
if isinstance(_detail, dict):
|
||||||
|
preview = _detail.get('preview', '')
|
||||||
|
title = _detail.get('desc', '') or _detail.get('title', '')
|
||||||
|
url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '')
|
||||||
|
else:
|
||||||
|
preview = title = url = ''
|
||||||
|
text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip()
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=text or '[收到一张JSON卡片]'))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=str(raw)))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text='[收到一张JSON卡片]'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
|
import uuid
|
||||||
|
|
||||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||||
import datetime
|
import datetime
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
|
||||||
|
|
||||||
|
|
||||||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -71,7 +77,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +88,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
||||||
|
if event.content and event.type == 'audio':
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
|
|
||||||
|
# Handle quoted/replied message - extract content as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
if event.quoted_message:
|
||||||
|
quote_info = event.quoted_message
|
||||||
|
msg_type = quote_info.get('msg_type', '')
|
||||||
|
|
||||||
|
# Process quoted file - add as top-level File component (same as private chat)
|
||||||
|
if msg_type == 'file' and quote_info.get('file_url'):
|
||||||
|
file_name = quote_info.get('file_name', 'file')
|
||||||
|
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
|
||||||
|
|
||||||
|
# Process quoted image - add as top-level Image component
|
||||||
|
elif msg_type == 'picture' and quote_info.get('picture'):
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
|
||||||
|
|
||||||
|
# Process quoted audio - add as top-level Voice component
|
||||||
|
elif msg_type == 'audio' and quote_info.get('audio'):
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
|
||||||
|
|
||||||
|
# Process quoted text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'text' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
|
# Process quoted rich text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'richText' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
@@ -138,6 +176,22 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
card_instance_id_dict: (
|
card_instance_id_dict: (
|
||||||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||||||
)
|
)
|
||||||
|
# outTrackId → form snapshot {session_key, launcher_type, launcher_id, form_token,
|
||||||
|
# workflow_run_id, actions, node_title, form_content, expires_at, open_space_id,
|
||||||
|
# user_id_hint, current_text}. Lookup keys for the data-source pull endpoint and
|
||||||
|
# the STREAM card-action callback.
|
||||||
|
card_state: dict
|
||||||
|
# session_key → out_track_id of the currently-active card for the
|
||||||
|
# conversation turn. Lets resumed-workflow chunks (which arrive on a
|
||||||
|
# synthetic event with a fresh resp_message_id) keep updating the same
|
||||||
|
# card the user clicked instead of getting a new one.
|
||||||
|
active_turn_card: dict
|
||||||
|
# session_key → accumulated streaming text for the active turn. Read
|
||||||
|
# by _paint_form_on_card so the post-pause form keeps the streamed
|
||||||
|
# context above the new prompt.
|
||||||
|
active_turn_text: dict
|
||||||
|
ap: typing.Any = None
|
||||||
|
bot_uuid: str = ''
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
required_keys = [
|
required_keys = [
|
||||||
@@ -162,10 +216,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
card_instance_id_dict={},
|
card_instance_id_dict={},
|
||||||
|
card_state={},
|
||||||
|
active_turn_card={},
|
||||||
|
active_turn_text={},
|
||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
listeners={},
|
listeners={},
|
||||||
)
|
)
|
||||||
|
# Wire the card-action callback after super().__init__ so we can reference
|
||||||
|
# self.* — the client's handler stores this as a soft reference and reads
|
||||||
|
# it at fire time.
|
||||||
|
self.bot.card_action_callback = self._on_card_action
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -190,28 +251,82 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
# event = await DingTalkEventConverter.yiri2target(
|
|
||||||
# message_source,
|
|
||||||
# )
|
|
||||||
# incoming_message = event.incoming_message
|
|
||||||
|
|
||||||
# msg_id = incoming_message.message_id
|
|
||||||
message_id = bot_message.resp_message_id
|
message_id = bot_message.resp_message_id
|
||||||
msg_seq = bot_message.msg_sequence
|
msg_seq = bot_message.msg_sequence
|
||||||
|
|
||||||
|
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
if is_final and self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk reply_message_chunk final: form_data_present={form_data is not None}, '
|
||||||
|
f'form_template_configured={bool(form_template_id)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if form_data and is_final:
|
||||||
|
await self._handle_form_chunk(message_source, bot_message, message, form_data)
|
||||||
|
return
|
||||||
|
|
||||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
markdown_enabled = self.config.get('markdown_card', False)
|
markdown_enabled = self.config.get('markdown_card', False)
|
||||||
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||||
|
|
||||||
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
|
|
||||||
if not content and bot_message.content:
|
if not content and bot_message.content:
|
||||||
content = bot_message.content # 兼容直接传入content的情况
|
content = bot_message.content # 兼容直接传入content的情况
|
||||||
# print(card_instance_id)
|
|
||||||
|
chat_card_entry = self.card_instance_id_dict.get(message_id)
|
||||||
|
if chat_card_entry is None:
|
||||||
|
# No streaming chat card was created for this query — common
|
||||||
|
# path for synthetic events (e.g. resumed workflow after a
|
||||||
|
# button click). Lazy-create one so the resumed output streams
|
||||||
|
# into a card just like a normal conversation, instead of
|
||||||
|
# being deferred and sent in one shot on is_final.
|
||||||
|
if not content:
|
||||||
|
return # nothing to stream yet
|
||||||
|
chat_card_entry = await self._lazy_create_resume_chat_card(message_source, message_id)
|
||||||
|
if chat_card_entry is None:
|
||||||
|
# Lazy-create failed (no template configured); fall back
|
||||||
|
# to a one-shot proactive message on the final chunk.
|
||||||
|
if is_final:
|
||||||
|
await self._send_proactive_to_event(message_source, content)
|
||||||
|
return
|
||||||
|
|
||||||
|
card_instance, card_instance_id = chat_card_entry
|
||||||
if content:
|
if content:
|
||||||
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
if form_template_id:
|
||||||
if is_final and bot_message.tool_calls is None:
|
# The card content has already been written via
|
||||||
# self.seq = 1 # 消息回复结束之后重置seq
|
# update_card_data (in _paint_form_on_card and the
|
||||||
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id
|
# initial card creation). The streaming endpoint
|
||||||
|
# (PUT /v1.0/card/streaming) does not propagate
|
||||||
|
# updates on cards whose content was last set via
|
||||||
|
# update_card_data — they take different code paths
|
||||||
|
# on the DingTalk client. Stick with update_card_data
|
||||||
|
# for the whole turn for consistency.
|
||||||
|
try:
|
||||||
|
await self.bot.update_card_data(
|
||||||
|
out_track_id=card_instance_id,
|
||||||
|
card_param_map={
|
||||||
|
'content': content,
|
||||||
|
'btns': '[]',
|
||||||
|
'flowStatus': '3' if is_final else '1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: update card content failed')
|
||||||
|
else:
|
||||||
|
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
||||||
|
if is_final:
|
||||||
|
if form_template_id and not content:
|
||||||
|
# Empty final chunk still needs to leave the card with
|
||||||
|
# flowStatus=3 so the spinner stops.
|
||||||
|
try:
|
||||||
|
await self.bot.update_card_data(
|
||||||
|
out_track_id=card_instance_id,
|
||||||
|
card_param_map={'flowStatus': '3'},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if bot_message.tool_calls is None:
|
||||||
|
self.card_instance_id_dict.pop(message_id, None)
|
||||||
|
|
||||||
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):
|
||||||
markdown_enabled = self.config.get('markdown_card', False)
|
markdown_enabled = self.config.get('markdown_card', False)
|
||||||
@@ -228,16 +343,80 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return is_stream
|
return is_stream
|
||||||
|
|
||||||
async def create_message_card(self, message_id, event):
|
async def create_message_card(self, message_id, event):
|
||||||
card_template_id = self.config['card_template_id']
|
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
|
||||||
|
legacy_template_id = self.config.get('card_template_id', '')
|
||||||
|
|
||||||
|
# Synthetic events (button clicks): look up the card already in
|
||||||
|
# active_turn_card so reply_message_chunk can stream to it.
|
||||||
|
if event is None or event.source_platform_object is None:
|
||||||
|
if form_template_id:
|
||||||
|
session_key = self._session_key_from_event(event) if event is not None else ''
|
||||||
|
carry = self.active_turn_card.get(session_key, '') if session_key else ''
|
||||||
|
if carry:
|
||||||
|
self.card_instance_id_dict[message_id] = (None, carry)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if form_template_id:
|
||||||
|
# Create one card with the form template, empty buttons,
|
||||||
|
# pending state. Streaming writes content to it; form pause
|
||||||
|
# paints buttons onto it. One card per turn, no duplication.
|
||||||
|
incoming_message = event.source_platform_object.incoming_message
|
||||||
|
out_track_id = uuid.uuid4().hex
|
||||||
|
is_group = str(incoming_message.conversation_type) == '2'
|
||||||
|
if is_group:
|
||||||
|
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
|
||||||
|
else:
|
||||||
|
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
|
||||||
|
try:
|
||||||
|
await self.bot.create_and_deliver_card(
|
||||||
|
card_template_id=form_template_id,
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
open_space_id=open_space_id,
|
||||||
|
is_group=is_group,
|
||||||
|
card_param_map={'content': '', 'btns': '[]', 'flowStatus': '1'},
|
||||||
|
callback_type='STREAM',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: create form-template card failed')
|
||||||
|
return False
|
||||||
|
self.card_instance_id_dict[message_id] = (None, out_track_id)
|
||||||
|
session_key = self._session_key_from_event(event)
|
||||||
|
if session_key:
|
||||||
|
self.active_turn_card[session_key] = out_track_id
|
||||||
|
self.active_turn_text[session_key] = ''
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Legacy chat-card path (no form template).
|
||||||
incoming_message = event.source_platform_object.incoming_message
|
incoming_message = event.source_platform_object.incoming_message
|
||||||
# message_id = incoming_message.message_id
|
card_auto_layout = self.config.get('card_auto_layout', False)
|
||||||
card_auto_layout = self.config.get('card_ auto_layout', False)
|
|
||||||
card_instance, card_instance_id = await self.bot.create_and_card(
|
card_instance, card_instance_id = await self.bot.create_and_card(
|
||||||
card_template_id, incoming_message, card_auto_layout=card_auto_layout
|
legacy_template_id, incoming_message, card_auto_layout=card_auto_layout
|
||||||
)
|
)
|
||||||
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _session_key_from_event(self, event) -> str:
|
||||||
|
"""Return launcher_type_launcher_id for an event, '' if unrecoverable."""
|
||||||
|
if event is None:
|
||||||
|
return ''
|
||||||
|
spo = event.source_platform_object
|
||||||
|
if spo is None:
|
||||||
|
try:
|
||||||
|
if isinstance(event, platform_events.GroupMessage):
|
||||||
|
return f'group_{event.group.id}'
|
||||||
|
return f'person_{event.sender.id}'
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
inc = spo.incoming_message
|
||||||
|
if str(inc.conversation_type) == '2':
|
||||||
|
return f'group_{inc.conversation_id}'
|
||||||
|
return f'person_{inc.sender_staff_id}'
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -277,3 +456,543 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
],
|
],
|
||||||
):
|
):
|
||||||
return super().unregister_listener(event_type, callback)
|
return super().unregister_listener(event_type, callback)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Dify human-input form support
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
|
"""Receive the bot uuid from the platform manager.
|
||||||
|
|
||||||
|
Used to compose the public-facing unified-webhook URL for the card
|
||||||
|
dynamic-data-source pull endpoint.
|
||||||
|
"""
|
||||||
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
def _derive_open_space(self, message_source: platform_events.MessageEvent) -> tuple[str, bool]:
|
||||||
|
"""Return (openSpaceId, is_group) for the given inbound event."""
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
return f'dtv1.card//IM_GROUP.{message_source.group.id}', True
|
||||||
|
return f'dtv1.card//IM_ROBOT.{message_source.sender.id}', False
|
||||||
|
|
||||||
|
def _derive_session_descriptor(
|
||||||
|
self, message_source: platform_events.MessageEvent
|
||||||
|
) -> tuple[provider_session.LauncherTypes, str, str]:
|
||||||
|
"""Return (launcher_type, launcher_id, sender_user_id) for routing."""
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
return (
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
str(message_source.group.id),
|
||||||
|
str(message_source.sender.id),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
str(message_source.sender.id),
|
||||||
|
str(message_source.sender.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_form_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
form_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Surface human-input prompt + buttons on the active card.
|
||||||
|
|
||||||
|
In single-card mode (form_template_id configured): update the
|
||||||
|
EXISTING card with form buttons so it transitions from streaming
|
||||||
|
output to prompt+buttons on the same card. In legacy mode:
|
||||||
|
finalize the chat card and deliver a separate form card.
|
||||||
|
"""
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk _handle_form_chunk: actions={len(form_data.get("actions") or [])}, '
|
||||||
|
f'node_title={form_data.get("node_title", "")!r}'
|
||||||
|
)
|
||||||
|
message_id = bot_message.resp_message_id
|
||||||
|
template_id = (self.config.get('human_input_card_template_id') or '').strip()
|
||||||
|
|
||||||
|
if template_id:
|
||||||
|
# Single-card mode: paint prompt + buttons onto the existing card.
|
||||||
|
session_key = self._session_key_from_event(message_source)
|
||||||
|
entry = self.card_instance_id_dict.get(message_id)
|
||||||
|
out_track_id = entry[1] if entry else None
|
||||||
|
if not out_track_id and session_key:
|
||||||
|
out_track_id = self.active_turn_card.get(session_key, '')
|
||||||
|
if out_track_id:
|
||||||
|
await self._paint_form_on_card(message_source, out_track_id, form_data, session_key)
|
||||||
|
self.card_instance_id_dict.pop(message_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No existing card (e.g. Dify paused immediately with no LLM
|
||||||
|
# output before the pause). Create a form card directly.
|
||||||
|
await self._send_form_card(message_source, form_data, template_id)
|
||||||
|
self.card_instance_id_dict.pop(message_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Legacy mode: finalize the streaming card with text fallback.
|
||||||
|
chat_card_entry = self.card_instance_id_dict.pop(message_id, None)
|
||||||
|
if chat_card_entry is not None:
|
||||||
|
_, chat_out_track_id = chat_card_entry
|
||||||
|
markdown_enabled = self.config.get('markdown_card', False)
|
||||||
|
text_content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||||
|
if not text_content and bot_message.content:
|
||||||
|
text_content = bot_message.content
|
||||||
|
try:
|
||||||
|
await self.bot.send_card_message(None, chat_out_track_id, text_content or '', True)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'DingTalk: finalize chat card before form failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
await self.send_message_text_form(message_source, form_data)
|
||||||
|
|
||||||
|
async def _paint_form_on_card(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
out_track_id: str,
|
||||||
|
form_data: dict,
|
||||||
|
session_key: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update an existing card's content + buttons for human-input."""
|
||||||
|
actions = list(form_data.get('actions') or [])
|
||||||
|
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
||||||
|
form_content = form_data.get('form_content', '') or ''
|
||||||
|
|
||||||
|
# Record form state for the click-handler.
|
||||||
|
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
|
||||||
|
self.card_state[out_track_id] = {
|
||||||
|
'session_key': session_key,
|
||||||
|
'launcher_type': launcher_type.value,
|
||||||
|
'launcher_id': launcher_id,
|
||||||
|
'sender_user_id': sender_user_id,
|
||||||
|
'form_token': form_data.get('form_token', ''),
|
||||||
|
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||||
|
'actions': actions,
|
||||||
|
'node_title': node_title,
|
||||||
|
'form_content': form_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
btns = self._build_btns(actions, out_track_id)
|
||||||
|
parts: list[str] = []
|
||||||
|
prior = self.active_turn_text.get(session_key, '') if session_key else ''
|
||||||
|
if prior.strip():
|
||||||
|
parts.append(prior.rstrip())
|
||||||
|
parts.append('---')
|
||||||
|
if node_title:
|
||||||
|
parts.append(f'**{node_title}**')
|
||||||
|
if form_content:
|
||||||
|
parts.append(form_content)
|
||||||
|
display_content = '\n\n'.join(parts) or '请选择一个操作以继续。'
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.bot.update_card_data(
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
card_param_map={
|
||||||
|
'content': display_content,
|
||||||
|
'btns': json.dumps(btns, ensure_ascii=False),
|
||||||
|
'flowStatus': '3',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: paint form on card failed')
|
||||||
|
await self.send_message_text_form(message_source, form_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
if session_key:
|
||||||
|
self.active_turn_text[session_key] = display_content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_btns(actions: list, out_track_id: str) -> list:
|
||||||
|
btns = []
|
||||||
|
for idx, action in enumerate(actions):
|
||||||
|
action_id = str(action.get('id') or '')
|
||||||
|
title = str(action.get('title') or action_id or f'选项 {idx + 1}')
|
||||||
|
style = (action.get('button_style') or '').lower()
|
||||||
|
if style == 'primary' or (style == '' and idx == 0):
|
||||||
|
color = 'blue'
|
||||||
|
elif style == 'danger':
|
||||||
|
color = 'red'
|
||||||
|
else:
|
||||||
|
color = 'gray'
|
||||||
|
btns.append(
|
||||||
|
{
|
||||||
|
'text': title,
|
||||||
|
'color': color,
|
||||||
|
'status': 'normal',
|
||||||
|
'event': {
|
||||||
|
'type': 'sendCardRequest',
|
||||||
|
'params': {
|
||||||
|
'actionId': action_id,
|
||||||
|
'params': {'action_id': action_id, 'out_track_id': out_track_id},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return btns
|
||||||
|
|
||||||
|
async def _send_form_card(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
template_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Deliver a new card pre-loaded with the human-input prompt + buttons."""
|
||||||
|
out_track_id = uuid.uuid4().hex
|
||||||
|
open_space_id, is_group = self._derive_open_space(message_source)
|
||||||
|
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
|
||||||
|
session_key = f'{launcher_type.value}_{launcher_id}'
|
||||||
|
|
||||||
|
actions = list(form_data.get('actions') or [])
|
||||||
|
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
||||||
|
form_content = form_data.get('form_content', '') or ''
|
||||||
|
|
||||||
|
self.card_state[out_track_id] = {
|
||||||
|
'session_key': session_key,
|
||||||
|
'launcher_type': launcher_type.value,
|
||||||
|
'launcher_id': launcher_id,
|
||||||
|
'sender_user_id': sender_user_id,
|
||||||
|
'form_token': form_data.get('form_token', ''),
|
||||||
|
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||||
|
'actions': actions,
|
||||||
|
'node_title': node_title,
|
||||||
|
'form_content': form_content,
|
||||||
|
'open_space_id': open_space_id,
|
||||||
|
'is_group': is_group,
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if node_title:
|
||||||
|
parts.append(f'**{node_title}**')
|
||||||
|
if form_content:
|
||||||
|
parts.append(form_content)
|
||||||
|
display_content = '\n\n'.join(parts) or '请选择一个操作以继续。'
|
||||||
|
|
||||||
|
btns = []
|
||||||
|
for idx, action in enumerate(actions):
|
||||||
|
action_id = str(action.get('id') or '')
|
||||||
|
title = str(action.get('title') or action_id or f'选项 {idx + 1}')
|
||||||
|
style = (action.get('button_style') or '').lower()
|
||||||
|
if style == 'primary' or (style == '' and idx == 0):
|
||||||
|
color = 'blue'
|
||||||
|
elif style == 'danger':
|
||||||
|
color = 'red'
|
||||||
|
else:
|
||||||
|
color = 'gray'
|
||||||
|
btns.append(
|
||||||
|
{
|
||||||
|
'text': title,
|
||||||
|
'color': color,
|
||||||
|
'status': 'normal',
|
||||||
|
'event': {
|
||||||
|
'type': 'sendCardRequest',
|
||||||
|
'params': {
|
||||||
|
'actionId': action_id,
|
||||||
|
'params': {'action_id': action_id, 'out_track_id': out_track_id},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk _send_form_card: out_track_id={out_track_id} template_id={template_id} '
|
||||||
|
f'open_space_id={open_space_id} is_group={is_group} btns={len(btns)}'
|
||||||
|
)
|
||||||
|
await self.bot.create_and_deliver_card(
|
||||||
|
card_template_id=template_id,
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
open_space_id=open_space_id,
|
||||||
|
is_group=is_group,
|
||||||
|
card_param_map={
|
||||||
|
'content': display_content,
|
||||||
|
'btns': json.dumps(btns, ensure_ascii=False),
|
||||||
|
'flowStatus': '3',
|
||||||
|
},
|
||||||
|
callback_type='STREAM',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'DingTalk: deliver form card failed: {traceback.format_exc()}')
|
||||||
|
await self.send_message_text_form(message_source, form_data)
|
||||||
|
self.card_state.pop(out_track_id, None)
|
||||||
|
|
||||||
|
async def _lazy_create_resume_chat_card(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message_id: str,
|
||||||
|
) -> typing.Optional[tuple]:
|
||||||
|
"""Create a new card for resumed-workflow streaming output.
|
||||||
|
|
||||||
|
Used after a button click triggers a synthetic event — the form
|
||||||
|
card stays put with the "已选择" notice, and a fresh card is
|
||||||
|
spawned here for the LLM reply to stream into.
|
||||||
|
"""
|
||||||
|
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
|
||||||
|
legacy_template_id = (self.config.get('card_template_id') or '').strip()
|
||||||
|
template_id = form_template_id or legacy_template_id
|
||||||
|
if not template_id:
|
||||||
|
return None
|
||||||
|
out_track_id = uuid.uuid4().hex
|
||||||
|
open_space_id, is_group = self._derive_open_space(message_source)
|
||||||
|
if form_template_id:
|
||||||
|
card_param_map = {'content': '', 'btns': '[]', 'flowStatus': '1'}
|
||||||
|
card_data_config = None
|
||||||
|
else:
|
||||||
|
card_param_map = {'content': '', 'query': '...'}
|
||||||
|
card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)}
|
||||||
|
try:
|
||||||
|
success = await self.bot.create_and_deliver_card(
|
||||||
|
card_template_id=template_id,
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
open_space_id=open_space_id,
|
||||||
|
is_group=is_group,
|
||||||
|
card_param_map=card_param_map,
|
||||||
|
card_data_config=card_data_config,
|
||||||
|
callback_type='STREAM',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: lazy create resume chat card failed')
|
||||||
|
return None
|
||||||
|
if not success:
|
||||||
|
return None
|
||||||
|
entry = (None, out_track_id)
|
||||||
|
self.card_instance_id_dict[message_id] = entry
|
||||||
|
# Register as the active card so any further chunks on this turn
|
||||||
|
# (and a subsequent re-pause) land on the same new card.
|
||||||
|
session_key = self._session_key_from_event(message_source)
|
||||||
|
if session_key:
|
||||||
|
self.active_turn_card[session_key] = out_track_id
|
||||||
|
self.active_turn_text[session_key] = ''
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def send_message_text_form(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Fallback: send the human-input prompt as plain text."""
|
||||||
|
display_text = _format_human_input_text(
|
||||||
|
form_data.get('node_title', ''),
|
||||||
|
form_data.get('form_content', ''),
|
||||||
|
form_data.get('actions', []) or [],
|
||||||
|
)
|
||||||
|
await self._send_proactive_to_event(message_source, display_text)
|
||||||
|
|
||||||
|
async def _send_proactive_to_event(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send `content` as a proactive message to the conversation behind
|
||||||
|
`message_source`. Used when no inbound chatbot message exists to
|
||||||
|
anchor a card on (e.g. resumed flows triggered by card actions).
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
if self.ap is not None:
|
||||||
|
target = (
|
||||||
|
str(message_source.group.id)
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage)
|
||||||
|
else str(message_source.sender.id)
|
||||||
|
)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk _send_proactive_to_event: target={target} '
|
||||||
|
f'is_group={isinstance(message_source, platform_events.GroupMessage)} content_len={len(content)}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
await self.bot.send_proactive_message_to_group(str(message_source.group.id), content)
|
||||||
|
else:
|
||||||
|
await self.bot.send_proactive_message_to_one(str(message_source.sender.id), content)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: send proactive message failed')
|
||||||
|
await self.logger.error(f'DingTalk: send proactive message failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _on_card_action(self, payload: dict) -> None:
|
||||||
|
"""Translate a card button click into a synthetic query.
|
||||||
|
|
||||||
|
Reads the clicked button's ``actionId`` (the real Dify action id —
|
||||||
|
the ButtonGroup template sends it back via `event.params.actionId`),
|
||||||
|
recovers the action title from ``card_state``, and enqueues a
|
||||||
|
synthetic `_dify_form_action` query the same way Lark / Telegram do.
|
||||||
|
"""
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk _on_card_action received: out_track_id={payload.get("out_track_id")} '
|
||||||
|
f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}'
|
||||||
|
)
|
||||||
|
out_track_id = payload.get('out_track_id') or ''
|
||||||
|
params = payload.get('params') or {}
|
||||||
|
# ButtonGroup `sendCardRequest` events surface the click id at the
|
||||||
|
# callback top level as `actionId`; fall back to `params.action_id`
|
||||||
|
# (alternate template wiring) and `params.actionId`.
|
||||||
|
raw_action_id = (
|
||||||
|
(payload.get('action_id') or '').strip()
|
||||||
|
or (params.get('action_id') or '').strip()
|
||||||
|
or (params.get('actionId') or '').strip()
|
||||||
|
or (params.get('id') or '').strip()
|
||||||
|
)
|
||||||
|
state = self.card_state.get(out_track_id)
|
||||||
|
if state is None:
|
||||||
|
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
||||||
|
return
|
||||||
|
if not raw_action_id:
|
||||||
|
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
|
||||||
|
return
|
||||||
|
|
||||||
|
actions = state.get('actions', []) or []
|
||||||
|
action_id = raw_action_id
|
||||||
|
action_title = raw_action_id
|
||||||
|
for action in actions:
|
||||||
|
if str(action.get('id', '')) == raw_action_id:
|
||||||
|
action_title = action.get('title') or raw_action_id
|
||||||
|
break
|
||||||
|
|
||||||
|
launcher_type = (
|
||||||
|
provider_session.LauncherTypes.GROUP
|
||||||
|
if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value
|
||||||
|
else provider_session.LauncherTypes.PERSON
|
||||||
|
)
|
||||||
|
launcher_id = state.get('launcher_id', '')
|
||||||
|
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'form_token': state.get('form_token', ''),
|
||||||
|
'workflow_run_id': state.get('workflow_run_id', ''),
|
||||||
|
'action_id': action_id,
|
||||||
|
'action_title': action_title,
|
||||||
|
'node_title': state.get('node_title', ''),
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=sender_user_id,
|
||||||
|
member_name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(datetime.datetime.now().timestamp()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=sender_user_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(datetime.datetime.now().timestamp()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
if self.ap is not None:
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} '
|
||||||
|
f'action_title={action_title!r} launcher_type={launcher_type.value} '
|
||||||
|
f'launcher_id={launcher_id} bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}'
|
||||||
|
)
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=sender_user_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.ap.logger.info('DingTalk _on_card_action: query enqueued OK')
|
||||||
|
except Exception:
|
||||||
|
self.ap.logger.exception('DingTalk: enqueue form action query failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Visual feedback on the form card itself: keep the prompt visible,
|
||||||
|
# add a "已选择" line, remove the buttons. The resumed-workflow
|
||||||
|
# output lives on a separate new card (lazy-created in
|
||||||
|
# reply_message_chunk on the synthetic event), so the form card
|
||||||
|
# stays put as a record of the user's selection.
|
||||||
|
asyncio.create_task(
|
||||||
|
self._mark_card_resolved(
|
||||||
|
out_track_id,
|
||||||
|
action_title,
|
||||||
|
node_title=state.get('node_title', ''),
|
||||||
|
form_content=state.get('form_content', ''),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crucial: do NOT leave the form card's out_track_id in
|
||||||
|
# active_turn_card — otherwise create_message_card for the
|
||||||
|
# synthetic event would reuse it for the resume output, painting
|
||||||
|
# the LLM reply on top of the "已选择" notice. Clear it so the
|
||||||
|
# resume goes through the lazy-create path and spawns a fresh card.
|
||||||
|
session_key = state.get('session_key', '')
|
||||||
|
if session_key and self.active_turn_card.get(session_key) == out_track_id:
|
||||||
|
self.active_turn_card.pop(session_key, None)
|
||||||
|
self.active_turn_text.pop(session_key, None)
|
||||||
|
|
||||||
|
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
|
||||||
|
self.card_state.pop(out_track_id, None)
|
||||||
|
|
||||||
|
async def _mark_card_resolved(
|
||||||
|
self,
|
||||||
|
out_track_id: str,
|
||||||
|
action_title: str,
|
||||||
|
*,
|
||||||
|
node_title: str = '',
|
||||||
|
form_content: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Update the form card to acknowledge the user's selection.
|
||||||
|
|
||||||
|
Keeps the original prompt visible, adds a "已选择: X" notice, and
|
||||||
|
clears the buttons. The card stays as a permanent record of the
|
||||||
|
choice; the resumed workflow's output goes to a separate new card.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if node_title:
|
||||||
|
parts.append(f'**{node_title}**')
|
||||||
|
if form_content:
|
||||||
|
parts.append(form_content)
|
||||||
|
parts.append(f'---\n✅ 已选择:**{action_title}**')
|
||||||
|
content = '\n\n'.join(parts)
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}')
|
||||||
|
try:
|
||||||
|
await self.bot.update_card_data(
|
||||||
|
out_track_id=out_track_id,
|
||||||
|
card_param_map={
|
||||||
|
'content': content,
|
||||||
|
'btns': '[]',
|
||||||
|
'flowStatus': '3',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.exception('DingTalk: mark card resolved failed')
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
|
||||||
|
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
|
||||||
|
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: dingtalk
|
||||||
|
required: false
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
@@ -40,6 +52,10 @@ spec:
|
|||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
zh_Hant: 機器人代碼
|
||||||
|
description:
|
||||||
|
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
|
||||||
|
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
|
||||||
|
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -87,6 +103,18 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
|
- name: human_input_card_template_id
|
||||||
|
label:
|
||||||
|
en_US: Human Input Card Template ID
|
||||||
|
zh_Hans: 人工输入卡片模板ID
|
||||||
|
zh_Hant: 人工輸入卡片範本ID
|
||||||
|
description:
|
||||||
|
en_US: "Template ID used as the SINGLE card for the whole conversation turn. Streamed LLM text fills the `content` markdown variable; on a Dify human-input pause the `btns` buttonGroup variable is populated so action buttons appear on the SAME card; after the user clicks a button the buttons disappear and resumed streaming continues into the same card. Use the bundled `src/langbot/templates/dingtalk_human_input_card.json` — it ships with `content` (MarkdownBlock) and `btns` (ButtonGroup) already wired. Leave empty to fall back to the legacy two-card behaviour (chat card streaming text + plain-text human-input prompts)."
|
||||||
|
zh_Hans: "用作整个对话回合**唯一**卡片的模板ID。流式 LLM 文本写入 `content` markdown 变量;Dify 人工输入暂停时同一张卡的 `btns` buttonGroup 变量被填上、按钮浮现;用户点击后按钮消失、恢复的流式内容继续追加到同一张卡。可使用项目附带的 `src/langbot/templates/dingtalk_human_input_card.json`——已经预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为(聊天卡走流式 + 人工输入走纯文本)。"
|
||||||
|
zh_Hant: "用作整個對話回合**唯一**卡片的範本ID。流式 LLM 文字寫入 `content` markdown 變數;Dify 人工輸入暫停時同一張卡的 `btns` buttonGroup 變數被填上、按鈕浮現;使用者點擊後按鈕消失、恢復的流式內容繼續追加到同一張卡。可使用專案附帶的 `src/langbot/templates/dingtalk_human_input_card.json`——已經預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為(聊天卡走流式 + 人工輸入走純文字)。"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./dingtalk.py
|
path: ./dingtalk.py
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,20 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
ja_JP: ワンクリックでアプリ作成
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
||||||
|
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
||||||
|
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
||||||
|
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: feishu
|
||||||
|
required: false
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,693 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
import asyncio
|
||||||
|
import traceback
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import nio
|
||||||
|
|
||||||
|
from langbot.pkg.utils import httpclient
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
|
||||||
|
components = []
|
||||||
|
for component in message_chain:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
components.append({'type': 'text', 'text': component.text})
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
image_bytes = None
|
||||||
|
if component.base64:
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
image_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
image_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
if image_bytes:
|
||||||
|
resp = await client.upload(image_bytes, content_type='image/png')
|
||||||
|
if isinstance(resp, nio.UploadResponse):
|
||||||
|
components.append({'type': 'image', 'mxc_url': resp.content_uri})
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = None
|
||||||
|
if component.base64:
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
file_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
if file_bytes:
|
||||||
|
file_name = getattr(component, 'name', None) or 'file'
|
||||||
|
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
|
||||||
|
if isinstance(resp, nio.UploadResponse):
|
||||||
|
components.append(
|
||||||
|
{
|
||||||
|
'type': 'file',
|
||||||
|
'mxc_url': resp.content_uri,
|
||||||
|
'filename': file_name,
|
||||||
|
'size': len(file_bytes),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
|
||||||
|
return components
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
|
||||||
|
message_components = []
|
||||||
|
|
||||||
|
if isinstance(event, nio.RoomMessageText):
|
||||||
|
text = event.body
|
||||||
|
if bot_user_id and bot_user_id in text:
|
||||||
|
message_components.append(platform_message.At(target=bot_user_id))
|
||||||
|
text = text.replace(bot_user_id, '').strip()
|
||||||
|
message_components.append(platform_message.Plain(text=text))
|
||||||
|
|
||||||
|
elif isinstance(event, nio.RoomMessageImage):
|
||||||
|
mxc_url = event.url
|
||||||
|
if mxc_url:
|
||||||
|
resp = await client.download(mxc_url)
|
||||||
|
if isinstance(resp, nio.DownloadResponse):
|
||||||
|
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||||
|
content_type = resp.content_type or 'image/png'
|
||||||
|
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
|
||||||
|
if event.body:
|
||||||
|
message_components.append(platform_message.Plain(text=event.body))
|
||||||
|
|
||||||
|
return platform_message.MessageChain(message_components)
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(event: platform_events.MessageEvent):
|
||||||
|
return event.source_platform_object
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(
|
||||||
|
event: nio.RoomMessageText | nio.RoomMessageImage,
|
||||||
|
room: nio.MatrixRoom,
|
||||||
|
client: nio.AsyncClient,
|
||||||
|
bot_user_id: str,
|
||||||
|
bridge_user_ids: list[str] | None = None,
|
||||||
|
):
|
||||||
|
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
|
||||||
|
|
||||||
|
# Determine if this is a direct/private chat or a group chat.
|
||||||
|
# Exclude bot itself and bridge bots, count remaining real users.
|
||||||
|
exclude_ids = {bot_user_id}
|
||||||
|
if bridge_user_ids:
|
||||||
|
exclude_ids.update(bridge_user_ids)
|
||||||
|
real_users = [uid for uid in room.users if uid not in exclude_ids]
|
||||||
|
is_direct = len(real_users) <= 1
|
||||||
|
|
||||||
|
if is_direct:
|
||||||
|
return platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=event.sender,
|
||||||
|
nickname=room.user_name(event.sender) or event.sender,
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=lb_message,
|
||||||
|
time=event.server_timestamp / 1000.0,
|
||||||
|
source_platform_object={'event': event, 'room': room},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=event.sender,
|
||||||
|
member_name=room.user_name(event.sender) or event.sender,
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=room.room_id,
|
||||||
|
name=room.display_name or room.room_id,
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
),
|
||||||
|
message_chain=lb_message,
|
||||||
|
time=event.server_timestamp / 1000.0,
|
||||||
|
source_platform_object={'event': event, 'room': room},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeState:
|
||||||
|
"""Per-bridge runtime state."""
|
||||||
|
|
||||||
|
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.login_command = login_command
|
||||||
|
self.logout_command = logout_command
|
||||||
|
self.success_keyword = success_keyword
|
||||||
|
self.check_command = check_command or login_command
|
||||||
|
self.logged_in = False
|
||||||
|
self.dm_room_id: str | None = None
|
||||||
|
self.login_task: asyncio.Task | None = None
|
||||||
|
self.check_task: asyncio.Task | None = None
|
||||||
|
self.check_responded = False
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
client: typing.Any = None
|
||||||
|
message_converter: MatrixMessageConverter = MatrixMessageConverter()
|
||||||
|
event_converter: MatrixEventConverter = MatrixEventConverter()
|
||||||
|
config: dict
|
||||||
|
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||||
|
_running: bool = False
|
||||||
|
_initial_sync_done: bool = False
|
||||||
|
_bridges: list = []
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
|
homeserver_url = config.get('homeserver_url', '')
|
||||||
|
access_token = config.get('access_token', '')
|
||||||
|
user_id = config.get('user_id', '')
|
||||||
|
|
||||||
|
if not homeserver_url or not access_token or not user_id:
|
||||||
|
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
|
||||||
|
|
||||||
|
client = nio.AsyncClient(homeserver_url, user_id)
|
||||||
|
client.access_token = access_token
|
||||||
|
client.user_id = user_id
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
bot_account_id=user_id,
|
||||||
|
client=client,
|
||||||
|
listeners={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
|
||||||
|
self._bridges = []
|
||||||
|
bridges_raw = config.get('bridges', '')
|
||||||
|
if bridges_raw:
|
||||||
|
if isinstance(bridges_raw, str):
|
||||||
|
try:
|
||||||
|
bridges_list = json.loads(bridges_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
|
||||||
|
else:
|
||||||
|
bridges_list = bridges_raw
|
||||||
|
for b in bridges_list:
|
||||||
|
if isinstance(b, dict) and b.get('user_id', '').strip():
|
||||||
|
self._bridges.append(
|
||||||
|
BridgeState(
|
||||||
|
user_id=b['user_id'].strip(),
|
||||||
|
login_command=b.get('login_command', '').strip(),
|
||||||
|
logout_command=b.get('logout_command', '').strip(),
|
||||||
|
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
|
||||||
|
check_command=b.get('check_command', '').strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Backward compatibility: old single-bridge config
|
||||||
|
if not self._bridges:
|
||||||
|
old_user_id = config.get('bridge_user_id', '').strip()
|
||||||
|
old_command = config.get('bridge_login_command', '').strip()
|
||||||
|
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
|
||||||
|
old_check = config.get('bridge_check_command', '').strip()
|
||||||
|
old_logout = config.get('bridge_logout_command', '').strip()
|
||||||
|
if old_user_id:
|
||||||
|
self._bridges.append(
|
||||||
|
BridgeState(
|
||||||
|
user_id=old_user_id,
|
||||||
|
login_command=old_command,
|
||||||
|
logout_command=old_logout,
|
||||||
|
success_keyword=old_keyword,
|
||||||
|
check_command=old_check,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
|
components = await self.message_converter.yiri2target(message, self.client)
|
||||||
|
for component in components:
|
||||||
|
await self._send_component(target_id, component)
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
):
|
||||||
|
source_obj = message_source.source_platform_object
|
||||||
|
room_id = source_obj['room'].room_id
|
||||||
|
components = await self.message_converter.yiri2target(message, self.client)
|
||||||
|
|
||||||
|
for component in components:
|
||||||
|
if quote_origin:
|
||||||
|
original_event = source_obj['event']
|
||||||
|
await self._send_component(room_id, component, reply_to=original_event.event_id)
|
||||||
|
else:
|
||||||
|
await self._send_component(room_id, component)
|
||||||
|
|
||||||
|
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
|
||||||
|
content = {}
|
||||||
|
if component['type'] == 'text':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.text',
|
||||||
|
'body': component['text'],
|
||||||
|
}
|
||||||
|
elif component['type'] == 'image':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.image',
|
||||||
|
'body': 'image.png',
|
||||||
|
'url': component['mxc_url'],
|
||||||
|
}
|
||||||
|
elif component['type'] == 'file':
|
||||||
|
content = {
|
||||||
|
'msgtype': 'm.file',
|
||||||
|
'body': component.get('filename', 'file'),
|
||||||
|
'url': component['mxc_url'],
|
||||||
|
'info': {'size': component.get('size', 0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply_to and content:
|
||||||
|
content['m.relates_to'] = {
|
||||||
|
'm.in_reply_to': {'event_id': reply_to},
|
||||||
|
}
|
||||||
|
|
||||||
|
if content:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners[event_type] = callback
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
self._running = True
|
||||||
|
await self.logger.info('Matrix adapter starting...')
|
||||||
|
|
||||||
|
# Debug: log bridge parsing result
|
||||||
|
bridges_raw = self.config.get('bridges', '')
|
||||||
|
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
|
||||||
|
await self.logger.debug(
|
||||||
|
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect all bridge bot user IDs for filtering
|
||||||
|
_bridge_user_ids = [b.user_id for b in self._bridges]
|
||||||
|
_bridge_user_id_set = set(_bridge_user_ids)
|
||||||
|
|
||||||
|
# Auto-join invited rooms
|
||||||
|
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
|
||||||
|
if event.membership == 'invite' and event.state_key == self.client.user_id:
|
||||||
|
await self.client.join(room.room_id)
|
||||||
|
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
|
||||||
|
|
||||||
|
# Handle text messages
|
||||||
|
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender == self.client.user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Admin commands (from any non-bridge user)
|
||||||
|
if event.sender not in _bridge_user_id_set:
|
||||||
|
body = (event.body or '').strip()
|
||||||
|
if body == '!relogin':
|
||||||
|
await self._handle_relogin_command(room.room_id)
|
||||||
|
return
|
||||||
|
if body == '!status':
|
||||||
|
await self._handle_status_command(room.room_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.sender in _bridge_user_id_set:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lb_event = await self.event_converter.target2yiri(
|
||||||
|
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||||
|
)
|
||||||
|
if type(lb_event) in self.listeners:
|
||||||
|
result = self.listeners[type(lb_event)](lb_event, self)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_message, nio.RoomMessageText)
|
||||||
|
|
||||||
|
# Handle image messages
|
||||||
|
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender == self.client.user_id:
|
||||||
|
return
|
||||||
|
if event.sender in _bridge_user_id_set:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lb_event = await self.event_converter.target2yiri(
|
||||||
|
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||||
|
)
|
||||||
|
if type(lb_event) in self.listeners:
|
||||||
|
result = self.listeners[type(lb_event)](lb_event, self)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_image, nio.RoomMessageImage)
|
||||||
|
|
||||||
|
# Set up bridge-specific callbacks for each bridge
|
||||||
|
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
|
||||||
|
|
||||||
|
for bridge in self._bridges:
|
||||||
|
# Login success detection (notice)
|
||||||
|
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
_b.check_responded = True
|
||||||
|
if _b.success_keyword in (event.body or ''):
|
||||||
|
_b.logged_in = True
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||||
|
# Disconnect detection
|
||||||
|
body_lower = (event.body or '').lower()
|
||||||
|
for kw in _disconnect_keywords:
|
||||||
|
if kw in body_lower and _b.logged_in:
|
||||||
|
_b.logged_in = False
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||||
|
self._restart_bridge_login(_b)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
|
||||||
|
|
||||||
|
# Login success + disconnect detection (text)
|
||||||
|
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
_b.check_responded = True
|
||||||
|
if _b.success_keyword in (event.body or ''):
|
||||||
|
_b.logged_in = True
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||||
|
body_lower = (event.body or '').lower()
|
||||||
|
for kw in _disconnect_keywords:
|
||||||
|
if kw in body_lower and _b.logged_in:
|
||||||
|
_b.logged_in = False
|
||||||
|
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||||
|
self._restart_bridge_login(_b)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
|
||||||
|
|
||||||
|
# QR code image forwarding
|
||||||
|
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
|
||||||
|
if not self._initial_sync_done:
|
||||||
|
return
|
||||||
|
if event.sender != _b.user_id:
|
||||||
|
return
|
||||||
|
mxc_url = event.url
|
||||||
|
if not mxc_url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = await self.client.download(mxc_url)
|
||||||
|
if isinstance(resp, nio.DownloadResponse):
|
||||||
|
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||||
|
content_type = resp.content_type or 'image/png'
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
|
||||||
|
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
|
||||||
|
|
||||||
|
await self.logger.debug('Matrix adapter running, starting sync...')
|
||||||
|
|
||||||
|
# Initial sync to skip old messages
|
||||||
|
resp = await self.client.sync(timeout=10000)
|
||||||
|
if isinstance(resp, nio.SyncResponse):
|
||||||
|
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
|
||||||
|
self._initial_sync_done = True
|
||||||
|
|
||||||
|
# Display account info
|
||||||
|
display_name = self.client.user_id
|
||||||
|
try:
|
||||||
|
profile_resp = await self.client.get_displayname(self.client.user_id)
|
||||||
|
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
|
||||||
|
display_name = profile_resp.displayname
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
joined_rooms = len(self.client.rooms)
|
||||||
|
homeserver = self.config.get('homeserver_url', '')
|
||||||
|
bridge_info = ''
|
||||||
|
if self._bridges:
|
||||||
|
bridge_names = ', '.join(b.user_id for b in self._bridges)
|
||||||
|
bridge_info = f' | 桥接: [{bridge_names}]'
|
||||||
|
await self.logger.info(
|
||||||
|
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
|
||||||
|
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start bridge login and status check tasks for each bridge
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if bridge.login_command:
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
|
||||||
|
f'关键词: "{bridge.success_keyword}")'
|
||||||
|
)
|
||||||
|
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||||
|
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
|
||||||
|
|
||||||
|
# Main sync loop
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self.client.sync(timeout=30000)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
async def _periodic_bridge_login(self, bridge: BridgeState):
|
||||||
|
"""Periodically send login command to a bridge bot until login succeeds."""
|
||||||
|
try:
|
||||||
|
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
|
||||||
|
dm_room_id = None
|
||||||
|
for room_id, room in self.client.rooms.items():
|
||||||
|
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
|
||||||
|
dm_room_id = room_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if not dm_room_id:
|
||||||
|
resp = await self.client.room_create(
|
||||||
|
is_direct=True,
|
||||||
|
invite=[bridge.user_id],
|
||||||
|
)
|
||||||
|
if isinstance(resp, nio.RoomCreateResponse):
|
||||||
|
dm_room_id = resp.room_id
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
|
||||||
|
else:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
|
||||||
|
return
|
||||||
|
|
||||||
|
bridge.dm_room_id = dm_room_id
|
||||||
|
|
||||||
|
# Force logout first on every adapter start
|
||||||
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
|
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
while self._running and not bridge.logged_in:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': bridge.login_command},
|
||||||
|
)
|
||||||
|
for _ in range(60):
|
||||||
|
if not self._running or bridge.logged_in:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if bridge.logged_in:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _restart_bridge_login(self, bridge: BridgeState):
|
||||||
|
"""Cancel existing login task and start a new one."""
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||||
|
|
||||||
|
async def _periodic_bridge_check(self, bridge: BridgeState):
|
||||||
|
"""Periodically check a bridge's login status."""
|
||||||
|
try:
|
||||||
|
while self._running and not bridge.logged_in:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
check_interval = 300 # 5 minutes
|
||||||
|
response_timeout = 30
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
for _ in range(check_interval):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if not bridge.logged_in or not bridge.dm_room_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
bridge.check_responded = False
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=bridge.dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': bridge.check_command},
|
||||||
|
)
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
|
||||||
|
|
||||||
|
for _ in range(response_timeout):
|
||||||
|
if bridge.check_responded or not self._running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if bridge.check_responded:
|
||||||
|
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
|
||||||
|
else:
|
||||||
|
await self.logger.info(
|
||||||
|
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
|
||||||
|
)
|
||||||
|
bridge.logged_in = False
|
||||||
|
self._restart_bridge_login(bridge)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_relogin_command(self, room_id: str):
|
||||||
|
"""Handle !relogin command: logout then re-login all bridges."""
|
||||||
|
if not self._bridges:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ['开始重新登录所有桥...']
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if not bridge.login_command or not bridge.dm_room_id:
|
||||||
|
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use configured logout command, fallback to deriving from login command
|
||||||
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
|
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||||
|
|
||||||
|
# Cancel existing tasks
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
if bridge.check_task and not bridge.check_task.done():
|
||||||
|
bridge.check_task.cancel()
|
||||||
|
|
||||||
|
# Send logout
|
||||||
|
try:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=bridge.dm_room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Reset state and restart login
|
||||||
|
bridge.logged_in = False
|
||||||
|
self._restart_bridge_login(bridge)
|
||||||
|
lines.append(f'[{bridge.user_id}] 已触发重新登录')
|
||||||
|
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_status_command(self, room_id: str):
|
||||||
|
"""Handle !status command: show bridge states."""
|
||||||
|
if not self._bridges:
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ['桥状态:']
|
||||||
|
for bridge in self._bridges:
|
||||||
|
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
|
||||||
|
dm = bridge.dm_room_id or '无'
|
||||||
|
lines.append(f'• {bridge.user_id}: {status} (DM: {dm})')
|
||||||
|
await self.client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type='m.room.message',
|
||||||
|
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
self._running = False
|
||||||
|
for bridge in self._bridges:
|
||||||
|
if bridge.login_task and not bridge.login_task.done():
|
||||||
|
bridge.login_task.cancel()
|
||||||
|
if bridge.check_task and not bridge.check_task.done():
|
||||||
|
bridge.check_task.cancel()
|
||||||
|
if self.client:
|
||||||
|
await self.client.close()
|
||||||
|
await self.logger.debug('Matrix adapter stopped')
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
|
],
|
||||||
|
):
|
||||||
|
if event_type in self.listeners:
|
||||||
|
del self.listeners[event_type]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: matrix
|
||||||
|
label:
|
||||||
|
en_US: Matrix
|
||||||
|
zh_Hans: Matrix
|
||||||
|
zh_Hant: Matrix
|
||||||
|
ja_JP: Matrix
|
||||||
|
th_TH: Matrix
|
||||||
|
vi_VN: Matrix
|
||||||
|
es_ES: Matrix
|
||||||
|
description:
|
||||||
|
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
|
||||||
|
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
|
||||||
|
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
|
||||||
|
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
|
||||||
|
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
|
||||||
|
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
|
||||||
|
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
|
||||||
|
icon: matrix.png
|
||||||
|
spec:
|
||||||
|
categories:
|
||||||
|
- global
|
||||||
|
- protocol
|
||||||
|
config:
|
||||||
|
- name: homeserver_url
|
||||||
|
label:
|
||||||
|
en_US: Homeserver URL
|
||||||
|
zh_Hans: Homeserver 地址
|
||||||
|
zh_Hant: Homeserver 地址
|
||||||
|
ja_JP: Homeserver URL
|
||||||
|
th_TH: URL ของ Homeserver
|
||||||
|
vi_VN: URL Homeserver
|
||||||
|
es_ES: URL del Homeserver
|
||||||
|
description:
|
||||||
|
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
|
||||||
|
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "http://localhost:8008"
|
||||||
|
- name: user_id
|
||||||
|
label:
|
||||||
|
en_US: Bot User ID
|
||||||
|
zh_Hans: 机器人用户 ID
|
||||||
|
zh_Hant: 機器人用戶 ID
|
||||||
|
ja_JP: ボットユーザー ID
|
||||||
|
th_TH: ID ผู้ใช้บอท
|
||||||
|
vi_VN: ID người dùng bot
|
||||||
|
es_ES: ID de usuario del bot
|
||||||
|
description:
|
||||||
|
en_US: "The full Matrix user ID, e.g. @bot:localhost"
|
||||||
|
zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "@langbot:localhost"
|
||||||
|
- name: access_token
|
||||||
|
label:
|
||||||
|
en_US: Access Token
|
||||||
|
zh_Hans: 访问令牌
|
||||||
|
zh_Hant: 訪問令牌
|
||||||
|
ja_JP: アクセストークン
|
||||||
|
th_TH: โทเค็นการเข้าถึง
|
||||||
|
vi_VN: Mã truy cập
|
||||||
|
es_ES: Token de acceso
|
||||||
|
description:
|
||||||
|
en_US: "Access token obtained by logging in via the Matrix client API"
|
||||||
|
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: bridge_user_id
|
||||||
|
label:
|
||||||
|
en_US: Bridge Bot User ID (single bridge, legacy)
|
||||||
|
zh_Hans: 桥机器人用户 ID(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
|
||||||
|
zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: bridge_login_command
|
||||||
|
label:
|
||||||
|
en_US: Bridge Login Command (single bridge, legacy)
|
||||||
|
zh_Hans: 桥登录命令(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Login command for single bridge (legacy). e.g. !discord login"
|
||||||
|
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: bridge_login_success_keyword
|
||||||
|
label:
|
||||||
|
en_US: Bridge Login Success Keyword (single bridge, legacy)
|
||||||
|
zh_Hans: 桥登录成功关键词(单桥兼容)
|
||||||
|
description:
|
||||||
|
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
|
||||||
|
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "Successfully logged in"
|
||||||
|
- name: bridges
|
||||||
|
label:
|
||||||
|
en_US: Bridges Config (Multi-bridge)
|
||||||
|
zh_Hans: 桥配置(多桥)
|
||||||
|
description:
|
||||||
|
en_US: >
|
||||||
|
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||||
|
"success_keyword": "logged in", "check_command": "!xx ping"}.
|
||||||
|
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||||
|
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||||
|
zh_Hans: >
|
||||||
|
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||||
|
"success_keyword": "logged in", "check_command": "!xx ping"}。
|
||||||
|
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||||
|
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./matrix.py
|
||||||
|
attr: MatrixAdapter
|
||||||
@@ -32,6 +32,20 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ilinkai.weixin.qq.com"
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
|
- name: qr-login
|
||||||
|
label:
|
||||||
|
en_US: Scan QR Login
|
||||||
|
zh_Hans: 扫码登录
|
||||||
|
zh_Hant: 掃碼登入
|
||||||
|
ja_JP: QRコードでログイン
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
|
||||||
|
zh_Hans: 使用微信扫码授权,自动填写令牌
|
||||||
|
zh_Hant: 使用微信掃碼授權,自動填寫令牌
|
||||||
|
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: weixin
|
||||||
|
required: false
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
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.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
from langbot.libs.qq_official_api.api import QQOfficialClient
|
from langbot.libs.qq_official_api.api import QQOfficialClient, build_keyboard_from_form
|
||||||
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||||
from ...utils import image
|
from ...utils import image
|
||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
|
def _is_base64_data(value: str) -> bool:
|
||||||
|
"""Check if a string contains base64-encoded data rather than a URL."""
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
# data: URI scheme (e.g. data:image/png;base64,xxx)
|
||||||
|
if value.startswith('data:'):
|
||||||
|
return True
|
||||||
|
# Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars
|
||||||
|
if value.startswith(('http://', 'https://', '/', './', '../')):
|
||||||
|
return False
|
||||||
|
# Check if it looks like base64 (only valid chars, reasonable length)
|
||||||
|
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
|
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
|
||||||
content_list = []
|
content_list = []
|
||||||
# 只实现了发文字
|
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
content_list.append(
|
content_list.append(
|
||||||
@@ -28,6 +44,49 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
|||||||
'content': msg.text,
|
'content': msg.text,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif type(msg) is platform_message.Image:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'image',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif type(msg) is platform_message.Voice:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'voice',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif type(msg) is platform_message.File:
|
||||||
|
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||||
|
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||||
|
# Some plugins store base64 data in the url field
|
||||||
|
if url and not b64 and _is_base64_data(url):
|
||||||
|
b64 = url
|
||||||
|
url = None
|
||||||
|
content_list.append(
|
||||||
|
{
|
||||||
|
'type': 'file',
|
||||||
|
'url': url,
|
||||||
|
'base64': b64,
|
||||||
|
'name': msg.name if hasattr(msg, 'name') else 'file',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@@ -129,12 +188,20 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
config: dict
|
config: dict
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
|
enable_webhook: bool = False
|
||||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||||
|
ap: typing.Any = None
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
|
|
||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
app_id=config['appid'],
|
||||||
|
secret=config['secret'],
|
||||||
|
token=config['token'],
|
||||||
|
logger=logger,
|
||||||
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -144,6 +211,38 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot_account_id=config['appid'],
|
bot_account_id=config['appid'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.enable_webhook = enable_webhook
|
||||||
|
self._ws_task: asyncio.Task = None
|
||||||
|
self._stream_ctx: dict = {}
|
||||||
|
self._stream_ctx_ts: dict[str, float] = {}
|
||||||
|
self._fallback_text: dict[str, str] = {}
|
||||||
|
self._fallback_text_ts: dict[str, float] = {}
|
||||||
|
# Dify form-action bookkeeping for the human-input button flow.
|
||||||
|
# session_key = "<scene>_<id>" where scene is c2c/group/channel and
|
||||||
|
# id is user_openid / group_openid / channel_id.
|
||||||
|
# session_key -> {form_data, msg_id, event_id, scene, target_id,
|
||||||
|
# sender_id, posted_at}
|
||||||
|
# Set when we send a markdown+keyboard card and consulted when:
|
||||||
|
# (a) INTERACTION_CREATE fires — we look up the form by
|
||||||
|
# session_key (button's `data` carries the action_id),
|
||||||
|
# (b) the resumed-workflow query needs to find a passive-reply
|
||||||
|
# event_id (INTERACTION_CREATE id, 30-min validity).
|
||||||
|
self._pending_forms: dict[str, dict] = {}
|
||||||
|
# session_key -> most recent ``INTERACTION_CREATE`` event_id, used
|
||||||
|
# as the passive event_id for the resumed query's LLM output.
|
||||||
|
self._session_event_ids: dict[str, dict] = {}
|
||||||
|
# Per-anchor msg_seq counter. QQ accepts up to 5 passive replies
|
||||||
|
# per (msg_id|event_id) within 60 min, but each reuse needs a
|
||||||
|
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
|
||||||
|
self._anchor_msg_seq: dict[str, int] = {}
|
||||||
|
|
||||||
|
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
|
||||||
|
# (ws mode is wired separately via on_event in _run_websocket so the
|
||||||
|
# raw payload bypasses get_message's message-only flattening.)
|
||||||
|
@self.bot.on_interaction()
|
||||||
|
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
|
||||||
|
await self._handle_interaction_create(event_data, interaction_id)
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -154,30 +253,27 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
message_source,
|
message_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Synthetic event (button-click resume): no inbound platform
|
||||||
|
# object → no msg_id. Route via the cached INTERACTION_CREATE
|
||||||
|
# event_id (valid 30 min, no quota cost).
|
||||||
|
if qq_official_event is None:
|
||||||
|
await self._reply_synthetic(message_source, message)
|
||||||
|
return
|
||||||
|
|
||||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||||
|
|
||||||
# 私聊消息
|
# 确定 target_type 和 target_id
|
||||||
|
target_type = None
|
||||||
|
target_id = None
|
||||||
|
|
||||||
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
||||||
for content in content_list:
|
target_type = 'c2c'
|
||||||
if content['type'] == 'text':
|
target_id = qq_official_event.user_openid
|
||||||
await self.bot.send_private_text_msg(
|
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||||
qq_official_event.user_openid,
|
target_type = 'group'
|
||||||
content['content'],
|
target_id = qq_official_event.group_openid
|
||||||
qq_official_event.d_id,
|
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||||
)
|
# 频道群聊使用频道 API,暂不支持富媒体
|
||||||
|
|
||||||
# 群聊消息
|
|
||||||
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
|
||||||
for content in content_list:
|
|
||||||
if content['type'] == 'text':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
qq_official_event.group_openid,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 频道群聊
|
|
||||||
if qq_official_event.t == 'AT_MESSAGE_CREATE':
|
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_group_text_msg(
|
await self.bot.send_channle_group_text_msg(
|
||||||
@@ -185,9 +281,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
# 频道私聊
|
elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||||
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
# 频道私聊使用频道 API,暂不支持富媒体
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_private_text_msg(
|
await self.bot.send_channle_private_text_msg(
|
||||||
@@ -195,6 +291,63 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# C2C 和群聊:支持文字 + 富媒体
|
||||||
|
for content in content_list:
|
||||||
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
|
if content_type == 'text':
|
||||||
|
if target_type == 'c2c':
|
||||||
|
await self.bot.send_private_text_msg(
|
||||||
|
target_id,
|
||||||
|
content['content'],
|
||||||
|
qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
await self.bot.send_group_text_msg(
|
||||||
|
target_id,
|
||||||
|
content['content'],
|
||||||
|
qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'image':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'voice':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_voice_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif content_type == 'file':
|
||||||
|
file_url = content.get('url')
|
||||||
|
file_data = content.get('base64')
|
||||||
|
file_name = content.get('name', 'file')
|
||||||
|
if file_url or file_data:
|
||||||
|
await self.bot.send_file_msg(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
msg_id=qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
@@ -238,17 +391,228 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
if not self.enable_webhook:
|
||||||
# 保持运行但不启动独立端口
|
await self._run_websocket()
|
||||||
|
else:
|
||||||
|
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||||
|
async def keep_alive():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def keep_alive():
|
await keep_alive()
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
await keep_alive()
|
async def _run_websocket(self):
|
||||||
|
"""以 WebSocket 模式运行网关连接"""
|
||||||
|
await self.logger.info('QQ Official adapter starting in WebSocket mode')
|
||||||
|
|
||||||
|
async def on_ready():
|
||||||
|
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||||
|
|
||||||
|
async def on_event(event_type: str, event_data: dict):
|
||||||
|
# INTERACTION_CREATE is dispatched via bot.on_interaction()
|
||||||
|
# (registered in __init__) so we get the top-level ws_event_id
|
||||||
|
# — needed as the passive-reply event_id. It never reaches here.
|
||||||
|
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||||
|
message_event_types = {
|
||||||
|
'C2C_MESSAGE_CREATE',
|
||||||
|
'DIRECT_MESSAGE_CREATE',
|
||||||
|
'GROUP_AT_MESSAGE_CREATE',
|
||||||
|
'AT_MESSAGE_CREATE',
|
||||||
|
}
|
||||||
|
if event_type not in message_event_types:
|
||||||
|
return
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
|
||||||
|
return
|
||||||
|
await self.logger.info(f'Processing message event: {event_type}')
|
||||||
|
# 构造与 webhook 模式相同的 payload 结构
|
||||||
|
payload = {'t': event_type, 'd': event_data}
|
||||||
|
message_data = await self.bot.get_message(payload)
|
||||||
|
if message_data:
|
||||||
|
event = QQOfficialEvent.from_payload(message_data)
|
||||||
|
await self.bot._handle_message(event)
|
||||||
|
|
||||||
|
async def on_error(error: Exception):
|
||||||
|
await self.logger.error(f'WebSocket error: {error}')
|
||||||
|
await self.logger.error(f'QQ Official WebSocket error: {error}')
|
||||||
|
|
||||||
|
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
|
||||||
|
try:
|
||||||
|
await self._ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
if self._ws_task:
|
||||||
|
self._ws_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._ws_task = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --------------- 流式输出 ---------------
|
||||||
|
|
||||||
|
_STREAM_CTX_TTL = 300 # seconds
|
||||||
|
|
||||||
|
async def _cleanup_stale_streams(self):
|
||||||
|
"""Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds."""
|
||||||
|
now = time.time()
|
||||||
|
stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||||
|
for mid in stale_ids:
|
||||||
|
self._stream_ctx.pop(mid, None)
|
||||||
|
self._stream_ctx_ts.pop(mid, None)
|
||||||
|
stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||||
|
for mid in stale_fb:
|
||||||
|
self._fallback_text.pop(mid, None)
|
||||||
|
self._fallback_text_ts.pop(mid, None)
|
||||||
|
if stale_ids or stale_fb:
|
||||||
|
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return self.config.get('enable-stream-reply', False)
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
|
source = event.source_platform_object
|
||||||
|
# Synthetic events (button-click resume) have no source object —
|
||||||
|
# they ride a cached INTERACTION_CREATE event_id, not a streamable
|
||||||
|
# msg_id. Skip stream setup; reply_message handles the one-shot
|
||||||
|
# send at is_final.
|
||||||
|
if source is None:
|
||||||
|
return False
|
||||||
|
# Streaming API only supports C2C private chat
|
||||||
|
if source.t != 'C2C_MESSAGE_CREATE':
|
||||||
|
return False
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
'user_openid': source.user_openid,
|
||||||
|
'msg_id': source.d_id,
|
||||||
|
'stream_msg_id': None,
|
||||||
|
'msg_seq': 1,
|
||||||
|
'index': 0,
|
||||||
|
'last_update_ts': 0,
|
||||||
|
'accumulated_text': '',
|
||||||
|
'sent_length': 0,
|
||||||
|
'session_started': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._stream_ctx[message_id] = ctx
|
||||||
|
self._stream_ctx_ts[message_id] = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message: dict,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
):
|
||||||
|
# Periodically clean up stale stream contexts
|
||||||
|
await self._cleanup_stale_streams()
|
||||||
|
|
||||||
|
# Dify human-input pause: when the runner attaches `_form_data` to
|
||||||
|
# the final chunk, finalize any in-flight stream session and send
|
||||||
|
# a markdown + keyboard message instead. Plain-text content from
|
||||||
|
# earlier chunks is already on the stream; we close it cleanly
|
||||||
|
# and the buttons land as a separate reply.
|
||||||
|
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
||||||
|
if is_final:
|
||||||
|
_resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None
|
||||||
|
_open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'QQ Official reply_message_chunk final: '
|
||||||
|
f'type={type(bot_message).__name__} '
|
||||||
|
f'is_final={is_final} '
|
||||||
|
f'form_data_present={form_data is not None} '
|
||||||
|
f'resume_from_form={_resume} open_new_card={_open_new} '
|
||||||
|
f'content_len={len(getattr(bot_message, "content", "") or "")}'
|
||||||
|
)
|
||||||
|
if form_data and is_final:
|
||||||
|
await self._handle_form_chunk(message_source, message, form_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 提取纯文本内容(当前 chunk 的文本)
|
||||||
|
text_parts = []
|
||||||
|
for msg in message:
|
||||||
|
if type(msg) is platform_message.Plain:
|
||||||
|
text_parts.append(msg.text)
|
||||||
|
chunk_text = '\n\n'.join(text_parts)
|
||||||
|
|
||||||
|
message_id = (
|
||||||
|
bot_message.get('resp_message_id')
|
||||||
|
if isinstance(bot_message, dict)
|
||||||
|
else getattr(bot_message, 'resp_message_id', None)
|
||||||
|
)
|
||||||
|
if not message_id or message_id not in self._stream_ctx:
|
||||||
|
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
||||||
|
if chunk_text:
|
||||||
|
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
||||||
|
self._fallback_text_ts[message_id] = time.time()
|
||||||
|
if is_final:
|
||||||
|
full_text = self._fallback_text.pop(message_id, '')
|
||||||
|
if full_text:
|
||||||
|
fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)])
|
||||||
|
await self.reply_message(message_source, fallback_msg, quote_origin)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx = self._stream_ctx[message_id]
|
||||||
|
|
||||||
|
# 累积文本
|
||||||
|
if chunk_text:
|
||||||
|
ctx['accumulated_text'] += chunk_text
|
||||||
|
|
||||||
|
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||||
|
if not ctx['session_started']:
|
||||||
|
if not ctx['accumulated_text']:
|
||||||
|
return
|
||||||
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
|
ctx['session_started'] = True
|
||||||
|
|
||||||
|
# 发送内容 = 全量累积文本
|
||||||
|
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
||||||
|
# - 首次:发送全部文本,建立会话
|
||||||
|
# - 后续:只能发送新增部分(append 行为)
|
||||||
|
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||||
|
if not content_to_send and not is_final:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
|
# Rate limiting: skip non-final updates if last update was <0.5s ago
|
||||||
|
now = time.time()
|
||||||
|
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
||||||
|
return
|
||||||
|
ctx['last_update_ts'] = now
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.bot.send_stream_msg(
|
||||||
|
user_openid=ctx['user_openid'],
|
||||||
|
content=content_to_send,
|
||||||
|
event_id=ctx['msg_id'],
|
||||||
|
msg_id=ctx['msg_id'],
|
||||||
|
msg_seq=ctx['msg_seq'],
|
||||||
|
index=ctx['index'],
|
||||||
|
stream_msg_id=ctx['stream_msg_id'],
|
||||||
|
input_state=input_state,
|
||||||
|
)
|
||||||
|
if resp and isinstance(resp, dict):
|
||||||
|
new_stream_id = resp.get('id')
|
||||||
|
if new_stream_id:
|
||||||
|
ctx['stream_msg_id'] = new_stream_id
|
||||||
|
ctx['sent_length'] = len(ctx['accumulated_text'])
|
||||||
|
ctx['index'] += 1
|
||||||
|
await self.logger.debug(
|
||||||
|
f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, '
|
||||||
|
f'sent_len={ctx["sent_length"]}, is_final={is_final}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to send stream message: {e}')
|
||||||
|
|
||||||
|
if is_final:
|
||||||
|
self._stream_ctx.pop(message_id, None)
|
||||||
|
|
||||||
def unregister_listener(
|
def unregister_listener(
|
||||||
self,
|
self,
|
||||||
@@ -258,3 +622,462 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
],
|
],
|
||||||
):
|
):
|
||||||
return super().unregister_listener(event_type, callback)
|
return super().unregister_listener(event_type, callback)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Dify human-input button-interaction support
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window.
|
||||||
|
_MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id.
|
||||||
|
|
||||||
|
def _next_msg_seq(self, anchor: str) -> typing.Optional[int]:
|
||||||
|
"""Return the next msg_seq for an anchor, or ``None`` if the
|
||||||
|
anchor has already been used 5 times (further sends would be
|
||||||
|
silently dropped by QQ)."""
|
||||||
|
if not anchor:
|
||||||
|
return 1
|
||||||
|
used = self._anchor_msg_seq.get(anchor, 0)
|
||||||
|
if used >= self._MAX_REPLIES_PER_ANCHOR:
|
||||||
|
return None
|
||||||
|
self._anchor_msg_seq[anchor] = used + 1
|
||||||
|
return used + 1
|
||||||
|
|
||||||
|
async def _reply_synthetic(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Deliver a reply for a synthetic (button-click-resume) event.
|
||||||
|
|
||||||
|
Synthetic events have ``source_platform_object=None`` and no
|
||||||
|
fresh inbound msg_id. The previous INTERACTION_CREATE id we
|
||||||
|
cached in :attr:`_session_event_ids` is a valid passive-reply
|
||||||
|
anchor (``event_id``) for up to 30 minutes — use it.
|
||||||
|
"""
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
target_type = 'group'
|
||||||
|
group = getattr(message_source, 'group', None) or (
|
||||||
|
message_source.sender.group if hasattr(message_source.sender, 'group') else None
|
||||||
|
)
|
||||||
|
target_id = str(group.id) if group else None
|
||||||
|
else:
|
||||||
|
target_type = 'c2c'
|
||||||
|
target_id = str(message_source.sender.id) if message_source.sender else None
|
||||||
|
|
||||||
|
if not target_id:
|
||||||
|
await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping')
|
||||||
|
return
|
||||||
|
|
||||||
|
session_key = f'{target_type}_{target_id}'
|
||||||
|
cached = self._session_event_ids.get(session_key)
|
||||||
|
event_id = cached.get('event_id') if cached else None
|
||||||
|
if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL:
|
||||||
|
event_id = None
|
||||||
|
|
||||||
|
if not event_id:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'QQ Official: no cached event_id for {session_key}; '
|
||||||
|
f'cannot deliver synthetic reply within passive-reply window'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||||
|
text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')]
|
||||||
|
if not text_parts:
|
||||||
|
await self.logger.info('QQ Official: synthetic reply has no text content; skipping')
|
||||||
|
return
|
||||||
|
text = '\n\n'.join(text_parts)
|
||||||
|
|
||||||
|
msg_seq = self._next_msg_seq(event_id)
|
||||||
|
if msg_seq is None:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); '
|
||||||
|
f'cannot deliver synthetic reply for {session_key}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if target_type == 'c2c':
|
||||||
|
await self.bot.send_private_text_msg(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=text,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
await self.bot.send_group_text_msg(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=text,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]:
|
||||||
|
"""Return ``(target_type, target_id)`` for sending a reply, or
|
||||||
|
``None`` if the scene cannot host a markdown+keyboard message."""
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
if source.t == 'C2C_MESSAGE_CREATE':
|
||||||
|
return 'c2c', source.user_openid
|
||||||
|
if source.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||||
|
return 'group', source.group_openid
|
||||||
|
if source.t == 'AT_MESSAGE_CREATE':
|
||||||
|
return 'channel', source.channel_id
|
||||||
|
# DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept
|
||||||
|
# markdown+keyboard at the time of writing — caller falls back to text.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_target_from_event(
|
||||||
|
self, message_source: platform_events.MessageEvent
|
||||||
|
) -> typing.Optional[tuple[str, str]]:
|
||||||
|
"""Resolve ``(target_type, target_id)`` from the public event.
|
||||||
|
|
||||||
|
Prefers the platform-native source when present; falls back to
|
||||||
|
the synthesized event's sender/group fields so button-click
|
||||||
|
resume queries can still find a destination.
|
||||||
|
"""
|
||||||
|
source = message_source.source_platform_object
|
||||||
|
if source is not None:
|
||||||
|
return self._resolve_target_from_source(source)
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
group = getattr(message_source, 'group', None) or (
|
||||||
|
message_source.sender.group
|
||||||
|
if message_source.sender and hasattr(message_source.sender, 'group')
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if group and getattr(group, 'id', None):
|
||||||
|
return 'group', str(group.id)
|
||||||
|
if isinstance(message_source, platform_events.FriendMessage):
|
||||||
|
if message_source.sender and getattr(message_source.sender, 'id', None):
|
||||||
|
return 'c2c', str(message_source.sender.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _prune_pending_forms(self) -> None:
|
||||||
|
now = time.time()
|
||||||
|
stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL]
|
||||||
|
for k in stale:
|
||||||
|
self._pending_forms.pop(k, None)
|
||||||
|
stale_e = [
|
||||||
|
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
|
||||||
|
]
|
||||||
|
for k in stale_e:
|
||||||
|
self._session_event_ids.pop(k, None)
|
||||||
|
|
||||||
|
async def _handle_form_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
form_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Send the markdown + keyboard form prompt for a Dify pause.
|
||||||
|
|
||||||
|
Called from ``reply_message_chunk`` when the runner attaches
|
||||||
|
``_form_data`` to the final chunk. Replaces what would otherwise
|
||||||
|
be a plain-text numbered-list fallback.
|
||||||
|
"""
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'QQ Official _handle_form_chunk entered; '
|
||||||
|
f'source_present={message_source.source_platform_object is not None} '
|
||||||
|
f'form_actions={len(form_data.get("actions") or [])}'
|
||||||
|
)
|
||||||
|
self._prune_pending_forms()
|
||||||
|
|
||||||
|
source = message_source.source_platform_object
|
||||||
|
scene_target = self._resolve_target_from_event(message_source)
|
||||||
|
if scene_target is None:
|
||||||
|
# No rich-UI fit — fall through to existing text path.
|
||||||
|
await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text')
|
||||||
|
text_parts = [m.text for m in message if type(m) is platform_message.Plain]
|
||||||
|
fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))])
|
||||||
|
try:
|
||||||
|
await self.reply_message(message_source, fallback_msg)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
target_type, target_id = scene_target
|
||||||
|
session_key = f'{target_type}_{target_id}'
|
||||||
|
|
||||||
|
# Cancel any in-flight stream / fallback ctx so plain-text prefix
|
||||||
|
# doesn't continue alongside the keyboard message.
|
||||||
|
msg_id = getattr(source, 'd_id', '') or '' if source is not None else ''
|
||||||
|
if msg_id:
|
||||||
|
self._stream_ctx.pop(msg_id, None)
|
||||||
|
self._stream_ctx_ts.pop(msg_id, None)
|
||||||
|
self._fallback_text.pop(msg_id, None)
|
||||||
|
self._fallback_text_ts.pop(msg_id, None)
|
||||||
|
|
||||||
|
node_title = form_data.get('node_title') or 'Confirmation needed'
|
||||||
|
form_content = form_data.get('form_content') or ''
|
||||||
|
parts = [f'### {node_title}']
|
||||||
|
if form_content.strip():
|
||||||
|
parts.append(form_content.strip())
|
||||||
|
parts.append('请点击下方按钮选择:')
|
||||||
|
markdown_content = '\n\n'.join(parts)
|
||||||
|
|
||||||
|
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
|
||||||
|
if not keyboard.get('content', {}).get('rows'):
|
||||||
|
# No actions to render — fall back to plain text.
|
||||||
|
text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)])
|
||||||
|
try:
|
||||||
|
await self.reply_message(message_source, text_msg)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prefer the inbound msg_id (no quota cost). If the source is a
|
||||||
|
# synthetic event from a prior click, the cached interaction id
|
||||||
|
# serves as event_id for up to 30 min.
|
||||||
|
event_id = None
|
||||||
|
if not msg_id:
|
||||||
|
cached = self._session_event_ids.get(session_key)
|
||||||
|
if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL:
|
||||||
|
event_id = cached.get('event_id')
|
||||||
|
|
||||||
|
anchor = msg_id or event_id or ''
|
||||||
|
msg_seq = self._next_msg_seq(anchor)
|
||||||
|
if msg_seq is None:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); '
|
||||||
|
f'cannot deliver form card for session={session_key}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.bot.send_markdown_keyboard(
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
markdown_content=markdown_content,
|
||||||
|
keyboard=keyboard,
|
||||||
|
msg_id=msg_id if (msg_id and not event_id) else None,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'QQ Official: form card sent '
|
||||||
|
f'target={target_type}/{target_id} '
|
||||||
|
f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.error(
|
||||||
|
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
await self.logger.error(
|
||||||
|
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)])
|
||||||
|
try:
|
||||||
|
await self.reply_message(message_source, text_msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
sender_id = ''
|
||||||
|
if source is not None:
|
||||||
|
sender_id = (
|
||||||
|
getattr(source, 'user_openid', None)
|
||||||
|
or getattr(source, 'member_openid', None)
|
||||||
|
or getattr(source, 'd_author_id', None)
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
if not sender_id and message_source.sender is not None:
|
||||||
|
sender_id = str(getattr(message_source.sender, 'id', '') or '')
|
||||||
|
self._pending_forms[session_key] = {
|
||||||
|
'form_data': form_data,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'sender_id': sender_id,
|
||||||
|
'target_type': target_type,
|
||||||
|
'target_id': target_id,
|
||||||
|
'source_event_t': source.t if source is not None else None,
|
||||||
|
'posted_at': time.time(),
|
||||||
|
}
|
||||||
|
await self.logger.info(
|
||||||
|
f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_interaction_create(
|
||||||
|
self,
|
||||||
|
event_data: dict,
|
||||||
|
ws_event_id: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a button-click INTERACTION_CREATE event.
|
||||||
|
|
||||||
|
Two IDs at play (QQ keeps them separate):
|
||||||
|
ws_event_id top-level payload ``id`` (or webhook ``X-Bot-
|
||||||
|
Event-Id``). The ONLY value accepted as
|
||||||
|
``event_id`` for subsequent passive replies.
|
||||||
|
d['id'] the interaction id — used for PUT
|
||||||
|
/interactions/{id} ack. Cannot be reused as
|
||||||
|
event_id (QQ returns 40034025 if you try).
|
||||||
|
|
||||||
|
Layout (https://bot.q.qq.com/.../msg-btn.html):
|
||||||
|
chat_type 0 channel / 1 group / 2 c2c
|
||||||
|
data.resolved.button_data what we set as ``action.data``
|
||||||
|
data.resolved.button_id ``id`` field on the button row
|
||||||
|
"""
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'QQ Official _handle_interaction_create entered; '
|
||||||
|
f'ws_event_id={ws_event_id!r} '
|
||||||
|
f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} '
|
||||||
|
f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}')
|
||||||
|
return
|
||||||
|
|
||||||
|
# ACK uses the interaction id, NOT the ws event id.
|
||||||
|
interaction_id = event_data.get('id') or ''
|
||||||
|
if interaction_id:
|
||||||
|
asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
|
||||||
|
|
||||||
|
resolved = (event_data.get('data') or {}).get('resolved') or {}
|
||||||
|
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
|
||||||
|
if not action_id:
|
||||||
|
await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring')
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_type = event_data.get('chat_type')
|
||||||
|
scene_target: typing.Optional[tuple[str, str]] = None
|
||||||
|
if chat_type == 2 or event_data.get('user_openid'):
|
||||||
|
scene_target = ('c2c', event_data.get('user_openid') or '')
|
||||||
|
elif chat_type == 1 or event_data.get('group_openid'):
|
||||||
|
scene_target = ('group', event_data.get('group_openid') or '')
|
||||||
|
elif chat_type == 0 or event_data.get('channel_id'):
|
||||||
|
scene_target = ('channel', event_data.get('channel_id') or '')
|
||||||
|
|
||||||
|
if not scene_target or not scene_target[1]:
|
||||||
|
await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}')
|
||||||
|
return
|
||||||
|
|
||||||
|
target_type, target_id = scene_target
|
||||||
|
session_key = f'{target_type}_{target_id}'
|
||||||
|
|
||||||
|
self._prune_pending_forms()
|
||||||
|
pending = self._pending_forms.pop(session_key, None)
|
||||||
|
if not pending:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cache ws_event_id so a follow-up pause / text reply can use it
|
||||||
|
# as event_id for passive delivery (30-min window). Falls back to
|
||||||
|
# the interaction_id only if no ws_event_id was provided (e.g.
|
||||||
|
# tests / older payload shape) — QQ will reject that value but
|
||||||
|
# we log so the mismatch is debuggable.
|
||||||
|
cached_event_id = ws_event_id or interaction_id
|
||||||
|
if cached_event_id:
|
||||||
|
self._session_event_ids[session_key] = {
|
||||||
|
'event_id': cached_event_id,
|
||||||
|
'posted_at': time.time(),
|
||||||
|
}
|
||||||
|
# New anchor → fresh 5-reply budget.
|
||||||
|
self._anchor_msg_seq[cached_event_id] = 0
|
||||||
|
if self.ap is not None and not ws_event_id:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
|
||||||
|
'falling back to interaction_id (passive reply may be rejected)'
|
||||||
|
)
|
||||||
|
|
||||||
|
form_data: dict = pending.get('form_data') or {}
|
||||||
|
actions = form_data.get('actions') or []
|
||||||
|
matched = next(
|
||||||
|
(a for a in actions if str(a.get('id', '')) == action_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
action_title = (matched or {}).get('title') or action_id
|
||||||
|
|
||||||
|
sender_id = pending.get('sender_id') or event_data.get('user_openid') or event_data.get('member_openid') or ''
|
||||||
|
|
||||||
|
# Build resume payload matching the shape every other adapter uses
|
||||||
|
# (DingTalk / Lark / Telegram / WeCom). The runner's
|
||||||
|
# _merge_pending_form_action consumes this verbatim.
|
||||||
|
if target_type == 'group' or target_type == 'channel':
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = target_id
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = sender_id or target_id
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'form_token': form_data.get('form_token', ''),
|
||||||
|
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||||
|
'action_id': action_id,
|
||||||
|
'action_title': action_title,
|
||||||
|
'node_title': form_data.get('node_title', ''),
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=sender_id or launcher_id,
|
||||||
|
member_name='',
|
||||||
|
permission='MEMBER',
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(time.time()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=sender_id or launcher_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(time.time()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ap is None:
|
||||||
|
await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query')
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=sender_id or launcher_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'QQ Official: button-click query enqueued action_id={action_id!r} session={session_key}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ metadata:
|
|||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
zh_Hant: QQ 官方 API
|
zh_Hant: QQ 官方 API
|
||||||
description:
|
description:
|
||||||
en_US: QQ Official API (Webhook)
|
en_US: QQ Official API (Webhook / WebSocket)
|
||||||
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式
|
||||||
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
|
||||||
icon: qqofficial.svg
|
icon: qqofficial.svg
|
||||||
spec:
|
spec:
|
||||||
categories:
|
categories:
|
||||||
@@ -19,18 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: one-click-bind
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: One-Click QR Binding
|
||||||
zh_Hans: Webhook 回调地址
|
zh_Hans: 一键扫码绑定
|
||||||
zh_Hant: Webhook 回調地址
|
zh_Hant: 一鍵掃碼綁定
|
||||||
description:
|
description:
|
||||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token still needs to be filled manually)
|
||||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(Token 仍需手动填写)
|
||||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(Token 仍需手動填寫)
|
||||||
type: webhook-url
|
type: qr-code-login
|
||||||
|
login_platform: qqofficial
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -52,9 +52,53 @@ spec:
|
|||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
zh_Hant: 令牌
|
zh_Hant: 令牌
|
||||||
|
description:
|
||||||
|
en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank.
|
||||||
|
zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。
|
||||||
|
zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: enable-webhook
|
||||||
|
label:
|
||||||
|
en_US: Enable Webhook Mode
|
||||||
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode
|
||||||
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: enable-stream-reply
|
||||||
|
label:
|
||||||
|
en_US: Enable Stream Reply Mode
|
||||||
|
zh_Hans: 启用流式回复模式
|
||||||
|
zh_Hant: 啟用串流回覆模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
|
||||||
|
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
|
||||||
|
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./qqofficial.py
|
path: ./qqofficial.py
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
import telegram
|
import telegram
|
||||||
import telegram.ext
|
import telegram.ext
|
||||||
from telegram import Update
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
|
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
|
||||||
import telegramify_markdown
|
import telegramify_markdown
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
import json
|
||||||
import base64
|
import base64
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
@@ -189,6 +189,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: telegram.Bot = pydantic.Field(exclude=True)
|
bot: telegram.Bot = pydantic.Field(exclude=True)
|
||||||
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
||||||
|
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
||||||
event_converter: TelegramEventConverter = TelegramEventConverter()
|
event_converter: TelegramEventConverter = TelegramEventConverter()
|
||||||
@@ -224,6 +225,102 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
telegram_callback,
|
telegram_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
try:
|
||||||
|
data = json.loads(query.data)
|
||||||
|
if data.get('form_action') or data.get('f'):
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
workflow_run_id = data.get('workflow_run_id', '')
|
||||||
|
w_suffix = data.get('w', '')
|
||||||
|
action_id = data.get('action_id') or data.get('a', '')
|
||||||
|
session_key = data.get('session_key') or data.get('s', '')
|
||||||
|
|
||||||
|
if session_key.startswith('group_') or session_key.startswith('g:'):
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('g:')
|
||||||
|
else session_key[len('group_') :]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('p:')
|
||||||
|
else session_key[len('person_') :]
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = str(query.from_user.id)
|
||||||
|
|
||||||
|
# Find bot_uuid and pipeline_uuid
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
for b in self.ap.platform_mgr.bots:
|
||||||
|
if b.adapter is self:
|
||||||
|
bot_uuid = b.bot_entity.uuid
|
||||||
|
pipeline_uuid = b.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'workflow_run_id': workflow_run_id,
|
||||||
|
'w_suffix': w_suffix,
|
||||||
|
'action_id': action_id,
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=f'[Form Action: {action_id}]')]
|
||||||
|
)
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=user_id,
|
||||||
|
member_name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=user_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=user_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
application.add_handler(CallbackQueryHandler(callback_query_handler))
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -319,14 +416,19 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
update = event.source_platform_object
|
update = event.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
chat_type = update.effective_chat.type
|
chat_type = update.effective_chat.type
|
||||||
message_thread_id = update.message.message_thread_id
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if chat_type == 'private':
|
if chat_type == 'private':
|
||||||
draft_id = int(time.time() * 1000)
|
import time as _time
|
||||||
self.msg_stream_id[message_id] = ('private', draft_id)
|
|
||||||
|
|
||||||
|
draft_id = int(_time.time() * 1000)
|
||||||
|
self.msg_stream_id[message_id] = ('private', draft_id)
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
||||||
await self.bot.send_message_draft(**args)
|
try:
|
||||||
|
await self.bot.send_message_draft(**args)
|
||||||
|
except (telegram.error.RetryAfter, telegram.error.BadRequest):
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||||
send_msg = await self.bot.send_message(**args)
|
send_msg = await self.bot.send_message(**args)
|
||||||
@@ -347,12 +449,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
assert isinstance(message_source.source_platform_object, Update)
|
assert isinstance(message_source.source_platform_object, Update)
|
||||||
update = message_source.source_platform_object
|
update = message_source.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
message_thread_id = update.message.message_thread_id
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if message_id not in self.msg_stream_id:
|
if message_id not in self.msg_stream_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_mode, draft_id = self.msg_stream_id[message_id]
|
chat_mode, stream_id = self.msg_stream_id[message_id]
|
||||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||||
|
|
||||||
if not components or components[0]['type'] != 'text':
|
if not components or components[0]['type'] != 'text':
|
||||||
@@ -361,16 +464,42 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
content = components[0]['text']
|
content = components[0]['text']
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
|
||||||
|
if form_data and is_final:
|
||||||
|
self.msg_stream_id.pop(message_id, None)
|
||||||
|
await self._send_form_action_buttons(message_source, form_data)
|
||||||
|
return
|
||||||
|
|
||||||
if chat_mode == 'private':
|
if chat_mode == 'private':
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
|
# Streaming via draft (ephemeral preview in the chat input area)
|
||||||
await self.bot.send_message_draft(**args)
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
|
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message_draft(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||||
|
try:
|
||||||
|
await self.bot.send_message_draft(**args)
|
||||||
|
except telegram.error.RetryAfter:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pass # Ignore other draft errors (cosmetic)
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
del args['draft_id']
|
# Finalise: send the real message, discard the draft
|
||||||
await self.bot.send_message(**args)
|
args = self._build_message_args(chat_id, content, message_thread_id)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||||
|
await self.bot.send_message(**args)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
else:
|
else:
|
||||||
stream_id = draft_id
|
# Streaming via edit_message_text (persistent message)
|
||||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
args = {
|
args = {
|
||||||
'message_id': stream_id,
|
'message_id': stream_id,
|
||||||
@@ -379,11 +508,68 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
if self.config.get('markdown_card', False):
|
if self.config.get('markdown_card', False):
|
||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
await self.bot.edit_message_text(**args)
|
try:
|
||||||
|
await self.bot.edit_message_text(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
|
||||||
|
await self.bot.edit_message_text(**args)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
|
|
||||||
|
async def _send_form_action_buttons(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
):
|
||||||
|
"""Send inline keyboard buttons for Dify human_input_required form actions."""
|
||||||
|
actions = form_data.get('actions', [])
|
||||||
|
node_title = form_data.get('node_title', '')
|
||||||
|
form_content = form_data.get('form_content', '')
|
||||||
|
workflow_run_id = form_data.get('workflow_run_id', '')
|
||||||
|
# Telegram callback_data is capped at 64 bytes, so we identify the
|
||||||
|
# paused workflow by the last 8 chars of workflow_run_id (unique
|
||||||
|
# within a session with overwhelming probability).
|
||||||
|
w_suffix = workflow_run_id[-8:] if workflow_run_id else ''
|
||||||
|
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
session_key = f'g:{message_source.group.id}'
|
||||||
|
else:
|
||||||
|
session_key = f'p:{message_source.sender.id}'
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
for action in actions:
|
||||||
|
action_id = action.get('id', '')
|
||||||
|
action_title = action.get('title', action_id)
|
||||||
|
callback_payload = {'f': 1, 'a': action_id, 's': session_key}
|
||||||
|
if w_suffix:
|
||||||
|
callback_payload['w'] = w_suffix
|
||||||
|
callback_data = json.dumps(callback_payload, separators=(',', ':'))
|
||||||
|
keyboard.append([InlineKeyboardButton(action_title, callback_data=callback_data)])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
update = message_source.source_platform_object
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
|
text_lines = [f'[{node_title}] Please select an action:']
|
||||||
|
if form_content:
|
||||||
|
text_lines.insert(0, form_content)
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
'reply_markup': reply_markup,
|
||||||
|
}
|
||||||
|
if message_thread_id:
|
||||||
|
args['message_thread_id'] = message_thread_id
|
||||||
|
|
||||||
|
await self.bot.send_message(**args)
|
||||||
|
|
||||||
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
if not isinstance(event.source_platform_object, Update):
|
if not isinstance(event.source_platform_object, Update):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: web_page_bot
|
||||||
|
label:
|
||||||
|
en_US: "Page Bot"
|
||||||
|
zh_Hans: "页面机器人"
|
||||||
|
zh_Hant: "頁面機器人"
|
||||||
|
ja_JP: "ページボット"
|
||||||
|
th_TH: "บอทหน้าเว็บ"
|
||||||
|
vi_VN: "Bot trang web"
|
||||||
|
es_ES: "Bot de página"
|
||||||
|
description:
|
||||||
|
en_US: "Embed a chat widget on any website with a simple script tag"
|
||||||
|
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
|
||||||
|
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
|
||||||
|
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
|
||||||
|
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
|
||||||
|
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
|
||||||
|
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
|
||||||
|
icon: "webpage.webp"
|
||||||
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
config:
|
||||||
|
- name: title
|
||||||
|
label:
|
||||||
|
en_US: Widget Title
|
||||||
|
zh_Hans: 组件标题
|
||||||
|
zh_Hant: 元件標題
|
||||||
|
ja_JP: ウィジェットタイトル
|
||||||
|
th_TH: ชื่อวิดเจ็ต
|
||||||
|
vi_VN: Tiêu đề widget
|
||||||
|
es_ES: Título del widget
|
||||||
|
description:
|
||||||
|
en_US: The title displayed in the chat widget header
|
||||||
|
zh_Hans: 显示在聊天组件顶部的标题
|
||||||
|
zh_Hant: 顯示在聊天元件頂部的標題
|
||||||
|
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
|
||||||
|
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
|
||||||
|
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
|
||||||
|
es_ES: El título que se muestra en el encabezado del widget de chat
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "LangBot"
|
||||||
|
- name: bubble_icon
|
||||||
|
label:
|
||||||
|
en_US: Bubble Icon
|
||||||
|
zh_Hans: 气泡图标
|
||||||
|
zh_Hant: 氣泡圖示
|
||||||
|
ja_JP: バブルアイコン
|
||||||
|
th_TH: ไอคอนบับเบิล
|
||||||
|
vi_VN: Biểu tượng bong bóng
|
||||||
|
es_ES: Icono de burbuja
|
||||||
|
ru_RU: Иконка пузырька
|
||||||
|
description:
|
||||||
|
en_US: "Icon displayed on the floating chat bubble"
|
||||||
|
zh_Hans: "浮动聊天气泡上显示的图标"
|
||||||
|
type: select
|
||||||
|
required: false
|
||||||
|
default: "logo"
|
||||||
|
options:
|
||||||
|
- name: "logo"
|
||||||
|
label:
|
||||||
|
en_US: "LangBot Logo"
|
||||||
|
zh_Hans: "LangBot 图标"
|
||||||
|
- name: "chat"
|
||||||
|
label:
|
||||||
|
en_US: "Chat Bubble"
|
||||||
|
zh_Hans: "聊天气泡"
|
||||||
|
- name: "robot"
|
||||||
|
label:
|
||||||
|
en_US: "Robot"
|
||||||
|
zh_Hans: "机器人"
|
||||||
|
- name: "headset"
|
||||||
|
label:
|
||||||
|
en_US: "Headset"
|
||||||
|
zh_Hans: "客服耳机"
|
||||||
|
- name: "sparkle"
|
||||||
|
label:
|
||||||
|
en_US: "Sparkle"
|
||||||
|
zh_Hans: "星光"
|
||||||
|
- name: "message"
|
||||||
|
label:
|
||||||
|
en_US: "Message"
|
||||||
|
zh_Hans: "消息"
|
||||||
|
- name: language
|
||||||
|
label:
|
||||||
|
en_US: Widget Language
|
||||||
|
zh_Hans: 组件语言
|
||||||
|
zh_Hant: 元件語言
|
||||||
|
ja_JP: ウィジェット言語
|
||||||
|
th_TH: ภาษาวิดเจ็ต
|
||||||
|
vi_VN: Ngôn ngữ widget
|
||||||
|
es_ES: Idioma del widget
|
||||||
|
ru_RU: Язык виджета
|
||||||
|
description:
|
||||||
|
en_US: "Display language of the chat widget"
|
||||||
|
zh_Hans: "聊天组件的显示语言"
|
||||||
|
zh_Hant: "聊天元件的顯示語言"
|
||||||
|
ja_JP: "チャットウィジェットの表示言語"
|
||||||
|
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
|
||||||
|
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
|
||||||
|
es_ES: "Idioma de visualización del widget de chat"
|
||||||
|
ru_RU: "Язык отображения виджета чата"
|
||||||
|
type: select
|
||||||
|
required: false
|
||||||
|
default: "en_US"
|
||||||
|
options:
|
||||||
|
- name: "en_US"
|
||||||
|
label:
|
||||||
|
en_US: "English"
|
||||||
|
- name: "zh_Hans"
|
||||||
|
label:
|
||||||
|
en_US: "简体中文"
|
||||||
|
- name: "zh_Hant"
|
||||||
|
label:
|
||||||
|
en_US: "繁體中文"
|
||||||
|
- name: "ja_JP"
|
||||||
|
label:
|
||||||
|
en_US: "日本語"
|
||||||
|
- name: "es_ES"
|
||||||
|
label:
|
||||||
|
en_US: "Español"
|
||||||
|
- name: "ru_RU"
|
||||||
|
label:
|
||||||
|
en_US: "Русский"
|
||||||
|
- name: "th_TH"
|
||||||
|
label:
|
||||||
|
en_US: "ไทย"
|
||||||
|
- name: "vi_VN"
|
||||||
|
label:
|
||||||
|
en_US: "Tiếng Việt"
|
||||||
|
- name: embed_code
|
||||||
|
label:
|
||||||
|
en_US: Embed Code
|
||||||
|
zh_Hans: 嵌入代码
|
||||||
|
zh_Hant: 嵌入代碼
|
||||||
|
ja_JP: 埋め込みコード
|
||||||
|
th_TH: โค้ดฝังตัว
|
||||||
|
vi_VN: Mã nhúng
|
||||||
|
es_ES: Código de incrustación
|
||||||
|
description:
|
||||||
|
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
|
||||||
|
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
|
||||||
|
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
|
||||||
|
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
|
||||||
|
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
|
||||||
|
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
|
||||||
|
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
|
||||||
|
type: embed-code
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: turnstile_site_key
|
||||||
|
label:
|
||||||
|
en_US: Turnstile Site Key
|
||||||
|
zh_Hans: Turnstile 站点密钥
|
||||||
|
description:
|
||||||
|
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
|
||||||
|
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: turnstile_secret_key
|
||||||
|
label:
|
||||||
|
en_US: Turnstile Secret Key
|
||||||
|
zh_Hans: Turnstile 服务端密钥
|
||||||
|
description:
|
||||||
|
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
|
||||||
|
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: "web_page_bot_adapter.py"
|
||||||
|
attr: "WebPageBotAdapter"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Web Page Bot adapter - lightweight adapter for embeddable chat widget"""
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
|
class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
"""Lightweight adapter for the embeddable page bot.
|
||||||
|
|
||||||
|
This adapter does not handle messages itself. The actual WebSocket
|
||||||
|
communication is handled by the singleton websocket_proxy_bot.
|
||||||
|
This adapter stores event listeners so that RuntimeBot can register
|
||||||
|
its handlers, which are then called by the websocket adapter when
|
||||||
|
a message arrives for this bot's pipeline.
|
||||||
|
|
||||||
|
Message sending/replying is delegated to the websocket_proxy_bot's
|
||||||
|
adapter so that replies are actually delivered over the WebSocket
|
||||||
|
connection while the dashboard correctly shows this adapter's name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
||||||
|
_ws_adapter: typing.Any = None
|
||||||
|
|
||||||
|
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
|
super().__init__(config=config, logger=logger, **kwargs)
|
||||||
|
|
||||||
|
def set_ws_adapter(self, ws_adapter) -> None:
|
||||||
|
"""Set the underlying WebSocket adapter used for actual message delivery."""
|
||||||
|
object.__setattr__(self, '_ws_adapter', ws_adapter)
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
) -> dict:
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.send_message(target_type, target_id, message)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.reply_message(message_source, message, quote_origin)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.reply_message_chunk(
|
||||||
|
message_source, bot_message, message, quote_origin, is_final
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
func: typing.Callable,
|
||||||
|
):
|
||||||
|
self.listeners[event_type] = func
|
||||||
|
|
||||||
|
def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
func: typing.Callable,
|
||||||
|
):
|
||||||
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def kill(self):
|
||||||
|
pass
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
async def _process_image_components(self, message_chain_obj: list):
|
async def _process_image_components(self, message_chain_obj: list):
|
||||||
"""
|
"""
|
||||||
处理消息链中的图片组件,将path转换为base64
|
处理消息链中的图片和文件组件,将path转换为base64
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_chain_obj: 消息链对象列表
|
message_chain_obj: 消息链对象列表
|
||||||
@@ -322,16 +322,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
storage_mgr = self.ap.storage_mgr
|
storage_mgr = self.ap.storage_mgr
|
||||||
|
|
||||||
for component in message_chain_obj:
|
for component in message_chain_obj:
|
||||||
if component.get('type') == 'Image' and component.get('path'):
|
comp_type = component.get('type', '')
|
||||||
try:
|
comp_path = component.get('path', '')
|
||||||
# 从storage读取文件
|
|
||||||
file_content = await storage_mgr.storage_provider.load(component['path'])
|
|
||||||
|
|
||||||
# 转换为base64
|
if not comp_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if comp_type == 'Image':
|
||||||
|
try:
|
||||||
|
file_content = await storage_mgr.storage_provider.load(comp_path)
|
||||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||||
|
|
||||||
# 添加data URI前缀(根据文件扩展名判断MIME类型)
|
file_key = comp_path
|
||||||
file_key = component['path']
|
|
||||||
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||||
mime_type = 'image/jpeg'
|
mime_type = 'image/jpeg'
|
||||||
elif file_key.lower().endswith('.png'):
|
elif file_key.lower().endswith('.png'):
|
||||||
@@ -341,19 +343,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
elif file_key.lower().endswith('.webp'):
|
elif file_key.lower().endswith('.webp'):
|
||||||
mime_type = 'image/webp'
|
mime_type = 'image/webp'
|
||||||
else:
|
else:
|
||||||
mime_type = 'image/png' # 默认
|
mime_type = 'image/png'
|
||||||
|
|
||||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||||
await storage_mgr.storage_provider.delete(component['path'])
|
await storage_mgr.storage_provider.delete(comp_path)
|
||||||
component['path'] = ''
|
component['path'] = ''
|
||||||
# 保留path字段用于后端处理,前端使用base64显示
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
|
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
|
||||||
|
|
||||||
async def handle_websocket_message(
|
async def handle_websocket_message(
|
||||||
self,
|
self,
|
||||||
connection: WebSocketConnection,
|
connection: WebSocketConnection,
|
||||||
message_data: dict,
|
message_data: dict,
|
||||||
|
owner_bot=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
处理从WebSocket接收的消息
|
处理从WebSocket接收的消息
|
||||||
@@ -366,6 +368,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
message_data: 消息数据,包含:
|
message_data: 消息数据,包含:
|
||||||
- message: 消息链
|
- message: 消息链
|
||||||
- stream: 是否启用流式输出 (可选,默认True)
|
- stream: 是否启用流式输出 (可选,默认True)
|
||||||
|
owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot).
|
||||||
|
When provided, its identity is used for logging and session tracking.
|
||||||
"""
|
"""
|
||||||
pipeline_uuid = connection.pipeline_uuid
|
pipeline_uuid = connection.pipeline_uuid
|
||||||
session_type = connection.session_type
|
session_type = connection.session_type
|
||||||
@@ -435,12 +439,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 设置流水线UUID
|
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
|
||||||
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 = pipeline_uuid
|
||||||
|
if owner_bot is not None:
|
||||||
|
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||||
|
|
||||||
# 异步触发事件处理(不等待结果)
|
# 异步触发事件处理
|
||||||
if event.__class__ in self.listeners:
|
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
||||||
asyncio.create_task(self.listeners[event.__class__](event, self))
|
listeners = (
|
||||||
|
owner_bot.adapter.listeners
|
||||||
|
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
|
||||||
|
else self.listeners
|
||||||
|
)
|
||||||
|
# Pass owner_bot's adapter so that downstream logging / dashboard
|
||||||
|
# attributes the message to the correct bot adapter name.
|
||||||
|
# Wire the ws adapter into the owner so replies are actually delivered.
|
||||||
|
if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'):
|
||||||
|
owner_bot.adapter.set_ws_adapter(self)
|
||||||
|
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
|
||||||
|
if event.__class__ in listeners:
|
||||||
|
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||||
|
|
||||||
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||||
"""获取消息历史"""
|
"""获取消息历史"""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -126,6 +127,107 @@ 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
|
||||||
)
|
)
|
||||||
@@ -192,6 +294,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
_ws_mode: bool = False
|
_ws_mode: bool = False
|
||||||
bot_name: str = ''
|
bot_name: str = ''
|
||||||
listeners: dict = {}
|
listeners: dict = {}
|
||||||
|
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
||||||
|
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
||||||
|
ap: typing.Any = None
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
@@ -228,8 +333,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
bot_name=bot_name,
|
bot_name=bot_name,
|
||||||
event_converter=event_converter,
|
event_converter=event_converter,
|
||||||
|
listeners={},
|
||||||
|
_stream_to_monitoring_msg={},
|
||||||
)
|
)
|
||||||
self.listeners = {}
|
|
||||||
|
# Both WecomBotClient (webhook) and WecomBotWsClient (ws long-conn)
|
||||||
|
# expose ``set_card_action_callback``. Wire the click handler so
|
||||||
|
# Dify human-input button taps resume the workflow on either mode.
|
||||||
|
if hasattr(self.bot, 'set_card_action_callback'):
|
||||||
|
self.bot.set_card_action_callback(self._on_card_action)
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -240,15 +352,37 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
# Synthetic events (button-click resume queries) have no inbound
|
||||||
|
# platform object. Fall back to a proactive send so error
|
||||||
|
# messages and one-shot replies still reach the user.
|
||||||
|
if event is None:
|
||||||
|
if _ws_mode:
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
chat_id = str(message_source.group.id)
|
||||||
|
else:
|
||||||
|
chat_id = str(message_source.sender.id)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id, content)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(
|
||||||
|
'WeComBot webhook mode cannot reply to a synthetic event '
|
||||||
|
'(no req_id and no proactive-send credentials); dropping.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
event = message_source.source_platform_object
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
req_id = event.get('req_id', '')
|
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, content)
|
await self.bot.reply_text(req_id, content)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(event.message_id, content)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
await self.bot.set_message(event.message_id, content)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -259,9 +393,56 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
msg_id = message_source.source_platform_object.message_id
|
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
|
# Synthetic events (e.g. button-click triggered form resume) have
|
||||||
|
# no inbound platform message — no msg_id, no req_id, no stream
|
||||||
|
# session. The output must go via the proactive-send path instead
|
||||||
|
# of the stream/reply path.
|
||||||
|
spo = message_source.source_platform_object
|
||||||
|
if spo is None:
|
||||||
|
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
||||||
|
|
||||||
|
msg_id = spo.message_id
|
||||||
|
|
||||||
|
# Dify human-input pause: when the runner attaches `_form_data` to
|
||||||
|
# the final chunk, hand the button_interaction card off to the
|
||||||
|
# underlying client. In webhook mode the card is queued for the
|
||||||
|
# next followup poll; in ws mode it's sent as a reply frame
|
||||||
|
# immediately. Falls back to plain text when the bot has no active
|
||||||
|
# stream session for this msg_id (rare).
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
if form_data and is_final:
|
||||||
|
if hasattr(self.bot, 'push_form_pause'):
|
||||||
|
ok, stream_id, task_id = await self.bot.push_form_pause(msg_id, form_data)
|
||||||
|
if ok:
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot: pending button_interaction registered '
|
||||||
|
f'stream_id={stream_id} task_id={task_id} ws_mode={_ws_mode}'
|
||||||
|
)
|
||||||
|
return {'stream': True, 'form': True, 'task_id': task_id}
|
||||||
|
await self.logger.warning(
|
||||||
|
'WeComBot: cannot register form pause (no active stream session); falling back to plain text'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
|
||||||
|
|
||||||
|
fallback = _format_human_input_text(
|
||||||
|
form_data.get('node_title', ''),
|
||||||
|
form_data.get('form_content', ''),
|
||||||
|
form_data.get('actions', []) or [],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
fallback = content or '(人工输入)'
|
||||||
|
if _ws_mode:
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
|
if req_id:
|
||||||
|
await self.bot.reply_text(req_id, fallback)
|
||||||
|
else:
|
||||||
|
await self.bot.set_message(msg_id, fallback)
|
||||||
|
return {'stream': False, 'form': True, 'fallback': True}
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
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:
|
||||||
@@ -280,6 +461,129 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""Whether streaming output is enabled for this bot instance."""
|
"""Whether streaming output is enabled for this bot instance."""
|
||||||
return self.config.get('enable-stream-reply', True)
|
return self.config.get('enable-stream-reply', True)
|
||||||
|
|
||||||
|
async def _handle_synthetic_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
content: str,
|
||||||
|
is_final: bool,
|
||||||
|
ws_mode: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""Handle reply_message_chunk for synthetic events (button clicks).
|
||||||
|
|
||||||
|
Synthetic events have no inbound message → no msg_id, no req_id,
|
||||||
|
no stream session. We can't do incremental streaming, so we
|
||||||
|
buffer chunks per-conversation and flush on ``is_final`` via the
|
||||||
|
proactive send path.
|
||||||
|
|
||||||
|
Buffer keyed by ``(launcher_type, launcher_id)`` from the
|
||||||
|
synthetic event itself. Only ws mode has a usable proactive-send
|
||||||
|
path right now (``ws_client.send_message`` /
|
||||||
|
``ws_client.send_template_card``); webhook mode requires a
|
||||||
|
corpid/secret we don't have, so it logs and drops.
|
||||||
|
"""
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
chat_id = str(message_source.group.id)
|
||||||
|
else:
|
||||||
|
chat_id = str(message_source.sender.id)
|
||||||
|
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
|
||||||
|
# Buffer streaming content until is_final.
|
||||||
|
buf_key = chat_id
|
||||||
|
if not hasattr(self, '_synthetic_buffers'):
|
||||||
|
# Attribute-not-declared trick: pydantic forbids dynamic attrs
|
||||||
|
# on the model, but plain instance dicts via object.__setattr__
|
||||||
|
# do work. Lazy-create on first call.
|
||||||
|
object.__setattr__(self, '_synthetic_buffers', {})
|
||||||
|
buffers: dict[str, str] = self._synthetic_buffers
|
||||||
|
if content and not form_data:
|
||||||
|
buffers[buf_key] = buffers.get(buf_key, '') + content
|
||||||
|
|
||||||
|
if not is_final:
|
||||||
|
return {'stream': True, 'synthetic': True, 'buffered': True}
|
||||||
|
|
||||||
|
final_content = buffers.pop(buf_key, '')
|
||||||
|
if content and final_content.startswith(content):
|
||||||
|
# is_final chunk re-emitted the full accumulated text — keep
|
||||||
|
# whichever is longer.
|
||||||
|
final_content = final_content if len(final_content) >= len(content) else content
|
||||||
|
elif content and not final_content:
|
||||||
|
final_content = content
|
||||||
|
|
||||||
|
if not ws_mode:
|
||||||
|
await self.logger.warning(
|
||||||
|
'WeComBot webhook mode cannot proactively push synthetic-event '
|
||||||
|
'output (no corpid/secret); the resume reply is dropped. '
|
||||||
|
f'content_len={len(final_content)} form_data_present={form_data is not None}'
|
||||||
|
)
|
||||||
|
return {'stream': False, 'synthetic': True, 'dropped': True}
|
||||||
|
|
||||||
|
# ws mode: proactive send.
|
||||||
|
try:
|
||||||
|
if form_data:
|
||||||
|
# Determine user_id / chat_id for the routing context of any
|
||||||
|
# subsequent click on this card.
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
routing_chat_id = str(message_source.group.id)
|
||||||
|
routing_user_id = str(message_source.sender.id)
|
||||||
|
else:
|
||||||
|
routing_chat_id = ''
|
||||||
|
routing_user_id = str(message_source.sender.id)
|
||||||
|
payload = self._build_button_interaction_payload_from_form(
|
||||||
|
form_data,
|
||||||
|
user_id=routing_user_id,
|
||||||
|
chat_id=routing_chat_id,
|
||||||
|
)
|
||||||
|
await self.bot.send_template_card(chat_id, payload)
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot ws: proactively sent template_card for synthetic event '
|
||||||
|
f'chat_id={chat_id} form_token={form_data.get("form_token")!r} '
|
||||||
|
f'workflow_run_id={form_data.get("workflow_run_id")!r}'
|
||||||
|
)
|
||||||
|
elif final_content:
|
||||||
|
await self.bot.send_message(chat_id, final_content)
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot ws: proactively sent text for synthetic event chat_id={chat_id} len={len(final_content)}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'WeComBot: synthetic event proactive send failed: {traceback.format_exc()}')
|
||||||
|
return {'stream': False, 'synthetic': True, 'error': True}
|
||||||
|
|
||||||
|
return {'stream': True, 'synthetic': True}
|
||||||
|
|
||||||
|
def _build_button_interaction_payload_from_form(
|
||||||
|
self, form_data: dict, *, user_id: str = '', chat_id: str = ''
|
||||||
|
) -> dict:
|
||||||
|
"""Build a button_interaction payload + track task_id for click resolution.
|
||||||
|
|
||||||
|
Unlike the inbound-event path (where push_form_pause registers the
|
||||||
|
task_id with the active stream session), proactive sends still
|
||||||
|
need the task_id registered so button clicks find pending_form.
|
||||||
|
For ws mode we stash it directly on the ws_client's pending dict.
|
||||||
|
"""
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import build_button_interaction_payload
|
||||||
|
import secrets as _secrets
|
||||||
|
|
||||||
|
task_id = f'dify-{_secrets.token_hex(12)}'
|
||||||
|
payload = build_button_interaction_payload(form_data, task_id)
|
||||||
|
|
||||||
|
# Register task_id → form_data so the click callback can find it.
|
||||||
|
# user_id / chat_id are required so _on_card_action can route the
|
||||||
|
# resulting synthetic query back to the right user. msg_id / req_id
|
||||||
|
# / stream_id are intentionally empty — synthetic cards have no
|
||||||
|
# inbound message to anchor on.
|
||||||
|
if hasattr(self.bot, '_pending_forms_by_task'):
|
||||||
|
self.bot._pending_forms_by_task[task_id] = {
|
||||||
|
'form_data': form_data,
|
||||||
|
'msg_id': '',
|
||||||
|
'user_id': user_id,
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'stream_id': '',
|
||||||
|
'req_id': '',
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
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)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
@@ -321,6 +625,23 @@ 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_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||||
|
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
|
||||||
|
try:
|
||||||
|
stream_id = query.message_event.source_platform_object.stream_id
|
||||||
|
if stream_id:
|
||||||
|
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
|
||||||
|
self._cleanup_stream_mapping()
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
|
||||||
|
|
||||||
|
def _cleanup_stream_mapping(self):
|
||||||
|
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del self._stream_to_monitoring_msg[k]
|
||||||
|
|
||||||
async def _on_feedback(self, **kwargs):
|
async def _on_feedback(self, **kwargs):
|
||||||
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
||||||
try:
|
try:
|
||||||
@@ -328,6 +649,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
feedback_type = kwargs.get('feedback_type', 0)
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
feedback_content = kwargs.get('feedback_content', '') or None
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) 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 = kwargs.get('session')
|
||||||
|
|
||||||
session_id = None
|
session_id = None
|
||||||
@@ -343,6 +667,16 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_id = session.msg_id
|
message_id = session.msg_id
|
||||||
stream_id = session.stream_id
|
stream_id = session.stream_id
|
||||||
|
|
||||||
|
# Resolve stream_id to LangBot monitoring message ID if available
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if stream_id and stream_id in self._stream_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
|
||||||
|
|
||||||
|
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(
|
event = platform_events.FeedbackEvent(
|
||||||
feedback_id=feedback_id,
|
feedback_id=feedback_id,
|
||||||
feedback_type=feedback_type,
|
feedback_type=feedback_type,
|
||||||
@@ -351,7 +685,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
stream_id=stream_id,
|
stream_id=monitoring_msg_id or stream_id,
|
||||||
source_platform_object=session,
|
source_platform_object=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -396,3 +730,114 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Dify human-input button-interaction click handling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _on_card_action(self, session, action_id: str, task_id: str, raw_event: dict) -> None:
|
||||||
|
"""Translate a button click on a button_interaction card into a
|
||||||
|
synthetic ``_dify_form_action`` query enqueued on the pool.
|
||||||
|
|
||||||
|
Pattern mirrors DingTalk / Lark / Telegram so the runner's
|
||||||
|
``_merge_pending_form_action`` path resumes the workflow.
|
||||||
|
"""
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
form = session.pending_form or {}
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot _on_card_action: task_id={task_id} action_id={action_id!r} '
|
||||||
|
f'form_token={form.get("form_token")!r} workflow_run_id={form.get("workflow_run_id")!r} '
|
||||||
|
f'session.user_id={session.user_id!r} session.chat_id={session.chat_id!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = form.get('actions') or []
|
||||||
|
clean_action_id = (action_id or '').strip()
|
||||||
|
action_title = clean_action_id
|
||||||
|
for a in actions:
|
||||||
|
if str(a.get('id', '')) == clean_action_id:
|
||||||
|
action_title = a.get('title') or clean_action_id
|
||||||
|
break
|
||||||
|
|
||||||
|
launcher_id = session.user_id or session.chat_id or ''
|
||||||
|
sender_user_id = session.user_id or launcher_id
|
||||||
|
# WeCom AI bot has both single-chat and group-chat; chat_id present
|
||||||
|
# indicates group context.
|
||||||
|
if session.chat_id:
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = session.chat_id
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = session.user_id or ''
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'form_token': form.get('form_token', ''),
|
||||||
|
'workflow_run_id': form.get('workflow_run_id', ''),
|
||||||
|
'action_id': clean_action_id,
|
||||||
|
'action_title': action_title,
|
||||||
|
'node_title': form.get('node_title', ''),
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=sender_user_id,
|
||||||
|
member_name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(time.time()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=sender_user_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(time.time()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ap is None:
|
||||||
|
await self.logger.error('WeComBot: ap not injected; cannot enqueue button-click query')
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=sender_user_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.logger.info(f'WeComBot: button-click query enqueued action_id={clean_action_id!r}')
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'WeComBot: enqueue button-click query failed: {traceback.format_exc()}')
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create Bot
|
||||||
|
zh_Hans: 一键创建机器人
|
||||||
|
zh_Hant: 一鍵建立機器人
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually."
|
||||||
|
zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。"
|
||||||
|
zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: wecombot
|
||||||
|
required: false
|
||||||
- name: BotId
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
en_US: BotId
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
import yaml
|
||||||
from async_lru import alru_cache
|
from async_lru import alru_cache
|
||||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ from ..core import taskmgr
|
|||||||
from ..entity.persistence import plugin as persistence_plugin
|
from ..entity.persistence import plugin as persistence_plugin
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRuntimeNotConnectedError(RuntimeError):
|
||||||
|
"""Raised when plugin runtime operations are requested before connection."""
|
||||||
|
|
||||||
|
|
||||||
class PluginRuntimeConnector:
|
class PluginRuntimeConnector:
|
||||||
"""Plugin runtime connector"""
|
"""Plugin runtime connector"""
|
||||||
|
|
||||||
@@ -191,44 +196,114 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
async def ping_plugin_runtime(self):
|
async def ping_plugin_runtime(self):
|
||||||
if not hasattr(self, 'handler'):
|
if not hasattr(self, 'handler'):
|
||||||
raise Exception('Plugin runtime is not connected')
|
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||||
|
|
||||||
return await self.handler.ping()
|
return await self.handler.ping()
|
||||||
|
|
||||||
def _extract_deps_metadata(
|
def _inspect_plugin_package(
|
||||||
self,
|
self,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
task_context: taskmgr.TaskContext | None,
|
task_context: taskmgr.TaskContext | None,
|
||||||
):
|
) -> tuple[str | None, str | None]:
|
||||||
"""Extract dependency count from requirements.txt inside plugin zip."""
|
"""Extract plugin identity and dependency metadata from a plugin package."""
|
||||||
if task_context is None:
|
plugin_author = None
|
||||||
return
|
plugin_name = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
for name in zf.namelist():
|
try:
|
||||||
if name.endswith('requirements.txt'):
|
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
|
||||||
content = zf.read(name).decode('utf-8', errors='ignore')
|
metadata = manifest.get('metadata', {})
|
||||||
deps = [
|
plugin_author = metadata.get('author')
|
||||||
line.strip()
|
plugin_name = metadata.get('name')
|
||||||
for line in content.splitlines()
|
except Exception:
|
||||||
if line.strip() and not line.strip().startswith('#')
|
pass
|
||||||
]
|
|
||||||
task_context.metadata['deps_total'] = len(deps)
|
if task_context is not None:
|
||||||
task_context.metadata['deps_list'] = deps
|
for name in zf.namelist():
|
||||||
break
|
if name.endswith('requirements.txt'):
|
||||||
|
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||||
|
deps = [
|
||||||
|
line.strip()
|
||||||
|
for line in content.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith('#')
|
||||||
|
]
|
||||||
|
task_context.metadata['deps_total'] = len(deps)
|
||||||
|
task_context.metadata['deps_list'] = deps
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
return plugin_author, plugin_name
|
||||||
|
|
||||||
|
def _build_plugin_startup_failure_message(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
) -> str:
|
||||||
|
dep_hint = ''
|
||||||
|
if task_context is not None:
|
||||||
|
current_dep = task_context.metadata.get('current_dep')
|
||||||
|
if current_dep:
|
||||||
|
dep_hint = f' Last dependency: {current_dep}.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
|
||||||
|
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
|
||||||
|
f'Please check the plugin requirements and runtime logs.'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait_for_installed_plugin_ready(
|
||||||
|
self,
|
||||||
|
plugin_author: str | None,
|
||||||
|
plugin_name: str | None,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
timeout: float = 30,
|
||||||
|
):
|
||||||
|
"""Wait until the installed plugin is registered by the runtime.
|
||||||
|
|
||||||
|
The plugin runtime launches plugins asynchronously. If dependency installation
|
||||||
|
fails, the plugin process exits before registration; without this check the
|
||||||
|
install task can incorrectly finish successfully.
|
||||||
|
"""
|
||||||
|
if not plugin_author or not plugin_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
plugin = await self.get_plugin_info(plugin_author, plugin_name)
|
||||||
|
if plugin is not None:
|
||||||
|
status = plugin.get('status')
|
||||||
|
if status == 'initialized':
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
|
||||||
|
if last_error is not None:
|
||||||
|
message = f'{message} Last runtime error: {last_error}'
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
install_info: dict[str, Any],
|
install_info: dict[str, Any],
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
):
|
):
|
||||||
|
plugin_author = install_info.get('plugin_author')
|
||||||
|
plugin_name = install_info.get('plugin_name')
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
self._extract_deps_metadata(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
@@ -265,7 +340,9 @@ class PluginRuntimeConnector:
|
|||||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
file_bytes = b''.join(chunks)
|
file_bytes = b''.join(chunks)
|
||||||
self._extract_deps_metadata(file_bytes, task_context)
|
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
@@ -289,6 +366,8 @@ class PluginRuntimeConnector:
|
|||||||
if metadata is not None and task_context is not None:
|
if metadata is not None and task_context is not None:
|
||||||
task_context.metadata.update(metadata)
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
|
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -431,6 +510,17 @@ class PluginRuntimeConnector:
|
|||||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||||
|
|
||||||
|
async def handle_page_api(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
page_id: str,
|
||||||
|
endpoint: str,
|
||||||
|
method: str,
|
||||||
|
body: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||||
|
|
||||||
async def get_debug_info(self) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
@@ -547,11 +637,12 @@ class PluginRuntimeConnector:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If plugin_id is not in the expected 'author/name' format.
|
ValueError: If plugin_id is not in the expected 'author/name' format.
|
||||||
"""
|
"""
|
||||||
if '/' not in plugin_id:
|
segments = plugin_id.split('/')
|
||||||
|
if len(segments) != 2 or not all(segments):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
||||||
)
|
)
|
||||||
return plugin_id.split('/', 1)
|
return segments[0], segments[1]
|
||||||
|
|
||||||
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Call plugin to ingest document.
|
"""Call plugin to ingest document.
|
||||||
|
|||||||
@@ -367,6 +367,22 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
owner_type = data['owner_type']
|
owner_type = data['owner_type']
|
||||||
owner = data['owner']
|
owner = data['owner']
|
||||||
value = base64.b64decode(data['value_base64'])
|
value = base64.b64decode(data['value_base64'])
|
||||||
|
max_value_bytes = (
|
||||||
|
self.ap.instance_config.data.get('plugin', {})
|
||||||
|
.get('binary_storage', {})
|
||||||
|
.get(
|
||||||
|
'max_value_bytes',
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
max_value_bytes = int(max_value_bytes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_value_bytes = 10 * 1024 * 1024
|
||||||
|
if max_value_bytes >= 0 and len(value) > max_value_bytes:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
|
||||||
|
)
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
@@ -939,6 +955,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
timeout=20,
|
timeout=20,
|
||||||
)
|
)
|
||||||
asset_file_key = result['file_file_key']
|
asset_file_key = result['file_file_key']
|
||||||
|
if not asset_file_key:
|
||||||
|
return {
|
||||||
|
'asset_base64': '',
|
||||||
|
'mime_type': '',
|
||||||
|
}
|
||||||
mime_type = result['mime_type']
|
mime_type = result['mime_type']
|
||||||
asset_bytes = await self.read_local_file(asset_file_key)
|
asset_bytes = await self.read_local_file(asset_file_key)
|
||||||
await self.delete_local_file(asset_file_key)
|
await self.delete_local_file(asset_file_key)
|
||||||
@@ -947,6 +968,30 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
'mime_type': mime_type,
|
'mime_type': mime_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def handle_page_api(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
page_id: str,
|
||||||
|
endpoint: str,
|
||||||
|
method: str,
|
||||||
|
body: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Forward a page API call to the plugin via runtime."""
|
||||||
|
result = await self.call_action(
|
||||||
|
LangBotToRuntimeAction.PAGE_API,
|
||||||
|
{
|
||||||
|
'plugin_author': plugin_author,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
'page_id': page_id,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'method': method,
|
||||||
|
'body': body,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||||
"""Cleanup plugin settings and binary storage"""
|
"""Cleanup plugin settings and binary storage"""
|
||||||
# Delete plugin settings
|
# Delete plugin settings
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user