mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 17:17:14 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bdf371b5d | |||
| cdfde83e39 | |||
| 4a753d98f5 | |||
| 354aadfce0 | |||
| 4b6931d294 | |||
| 2a3f7f8059 | |||
| 5eb2b90ae6 | |||
| d8c14fc969 | |||
| 6cae954a67 | |||
| 183fb87649 | |||
| 56b3c15213 | |||
| 0cde4c71cd | |||
| 98a633c712 | |||
| 940234a0d8 | |||
| 0c405901d2 | |||
| 00e2103873 | |||
| 209706b0b9 | |||
| 205404e3da | |||
| 3e93ccfb45 | |||
| c5d7e3dcb1 | |||
| cd6d5d9c2f | |||
| 1f7d9339fc | |||
| f3b5fcfb7c | |||
| ffabb91bfe | |||
| 96c84740db | |||
| 85b5b5b54b | |||
| 28bffdef21 | |||
| bf3c96026b | |||
| ed9343c686 | |||
| 9a8cdde86c | |||
| a2655e16f8 | |||
| 776d48380e | |||
| 5e6b3cc550 | |||
| ea4cd80939 | |||
| ff7fd2f5e0 | |||
| 3b3aa93cf8 | |||
| f658f0ede5 | |||
| 47d75082e8 | |||
| 8849fb172e | |||
| 6818ae2783 | |||
| e7f1059194 | |||
| 537677044e | |||
| 321891441c | |||
| 7f36103aa4 | |||
| b1750882ed | |||
| b55f073e62 | |||
| e6cfee541f | |||
| f10ea82418 | |||
| a32c4d152f | |||
| 83b0d26e99 | |||
| e08b5db625 | |||
| d0f65b17ec | |||
| 2b533c4a00 | |||
| f663d87a60 | |||
| 60e5b873ee | |||
| b96f209b98 |
@@ -51,7 +51,7 @@ 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/).
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ 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/)。
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/).
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/).
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/)。
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/).
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/).
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ 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/)。
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ 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/).
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Valkey Search Vector Database Integration
|
||||||
|
|
||||||
|
This document describes how to use **Valkey Search** (the search/vector module bundled in
|
||||||
|
`valkey/valkey-bundle`) as the vector database backend for LangBot's knowledge base (RAG)
|
||||||
|
feature.
|
||||||
|
|
||||||
|
## What is Valkey Search?
|
||||||
|
|
||||||
|
**Valkey Search** is a module that adds vector similarity search and full-text search to
|
||||||
|
[Valkey](https://valkey.io/), the open-source, BSD-licensed in-memory data store forked from
|
||||||
|
Redis OSS. It is distributed in the `valkey/valkey-bundle` image alongside other modules
|
||||||
|
(JSON, Bloom, LDAP).
|
||||||
|
|
||||||
|
LangBot talks to Valkey through the official [`valkey-glide`](https://pypi.org/project/valkey-glide/)
|
||||||
|
client (Rust core + async Python wrapper), using its native `ft` (search) command namespace.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Vector search**: ANN via HNSW or exact via FLAT, with COSINE / L2 / IP distance metrics
|
||||||
|
- **Full-text search**: term, prefix and phrase matching over indexed text fields
|
||||||
|
- **Hybrid search**: a metadata/text filter pre-selects candidates, then KNN ranks them
|
||||||
|
- **In-memory speed**: vectors and documents are stored as Valkey HASH keys
|
||||||
|
- **Auth + TLS**: optional username/password and TLS for production (toB / SaaS) deployments
|
||||||
|
|
||||||
|
### Licensing
|
||||||
|
|
||||||
|
- Valkey core and the Search module are **BSD-3-Clause**.
|
||||||
|
- The `valkey-glide` client is **Apache-2.0**.
|
||||||
|
|
||||||
|
Both are compatible with LangBot.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Valkey Search support is included when you install LangBot — the `valkey-glide` dependency is
|
||||||
|
declared in `pyproject.toml`. To install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'valkey-glide>=2.4.1,<3.0.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
You also need a running Valkey server with the Search module loaded. The simplest way is the
|
||||||
|
bundled image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run valkey-bundle (includes the Search module) on host port 6380
|
||||||
|
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
|
||||||
|
# (docker run ... works identically)
|
||||||
|
```
|
||||||
|
|
||||||
|
`valkey-bundle` ships multi-arch images (linux/amd64 + linux/arm64), so it runs on both CI
|
||||||
|
(x86_64) and Apple-silicon dev machines.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Valkey Search is **opt-in and disabled by default** — the default `vdb.use` stays `chroma`,
|
||||||
|
so existing single-process deployments are unaffected. To enable it, edit your `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: valkey_search
|
||||||
|
valkey_search:
|
||||||
|
host: 'localhost'
|
||||||
|
port: 6379 # use 6380 if you started the container as shown above
|
||||||
|
db: 0
|
||||||
|
password: '' # optional (ACL / requirepass) — never logged
|
||||||
|
username: '' # optional (ACL user)
|
||||||
|
tls: false # optional (toB / SaaS)
|
||||||
|
index_algorithm: 'HNSW' # HNSW | FLAT
|
||||||
|
distance_metric: 'COSINE' # COSINE | L2 | IP
|
||||||
|
request_timeout: 5000 # per-request timeout in ms
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `host` | `localhost` | Valkey host |
|
||||||
|
| `port` | `6379` | Valkey port |
|
||||||
|
| `db` | `0` | Logical database id |
|
||||||
|
| `password` | `''` | Optional auth password (empty = no auth). Never logged. |
|
||||||
|
| `username` | `''` | Optional ACL username. Configuring a username without a password fails closed (raises) rather than connecting unauthenticated. |
|
||||||
|
| `tls` | `false` | Enable TLS for the connection |
|
||||||
|
| `index_algorithm` | `HNSW` | `HNSW` (approximate) or `FLAT` (exact) |
|
||||||
|
| `distance_metric` | `COSINE` | `COSINE`, `L2`, or `IP` |
|
||||||
|
| `request_timeout` | `5000` | Per-request timeout in milliseconds. The valkey-glide default (250ms) is too low for vector KNN under load; raise it further for remote/cross-AZ Valkey. |
|
||||||
|
|
||||||
|
### Connection behavior
|
||||||
|
|
||||||
|
The backend uses a **lazy** connection (`lazy_connect=True`): the client is created on first
|
||||||
|
use and the connection is deferred to the first command. A misconfigured or unreachable Valkey
|
||||||
|
server therefore does **not** block LangBot from booting — knowledge-base operations will error
|
||||||
|
at call time instead, and you can recover by switching `vdb.use` back to another backend.
|
||||||
|
|
||||||
|
The connection sets a fixed `client_name` of `langbot_vector_client` so it is identifiable in
|
||||||
|
`CLIENT LIST` and monitoring dashboards.
|
||||||
|
|
||||||
|
## Supported search types
|
||||||
|
|
||||||
|
| Type | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `vector` | Pure KNN over the embedding field |
|
||||||
|
| `full_text` | Term/phrase match over the indexed `document` text field |
|
||||||
|
| `hybrid` | Metadata/text filter **pre-selects** candidates, then KNN ranks them |
|
||||||
|
|
||||||
|
### ⚠️ Important: `vector_weight` is NOT honored
|
||||||
|
|
||||||
|
Valkey Search hybrid queries follow a **filter-then-KNN** model: the filter (and/or full-text
|
||||||
|
clause) narrows the candidate set, and the KNN stage ranks the survivors by vector distance.
|
||||||
|
There is **no native weighted score fusion** (unlike, e.g., SeekDB's RRF boost).
|
||||||
|
|
||||||
|
For interface compatibility the backend still accepts a `vector_weight` argument, but it is
|
||||||
|
**ignored** — passing different weights does not change result ordering. The first time a
|
||||||
|
non-default weight is supplied, the backend logs a one-time warning.
|
||||||
|
|
||||||
|
If weighted hybrid ranking is needed in the future, it can be added **application-side** (run
|
||||||
|
vector KNN and full-text search separately and blend the scores). That is intentionally out of
|
||||||
|
scope for this integration.
|
||||||
|
|
||||||
|
## Metadata & filtering
|
||||||
|
|
||||||
|
Documents are stored as Valkey HASH keys under the prefix `kb:{collection}:{id}` with fields:
|
||||||
|
|
||||||
|
- `vector` — the embedding, packed as little-endian FLOAT32
|
||||||
|
- `document` — the raw text (indexed as TEXT for full-text/hybrid search)
|
||||||
|
- `file_id` — promoted to an indexed TAG field so it is filterable
|
||||||
|
- `metadata_json` — the full metadata dict, preserved verbatim as JSON
|
||||||
|
|
||||||
|
Only **indexed** fields are filterable. Currently that is `file_id`. Filters referencing
|
||||||
|
non-indexed metadata keys are dropped with a warning (the same pragmatism used by the Milvus
|
||||||
|
and pgvector backends). All other metadata still round-trips intact via `metadata_json`.
|
||||||
|
|
||||||
|
Supported filter operators (canonical Chroma-style `where` syntax): `$eq`, `$ne`, `$gt`,
|
||||||
|
`$gte`, `$lt`, `$lte`, `$in`, `$nin`. Multiple top-level keys are AND-ed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests (filter mapping, float32 packing, reply parsing, import guard) run in the fast lane
|
||||||
|
with no server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/unit_tests/vector/test_valkey_search_filter.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests are **slow-gated** on `TEST_VALKEY_URL` and require a running server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
|
||||||
|
TEST_VALKEY_URL=valkey://localhost:6380 \
|
||||||
|
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
|
||||||
|
```
|
||||||
|
|
||||||
|
The default upstream fast CI lane (`-m "not slow"`) skips these, matching the existing
|
||||||
|
PostgreSQL migration-test precedent.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause / fix |
|
||||||
|
|---------|-------------|
|
||||||
|
| Tests skip with "Valkey Search module not available" | The server is plain Valkey without the Search module. Use the `valkey/valkey-bundle` image. |
|
||||||
|
| `ConnectionError` at call time | Check `host`/`port`/auth; remember `lazy_connect` defers errors to first use. |
|
||||||
|
| Empty search results right after insert | The Search indexer is asynchronous; results become visible within a short delay. The integration tests poll/retry to account for this. |
|
||||||
|
| Hybrid ranking ignores `vector_weight` | Expected — see the caveat above. |
|
||||||
|
|
||||||
|
## Production considerations
|
||||||
|
|
||||||
|
- **Cluster mode**: Valkey Search in cluster mode uses an additional coordination port. This
|
||||||
|
integration targets standalone mode; cluster support is a future consideration.
|
||||||
|
- **Persistence**: configure Valkey RDB/AOF persistence if the knowledge base must survive
|
||||||
|
restarts; otherwise an in-memory store is ephemeral.
|
||||||
|
- **Security**: set `password`/`username` and `tls: true` for any non-local deployment.
|
||||||
|
Credentials are never written to logs.
|
||||||
+2
-1
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"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.4.7",
|
"langbot-plugin==0.4.13",
|
||||||
"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",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -80,6 +80,7 @@ dependencies = [
|
|||||||
"pgvector>=0.4.1",
|
"pgvector>=0.4.1",
|
||||||
"botocore>=1.42.39",
|
"botocore>=1.42.39",
|
||||||
"litellm>=1.0.0",
|
"litellm>=1.0.0",
|
||||||
|
"valkey-glide>=2.4.1,<3.0.0",
|
||||||
]
|
]
|
||||||
keywords = [
|
keywords = [
|
||||||
"bot",
|
"bot",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,62 @@ 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:
|
||||||
|
if r.status_code != 200:
|
||||||
|
body = (await r.aread()).decode(errors='replace')
|
||||||
|
raise DifyAPIError(f'{r.status_code} {body}')
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
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,48 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
import typing
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
|
||||||
|
"""DingTalk cardParamMap only accepts string values.
|
||||||
|
|
||||||
|
Keep callers free to pass structured values for template variables such
|
||||||
|
as button groups or select options, then encode them once at the API
|
||||||
|
boundary.
|
||||||
|
"""
|
||||||
|
if not card_param_map:
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
for key, value in card_param_map.items():
|
||||||
|
if value is None:
|
||||||
|
result[key] = ''
|
||||||
|
elif isinstance(value, str):
|
||||||
|
result[key] = value
|
||||||
|
else:
|
||||||
|
result[key] = json.dumps(value, ensure_ascii=False)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class DingTalkClient:
|
class DingTalkClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -21,6 +52,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 +62,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': [],
|
||||||
}
|
}
|
||||||
@@ -39,8 +79,24 @@ class DingTalkClient:
|
|||||||
self.access_token_expiry_time = ''
|
self.access_token_expiry_time = ''
|
||||||
self.markdown_card = markdown_card
|
self.markdown_card = markdown_card
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
# Legacy access_token used by the OLD oapi.dingtalk.com endpoints
|
||||||
|
# (e.g. /media/upload, which is the only documented way to get an
|
||||||
|
# `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
|
||||||
|
# token doesn't work there — different auth domain.
|
||||||
|
self.legacy_access_token = ''
|
||||||
|
self.legacy_access_token_expiry_time: typing.Optional[float] = None
|
||||||
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'}
|
||||||
@@ -429,18 +485,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()}')
|
||||||
|
|
||||||
@@ -456,7 +529,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}),
|
||||||
@@ -477,47 +550,334 @@ 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'] = ''
|
|
||||||
|
|
||||||
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
|
||||||
if incoming_message.message_type == 'text':
|
`(None, out_track_id)` to keep call sites compatible with the
|
||||||
card_data['query'] = incoming_message.get_text_list()[0]
|
previous `(card_instance, card_instance_id)` shape — the first slot
|
||||||
|
is unused now that everything is driven by out_track_id.
|
||||||
|
"""
|
||||||
|
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:
|
else:
|
||||||
card_data['query'] = '...'
|
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
|
||||||
|
|
||||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
card_param_map = {'content': ''}
|
||||||
# print(card_instance)
|
if incoming_message.message_type == 'text':
|
||||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
card_param_map['query'] = incoming_message.get_text_list()[0]
|
||||||
card_instance_id = await card_instance.async_create_and_deliver_card(
|
else:
|
||||||
temp_card_id,
|
card_param_map['query'] = '...'
|
||||||
card_data,
|
|
||||||
|
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': _stringify_card_param_map(card_param_map)}
|
||||||
|
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': _stringify_card_param_map(card_param_map)},
|
||||||
|
}
|
||||||
|
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)[:1500],
|
||||||
|
)
|
||||||
|
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 get_legacy_access_token(self) -> Optional[str]:
|
||||||
|
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
|
||||||
|
different auth domain from the v1.0 token cached in
|
||||||
|
``self.access_token`` — only the legacy token authorises the
|
||||||
|
``/media/upload`` endpoint that returns an ``@xxx`` media_id
|
||||||
|
consumable by card components like Avatar.imageUrl.
|
||||||
|
|
||||||
|
Returns the token string on success, None on failure. Caches
|
||||||
|
with a 60s safety margin before the documented 7200s expiry.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self.legacy_access_token
|
||||||
|
and self.legacy_access_token_expiry_time
|
||||||
|
and now < self.legacy_access_token_expiry_time
|
||||||
|
):
|
||||||
|
return self.legacy_access_token
|
||||||
|
|
||||||
|
url = 'https://oapi.dingtalk.com/gettoken'
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
|
||||||
|
data = response.json() if response.status_code == 200 else {}
|
||||||
|
if data.get('errcode') == 0 and data.get('access_token'):
|
||||||
|
self.legacy_access_token = data['access_token']
|
||||||
|
expires_in = int(data.get('expires_in', 7200))
|
||||||
|
self.legacy_access_token_expiry_time = now + expires_in - 60
|
||||||
|
return self.legacy_access_token
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(
|
||||||
|
f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_stdout_logger.exception('DingTalk legacy gettoken error')
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def upload_image_media(self, file_path: str) -> Optional[str]:
|
||||||
|
"""Upload an image file to DingTalk media storage and return the
|
||||||
|
``@xxx`` media_id, which can be passed straight into card variables
|
||||||
|
like Avatar.imageUrl. Endpoint:
|
||||||
|
|
||||||
|
POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image
|
||||||
|
|
||||||
|
Returns the media_id on success, None on any failure (caller
|
||||||
|
should handle a None gracefully — DingTalk falls back to a
|
||||||
|
default avatar when imageUrl is empty/unknown).
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = await self.get_legacy_access_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = 'https://oapi.dingtalk.com/media/upload'
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
# Best-effort content-type guess; DingTalk accepts the major image
|
||||||
|
# mime types and otherwise infers from the bytes.
|
||||||
|
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
|
||||||
|
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
|
||||||
|
ext, 'application/octet-stream'
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
params={'access_token': token, 'type': 'image'},
|
||||||
|
files={'media': (file_name, file_bytes, mime)},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
data = response.json() if response.status_code == 200 else {}
|
||||||
|
if data.get('errcode') == 0 and data.get('media_id'):
|
||||||
|
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
|
||||||
|
return data['media_id']
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(
|
||||||
|
f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_stdout_logger.exception('DingTalk upload_image_media error')
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
|
||||||
|
return None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""启动 WebSocket 连接,监听消息"""
|
"""启动 WebSocket 连接,监听消息"""
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""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'),
|
||||||
|
('actionData', 'cardPrivateData', '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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_params(*sources: dict) -> dict:
|
||||||
|
merged = {}
|
||||||
|
for source in sources:
|
||||||
|
if isinstance(source, dict):
|
||||||
|
merged.update(source)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
content = 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 {}
|
||||||
|
params = _merge_params(_extract_params(content), _extract_params(raw))
|
||||||
|
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'
|
||||||
@@ -12,6 +12,142 @@ import traceback
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
|
||||||
|
QQ_SELECT_ACTION_PREFIX = '__langbot_select__:'
|
||||||
|
|
||||||
|
|
||||||
|
def get_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
||||||
|
"""Return the active select field name and its display/submission values."""
|
||||||
|
field_name = str(form_data.get('_current_input_field') or '').strip()
|
||||||
|
if not field_name:
|
||||||
|
return '', []
|
||||||
|
|
||||||
|
field = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in form_data.get('input_defs') or []
|
||||||
|
if str(item.get('output_variable_name') or '').strip() == field_name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not field or str(field.get('type') or '').strip().lower() != 'select':
|
||||||
|
return '', []
|
||||||
|
|
||||||
|
source = field.get('option_source') or {}
|
||||||
|
source_value = source.get('value') if isinstance(source, dict) else None
|
||||||
|
if isinstance(source_value, list):
|
||||||
|
return field_name, [str(item) for item in source_value]
|
||||||
|
if isinstance(source_value, str):
|
||||||
|
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
|
||||||
|
|
||||||
|
options = field.get('options')
|
||||||
|
if not isinstance(options, list):
|
||||||
|
return field_name, []
|
||||||
|
values = []
|
||||||
|
for item in options:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
values.append(str(item.get('label') or item.get('value') or ''))
|
||||||
|
else:
|
||||||
|
values.append(str(item))
|
||||||
|
return field_name, [value for value in values if value]
|
||||||
|
|
||||||
|
|
||||||
|
def build_keyboard_from_select_field(form_data: dict, *, buttons_per_row: int | None = None) -> dict:
|
||||||
|
"""Build callback buttons for the currently active Dify select field."""
|
||||||
|
_, options = get_select_field_options(form_data)
|
||||||
|
visible_options = options[:25]
|
||||||
|
if buttons_per_row is None:
|
||||||
|
# Keep small choices readable while fitting up to QQ's 5x5 limit.
|
||||||
|
buttons_per_row = min(5, max(2, (len(visible_options) + 4) // 5))
|
||||||
|
selection_actions = [
|
||||||
|
{
|
||||||
|
'id': f'{QQ_SELECT_ACTION_PREFIX}{idx}',
|
||||||
|
'title': option,
|
||||||
|
'button_style': 'secondary',
|
||||||
|
}
|
||||||
|
for idx, option in enumerate(visible_options)
|
||||||
|
]
|
||||||
|
return build_keyboard_from_form({'actions': selection_actions}, buttons_per_row=buttons_per_row)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_select_button_action(form_data: dict, action_id: str) -> tuple[str, str] | None:
|
||||||
|
"""Resolve a select-button callback to ``(field_name, option_value)``."""
|
||||||
|
if not action_id.startswith(QQ_SELECT_ACTION_PREFIX):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
option_index = int(action_id[len(QQ_SELECT_ACTION_PREFIX) :])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
field_name, options = get_select_field_options(form_data)
|
||||||
|
if not field_name or option_index < 0 or option_index >= len(options) or option_index >= 25:
|
||||||
|
return None
|
||||||
|
return field_name, options[option_index]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -30,6 +166,10 @@ 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
|
||||||
@@ -107,6 +247,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)
|
||||||
@@ -133,6 +290,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
|
||||||
@@ -177,8 +349,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()
|
||||||
|
|
||||||
@@ -188,11 +372,15 @@ 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:
|
||||||
@@ -201,8 +389,19 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {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()
|
||||||
|
|
||||||
@@ -212,11 +411,15 @@ 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
|
||||||
@@ -485,6 +688,107 @@ class QQOfficialClient:
|
|||||||
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
async def send_markdown_keyboard(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
markdown_content: str,
|
||||||
|
keyboard: Optional[dict] = None,
|
||||||
|
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},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if keyboard and keyboard.get('content', {}).get('rows'):
|
||||||
|
body['keyboard'] = keyboard
|
||||||
|
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:
|
||||||
@@ -653,6 +957,12 @@ class QQOfficialClient:
|
|||||||
d = payload.get('d', {})
|
d = payload.get('d', {})
|
||||||
s = payload.get('s')
|
s = payload.get('s')
|
||||||
t = payload.get('t')
|
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):
|
if not isinstance(d, dict):
|
||||||
d = {}
|
d = {}
|
||||||
@@ -731,7 +1041,22 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await self.logger.debug(f'Received event: {t}, seq={s}')
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
if on_event:
|
# 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:
|
try:
|
||||||
result = on_event(t, d)
|
result = on_event(t, d)
|
||||||
if asyncio.iscoroutine(result):
|
if asyncio.iscoroutine(result):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,19 @@ 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, StreamSession
|
from langbot.libs.wecom_ai_bot_api.api import (
|
||||||
|
parse_wecom_bot_message,
|
||||||
|
StreamSession,
|
||||||
|
build_human_input_template_card_payload,
|
||||||
|
build_human_input_text_prompt,
|
||||||
|
build_button_interaction_update_card,
|
||||||
|
build_multiple_interaction_update_card,
|
||||||
|
extract_template_card_action,
|
||||||
|
extract_template_card_event_payload,
|
||||||
|
extract_template_card_selections,
|
||||||
|
extract_wecom_event_type,
|
||||||
|
parse_select_button_action,
|
||||||
|
)
|
||||||
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'
|
||||||
@@ -43,6 +55,10 @@ def _generate_req_id(prefix: str) -> str:
|
|||||||
return f'{prefix}_{ts}_{rand}'
|
return f'{prefix}_{ts}_{rand}'
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_snippet(frame: dict, limit: int = 1000) -> str:
|
||||||
|
return json.dumps(frame, ensure_ascii=False, default=str)[:limit]
|
||||||
|
|
||||||
|
|
||||||
class WecomBotWsClient:
|
class WecomBotWsClient:
|
||||||
"""WeChat Work AI Bot WebSocket long connection client.
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
@@ -103,6 +119,22 @@ class WecomBotWsClient:
|
|||||||
# msg_id -> feedback_id (for associating feedback with message)
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
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
|
||||||
|
# Optional `source` block injected into every interactive
|
||||||
|
# template_card the client builds via `push_form_pause`. Set via
|
||||||
|
# `set_card_source` from the adapter after reading config.
|
||||||
|
self.card_source: Optional[dict] = None
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
@@ -236,6 +268,132 @@ 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)
|
||||||
|
|
||||||
|
async def update_template_card(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
template_card: dict[str, Any],
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Update an existing template_card via WebSocket.
|
||||||
|
|
||||||
|
Uses the ``aibot_respond_update_msg`` command. Must be called
|
||||||
|
within 5 seconds of receiving the ``template_card_event`` callback,
|
||||||
|
using the **same req_id** from that callback.
|
||||||
|
|
||||||
|
The ``template_card`` dict should contain ``card_type`` and the
|
||||||
|
new content fields (e.g. ``main_title``, ``button_list`` with
|
||||||
|
disabled buttons and ``replace_text``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
'response_type': 'update_template_card',
|
||||||
|
'template_card': template_card,
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_UPDATE)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def set_card_source(self, source: Optional[dict]) -> None:
|
||||||
|
"""Set the `source` block injected into every interactive
|
||||||
|
template_card pushed via `push_form_pause`. Pass None to clear."""
|
||||||
|
self.card_source = source
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
text_prompt = build_human_input_text_prompt(form_data)
|
||||||
|
if text_prompt:
|
||||||
|
try:
|
||||||
|
ack = await self.reply_text(req_id, text_prompt)
|
||||||
|
if ack is None:
|
||||||
|
return False, stream_id, None
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}')
|
||||||
|
return False, stream_id, None
|
||||||
|
|
||||||
|
self._stream_ids.pop(msg_id, None)
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
|
return True, stream_id, None
|
||||||
|
|
||||||
|
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_human_input_template_card_payload(
|
||||||
|
form_data,
|
||||||
|
task_id,
|
||||||
|
source=self.card_source,
|
||||||
|
select_as_buttons=True,
|
||||||
|
)
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -258,6 +416,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.
|
||||||
|
|
||||||
@@ -276,10 +451,31 @@ class WecomBotWsClient:
|
|||||||
return False
|
return False
|
||||||
req_id, stream_id = key.split('|', 1)
|
req_id, stream_id = key.split('|', 1)
|
||||||
try:
|
try:
|
||||||
|
previous_content = self._stream_last_content.get(msg_id, '')
|
||||||
|
if previous_content and content.startswith(previous_content):
|
||||||
|
next_content = content
|
||||||
|
elif previous_content and not content:
|
||||||
|
next_content = previous_content
|
||||||
|
else:
|
||||||
|
next_content = previous_content + content if previous_content else content
|
||||||
|
|
||||||
# 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 next_content == previous_content:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Skip empty/whitespace-only snapshots — the runner injects a
|
||||||
|
# zero-width space ('') as a pass-through when workflow_paused
|
||||||
|
# fires without any preceding LLM output. WeCom renders that
|
||||||
|
# as an empty bubble that sits before the form card; skip it.
|
||||||
|
# NOTE: Python str.strip() does NOT strip , so we use
|
||||||
|
# a regex that treats any character with Unicode category Zs
|
||||||
|
# (separator space) or Cf (format char like ZWS) as blank.
|
||||||
|
if not is_final:
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
if not _re.sub(r'[\s]', '', next_content):
|
||||||
|
return True
|
||||||
|
|
||||||
# Generate feedback_id for final chunk
|
# Generate feedback_id for final chunk
|
||||||
feedback_id = ''
|
feedback_id = ''
|
||||||
if is_final:
|
if is_final:
|
||||||
@@ -290,8 +486,10 @@ class WecomBotWsClient:
|
|||||||
if session_info:
|
if session_info:
|
||||||
self._feedback_sessions[feedback_id] = 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)
|
# WeCom replaces the displayed stream content on each refresh, so
|
||||||
self._stream_last_content[msg_id] = content
|
# every frame must contain the complete snapshot, not only a delta.
|
||||||
|
await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
|
||||||
|
self._stream_last_content[msg_id] = next_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)
|
||||||
@@ -465,7 +663,7 @@ class WecomBotWsClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Unknown frame
|
# Unknown frame
|
||||||
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
|
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
|
||||||
|
|
||||||
async def _handle_message_callback(self, frame: dict):
|
async def _handle_message_callback(self, frame: dict):
|
||||||
"""Handle an incoming message callback frame."""
|
"""Handle an incoming message callback frame."""
|
||||||
@@ -473,6 +671,13 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
event_type = extract_wecom_event_type(body)
|
||||||
|
if event_type == 'template_card_event':
|
||||||
|
await self._handle_template_card_event_frame(frame, body)
|
||||||
|
return
|
||||||
|
if event_type:
|
||||||
|
await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}')
|
||||||
|
|
||||||
# Parse message using shared logic
|
# Parse message using shared logic
|
||||||
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
if not message_data:
|
if not message_data:
|
||||||
@@ -506,8 +711,12 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
event_info = body.get('event', {})
|
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
|
||||||
event_type = event_info.get('eventtype', '')
|
event_type = extract_wecom_event_type(body)
|
||||||
|
if not event_type:
|
||||||
|
await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}')
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'Received event_callback event_type={event_type}')
|
||||||
|
|
||||||
message_data = {
|
message_data = {
|
||||||
'msgtype': 'event',
|
'msgtype': 'event',
|
||||||
@@ -568,6 +777,10 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if event_type == 'template_card_event':
|
||||||
|
await self._handle_template_card_event_frame(frame, body)
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
@@ -581,6 +794,72 @@ class WecomBotWsClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_template_card_event_frame(self, frame: dict, body: dict):
|
||||||
|
"""Handle template_card_event frames from event_callback or msg_callback."""
|
||||||
|
tce = extract_template_card_event_payload(body)
|
||||||
|
task_id, event_key, card_type = extract_template_card_action(tce)
|
||||||
|
await self.logger.info(
|
||||||
|
f'Received template_card_event (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'No pending_form found for task_id={task_id} (ws); card event ignored')
|
||||||
|
return
|
||||||
|
|
||||||
|
req_id_for_update = frame.get('headers', {}).get('req_id', '')
|
||||||
|
form_data = pending.get('form_data', {}) or {}
|
||||||
|
selections = extract_template_card_selections(tce, form_data)
|
||||||
|
if not selections:
|
||||||
|
selections = parse_select_button_action(event_key, form_data)
|
||||||
|
if card_type == 'multiple_interaction' and not selections:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}'
|
||||||
|
)
|
||||||
|
self._drop_pending_form_task(task_id, pending)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_card = build_button_interaction_update_card(
|
||||||
|
form_data,
|
||||||
|
task_id,
|
||||||
|
event_key,
|
||||||
|
source=self.card_source,
|
||||||
|
)
|
||||||
|
if card_type == 'multiple_interaction' or selections:
|
||||||
|
update_card = build_multiple_interaction_update_card(
|
||||||
|
form_data,
|
||||||
|
task_id,
|
||||||
|
selections,
|
||||||
|
source=self.card_source,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.update_template_card(req_id_for_update, update_card)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}')
|
||||||
|
|
||||||
|
if 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()}')
|
||||||
|
|
||||||
|
self._drop_pending_form_task(task_id, pending)
|
||||||
|
|
||||||
|
def _drop_pending_form_task(self, task_id: str, pending: dict) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""Dispatch a message event to registered handlers with deduplication."""
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ 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:
|
||||||
@@ -37,6 +60,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
filename = 'dingtalk_human_input_card.json'
|
||||||
|
response = quart.Response(
|
||||||
|
importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
|
||||||
|
)
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
||||||
|
return response
|
||||||
|
|
||||||
# In-memory session store for active registrations
|
# In-memory session store for active registrations
|
||||||
_create_app_sessions: dict = {}
|
_create_app_sessions: dict = {}
|
||||||
_SESSION_TTL = 900 # 15 minutes
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
@@ -650,3 +682,220 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if session and session.get('task') and not session['task'].done():
|
if session and session.get('task') and not session['task'].done():
|
||||||
session['task'].cancel()
|
session['task'].cancel()
|
||||||
return self.success(data={})
|
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={})
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(server_name: str) -> str:
|
||||||
|
"""Get logs from an MCP server"""
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
try:
|
||||||
|
limit = int(quart.request.args.get('limit', 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 200
|
||||||
|
limit = min(limit, 500)
|
||||||
|
level = quart.request.args.get('level') or None
|
||||||
|
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
|
||||||
|
return self.success(data={'logs': logs})
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Read a resource from an MCP server"""
|
"""Read a resource from an MCP server"""
|
||||||
|
|||||||
@@ -188,10 +188,22 @@ class MCPService:
|
|||||||
persisted_session = runtime_mcp_session
|
persisted_session = runtime_mcp_session
|
||||||
|
|
||||||
async def _refresh_and_report() -> None:
|
async def _refresh_and_report() -> None:
|
||||||
if persisted_session.status == MCPSessionStatus.ERROR:
|
# Testing a persisted server should REUSE its live shared-session
|
||||||
|
# process, not rebuild it. Try a lightweight refresh (a real
|
||||||
|
# list_tools probe over the existing connection) first; only fall
|
||||||
|
# back to a full start() when the session has no live connection
|
||||||
|
# to probe (never connected, or the process is actually gone).
|
||||||
|
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||||
|
if needs_start:
|
||||||
await persisted_session.start()
|
await persisted_session.start()
|
||||||
else:
|
else:
|
||||||
await persisted_session.refresh()
|
try:
|
||||||
|
await persisted_session.refresh()
|
||||||
|
except Exception:
|
||||||
|
# The live connection was stale/dropped: reconnect once
|
||||||
|
# (reusing the live managed process where possible) and
|
||||||
|
# re-probe, instead of reporting a false failure.
|
||||||
|
await persisted_session.start()
|
||||||
# Surface the discovered tools so the config page can render them
|
# Surface the discovered tools so the config page can render them
|
||||||
# even for an already-hosted server.
|
# even for an already-hosted server.
|
||||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||||
@@ -232,3 +244,19 @@ class MCPService:
|
|||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
return wrapper.id
|
return wrapper.id
|
||||||
|
|
||||||
|
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
|
||||||
|
"""Get recent log lines captured from the MCP server's stderr."""
|
||||||
|
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||||
|
if not session:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get logs from the session's buffer
|
||||||
|
logs = list(session._log_buffer)
|
||||||
|
|
||||||
|
# Filter by level if specified
|
||||||
|
if level:
|
||||||
|
logs = [log for log in logs if log.get('level') == level]
|
||||||
|
|
||||||
|
# Return the most recent 'limit' logs
|
||||||
|
return logs[-limit:]
|
||||||
|
|||||||
@@ -168,7 +168,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(
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ class QueryPool:
|
|||||||
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,
|
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,
|
||||||
@@ -53,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={'_routed_by_rule': routed_by_rule},
|
variables=initial_variables,
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
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],
|
||||||
|
|||||||
@@ -501,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'):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,41 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
|
- name: human_input_card_template_download
|
||||||
|
label:
|
||||||
|
en_US: Download Human Input Card Template
|
||||||
|
zh_Hans: 下载人工输入卡片模板
|
||||||
|
zh_Hant: 下載人工輸入卡片範本
|
||||||
|
description:
|
||||||
|
en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior."
|
||||||
|
zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。"
|
||||||
|
zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。"
|
||||||
|
type: download-link
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
|
||||||
|
download_filename: dingtalk_human_input_card.json
|
||||||
|
help_links:
|
||||||
|
zh: https://open-dev.dingtalk.com/fe/card
|
||||||
|
en: https://open-dev.dingtalk.com/fe/card
|
||||||
|
ja: https://open-dev.dingtalk.com/fe/card
|
||||||
|
help_label:
|
||||||
|
en_US: Import Guide
|
||||||
|
zh_Hans: 导入指引
|
||||||
|
zh_Hant: 匯入指引
|
||||||
|
ja_JP: インポート手順
|
||||||
|
- name: human_input_card_template_id
|
||||||
|
label:
|
||||||
|
en_US: Human Input Card Template ID
|
||||||
|
zh_Hans: 人工输入卡片模板ID
|
||||||
|
zh_Hant: 人工輸入卡片範本ID
|
||||||
|
description:
|
||||||
|
en_US: "Paste the template ID generated after importing the human input card template."
|
||||||
|
zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。"
|
||||||
|
zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./dingtalk.py
|
path: ./dingtalk.py
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from discord import ui as discord_ui
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import re
|
import re
|
||||||
@@ -8,6 +9,8 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
# 使用BytesIO创建文件对象,避免路径问题
|
# 使用BytesIO创建文件对象,避免路径问题
|
||||||
import io
|
import io
|
||||||
@@ -824,6 +827,69 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordFormView(discord_ui.View):
|
||||||
|
"""Discord ``ui.View`` that renders one button per Dify form action.
|
||||||
|
|
||||||
|
Each button's click triggers ``adapter._on_form_button_click`` which
|
||||||
|
acks the interaction, locks the buttons in place, and enqueues a
|
||||||
|
synthetic ``_dify_form_action`` query so the runner resumes the
|
||||||
|
workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Discord button style mapping for Dify ``button_style`` values.
|
||||||
|
_STYLE_MAP: typing.ClassVar[dict] = {
|
||||||
|
'primary': discord.ButtonStyle.primary,
|
||||||
|
'danger': discord.ButtonStyle.danger,
|
||||||
|
'warning': discord.ButtonStyle.danger,
|
||||||
|
'success': discord.ButtonStyle.success,
|
||||||
|
'default': discord.ButtonStyle.secondary,
|
||||||
|
'': discord.ButtonStyle.secondary,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: 'DiscordAdapter',
|
||||||
|
session_key: str,
|
||||||
|
actions: list,
|
||||||
|
timeout: float = 1800,
|
||||||
|
):
|
||||||
|
super().__init__(timeout=timeout)
|
||||||
|
self._adapter = adapter
|
||||||
|
self._session_key = session_key
|
||||||
|
# Discord caps a view at 25 children (5 rows × 5 buttons). Trim
|
||||||
|
# silently — most Dify forms have ≤10 actions in practice.
|
||||||
|
for idx, action in enumerate(actions[:25]):
|
||||||
|
action_id = str(action.get('id') or '')
|
||||||
|
label = str(action.get('title') or action_id or f'Option {idx + 1}')
|
||||||
|
style = self._STYLE_MAP.get(
|
||||||
|
str(action.get('button_style') or '').lower(),
|
||||||
|
discord.ButtonStyle.secondary,
|
||||||
|
)
|
||||||
|
# custom_id must be unique within the view and ≤100 chars.
|
||||||
|
# Encode (session, idx) so we can recover the action even
|
||||||
|
# if Dify ids contain unsafe characters.
|
||||||
|
custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100]
|
||||||
|
button = discord_ui.Button(
|
||||||
|
label=label[:80], # Discord label limit
|
||||||
|
style=style,
|
||||||
|
custom_id=custom_id,
|
||||||
|
)
|
||||||
|
button.callback = self._make_callback(action_id, label)
|
||||||
|
self.add_item(button)
|
||||||
|
|
||||||
|
def _make_callback(self, action_id: str, action_title: str):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
await self._adapter._on_form_button_click(
|
||||||
|
interaction=interaction,
|
||||||
|
session_key=self._session_key,
|
||||||
|
action_id=action_id,
|
||||||
|
action_title=action_title,
|
||||||
|
view=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
|
||||||
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: discord.Client = pydantic.Field(exclude=True)
|
bot: discord.Client = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
@@ -837,6 +903,10 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
|
# Injected by botmgr at construction so the form-button callback can
|
||||||
|
# enqueue a synthetic resume query (`_dify_form_action`) on the pool.
|
||||||
|
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
bot_account_id = config['client_id']
|
bot_account_id = config['client_id']
|
||||||
|
|
||||||
@@ -860,8 +930,18 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
if os.getenv('http_proxy'):
|
# Proxy: config > env var > auto-detect.
|
||||||
args['proxy'] = os.getenv('http_proxy')
|
# discord.py uses aiohttp which does NOT respect http_proxy env
|
||||||
|
# vars by default — we must pass proxy= explicitly.
|
||||||
|
proxy = (
|
||||||
|
config.get('proxy')
|
||||||
|
or os.getenv('http_proxy')
|
||||||
|
or os.getenv('HTTP_PROXY')
|
||||||
|
or os.getenv('https_proxy')
|
||||||
|
or os.getenv('HTTPS_PROXY')
|
||||||
|
)
|
||||||
|
if proxy:
|
||||||
|
args['proxy'] = proxy
|
||||||
|
|
||||||
bot = MyClient(intents=intents, **args)
|
bot = MyClient(intents=intents, **args)
|
||||||
|
|
||||||
@@ -875,6 +955,19 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Per-resp-message-id buffer for the accumulated text yielded by
|
||||||
|
# the runner. Discord's edit-message ratelimit (5/5s) makes true
|
||||||
|
# progressive streaming impractical, so we collect chunks and
|
||||||
|
# render once on is_final. ``_form_data`` on the final chunk
|
||||||
|
# diverts to the button-view path.
|
||||||
|
self._stream_buffer: dict[str, str] = {}
|
||||||
|
# session_key -> {form_data, channel_id, thread_id, sender_id,
|
||||||
|
# posted_at, view_message_id}
|
||||||
|
# Populated when we send a form view; consumed when the user
|
||||||
|
# clicks a button so we know which workflow_run / form_token to
|
||||||
|
# resume.
|
||||||
|
self._pending_forms: dict[str, dict] = {}
|
||||||
|
|
||||||
# Voice functionality methods
|
# Voice functionality methods
|
||||||
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
||||||
"""
|
"""
|
||||||
@@ -1068,7 +1161,12 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||||
|
|
||||||
assert isinstance(message_source.source_platform_object, discord.Message)
|
# Synthetic events (button-click resume) have no inbound discord
|
||||||
|
# Message. Route via the channel we cached when the user clicked.
|
||||||
|
source = message_source.source_platform_object
|
||||||
|
if not isinstance(source, discord.Message):
|
||||||
|
await self._reply_synthetic(message_source, msg_to_send, files)
|
||||||
|
return
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
'content': msg_to_send,
|
'content': msg_to_send,
|
||||||
@@ -1078,7 +1176,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
args['files'] = files
|
args['files'] = files
|
||||||
|
|
||||||
if quote_origin:
|
if quote_origin:
|
||||||
args['reference'] = message_source.source_platform_object
|
args['reference'] = source
|
||||||
|
|
||||||
has_at = False
|
has_at = False
|
||||||
|
|
||||||
@@ -1090,7 +1188,422 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if has_at:
|
if has_at:
|
||||||
args['mention_author'] = True
|
args['mention_author'] = True
|
||||||
|
|
||||||
await message_source.source_platform_object.channel.send(**args)
|
await source.channel.send(**args)
|
||||||
|
|
||||||
|
async def _reply_synthetic(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
msg_to_send: str,
|
||||||
|
files: list,
|
||||||
|
) -> None:
|
||||||
|
"""Deliver a reply for a button-click-resumed (synthetic) event.
|
||||||
|
|
||||||
|
We don't have an inbound discord.Message to anchor to; instead
|
||||||
|
look up the channel cached in ``_pending_forms[session_key +
|
||||||
|
'__last_channel']`` from the most recent button click.
|
||||||
|
"""
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
# _handle_form_chunk uses channel_id alone as the session
|
||||||
|
# scope, and launcher_id was set to channel_id when
|
||||||
|
# synthesizing the event.
|
||||||
|
session_key = f'c:{message_source.group.id}'
|
||||||
|
else:
|
||||||
|
session_key = f'p:{message_source.sender.id}'
|
||||||
|
|
||||||
|
cached = self._pending_forms.get(session_key + '__last_channel') or {}
|
||||||
|
channel = cached.get('channel')
|
||||||
|
if channel is None:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Discord: synthetic reply has no cached channel for '
|
||||||
|
f'{session_key}; dropping content (len={len(msg_to_send)})'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
args: dict[str, typing.Any] = {'content': msg_to_send}
|
||||||
|
if files:
|
||||||
|
args['files'] = files
|
||||||
|
try:
|
||||||
|
await channel.send(**args)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# Discord allows 5 edits per 5 seconds per message. We throttle
|
||||||
|
# to one edit per 8 runner-chunks (runner already yields every 8
|
||||||
|
# text_chunks internally), which stays comfortably within limits.
|
||||||
|
_STREAM_EDIT_INTERVAL = 8
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
|
"""Set up a stream context for progressive editing.
|
||||||
|
|
||||||
|
The first non-empty reply_message_chunk will send the initial
|
||||||
|
message; subsequent chunks edit it in place.
|
||||||
|
"""
|
||||||
|
source = event.source_platform_object
|
||||||
|
if not isinstance(source, discord.Message):
|
||||||
|
return False
|
||||||
|
self._stream_buffer[message_id] = {
|
||||||
|
'channel': source.channel,
|
||||||
|
'sent_message': None, # discord.Message set on first send
|
||||||
|
'last_content': '',
|
||||||
|
'chunk_count': 0,
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message: typing.Any,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
):
|
||||||
|
msg_id = (
|
||||||
|
bot_message.get('resp_message_id')
|
||||||
|
if isinstance(bot_message, dict)
|
||||||
|
else getattr(bot_message, 'resp_message_id', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)]
|
||||||
|
chunk_text = '\n\n'.join(t for t in text_parts if t)
|
||||||
|
|
||||||
|
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
||||||
|
|
||||||
|
ctx = self._stream_buffer.get(msg_id) if msg_id else None
|
||||||
|
|
||||||
|
# If the stream ctx was not set up (create_message_card wasn't
|
||||||
|
# called, e.g. synthetic event), or the final chunk carries a
|
||||||
|
# form, skip progressive editing entirely.
|
||||||
|
if ctx is None or form_data:
|
||||||
|
try:
|
||||||
|
if form_data and is_final:
|
||||||
|
await self._handle_form_chunk(message_source, form_data)
|
||||||
|
elif is_final and chunk_text:
|
||||||
|
await self.reply_message(
|
||||||
|
message_source,
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text=chunk_text)]),
|
||||||
|
quote_origin,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._stream_buffer.pop(msg_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Progressive streaming path: send first chunk, edit subsequent.
|
||||||
|
ctx['chunk_count'] += 1
|
||||||
|
|
||||||
|
# Runner yields the full accumulated text on each chunk, so we
|
||||||
|
# always replace (not append).
|
||||||
|
if chunk_text:
|
||||||
|
ctx['last_content'] = chunk_text
|
||||||
|
|
||||||
|
sent = ctx['sent_message']
|
||||||
|
|
||||||
|
if sent is None:
|
||||||
|
# First non-empty chunk — send the initial message.
|
||||||
|
if not ctx['last_content']:
|
||||||
|
return # No content yet, wait for next chunk.
|
||||||
|
try:
|
||||||
|
sent = await ctx['channel'].send(ctx['last_content'])
|
||||||
|
ctx['sent_message'] = sent
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.error(f'Discord stream send failed: {traceback.format_exc()}')
|
||||||
|
self._stream_buffer.pop(msg_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_final:
|
||||||
|
# Final chunk — edit to the full content, then clean up.
|
||||||
|
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
||||||
|
try:
|
||||||
|
await sent.edit(content=ctx['last_content'][:2000])
|
||||||
|
except Exception:
|
||||||
|
pass # Best-effort
|
||||||
|
self._stream_buffer.pop(msg_id, None)
|
||||||
|
elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0:
|
||||||
|
# Intermediate edit — throttle to avoid rate limits.
|
||||||
|
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
||||||
|
try:
|
||||||
|
await sent.edit(content=ctx['last_content'][:2000])
|
||||||
|
except Exception:
|
||||||
|
pass # Rate-limited or deleted — ignore.
|
||||||
|
|
||||||
|
async def _handle_form_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Render a Dify form pause as a Discord embed + button View.
|
||||||
|
|
||||||
|
Mirrors the QQ / Telegram / Lark form path: the button's click
|
||||||
|
callback synthesizes a ``_dify_form_action`` query so the runner's
|
||||||
|
``_merge_pending_form_action`` resumes the workflow.
|
||||||
|
"""
|
||||||
|
source = message_source.source_platform_object
|
||||||
|
|
||||||
|
actions = form_data.get('actions') or []
|
||||||
|
if not actions:
|
||||||
|
# Nothing clickable — fall back to plain text.
|
||||||
|
if source is not None:
|
||||||
|
await self.reply_message(
|
||||||
|
message_source,
|
||||||
|
platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=str(form_data.get('node_title') or ''))]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
node_title = str(form_data.get('node_title') or 'Confirmation needed')
|
||||||
|
form_content = str(form_data.get('form_content') or '').strip()
|
||||||
|
|
||||||
|
# Two paths:
|
||||||
|
# (a) Real message — extract channel from source.
|
||||||
|
# (b) Synthetic event (button-click resume) — no
|
||||||
|
# source_platform_object; recover the channel we cached
|
||||||
|
# when the user clicked.
|
||||||
|
if isinstance(source, discord.Message):
|
||||||
|
channel = source.channel
|
||||||
|
guild_id = str(source.guild.id) if source.guild else ''
|
||||||
|
sender_id = str(source.author.id)
|
||||||
|
channel_id = str(source.channel.id)
|
||||||
|
session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}'
|
||||||
|
else:
|
||||||
|
# Synthetic event — resolve session_key from event shape,
|
||||||
|
# then look up the cached channel from the click.
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
# launcher_id was set to channel_id when we synthesized.
|
||||||
|
channel_id = str(message_source.group.id)
|
||||||
|
session_key = f'c:{channel_id}'
|
||||||
|
else:
|
||||||
|
session_key = f'p:{message_source.sender.id}'
|
||||||
|
channel_id = ''
|
||||||
|
|
||||||
|
cached = self._pending_forms.get(session_key + '__last_channel')
|
||||||
|
channel = cached.get('channel') if cached else None
|
||||||
|
guild_id = (cached or {}).get('guild_id', '')
|
||||||
|
sender_id = str(message_source.sender.id) if message_source.sender else ''
|
||||||
|
if channel is None:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Discord: synthetic form chunk has no cached channel for '
|
||||||
|
f'{session_key}; cannot render form buttons'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
body_parts: list[str] = []
|
||||||
|
if form_content:
|
||||||
|
body_parts.append(form_content)
|
||||||
|
embed_body = '\n\n'.join(body_parts)
|
||||||
|
# Discord embed.description has a 4096 char limit — defensive trim.
|
||||||
|
if len(embed_body) > 4000:
|
||||||
|
embed_body = embed_body[:3990] + '\n\n…(truncated)'
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=node_title[:256],
|
||||||
|
description=embed_body,
|
||||||
|
color=discord.Color.blurple(),
|
||||||
|
)
|
||||||
|
|
||||||
|
view = DiscordFormView(
|
||||||
|
adapter=self,
|
||||||
|
session_key=session_key,
|
||||||
|
actions=actions,
|
||||||
|
timeout=1800, # 30 min — matches Dify form_token TTL
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sent_msg = await channel.send(embed=embed, view=view)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pending_forms[session_key] = {
|
||||||
|
'form_data': form_data,
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'sender_id': sender_id,
|
||||||
|
'view_message_id': str(sent_msg.id),
|
||||||
|
'posted_at': time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}')
|
||||||
|
|
||||||
|
async def _on_form_button_click(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
session_key: str,
|
||||||
|
action_id: str,
|
||||||
|
action_title: str,
|
||||||
|
view: DiscordFormView,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a click on a form button — ack, resume the workflow,
|
||||||
|
and disable the View buttons so the choice is visually locked in."""
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
# ACK first (3-second deadline before Discord shows "interaction failed").
|
||||||
|
try:
|
||||||
|
await interaction.response.defer()
|
||||||
|
except discord.HTTPException:
|
||||||
|
# Already responded somehow — proceed regardless.
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending = self._pending_forms.get(session_key)
|
||||||
|
if not pending:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})'
|
||||||
|
)
|
||||||
|
await self._lock_view_message(interaction, view, action_title, stale=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
form_data: dict = pending.get('form_data') or {}
|
||||||
|
guild_id = pending.get('guild_id', '')
|
||||||
|
channel_id = pending.get('channel_id', '')
|
||||||
|
initiator_id = str(pending.get('sender_id', '') or '')
|
||||||
|
actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id
|
||||||
|
if not guild_id and initiator_id and actor_id != initiator_id:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Discord: user {actor_id} cannot act on private form created for {initiator_id}'
|
||||||
|
)
|
||||||
|
await self._lock_view_message(interaction, view, action_title, stale=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pending_forms.pop(session_key, None)
|
||||||
|
|
||||||
|
# Lock the buttons in place: disable everything, mark chosen one.
|
||||||
|
await self._lock_view_message(interaction, view, action_title)
|
||||||
|
|
||||||
|
# In group context the launcher remains the channel so Dify resumes
|
||||||
|
# the original group session. The synthetic sender is still the real
|
||||||
|
# clicker, preserving actor identity for auditing and routing rules.
|
||||||
|
if guild_id:
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = channel_id
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = initiator_id or actor_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}]')])
|
||||||
|
|
||||||
|
# Synthesize a platform event so the pipeline can run the resume
|
||||||
|
# query. source_platform_object=None signals "no inbound discord
|
||||||
|
# message" — reply_message must tolerate this (it falls through
|
||||||
|
# to channel.send via the cached interaction.channel below).
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=actor_id,
|
||||||
|
member_name=interaction.user.display_name if interaction.user else '',
|
||||||
|
permission='MEMBER',
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name=channel_id,
|
||||||
|
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=actor_id,
|
||||||
|
nickname=interaction.user.display_name if interaction.user else '',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=int(time.time()),
|
||||||
|
source_platform_object=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ap is None:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error('Discord: ap not injected; cannot enqueue button-click query')
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = form_data.get('pipeline_uuid') or None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
# Remember the channel so _reply_synthetic and _handle_form_chunk
|
||||||
|
# (synthetic-event path) can find a target. guild_id is needed
|
||||||
|
# to reconstruct the launcher_type on subsequent form pauses.
|
||||||
|
self._pending_forms[session_key + '__last_channel'] = {
|
||||||
|
'channel': interaction.channel,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'posted_at': time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=actor_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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Discord: button-click query enqueued action_id={action_id!r} '
|
||||||
|
f'session={session_key} actor_id={actor_id}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _lock_view_message(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
view: DiscordFormView,
|
||||||
|
chosen_title: str,
|
||||||
|
stale: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Disable all buttons on the form view and annotate the chosen
|
||||||
|
one — mirrors DingTalk/Lark's in-card selection feedback."""
|
||||||
|
try:
|
||||||
|
for child in view.children:
|
||||||
|
if not isinstance(child, discord_ui.Button):
|
||||||
|
continue
|
||||||
|
child.disabled = True
|
||||||
|
if not stale and child.label == chosen_title:
|
||||||
|
child.style = discord.ButtonStyle.success
|
||||||
|
if not (child.label or '').startswith('✓ '):
|
||||||
|
child.label = f'✓ {child.label}'
|
||||||
|
view.stop()
|
||||||
|
if interaction.message is not None:
|
||||||
|
await interaction.message.edit(view=view)
|
||||||
|
except Exception:
|
||||||
|
if self.ap is not None:
|
||||||
|
self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}')
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,13 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
|||||||
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 (
|
||||||
|
QQ_SELECT_ACTION_PREFIX,
|
||||||
|
QQOfficialClient,
|
||||||
|
build_keyboard_from_form,
|
||||||
|
build_keyboard_from_select_field,
|
||||||
|
resolve_select_button_action,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -191,6 +197,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
enable_webhook: bool = False
|
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)
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
@@ -216,6 +223,31 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
self._stream_ctx_ts: dict[str, float] = {}
|
self._stream_ctx_ts: dict[str, float] = {}
|
||||||
self._fallback_text: dict[str, str] = {}
|
self._fallback_text: dict[str, str] = {}
|
||||||
self._fallback_text_ts: dict[str, float] = {}
|
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,
|
||||||
@@ -227,6 +259,13 @@ 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 和 target_id
|
||||||
@@ -376,6 +415,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||||
|
|
||||||
async def on_event(event_type: str, event_data: dict):
|
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 等系统事件
|
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||||
message_event_types = {
|
message_event_types = {
|
||||||
'C2C_MESSAGE_CREATE',
|
'C2C_MESSAGE_CREATE',
|
||||||
@@ -437,12 +479,36 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return self.config.get('enable-stream-reply', False)
|
return self.config.get('enable-stream-reply', False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_form_placeholder_chunk(text: str) -> bool:
|
||||||
|
"""Return True for invisible placeholder chunks used to carry forms."""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||||
|
# Some Windows consoles/logs display the zero-width placeholder as
|
||||||
|
# mojibake. Treat those variants as the same non-user-facing marker.
|
||||||
|
return cleaned in {'', '鈥?', '​'}
|
||||||
|
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
source = event.source_platform_object
|
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
|
# Streaming API only supports C2C private chat
|
||||||
if source.t != 'C2C_MESSAGE_CREATE':
|
if source.t != 'C2C_MESSAGE_CREATE':
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# The stream endpoint still consumes msg_seq for this inbound msg_id.
|
||||||
|
# Keep the passive-reply counter in sync so a follow-up form card uses
|
||||||
|
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
|
||||||
|
if source.d_id:
|
||||||
|
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
'user_openid': source.user_openid,
|
'user_openid': source.user_openid,
|
||||||
'msg_id': source.d_id,
|
'msg_id': source.d_id,
|
||||||
@@ -469,12 +535,38 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
):
|
):
|
||||||
# Periodically clean up stale stream contexts
|
# Periodically clean up stale stream contexts
|
||||||
await self._cleanup_stale_streams()
|
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 的文本)
|
# 提取纯文本内容(当前 chunk 的文本)
|
||||||
text_parts = []
|
text_parts = []
|
||||||
for msg in message:
|
for msg in message:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
text_parts.append(msg.text)
|
text_parts.append(msg.text)
|
||||||
chunk_text = '\n\n'.join(text_parts)
|
chunk_text = '\n\n'.join(text_parts)
|
||||||
|
if self._is_form_placeholder_chunk(chunk_text):
|
||||||
|
await self.logger.debug('QQ Official: skipped invisible form placeholder chunk')
|
||||||
|
return
|
||||||
|
|
||||||
message_id = (
|
message_id = (
|
||||||
bot_message.get('resp_message_id')
|
bot_message.get('resp_message_id')
|
||||||
@@ -484,7 +576,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
if not message_id or message_id not in self._stream_ctx:
|
if not message_id or message_id not in self._stream_ctx:
|
||||||
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
||||||
if chunk_text:
|
if chunk_text:
|
||||||
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
# Chunks carry the latest full snapshot, not a text delta.
|
||||||
|
self._fallback_text[message_id] = chunk_text
|
||||||
self._fallback_text_ts[message_id] = time.time()
|
self._fallback_text_ts[message_id] = time.time()
|
||||||
if is_final:
|
if is_final:
|
||||||
full_text = self._fallback_text.pop(message_id, '')
|
full_text = self._fallback_text.pop(message_id, '')
|
||||||
@@ -497,7 +590,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
|
|
||||||
# 累积文本
|
# 累积文本
|
||||||
if chunk_text:
|
if chunk_text:
|
||||||
ctx['accumulated_text'] += chunk_text
|
ctx['accumulated_text'] = chunk_text
|
||||||
|
|
||||||
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||||
if not ctx['session_started']:
|
if not ctx['session_started']:
|
||||||
@@ -557,3 +650,489 @@ 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 ''
|
||||||
|
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
||||||
|
parts = [f'### {node_title}']
|
||||||
|
plain_parts = [node_title]
|
||||||
|
if form_content.strip():
|
||||||
|
parts.append(form_content.strip())
|
||||||
|
plain_parts.append(form_content.strip())
|
||||||
|
markdown_content = '\n\n'.join(parts)
|
||||||
|
plain_content = '\n\n'.join(plain_parts)
|
||||||
|
|
||||||
|
keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
|
||||||
|
is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows')
|
||||||
|
if is_text_field_step:
|
||||||
|
keyboard = None
|
||||||
|
if keyboard is None and not is_text_field_step:
|
||||||
|
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
|
||||||
|
if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step:
|
||||||
|
# No actions to render — fall back to plain text.
|
||||||
|
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_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=plain_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.get(session_key)
|
||||||
|
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 []
|
||||||
|
select_choice = resolve_select_button_action(form_data, action_id)
|
||||||
|
if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None:
|
||||||
|
await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}')
|
||||||
|
return
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
if select_choice is None:
|
||||||
|
matched = next(
|
||||||
|
(a for a in actions if str(a.get('id', '')) == action_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if matched is None:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._pending_forms.pop(session_key, None)
|
||||||
|
action_title = select_choice[1] if select_choice else matched.get('title') or action_id
|
||||||
|
|
||||||
|
initiator_id = str(pending.get('sender_id') or '')
|
||||||
|
actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id)
|
||||||
|
|
||||||
|
# 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 = target_id
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'form_token': form_data.get('form_token', ''),
|
||||||
|
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||||
|
'action_id': '' if select_choice else action_id,
|
||||||
|
'action_title': action_title,
|
||||||
|
'node_title': form_data.get('node_title', ''),
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {'select': select_choice[1]} if select_choice else {},
|
||||||
|
}
|
||||||
|
if select_choice:
|
||||||
|
form_action_data['_current_input_field'] = select_choice[0]
|
||||||
|
form_action_data['_input_progress'] = True
|
||||||
|
|
||||||
|
event_label = 'Form Select' if select_choice else 'Form Action'
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')])
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=actor_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=actor_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 = form_data.get('pipeline_uuid') or None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = pipeline_uuid or 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=actor_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} '
|
||||||
|
f'session={session_key} actor_id={actor_id}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ spec:
|
|||||||
type: array[string]
|
type: array[string]
|
||||||
required: false
|
required: false
|
||||||
default: []
|
default: []
|
||||||
|
- name: one-click-bind
|
||||||
|
label:
|
||||||
|
en_US: One-Click QR Binding
|
||||||
|
zh_Hans: 一键扫码绑定
|
||||||
|
zh_Hant: 一鍵掃碼綁定
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank)
|
||||||
|
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空)
|
||||||
|
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空)
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: qqofficial
|
||||||
|
required: false
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -52,8 +64,12 @@ 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
|
- name: enable-webhook
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
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 ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
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 time
|
||||||
|
import uuid
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
from langbot.pkg.utils import httpclient
|
from langbot.pkg.utils import httpclient
|
||||||
@@ -20,6 +22,61 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
|||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
||||||
|
"""Return the active select field and its option values."""
|
||||||
|
field_name = str(form_data.get('_current_input_field') or '').strip()
|
||||||
|
if not field_name:
|
||||||
|
return '', []
|
||||||
|
field = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in form_data.get('input_defs') or []
|
||||||
|
if str(item.get('output_variable_name') or '').strip() == field_name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not field or str(field.get('type') or '').strip().lower() != 'select':
|
||||||
|
return '', []
|
||||||
|
|
||||||
|
source = field.get('option_source') or {}
|
||||||
|
source_value = source.get('value') if isinstance(source, dict) else None
|
||||||
|
if isinstance(source_value, list):
|
||||||
|
return field_name, [str(item) for item in source_value]
|
||||||
|
if isinstance(source_value, str):
|
||||||
|
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
|
||||||
|
|
||||||
|
options = field.get('options')
|
||||||
|
if not isinstance(options, list):
|
||||||
|
return field_name, []
|
||||||
|
values = []
|
||||||
|
for item in options:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
values.append(str(item.get('label') or item.get('value') or ''))
|
||||||
|
else:
|
||||||
|
values.append(str(item))
|
||||||
|
return field_name, [value for value in values if value]
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_form_action_from_callback(data: dict) -> dict | None:
|
||||||
|
"""Translate compact Telegram callback data into a runner form action."""
|
||||||
|
if 'x' not in data:
|
||||||
|
return {
|
||||||
|
'action_id': str(data.get('action_id') or data.get('a') or ''),
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
option_index = int(data['x'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if option_index < 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'action_id': '',
|
||||||
|
'inputs': {'select': {'index': option_index}},
|
||||||
|
'_input_progress': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
||||||
@@ -167,7 +224,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
time=event.message.date.timestamp(),
|
time=event.message.date.timestamp(),
|
||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
elif event.effective_chat.type == 'group' or 'supergroup':
|
elif event.effective_chat.type in ('group', 'supergroup'):
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.effective_chat.id,
|
id=event.effective_chat.id,
|
||||||
@@ -189,6 +246,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()
|
||||||
@@ -204,6 +262,48 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
|
_FORM_ACTION_CACHE_TTL = 30 * 60
|
||||||
|
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
|
||||||
|
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
|
||||||
|
|
||||||
|
def _prune_form_action_titles(self, now: float | None = None) -> None:
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
|
||||||
|
for key in expired:
|
||||||
|
self._form_action_titles.pop(key, None)
|
||||||
|
|
||||||
|
def _cache_form_action_titles(
|
||||||
|
self,
|
||||||
|
mappings: dict[str, str],
|
||||||
|
pipeline_uuid: str = '',
|
||||||
|
now: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
self._prune_form_action_titles(now)
|
||||||
|
group_id = uuid.uuid4().hex
|
||||||
|
expires_at = now + self._FORM_ACTION_CACHE_TTL
|
||||||
|
self._form_action_titles.update(
|
||||||
|
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
|
||||||
|
"""Consume a callback and invalidate every button from the same form."""
|
||||||
|
self._prune_form_action_titles(now)
|
||||||
|
entry = self._form_action_titles.get(callback_data)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
title, pipeline_uuid, _, group_id = entry
|
||||||
|
group_keys = [
|
||||||
|
key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
|
||||||
|
]
|
||||||
|
for key in group_keys:
|
||||||
|
self._form_action_titles.pop(key, None)
|
||||||
|
return title, pipeline_uuid
|
||||||
|
|
||||||
|
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
|
||||||
|
context = self._take_form_action_context(callback_data, now)
|
||||||
|
return context[0] if context else None
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if update.message.from_user.is_bot:
|
if update.message.from_user.is_bot:
|
||||||
@@ -224,6 +324,117 @@ 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 is not in the callback payload (too large
|
||||||
|
# for Telegram's 64-byte limit). Only w_suffix is sent;
|
||||||
|
# the runner resolves the full run id from _PENDING_FORMS.
|
||||||
|
w_suffix = data.get('w', '')
|
||||||
|
session_key = data.get('session_key') or data.get('s', '')
|
||||||
|
callback_action = _telegram_form_action_from_callback(data)
|
||||||
|
action_context = self._take_form_action_context(query.data) if callback_action is not None else None
|
||||||
|
if callback_action is None or action_context is None:
|
||||||
|
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
|
||||||
|
return
|
||||||
|
action_title, pipeline_uuid = action_context
|
||||||
|
# Show selected action feedback by editing the original message
|
||||||
|
try:
|
||||||
|
original_text = query.message.text or ''
|
||||||
|
selected_text = f'{original_text}\n\n✅ {action_title}'
|
||||||
|
await query.edit_message_text(text=selected_text, reply_markup=None)
|
||||||
|
except Exception:
|
||||||
|
# If edit fails (e.g. message too long), just pass
|
||||||
|
pass
|
||||||
|
|
||||||
|
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 = ''
|
||||||
|
for b in self.ap.platform_mgr.bots:
|
||||||
|
if b.adapter is self:
|
||||||
|
bot_uuid = b.bot_entity.uuid
|
||||||
|
pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
# workflow_run_id is intentionally omitted; the runner
|
||||||
|
# resolves it from w_suffix via _PENDING_FORMS.
|
||||||
|
'w_suffix': w_suffix,
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
**callback_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action'
|
||||||
|
message_chain = platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=f'[{event_label}: {action_title}]')]
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -314,23 +525,34 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None):
|
||||||
|
if chat_mode != 'group' or stream_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id=chat_id, message_id=stream_id)
|
||||||
|
except telegram.error.TelegramError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_form_placeholder_chunk(text: str) -> bool:
|
||||||
|
"""Return True for invisible placeholder chunks used to carry forms."""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||||
|
return cleaned == ''
|
||||||
|
|
||||||
async def create_message_card(self, message_id, event):
|
async def create_message_card(self, message_id, event):
|
||||||
assert isinstance(event.source_platform_object, Update)
|
assert isinstance(event.source_platform_object, Update)
|
||||||
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
|
effective_message = update.effective_message
|
||||||
message_thread_id = update.message.message_thread_id
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if chat_type == 'private':
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||||
draft_id = int(time.time() * 1000)
|
send_msg = await self.bot.send_message(**args)
|
||||||
self.msg_stream_id[message_id] = ('private', draft_id)
|
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
|
||||||
|
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
|
||||||
await self.bot.send_message_draft(**args)
|
|
||||||
else:
|
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
|
||||||
send_msg = await self.bot.send_message(**args)
|
|
||||||
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -347,12 +569,15 @@ 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]
|
stream_state = self.msg_stream_id[message_id]
|
||||||
|
chat_mode, stream_id = stream_state[:2]
|
||||||
|
has_visible_content = len(stream_state) > 2 and stream_state[2]
|
||||||
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,17 +586,68 @@ 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:
|
||||||
|
if not has_visible_content:
|
||||||
|
await self._send_form_action_buttons(message_source, form_data, edit_message_id=stream_id)
|
||||||
|
else:
|
||||||
|
await self._send_form_action_buttons(message_source, form_data)
|
||||||
|
self.msg_stream_id.pop(message_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._is_form_placeholder_chunk(content):
|
||||||
|
if is_final and bot_message.tool_calls is None and not has_visible_content:
|
||||||
|
await self._delete_group_stream_message(chat_mode, chat_id, stream_id)
|
||||||
|
self.msg_stream_id.pop(message_id, None)
|
||||||
|
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)
|
||||||
|
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
||||||
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 stream_id is None:
|
||||||
|
args = self._build_message_args(chat_id, content, message_thread_id)
|
||||||
|
try:
|
||||||
|
send_msg = await self.bot.send_message(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = self._process_markdown(content[:4000] + '\n\n鈥?(truncated)')
|
||||||
|
send_msg = await self.bot.send_message(**args)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True)
|
||||||
|
if is_final and bot_message.tool_calls is None:
|
||||||
|
self.msg_stream_id.pop(message_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
args = {
|
args = {
|
||||||
'message_id': stream_id,
|
'message_id': stream_id,
|
||||||
'chat_id': chat_id,
|
'chat_id': chat_id,
|
||||||
@@ -379,11 +655,137 @@ 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
|
||||||
|
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
||||||
|
|
||||||
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,
|
||||||
|
edit_message_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Send inline keyboard buttons for Dify form fields or 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}'
|
||||||
|
|
||||||
|
current_field = str(form_data.get('_current_input_field') or '').strip()
|
||||||
|
is_field_step = bool(current_field) and not form_data.get('_action_select_only')
|
||||||
|
select_field, select_options = _telegram_select_field_options(form_data)
|
||||||
|
is_select_field = bool(select_field and select_options)
|
||||||
|
if is_select_field:
|
||||||
|
choices = [(option, {'x': idx}) for idx, option in enumerate(select_options)]
|
||||||
|
elif is_field_step:
|
||||||
|
choices = []
|
||||||
|
else:
|
||||||
|
choices = [(action.get('title', action.get('id', '')), {'a': action.get('id', '')}) for action in actions]
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
pending_title_mappings: dict[str, str] = {}
|
||||||
|
oversized = False
|
||||||
|
buttons_per_row = 2 if is_select_field else 1
|
||||||
|
current_row = []
|
||||||
|
for title, choice_data in choices:
|
||||||
|
callback_payload = {'f': 1, **choice_data, 's': session_key}
|
||||||
|
if w_suffix:
|
||||||
|
callback_payload['w'] = w_suffix
|
||||||
|
callback_data = json.dumps(callback_payload, separators=(',', ':'))
|
||||||
|
if len(callback_data.encode('utf-8')) > 64:
|
||||||
|
oversized = True
|
||||||
|
break
|
||||||
|
pending_title_mappings[callback_data] = str(title)
|
||||||
|
current_row.append(InlineKeyboardButton(str(title), callback_data=callback_data))
|
||||||
|
if len(current_row) == buttons_per_row:
|
||||||
|
keyboard.append(current_row)
|
||||||
|
current_row = []
|
||||||
|
if current_row and not oversized:
|
||||||
|
keyboard.append(current_row)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
heading = f'[{node_title}]'
|
||||||
|
text_lines = [heading]
|
||||||
|
if form_content:
|
||||||
|
text_lines.append(form_content)
|
||||||
|
|
||||||
|
if oversized:
|
||||||
|
# callback_data exceeds Telegram's 64-byte limit — fall back to
|
||||||
|
# a plain-text numbered list so the user can reply by number.
|
||||||
|
for idx, (title, _) in enumerate(choices, start=1):
|
||||||
|
text_lines.append(f' {idx}. {title}')
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
}
|
||||||
|
elif keyboard:
|
||||||
|
self._cache_form_action_titles(
|
||||||
|
pending_title_mappings,
|
||||||
|
str(form_data.get('pipeline_uuid') or ''),
|
||||||
|
)
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
'reply_markup': reply_markup,
|
||||||
|
}
|
||||||
|
elif is_field_step:
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
# Telegram privacy-mode bots receive replies to ForceReply
|
||||||
|
# prompts even when they cannot read ordinary group messages.
|
||||||
|
'reply_markup': ForceReply(
|
||||||
|
selective=False,
|
||||||
|
input_field_placeholder=current_field,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
}
|
||||||
|
|
||||||
|
if message_thread_id:
|
||||||
|
args['message_thread_id'] = message_thread_id
|
||||||
|
|
||||||
|
if edit_message_id is not None:
|
||||||
|
edit_args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'message_id': edit_message_id,
|
||||||
|
'text': args['text'],
|
||||||
|
}
|
||||||
|
edit_args['reply_markup'] = args.get('reply_markup')
|
||||||
|
try:
|
||||||
|
await self.bot.edit_message_text(**edit_args)
|
||||||
|
return
|
||||||
|
except telegram.error.TelegramError:
|
||||||
|
await self._delete_group_stream_message('group', chat_id, edit_message_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
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ 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 ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
from langbot.libs.wecom_ai_bot_api.api import (
|
||||||
|
WecomBotClient,
|
||||||
|
extract_template_card_action,
|
||||||
|
extract_template_card_event_payload,
|
||||||
|
extract_template_card_selections,
|
||||||
|
parse_select_button_action,
|
||||||
|
)
|
||||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +302,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners: dict = {}
|
listeners: dict = {}
|
||||||
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
||||||
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
_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)
|
||||||
@@ -336,6 +343,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
_stream_to_monitoring_msg={},
|
_stream_to_monitoring_msg={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Hand the client a `source` block so every interactive
|
||||||
|
# template_card it emits carries the LangBot logo + name at the
|
||||||
|
# top — the WeCom analogue of DingTalk's Avatar header.
|
||||||
|
# Always on; icon_url accepts plain HTTPS URLs (no upload needed).
|
||||||
|
if hasattr(self.bot, 'set_card_source'):
|
||||||
|
self.bot.set_card_source(
|
||||||
|
{
|
||||||
|
'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png',
|
||||||
|
'desc': 'LangBot',
|
||||||
|
'desc_color': 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -345,15 +371,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,
|
||||||
@@ -364,9 +412,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:
|
||||||
@@ -385,6 +480,142 @@ 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:
|
||||||
|
previous = buffers.get(buf_key, '')
|
||||||
|
if previous and content.startswith(previous):
|
||||||
|
buffers[buf_key] = content
|
||||||
|
elif previous and previous.endswith(content):
|
||||||
|
buffers[buf_key] = previous
|
||||||
|
else:
|
||||||
|
buffers[buf_key] = previous + content
|
||||||
|
|
||||||
|
if not is_final:
|
||||||
|
return {'stream': True, 'synthetic': True, 'buffered': True}
|
||||||
|
|
||||||
|
final_content = buffers.pop(buf_key, '')
|
||||||
|
if content:
|
||||||
|
if final_content and content.startswith(final_content):
|
||||||
|
final_content = content
|
||||||
|
elif final_content and final_content.endswith(content):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
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_human_input_template_card_payload
|
||||||
|
import secrets as _secrets
|
||||||
|
|
||||||
|
task_id = f'dify-{_secrets.token_hex(12)}'
|
||||||
|
source = getattr(self.bot, 'card_source', None)
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
form_data,
|
||||||
|
task_id,
|
||||||
|
source=source,
|
||||||
|
select_as_buttons=not self.config.get('enable-webhook', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
@@ -531,3 +762,191 @@ 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 []
|
||||||
|
tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {}
|
||||||
|
_, _, card_type = extract_template_card_action(tce)
|
||||||
|
selections = extract_template_card_selections(tce, form)
|
||||||
|
if not selections:
|
||||||
|
selections = parse_select_button_action(action_id, form)
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}'
|
||||||
|
)
|
||||||
|
if card_type == 'multiple_interaction' and not selections:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
is_select_submit = card_type == 'multiple_interaction' or bool(selections)
|
||||||
|
|
||||||
|
clean_action_id = '' if is_select_submit else (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
|
||||||
|
|
||||||
|
inputs = dict(form.get('inputs') or {})
|
||||||
|
inputs.update(selections)
|
||||||
|
|
||||||
|
def _missing_fields_after_select() -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
for field in form.get('input_defs') or form.get('all_input_defs') or []:
|
||||||
|
field_name = str(field.get('output_variable_name') or '').strip()
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
if inputs.get(field_name) in (None, '', []):
|
||||||
|
missing.append(field_name)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
input_progress = False
|
||||||
|
if is_select_submit:
|
||||||
|
missing_fields = _missing_fields_after_select()
|
||||||
|
if not missing_fields and len(actions) == 1:
|
||||||
|
action = actions[0]
|
||||||
|
clean_action_id = str(action.get('id') or '').strip()
|
||||||
|
action_title = action.get('title') or clean_action_id
|
||||||
|
elif not missing_fields and len(actions) > 1:
|
||||||
|
if not self.config.get('enable-webhook', False):
|
||||||
|
action_form_data = {
|
||||||
|
'form_content': form.get('raw_form_content') or form.get('form_content') or '',
|
||||||
|
'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '',
|
||||||
|
'input_defs': [],
|
||||||
|
'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [],
|
||||||
|
'inputs': inputs,
|
||||||
|
'actions': actions,
|
||||||
|
'node_title': form.get('node_title', ''),
|
||||||
|
'workflow_run_id': form.get('workflow_run_id', ''),
|
||||||
|
'form_token': form.get('form_token', ''),
|
||||||
|
'pipeline_uuid': form.get('pipeline_uuid', ''),
|
||||||
|
'_action_select_only': True,
|
||||||
|
}
|
||||||
|
target_chat_id = session.chat_id or session.user_id or ''
|
||||||
|
try:
|
||||||
|
payload = self._build_button_interaction_payload_from_form(
|
||||||
|
action_form_data,
|
||||||
|
user_id=session.user_id or '',
|
||||||
|
chat_id=session.chat_id or '',
|
||||||
|
)
|
||||||
|
await self.bot.send_template_card(target_chat_id, payload)
|
||||||
|
await self.logger.info(
|
||||||
|
f'WeComBot: sent action-select button card after select submit '
|
||||||
|
f'task_id={task_id} action_count={len(actions)}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'WeComBot: failed to send action-select button card: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self.logger.warning(
|
||||||
|
'WeComBot webhook mode cannot proactively send action-select button card after select submit'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
input_progress = True
|
||||||
|
action_title = 'Submit'
|
||||||
|
|
||||||
|
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': inputs,
|
||||||
|
}
|
||||||
|
if input_progress:
|
||||||
|
form_action_data['_input_progress'] = True
|
||||||
|
|
||||||
|
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 = form.get('pipeline_uuid') or None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = pipeline_uuid or 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()}')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
import traceback
|
import traceback
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
@@ -18,6 +18,7 @@ from mcp import ClientSession, StdioServerParameters, types as mcp_types
|
|||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
@@ -25,7 +26,7 @@ from ....core import app
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
from ....entity.persistence import mcp as persistence_mcp
|
from ....entity.persistence import mcp as persistence_mcp
|
||||||
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401
|
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||||
|
|
||||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||||
@@ -185,6 +186,16 @@ class MCPSessionStatus(enum.Enum):
|
|||||||
ERROR = 'error'
|
ERROR = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class _TransportReconnect(Exception):
|
||||||
|
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||||
|
process is still alive. Triggers a lightweight transport reconnect that
|
||||||
|
reuses the live process, instead of a full process rebuild.
|
||||||
|
|
||||||
|
Reconnect attempts are NOT counted toward the fatal retry budget, so a
|
||||||
|
long-lived session can survive arbitrarily many transient drops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RuntimeMCPSession:
|
class RuntimeMCPSession:
|
||||||
"""运行时 MCP 会话"""
|
"""运行时 MCP 会话"""
|
||||||
|
|
||||||
@@ -254,6 +265,16 @@ class RuntimeMCPSession:
|
|||||||
self._lifecycle_task = None
|
self._lifecycle_task = None
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
|
# Set transiently when a WS transport drop should NOT stop the managed
|
||||||
|
# process (it will be re-attached on the next initialize()).
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
|
||||||
|
# Log buffer for capturing stderr from Box managed process (maxlen=500 keeps
|
||||||
|
# recent lines without unbounded memory growth)
|
||||||
|
import collections as _collections
|
||||||
|
|
||||||
|
self._log_buffer: _collections.deque = _collections.deque(maxlen=500)
|
||||||
|
self._last_stderr_text: str = ''
|
||||||
|
|
||||||
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
||||||
self.box_config = self._box_stdio_runtime.config
|
self.box_config = self._box_stdio_runtime.config
|
||||||
@@ -315,23 +336,34 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
await self.session.initialize()
|
await self.session.initialize()
|
||||||
|
|
||||||
async def _init_streamable_http_server(self):
|
@asynccontextmanager
|
||||||
transport = await self.exit_stack.enter_async_context(
|
async def _streamable_http_session(self) -> typing.AsyncIterator[ClientSession]:
|
||||||
streamable_http_client(
|
"""Enter a fully initialized Streamable HTTP session as one context.
|
||||||
|
|
||||||
|
Initialization must happen inside the same context manager that owns the
|
||||||
|
MCP transport. The SDK reports request failures by cancelling the host
|
||||||
|
task and raises the real HTTP error from its TaskGroup during context
|
||||||
|
exit. Keeping these nested contexts together guarantees a failed
|
||||||
|
``__aenter__`` unwinds immediately, so callers see the HTTPStatusError
|
||||||
|
instead of a detached CancelledError. It also owns the injected HTTPX
|
||||||
|
client, which the MCP SDK deliberately does not close for callers.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
headers=self.server_config.get('headers', {}),
|
||||||
|
timeout=self.server_config.get('timeout', 10),
|
||||||
|
follow_redirects=True,
|
||||||
|
) as http_client:
|
||||||
|
async with streamable_http_client(
|
||||||
self.server_config['url'],
|
self.server_config['url'],
|
||||||
http_client=httpx.AsyncClient(
|
http_client=http_client,
|
||||||
headers=self.server_config.get('headers', {}),
|
) as transport:
|
||||||
timeout=self.server_config.get('timeout', 10),
|
read, write, _ = transport
|
||||||
follow_redirects=True,
|
async with ClientSession(read, write) as session:
|
||||||
),
|
await session.initialize()
|
||||||
)
|
yield session
|
||||||
)
|
|
||||||
|
|
||||||
read, write, _ = transport
|
async def _init_streamable_http_server(self):
|
||||||
|
self.session = await self.exit_stack.enter_async_context(self._streamable_http_session())
|
||||||
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
|
||||||
|
|
||||||
await self.session.initialize()
|
|
||||||
|
|
||||||
async def _init_remote_server(self):
|
async def _init_remote_server(self):
|
||||||
"""Connect to a remote MCP server, auto-detecting the transport.
|
"""Connect to a remote MCP server, auto-detecting the transport.
|
||||||
@@ -346,9 +378,15 @@ class RuntimeMCPSession:
|
|||||||
await self._init_streamable_http_server()
|
await self._init_streamable_http_server()
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if not self._should_fallback_to_sse(e):
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||||
|
f'({self._describe_exception(e)}); not falling back to SSE'
|
||||||
|
)
|
||||||
|
raise
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
f'MCP server {self.server_name}: Streamable HTTP initialize failed with a compatible HTTP status '
|
||||||
f'({self._describe_exception(e)}), falling back to SSE'
|
f'({self._describe_exception(e)}), falling back to legacy SSE'
|
||||||
)
|
)
|
||||||
|
|
||||||
# The Streamable HTTP attempt may have partially entered the transport /
|
# The Streamable HTTP attempt may have partially entered the transport /
|
||||||
@@ -399,11 +437,39 @@ class RuntimeMCPSession:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
for task in done:
|
for task in done:
|
||||||
if task is monitor_task and not self._shutdown_event.is_set():
|
if task is monitor_task and not self._shutdown_event.is_set():
|
||||||
|
# The monitor completed. This is EITHER the managed
|
||||||
|
# process actually exiting OR just the WS transport
|
||||||
|
# dropping while the process stays alive in the Box
|
||||||
|
# runtime. Re-check the real process state so a
|
||||||
|
# transient transport drop reconnects (reusing the live
|
||||||
|
# process) instead of tearing the process down and
|
||||||
|
# running a full rebuild+backoff cycle.
|
||||||
|
process_still_running = False
|
||||||
|
try:
|
||||||
|
process_still_running = await self._box_stdio_runtime._managed_process_is_running()
|
||||||
|
except Exception:
|
||||||
|
process_still_running = False
|
||||||
|
if process_still_running:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: transport dropped but '
|
||||||
|
f'managed process is still running; reconnecting transport'
|
||||||
|
)
|
||||||
|
self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
|
# Preserve the live process across the finally-block
|
||||||
|
# cleanup: only the WS transport should be torn down.
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
raise _TransportReconnect('Box managed process transport dropped; reconnecting')
|
||||||
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
||||||
raise Exception('Box managed process exited unexpectedly')
|
raise Exception('Box managed process exited unexpectedly')
|
||||||
else:
|
else:
|
||||||
await self._shutdown_event.wait()
|
await self._shutdown_event.wait()
|
||||||
|
|
||||||
|
except _ColdStartRetry:
|
||||||
|
# Cold-start in progress: set the preserve flag BEFORE the finally
|
||||||
|
# block runs so it does not stop the live managed process. The outer
|
||||||
|
# _lifecycle_loop_with_retry will reuse it on the next attempt.
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
self.error_message = str(e)
|
||||||
@@ -424,14 +490,55 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||||
finally:
|
finally:
|
||||||
await self._cleanup_box_stdio_session()
|
# On a transport-only reconnect the managed process is healthy
|
||||||
|
# and will be re-attached on the next initialize(); do NOT stop
|
||||||
|
# it. Any other exit path fully tears the session down.
|
||||||
|
if getattr(self, '_preserve_managed_process', False):
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
else:
|
||||||
|
await self._cleanup_box_stdio_session()
|
||||||
|
|
||||||
async def _lifecycle_loop_with_retry(self):
|
async def _lifecycle_loop_with_retry(self):
|
||||||
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
attempt = 0
|
||||||
|
while attempt <= self._MAX_RETRIES:
|
||||||
try:
|
try:
|
||||||
await self._lifecycle_loop()
|
await self._lifecycle_loop()
|
||||||
return # Normal shutdown, don't retry
|
return # Normal shutdown, don't retry
|
||||||
|
except _TransportReconnect as e:
|
||||||
|
# Transient WS transport drop while the managed process is still
|
||||||
|
# alive. Reconnect promptly WITHOUT consuming the fatal retry
|
||||||
|
# budget and WITHOUT stopping the process — initialize() will
|
||||||
|
# re-attach to the live process. This is what lets a long-lived
|
||||||
|
# stdio MCP survive repeated brief event-loop stalls / pings.
|
||||||
|
if self._shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})'
|
||||||
|
)
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
except _ColdStartRetry as e:
|
||||||
|
# The managed process is alive but still cold-starting (e.g.
|
||||||
|
# `npx -y <pkg>` is still installing) and cannot yet answer the
|
||||||
|
# handshake. Reuse the live process and retry the attach WITHOUT
|
||||||
|
# consuming the fatal retry budget or stopping the process, so a
|
||||||
|
# slow cold start is waited out instead of failing. Preserve the
|
||||||
|
# process across the finally-block cleanup.
|
||||||
|
if self._shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})'
|
||||||
|
)
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.retry_count = attempt + 1
|
self.retry_count = attempt + 1
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
@@ -460,6 +567,7 @@ class RuntimeMCPSession:
|
|||||||
self.error_message = None
|
self.error_message = None
|
||||||
self.error_phase = None
|
self.error_phase = None
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _describe_exception(exc: BaseException) -> str:
|
def _describe_exception(exc: BaseException) -> str:
|
||||||
@@ -485,6 +593,36 @@ class RuntimeMCPSession:
|
|||||||
unique = [m for m in leaves if not (m in seen or seen.add(m))]
|
unique = [m for m in leaves if not (m in seen or seen.add(m))]
|
||||||
return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}'
|
return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_exception_leaves(exc: BaseException) -> typing.Iterator[BaseException]:
|
||||||
|
sub = getattr(exc, 'exceptions', None)
|
||||||
|
if sub: # ExceptionGroup / BaseExceptionGroup
|
||||||
|
for child in sub:
|
||||||
|
yield from RuntimeMCPSession._iter_exception_leaves(child)
|
||||||
|
else:
|
||||||
|
yield exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
||||||
|
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
||||||
|
|
||||||
|
Only protocol-compatibility responses trigger fallback. Authentication,
|
||||||
|
authorization, throttling, and server failures must remain visible
|
||||||
|
instead of being retried against a different transport.
|
||||||
|
|
||||||
|
MCP SDK 1.26 translates an HTTP 404 initialize response into a synthetic
|
||||||
|
``McpError(32600, 'Session terminated')`` rather than preserving the
|
||||||
|
HTTPStatusError, so recognize that exact SDK sentinel as 404-compatible.
|
||||||
|
"""
|
||||||
|
fallback_statuses = {400, 404, 405}
|
||||||
|
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||||
|
if isinstance(leaf, httpx.HTTPStatusError):
|
||||||
|
if leaf.response.status_code in fallback_statuses:
|
||||||
|
return True
|
||||||
|
elif isinstance(leaf, McpError) and leaf.error.code == 32600 and leaf.error.message == 'Session terminated':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
_MONITOR_POLL_INTERVAL = 5
|
_MONITOR_POLL_INTERVAL = 5
|
||||||
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
||||||
|
|
||||||
@@ -927,11 +1065,14 @@ class RuntimeMCPSession:
|
|||||||
return self._box_stdio_runtime.uses_box_stdio()
|
return self._box_stdio_runtime.uses_box_stdio()
|
||||||
|
|
||||||
def _build_box_session_id(self) -> str:
|
def _build_box_session_id(self) -> str:
|
||||||
# Transient test sessions get their own isolated Box session so a
|
# Both live servers and transient config-page tests share ONE Box
|
||||||
# failing/short-lived test can never disturb the shared session that
|
# session ('mcp-shared'). A test therefore reuses the already-running
|
||||||
# hosts live, already-connected MCP servers.
|
# container (and, for an existing server, its live managed process)
|
||||||
if self.is_transient:
|
# instead of paying a full per-test session cold-start + dependency
|
||||||
return f'mcp-test-{self.server_uuid}'
|
# bootstrap. Isolation between a test and the live servers is provided
|
||||||
|
# at the *process* level: each server/test has its own process_id and a
|
||||||
|
# test only ever stops its own process_id (see cleanup_session), so it
|
||||||
|
# never disturbs another server's process or the shared session itself.
|
||||||
return 'mcp-shared'
|
return 'mcp-shared'
|
||||||
|
|
||||||
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import threading
|
import threading
|
||||||
from contextlib import suppress
|
from contextlib import suppress, AsyncExitStack
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -57,6 +57,23 @@ class MCPSessionErrorPhase(enum.Enum):
|
|||||||
BOX_UNAVAILABLE = 'box_unavailable'
|
BOX_UNAVAILABLE = 'box_unavailable'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_memory_mb(ap) -> int:
|
||||||
|
"""Read box.default_memory_mb from instance config (env: BOX__DEFAULT_MEMORY_MB).
|
||||||
|
|
||||||
|
Falls back to 1536 MB — a safe floor for Node.js V8 + WASM under nsjail.
|
||||||
|
Operators running memory-constrained hosts can lower this; those with large
|
||||||
|
machines can raise it. Individual MCP servers can still override via their
|
||||||
|
own box.memory_mb setting.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = getattr(getattr(ap, 'instance_config', None), 'data', None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return int(data.get('box', {}).get('default_memory_mb', 1536))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return 1536
|
||||||
|
|
||||||
|
|
||||||
class MCPServerBoxConfig(pydantic.BaseModel):
|
class MCPServerBoxConfig(pydantic.BaseModel):
|
||||||
"""Structured configuration for running an MCP server inside a Box container."""
|
"""Structured configuration for running an MCP server inside a Box container."""
|
||||||
|
|
||||||
@@ -74,6 +91,35 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
|||||||
model_config = pydantic.ConfigDict(extra='ignore')
|
model_config = pydantic.ConfigDict(extra='ignore')
|
||||||
|
|
||||||
|
|
||||||
|
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class _TransferredStack:
|
||||||
|
"""Adapts an already-populated AsyncExitStack into an async context manager
|
||||||
|
so ownership of its resources can be transferred into another exit stack.
|
||||||
|
Entering is a no-op; exiting closes the wrapped stack (and thus the live WS
|
||||||
|
transport + ClientSession) when the owning session shuts down."""
|
||||||
|
|
||||||
|
def __init__(self, stack: AsyncExitStack):
|
||||||
|
self._stack = stack
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
await self._stack.aclose()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _ColdStartRetry(Exception):
|
||||||
|
"""Signal: the managed process is alive but not yet answering the MCP
|
||||||
|
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
|
||||||
|
installing). The outer lifecycle retry treats this like a transient
|
||||||
|
reconnect: it reuses the live process and does not count toward the fatal
|
||||||
|
retry budget, so a slow cold start is waited out rather than failing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BoxStdioSessionRuntime:
|
class BoxStdioSessionRuntime:
|
||||||
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
||||||
|
|
||||||
@@ -113,7 +159,17 @@ class BoxStdioSessionRuntime:
|
|||||||
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
||||||
image=self.config.image,
|
image=self.config.image,
|
||||||
cpus=self.config.cpus,
|
cpus=self.config.cpus,
|
||||||
memory_mb=self.config.memory_mb,
|
# Node.js runtimes (npx/bunx) reserve large virtual address space and
|
||||||
|
# load WebAssembly modules (llhttp) on startup; the default 512 MB
|
||||||
|
# cgroup_mem_max is too small and causes OOM kills (return_code=137).
|
||||||
|
# Auto-bump to 1024 MB when the runner is npx/bunx/pnpm dlx.
|
||||||
|
# Per-server override wins; global default comes from
|
||||||
|
# config.yaml box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB).
|
||||||
|
# Hard floor of 1536 MB: enough for Node.js V8 + WASM without OOM.
|
||||||
|
# Per-server override wins; global default from config.yaml
|
||||||
|
# box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB), hard floor
|
||||||
|
# of 1536 MB so Node.js V8 + WASM never OOM under nsjail.
|
||||||
|
memory_mb=(self.config.memory_mb or _get_default_memory_mb(self.ap)),
|
||||||
pids_limit=self.config.pids_limit,
|
pids_limit=self.config.pids_limit,
|
||||||
persistent=True,
|
persistent=True,
|
||||||
)
|
)
|
||||||
@@ -173,28 +229,55 @@ class BoxStdioSessionRuntime:
|
|||||||
stderr_preview = (result.stderr or '')[:500]
|
stderr_preview = (result.stderr or '')[:500]
|
||||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||||
|
|
||||||
try:
|
# Reuse an already-running managed process instead of rebuilding it.
|
||||||
process_workspace = (
|
# The Box runtime keeps the managed process alive across a transient
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
# WebSocket transport drop, so on a reconnect we only need to re-attach
|
||||||
if host_path
|
# the WS below. Rebuilding here would needlessly stop a healthy process
|
||||||
else workspace
|
# and re-run the (slow, network-touching) dependency bootstrap.
|
||||||
|
if not await self._managed_process_is_running():
|
||||||
|
try:
|
||||||
|
process_workspace = (
|
||||||
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
|
if host_path
|
||||||
|
else workspace
|
||||||
|
)
|
||||||
|
payload = process_workspace.build_process_payload(
|
||||||
|
self.server_config['command'],
|
||||||
|
self.server_config.get('args', []),
|
||||||
|
env=self.server_config.get('env', {}),
|
||||||
|
cwd=process_cwd,
|
||||||
|
)
|
||||||
|
if install_cmd:
|
||||||
|
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||||
|
payload['process_id'] = self.process_id
|
||||||
|
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||||
|
except Exception:
|
||||||
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: reusing live managed process '
|
||||||
|
f'process_id={self.process_id} (transport reconnect)'
|
||||||
)
|
)
|
||||||
payload = process_workspace.build_process_payload(
|
|
||||||
self.server_config['command'],
|
|
||||||
self.server_config.get('args', []),
|
|
||||||
env=self.server_config.get('env', {}),
|
|
||||||
cwd=process_cwd,
|
|
||||||
)
|
|
||||||
if install_cmd:
|
|
||||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
|
||||||
payload['process_id'] = self.process_id
|
|
||||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
|
||||||
except Exception:
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||||
|
|
||||||
|
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
|
||||||
|
# in the same task as the serve loop that follows. websocket_client and
|
||||||
|
# ClientSession use anyio task groups whose cancel scope is bound to the
|
||||||
|
# frame/stack that entered them, so they must live on the owner exit
|
||||||
|
# stack (not a deferred/transferred one) or the streams close the moment
|
||||||
|
# initialize() returns and the next request fails with "Connection
|
||||||
|
# closed".
|
||||||
|
#
|
||||||
|
# A slow (`npx -y <pkg>`) cold start makes this single attempt fail
|
||||||
|
# while the process is still alive — the package is still installing and
|
||||||
|
# cannot answer the handshake. We surface that to the outer retry loop
|
||||||
|
# as a _ColdStartRetry: it must NOT stop the process (it is healthy and
|
||||||
|
# will be reused) and must NOT consume the fatal retry budget. The next
|
||||||
|
# attempt re-attaches to the same live process; once it has finished
|
||||||
|
# cold start the handshake succeeds and stays healthy.
|
||||||
try:
|
try:
|
||||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
|
||||||
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||||
read_stream, write_stream = transport
|
read_stream, write_stream = transport
|
||||||
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||||
@@ -202,12 +285,19 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
|
if not await self._managed_process_has_exited():
|
||||||
|
# Process is alive but not yet serving (cold start) — reconnect.
|
||||||
|
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.owner.session.initialize()
|
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
||||||
|
if not await self._managed_process_has_exited():
|
||||||
|
raise _ColdStartRetry(
|
||||||
|
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def monitor_process_health(self) -> None:
|
async def monitor_process_health(self) -> None:
|
||||||
@@ -234,8 +324,74 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Capture stderr logs from the managed process
|
||||||
|
if isinstance(info, dict):
|
||||||
|
stderr_text = info.get('stderr', '') or info.get('stderr_preview', '')
|
||||||
|
else:
|
||||||
|
stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '')
|
||||||
|
|
||||||
|
if stderr_text and stderr_text != self.owner._last_stderr_text:
|
||||||
|
# Find new lines not in the previous snapshot
|
||||||
|
old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set()
|
||||||
|
new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines]
|
||||||
|
self.owner._last_stderr_text = stderr_text
|
||||||
|
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
for line in new_lines:
|
||||||
|
level = (
|
||||||
|
'error'
|
||||||
|
if any(k in line.upper() for k in ('ERROR', 'CRITICAL'))
|
||||||
|
else 'warning'
|
||||||
|
if 'WARNING' in line.upper()
|
||||||
|
else 'debug'
|
||||||
|
if 'DEBUG' in line.upper()
|
||||||
|
else 'info'
|
||||||
|
)
|
||||||
|
self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line})
|
||||||
|
|
||||||
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def _managed_process_is_running(self) -> bool:
|
||||||
|
"""Return True if this server's managed process exists and is running.
|
||||||
|
|
||||||
|
Used to decide whether initialize() must (re)start the process or can
|
||||||
|
simply re-attach the WebSocket transport to a process the Box runtime
|
||||||
|
kept alive across a transient transport drop.
|
||||||
|
"""
|
||||||
|
from langbot_plugin.box.models import BoxManagedProcessStatus
|
||||||
|
|
||||||
|
workspace = self._build_workspace()
|
||||||
|
try:
|
||||||
|
info = await workspace.get_managed_process(self.process_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
||||||
|
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
|
||||||
|
|
||||||
|
async def _managed_process_has_exited(self) -> bool:
|
||||||
|
"""Return True only if the process is DEFINITIVELY gone (reports EXITED).
|
||||||
|
|
||||||
|
Distinct from ``not _managed_process_is_running()``: a process that has
|
||||||
|
just been spawned may not yet report RUNNING, and a transient query
|
||||||
|
error is not proof of exit. During the cold-start handshake retry we
|
||||||
|
must NOT treat 'not yet running' or 'query failed' as a terminal
|
||||||
|
failure, or we bail out to the outer rebuild path and churn the
|
||||||
|
process (relay then rejects the early re-attach with HTTP 400). Only a
|
||||||
|
successful query that reports EXITED stops the retry loop.
|
||||||
|
"""
|
||||||
|
from langbot_plugin.box.models import BoxManagedProcessStatus
|
||||||
|
|
||||||
|
workspace = self._build_workspace()
|
||||||
|
try:
|
||||||
|
info = await workspace.get_managed_process(self.process_id)
|
||||||
|
except Exception:
|
||||||
|
# Unknown — treat as 'still coming up', not exited.
|
||||||
|
return False
|
||||||
|
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
||||||
|
return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED)
|
||||||
|
|
||||||
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
||||||
source_path = normalize_host_path(host_path)
|
source_path = normalize_host_path(host_path)
|
||||||
if not source_path:
|
if not source_path:
|
||||||
@@ -342,16 +498,20 @@ class BoxStdioSessionRuntime:
|
|||||||
|
|
||||||
workspace = self._build_workspace(host_path=None)
|
workspace = self._build_workspace(host_path=None)
|
||||||
|
|
||||||
# Transient test sessions own their isolated Box session, so tear the
|
# Transient config-page tests now share the same 'mcp-shared' Box
|
||||||
# whole session down rather than leaking it. This cannot affect live
|
# session as live servers, so we must NOT tear the session down here —
|
||||||
# servers because they live in the separate shared session.
|
# that would kill every other MCP server in the container. A test is
|
||||||
|
# isolated at the process level: it ran under its own process_id, so we
|
||||||
|
# stop only that process, exactly like a live server does below. The
|
||||||
|
# shared session and all other servers' live processes are untouched.
|
||||||
|
# (Staged per-test workspace files are still cleaned up.)
|
||||||
if getattr(self.owner, 'is_transient', False):
|
if getattr(self.owner, 'is_transient', False):
|
||||||
try:
|
try:
|
||||||
await workspace.cleanup()
|
await workspace.stop_managed_process(self.process_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'MCP server {self.server_name}: failed to delete transient test session '
|
f'MCP server {self.server_name}: failed to stop transient test process '
|
||||||
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}'
|
f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
|
||||||
)
|
)
|
||||||
await self._cleanup_staged_workspace()
|
await self._cleanup_staged_workspace()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ class VectorDBManager:
|
|||||||
self.vector_db = SeekDBVectorDatabase(self.ap)
|
self.vector_db = SeekDBVectorDatabase(self.ap)
|
||||||
self.ap.logger.info('Initialized SeekDB vector database backend.')
|
self.ap.logger.info('Initialized SeekDB vector database backend.')
|
||||||
|
|
||||||
|
elif vdb_type == 'valkey_search':
|
||||||
|
from .vdbs.valkey_search import ValkeySearchVectorDatabase
|
||||||
|
|
||||||
|
self.vector_db = ValkeySearchVectorDatabase(self.ap)
|
||||||
|
self.ap.logger.info('Initialized Valkey Search vector database backend.')
|
||||||
|
|
||||||
elif vdb_type == 'milvus':
|
elif vdb_type == 'milvus':
|
||||||
from .vdbs.milvus import MilvusVectorDatabase
|
from .vdbs.milvus import MilvusVectorDatabase
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,828 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langbot.pkg.core import app
|
||||||
|
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
|
||||||
|
from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
|
||||||
|
|
||||||
|
try:
|
||||||
|
from glide import (
|
||||||
|
Batch,
|
||||||
|
GlideClient,
|
||||||
|
GlideClientConfiguration,
|
||||||
|
NodeAddress,
|
||||||
|
RequestError,
|
||||||
|
ServerCredentials,
|
||||||
|
ft,
|
||||||
|
VectorField,
|
||||||
|
VectorFieldAttributesHnsw,
|
||||||
|
VectorFieldAttributesFlat,
|
||||||
|
VectorAlgorithm,
|
||||||
|
VectorType,
|
||||||
|
DistanceMetricType,
|
||||||
|
TagField,
|
||||||
|
TextField,
|
||||||
|
FtCreateOptions,
|
||||||
|
DataType,
|
||||||
|
FtSearchOptions,
|
||||||
|
FtSearchLimit,
|
||||||
|
ReturnField,
|
||||||
|
)
|
||||||
|
|
||||||
|
VALKEY_SEARCH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
VALKEY_SEARCH_AVAILABLE = False
|
||||||
|
|
||||||
|
# Default per-request timeout (ms) for the glide client. The glide library
|
||||||
|
# default is 250ms, which is too low for vector KNN (``FT.SEARCH ... =>[KNN]``)
|
||||||
|
# under moderate load or with large indexes and yields spurious TimeoutErrors.
|
||||||
|
# Overridable via the ``vdb.valkey_search.request_timeout`` config option.
|
||||||
|
_DEFAULT_REQUEST_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
# Safety cap on the number of SCAN rounds when purging a collection's keys, so
|
||||||
|
# a cursor-handling bug or pathological keyspace can never spin forever.
|
||||||
|
_MAX_SCAN_ROUNDS = 100000
|
||||||
|
|
||||||
|
|
||||||
|
# Mandatory client name for production observability (CLIENT LIST / dashboards).
|
||||||
|
VALKEY_CLIENT_NAME = 'langbot_vector_client'
|
||||||
|
|
||||||
|
# Fixed, indexed metadata schema. LangBot's RAG layer stores ``file_id`` on
|
||||||
|
# every chunk; it is the only metadata field we promote to a first-class
|
||||||
|
# (filterable) index field. All other metadata is preserved verbatim inside
|
||||||
|
# the ``metadata_json`` field so it survives a round-trip, but is NOT
|
||||||
|
# filterable (the established Milvus / pgvector pragmatism).
|
||||||
|
_INDEXED_TAG_FIELDS = {'file_id'}
|
||||||
|
_SUPPORTED_FILTER_FIELDS = set(_INDEXED_TAG_FIELDS)
|
||||||
|
|
||||||
|
# Hash field names used for stored documents.
|
||||||
|
_FIELD_VECTOR = 'vector'
|
||||||
|
_FIELD_DOCUMENT = 'document'
|
||||||
|
_FIELD_FILE_ID = 'file_id'
|
||||||
|
_FIELD_METADATA = 'metadata_json'
|
||||||
|
_VEC_SCORE_ALIAS = '__vec_score'
|
||||||
|
|
||||||
|
# Valkey Search has no bare "match everything" token for non-vector queries
|
||||||
|
# (a standalone ``*`` is a syntax error). A negated match on a sentinel tag
|
||||||
|
# value that can never exist matches every key, which is the canonical
|
||||||
|
# match-all idiom for FT.SEARCH.
|
||||||
|
_MATCH_ALL = '-@file_id:{__langbot_match_all_sentinel__}'
|
||||||
|
|
||||||
|
# Page size used when enumerating matching keys for deletion. Deletes
|
||||||
|
# paginate through the full result set in batches of this size so that
|
||||||
|
# files/filters matching more than one page of chunks are fully removed
|
||||||
|
# (no silent truncation / orphaned vectors).
|
||||||
|
_DELETE_SCAN_BATCH = 10000
|
||||||
|
|
||||||
|
# Characters Valkey Search's TAG query parser cannot handle even when
|
||||||
|
# backslash-escaped (the brace delimiters and the wildcard). file_id TAG
|
||||||
|
# values are percent-encoded over this set (plus '%' itself, so the encoding
|
||||||
|
# is reversible/unambiguous) before being stored or queried, so an arbitrary
|
||||||
|
# file_id round-trips instead of producing an unparseable query. For normal
|
||||||
|
# UUID/hash file_ids none of these characters occur, so the encoding is a
|
||||||
|
# no-op and the stored value is unchanged. The original file_id is always
|
||||||
|
# preserved verbatim inside ``metadata_json``.
|
||||||
|
_FT_UNSAFE_TAG_CHARS = frozenset('{}*%')
|
||||||
|
|
||||||
|
|
||||||
|
class ValkeySearchVectorDatabase(VectorDatabase):
|
||||||
|
"""Valkey Search (valkey-bundle) vector database adapter for LangBot.
|
||||||
|
|
||||||
|
Backed by the Valkey Search module shipped in ``valkey/valkey-bundle``,
|
||||||
|
accessed through the official ``valkey-glide`` client's native ``ft``
|
||||||
|
(search) command namespace. Documents are stored as Valkey HASH keys
|
||||||
|
under a per-collection prefix and indexed by one ``FT.CREATE`` index per
|
||||||
|
collection.
|
||||||
|
|
||||||
|
Supported search types: ``VECTOR``, ``FULL_TEXT`` and ``HYBRID``.
|
||||||
|
|
||||||
|
Hybrid search semantics (IMPORTANT)
|
||||||
|
-----------------------------------
|
||||||
|
Valkey Search hybrid queries follow a *filter-then-KNN* model: the text /
|
||||||
|
metadata filter pre-selects candidate keys and the KNN stage ranks them by
|
||||||
|
vector distance. This backend does **NOT** implement application-side
|
||||||
|
weighted score fusion. The ``vector_weight`` argument is therefore
|
||||||
|
accepted for interface compatibility but is **not honored** — passing
|
||||||
|
different weights does not change result ordering. A one-time warning is
|
||||||
|
emitted the first time a non-default weight is supplied. App-side score
|
||||||
|
fusion can be layered on later if weighted hybrid ranking is required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_search_types(cls) -> list[SearchType]:
|
||||||
|
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application):
|
||||||
|
if not VALKEY_SEARCH_AVAILABLE:
|
||||||
|
raise ImportError(
|
||||||
|
"valkey-glide is not installed. Install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.ap = ap
|
||||||
|
config = self.ap.instance_config.data['vdb']['valkey_search']
|
||||||
|
|
||||||
|
self._host = config.get('host', 'localhost')
|
||||||
|
self._port = int(config.get('port', 6379))
|
||||||
|
self._db = int(config.get('db', 0))
|
||||||
|
# Auth / TLS are optional (toB / SaaS). Never logged.
|
||||||
|
self._password = config.get('password', '') or None
|
||||||
|
self._username = config.get('username', '') or None
|
||||||
|
self._tls = bool(config.get('tls', False))
|
||||||
|
self._request_timeout = int(config.get('request_timeout', _DEFAULT_REQUEST_TIMEOUT_MS))
|
||||||
|
|
||||||
|
algorithm = str(config.get('index_algorithm', 'HNSW')).upper()
|
||||||
|
self._algorithm = VectorAlgorithm.FLAT if algorithm == 'FLAT' else VectorAlgorithm.HNSW
|
||||||
|
|
||||||
|
metric = str(config.get('distance_metric', 'COSINE')).upper()
|
||||||
|
self._distance_metric = {
|
||||||
|
'COSINE': DistanceMetricType.COSINE,
|
||||||
|
'L2': DistanceMetricType.L2,
|
||||||
|
'IP': DistanceMetricType.IP,
|
||||||
|
}.get(metric, DistanceMetricType.COSINE)
|
||||||
|
|
||||||
|
# Lazily-created client (created on first use so a down Valkey does not
|
||||||
|
# block LangBot boot).
|
||||||
|
self._client: GlideClient | None = None
|
||||||
|
# Serializes lazy client creation so concurrent first-use callers do not
|
||||||
|
# each construct (and leak) a separate GlideClient.
|
||||||
|
self._client_lock = asyncio.Lock()
|
||||||
|
# Index names we have already ensured this process lifetime.
|
||||||
|
self._ensured_indexes: set[str] = set()
|
||||||
|
# Whether we have already warned about the non-honored vector_weight.
|
||||||
|
self._vector_weight_warned = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Client lifecycle
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def _ensure_client(self) -> GlideClient:
|
||||||
|
"""Create the glide client on first use (lazy, non-blocking boot)."""
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
# Double-checked locking: serialize creation so two concurrent
|
||||||
|
# first-use callers don't both build a client and leak one.
|
||||||
|
async with self._client_lock:
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
credentials = None
|
||||||
|
if self._password is not None:
|
||||||
|
# username is optional alongside a password (ACL "user" vs default user).
|
||||||
|
credentials = ServerCredentials(password=self._password, username=self._username)
|
||||||
|
elif self._username is not None:
|
||||||
|
# A username without a password is not a valid credential pair, and silently
|
||||||
|
# connecting unauthenticated to a potentially shared Valkey instance is a
|
||||||
|
# security footgun (e.g. an env var that failed to resolve). Fail closed.
|
||||||
|
raise ValueError(
|
||||||
|
'Valkey Search: a username was configured without a password. '
|
||||||
|
'Set both username and password to use ACL authentication, or remove both.'
|
||||||
|
)
|
||||||
|
|
||||||
|
conf = GlideClientConfiguration(
|
||||||
|
addresses=[NodeAddress(self._host, self._port)],
|
||||||
|
client_name=VALKEY_CLIENT_NAME,
|
||||||
|
database_id=self._db,
|
||||||
|
use_tls=self._tls,
|
||||||
|
lazy_connect=True,
|
||||||
|
credentials=credentials,
|
||||||
|
request_timeout=self._request_timeout,
|
||||||
|
)
|
||||||
|
self._client = await GlideClient.create(conf)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Initialized Valkey Search client to {self._host}:{self._port} (db={self._db}, tls={self._tls})'
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the glide client and reset state.
|
||||||
|
|
||||||
|
Safe to call when no client was created. After ``close`` the next
|
||||||
|
operation transparently re-creates the client (``_ensure_client``
|
||||||
|
guards on ``self._client is None``).
|
||||||
|
"""
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
await self._client.close()
|
||||||
|
except Exception:
|
||||||
|
self.ap.logger.warning('Valkey Search: error while closing client (ignored)')
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
self._ensured_indexes.clear()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Naming helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def _index_name(collection: str) -> str:
|
||||||
|
return f'idx:{collection}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key_prefix(collection: str) -> str:
|
||||||
|
return f'kb:{collection}:'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pack_vector(vec: list[float]) -> bytes:
|
||||||
|
"""Pack a float vector into little-endian float32 bytes.
|
||||||
|
|
||||||
|
Valkey Search stores and queries vectors as FLOAT32 little-endian
|
||||||
|
blobs (per the search query-language spec).
|
||||||
|
"""
|
||||||
|
return struct.pack(f'<{len(vec)}f', *[float(x) for x in vec])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_tag(value: str) -> str:
|
||||||
|
"""Escape characters that are special inside a TAG ``{...}`` clause.
|
||||||
|
|
||||||
|
The backslash is escaped first so it cannot consume a following
|
||||||
|
escape. This neutralises injection-style values (quotes, parens,
|
||||||
|
``|``, ``@``, ``:``, spaces, dashes) so a crafted ``file_id`` cannot
|
||||||
|
break out of the clause.
|
||||||
|
|
||||||
|
Note: Valkey Search's TAG query parser cannot handle a literal brace
|
||||||
|
(``{`` / ``}``) or ``*`` even when backslash-escaped. Callers that pass
|
||||||
|
a ``file_id`` route it through ``_encode_and_escape_tag`` /
|
||||||
|
``_encode_file_id`` first, which percent-encodes exactly those
|
||||||
|
characters, so an arbitrary ``file_id`` round-trips safely. This raw
|
||||||
|
escaper is still correct for all other special characters.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for ch in str(value):
|
||||||
|
if ch in '\\,.<>{}[]"\':;!@#$%^&*()-+=~| ':
|
||||||
|
out.append('\\')
|
||||||
|
out.append(ch)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_file_id(value: str) -> str:
|
||||||
|
"""Make a ``file_id`` safe to use as an FT TAG token AND query value.
|
||||||
|
|
||||||
|
Percent-encodes the characters Valkey Search's TAG parser cannot handle
|
||||||
|
even when backslash-escaped (``{``, ``}``, ``*``) plus ``%`` itself for
|
||||||
|
reversibility. Applied identically at write time (the stored TAG field)
|
||||||
|
and query time (filters / ``delete_by_file_id``) so any value matches
|
||||||
|
itself. For normal UUID/hash ids none of these characters occur, so
|
||||||
|
this is a no-op. The original value is always kept verbatim in
|
||||||
|
``metadata_json``; this encoded form is only ever used for the indexed
|
||||||
|
TAG.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for ch in str(value):
|
||||||
|
if ch in _FT_UNSAFE_TAG_CHARS:
|
||||||
|
out.append('%{:02X}'.format(ord(ch)))
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
def _encode_and_escape_tag(self, value: str) -> str:
|
||||||
|
"""Encode an FT-unsafe ``file_id`` then escape TAG special chars."""
|
||||||
|
return self._escape_tag(self._encode_file_id(value))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Filter mapping (canonical triples -> FT query fragment)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def _triples_to_ft(self, filter: dict[str, Any] | None) -> str:
|
||||||
|
"""Translate a canonical filter dict into an FT filter expression.
|
||||||
|
|
||||||
|
Only indexed fields (``file_id``) are filterable; unsupported fields
|
||||||
|
are dropped with a warning (matching the Milvus / pgvector pattern).
|
||||||
|
Returns an empty string when there is no usable filter.
|
||||||
|
"""
|
||||||
|
triples = normalize_filter(filter)
|
||||||
|
if not triples:
|
||||||
|
return ''
|
||||||
|
triples = strip_unsupported_fields(triples, _SUPPORTED_FILTER_FIELDS)
|
||||||
|
|
||||||
|
fragments: list[str] = []
|
||||||
|
for field, op, value in triples:
|
||||||
|
# All currently-indexed fields are TAG fields; file_id values are
|
||||||
|
# encoded (FT-unsafe chars) then escaped so any value round-trips.
|
||||||
|
if op == '$eq':
|
||||||
|
fragments.append(f'@{field}:{{{self._encode_and_escape_tag(value)}}}')
|
||||||
|
elif op == '$ne':
|
||||||
|
fragments.append(f'-@{field}:{{{self._encode_and_escape_tag(value)}}}')
|
||||||
|
elif op == '$in':
|
||||||
|
joined = '|'.join(self._encode_and_escape_tag(v) for v in value)
|
||||||
|
fragments.append(f'@{field}:{{{joined}}}')
|
||||||
|
elif op == '$nin':
|
||||||
|
joined = '|'.join(self._encode_and_escape_tag(v) for v in value)
|
||||||
|
fragments.append(f'-@{field}:{{{joined}}}')
|
||||||
|
elif op == '$gt':
|
||||||
|
fragments.append(f'@{field}:[({float(value)} +inf]')
|
||||||
|
elif op == '$gte':
|
||||||
|
fragments.append(f'@{field}:[{float(value)} +inf]')
|
||||||
|
elif op == '$lt':
|
||||||
|
fragments.append(f'@{field}:[-inf ({float(value)}]')
|
||||||
|
elif op == '$lte':
|
||||||
|
fragments.append(f'@{field}:[-inf {float(value)}]')
|
||||||
|
else:
|
||||||
|
# normalize_filter() already rejects unknown operators, so this
|
||||||
|
# only triggers if SUPPORTED_OPS grows without this chain being
|
||||||
|
# updated. Fail closed (rather than silently dropping the
|
||||||
|
# condition, which would widen delete_by_filter's match set).
|
||||||
|
raise ValueError(f'Valkey Search: unhandled filter operator {op!r} on field {field!r}')
|
||||||
|
|
||||||
|
return ' '.join(fragments)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_text_clause(text: str) -> str:
|
||||||
|
"""Build a field-scoped full-text clause for the ``document`` field.
|
||||||
|
|
||||||
|
Each whitespace-delimited word becomes a ``@document:<term>`` term and
|
||||||
|
the terms are AND-ed (space separated). FT special characters in each
|
||||||
|
term are escaped. Returns an empty string when *text* has no words.
|
||||||
|
"""
|
||||||
|
words = [w for w in str(text).split() if w]
|
||||||
|
if not words:
|
||||||
|
return ''
|
||||||
|
terms = [f'@{_FIELD_DOCUMENT}:{ValkeySearchVectorDatabase._escape_text(w)}' for w in words]
|
||||||
|
return ' '.join(terms)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_text(text: str) -> str:
|
||||||
|
"""Escape FT full-text special characters in a single term."""
|
||||||
|
out = []
|
||||||
|
for ch in str(text):
|
||||||
|
if ch in '@!{}[]()|-"~*:\\':
|
||||||
|
out.append('\\')
|
||||||
|
out.append(ch)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Index management
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def _ensure_index(self, client: GlideClient, collection: str, dim: int) -> None:
|
||||||
|
index = self._index_name(collection)
|
||||||
|
if index in self._ensured_indexes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ft.info is O(1) and raises RequestError when the index is absent —
|
||||||
|
# cheaper than ft.list (O(n) over all indexes) and it closes the
|
||||||
|
# check-then-create TOCTOU window.
|
||||||
|
try:
|
||||||
|
await ft.info(client, index)
|
||||||
|
self._ensured_indexes.add(index)
|
||||||
|
return
|
||||||
|
except RequestError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._algorithm == VectorAlgorithm.FLAT:
|
||||||
|
vector_attrs = VectorFieldAttributesFlat(
|
||||||
|
dimensions=dim,
|
||||||
|
distance_metric=self._distance_metric,
|
||||||
|
type=VectorType.FLOAT32,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
vector_attrs = VectorFieldAttributesHnsw(
|
||||||
|
dimensions=dim,
|
||||||
|
distance_metric=self._distance_metric,
|
||||||
|
type=VectorType.FLOAT32,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = [
|
||||||
|
VectorField(name=_FIELD_VECTOR, algorithm=self._algorithm, attributes=vector_attrs),
|
||||||
|
TagField(name=_FIELD_FILE_ID),
|
||||||
|
TextField(name=_FIELD_DOCUMENT),
|
||||||
|
]
|
||||||
|
options = FtCreateOptions(data_type=DataType.HASH, prefixes=[self._key_prefix(collection)])
|
||||||
|
await ft.create(client, index, schema, options)
|
||||||
|
self._ensured_indexes.add(index)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"Valkey Search index '{index}' created (dim={dim}, algo={self._algorithm.value}, "
|
||||||
|
f'metric={self._distance_metric.value})'
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode(value: Any) -> str:
|
||||||
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||||
|
return bytes(value).decode('utf-8', errors='replace')
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# VectorDatabase ABC implementation
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def get_or_create_collection(self, collection: str):
|
||||||
|
"""Ensure a client exists.
|
||||||
|
|
||||||
|
The index itself requires the vector dimension, which is only known at
|
||||||
|
first ``add_embeddings`` (same constraint as Qdrant / SeekDB), so this
|
||||||
|
is a best-effort no-op when the index does not yet exist.
|
||||||
|
"""
|
||||||
|
await self._ensure_client()
|
||||||
|
|
||||||
|
async def add_embeddings(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
ids: list[str],
|
||||||
|
embeddings_list: list[list[float]],
|
||||||
|
metadatas: list[dict[str, Any]],
|
||||||
|
documents: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not embeddings_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = await self._ensure_client()
|
||||||
|
dim = len(embeddings_list[0])
|
||||||
|
# The index schema is fixed to the first embedding's dimension. A later
|
||||||
|
# embedding of a different length would be packed into a wrong-sized
|
||||||
|
# blob that Valkey stores silently but that yields garbage KNN
|
||||||
|
# distances, so reject mixed dimensions up-front.
|
||||||
|
if any(len(e) != dim for e in embeddings_list[1:]):
|
||||||
|
raise ValueError(f'All embeddings must have dimension {dim}; got mixed lengths')
|
||||||
|
await self._ensure_index(client, collection, dim)
|
||||||
|
|
||||||
|
prefix = self._key_prefix(collection)
|
||||||
|
|
||||||
|
batch = Batch(is_atomic=False)
|
||||||
|
for i, _id in enumerate(ids):
|
||||||
|
key = prefix + str(_id)
|
||||||
|
metadata = metadatas[i] if i < len(metadatas) else {}
|
||||||
|
mapping: dict[str, Any] = {
|
||||||
|
_FIELD_VECTOR: self._pack_vector(embeddings_list[i]),
|
||||||
|
_FIELD_METADATA: json.dumps(metadata, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
file_id = metadata.get('file_id')
|
||||||
|
if file_id is not None:
|
||||||
|
mapping[_FIELD_FILE_ID] = self._encode_file_id(str(file_id))
|
||||||
|
if documents is not None and i < len(documents) and documents[i] is not None:
|
||||||
|
mapping[_FIELD_DOCUMENT] = documents[i]
|
||||||
|
|
||||||
|
batch.hset(key, mapping)
|
||||||
|
|
||||||
|
# Pipeline all HSETs into a single round-trip (non-atomic) instead of
|
||||||
|
# one await per embedding, which is N sequential round-trips for N
|
||||||
|
# chunks.
|
||||||
|
await client.exec(batch, raise_on_error=True)
|
||||||
|
|
||||||
|
self.ap.logger.info(f"Added {len(ids)} embeddings to Valkey Search collection '{collection}'")
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int = 5,
|
||||||
|
search_type: str = 'vector',
|
||||||
|
query_text: str = '',
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
index = self._index_name(collection)
|
||||||
|
|
||||||
|
if not await self._index_exists(client, index):
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
|
||||||
|
# vector_weight is accepted for interface parity but NOT honored by this
|
||||||
|
# backend (filter-then-KNN, no weighted fusion). Warn once.
|
||||||
|
if vector_weight is not None and not self._vector_weight_warned:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
'Valkey Search backend does not honor vector_weight: hybrid search uses '
|
||||||
|
'filter-then-KNN without weighted score fusion. The vector_weight value '
|
||||||
|
'is ignored. See docs/VALKEY_SEARCH_INTEGRATION.md.'
|
||||||
|
)
|
||||||
|
self._vector_weight_warned = True
|
||||||
|
|
||||||
|
filter_expr = self._triples_to_ft(filter)
|
||||||
|
|
||||||
|
if search_type == SearchType.FULL_TEXT:
|
||||||
|
if not query_text:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
text_clause = self._build_text_clause(query_text)
|
||||||
|
if not text_clause:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
query = f'{filter_expr} {text_clause}'.strip() if filter_expr else text_clause
|
||||||
|
return await self._run_text_search(client, index, query, k)
|
||||||
|
|
||||||
|
if search_type == SearchType.HYBRID:
|
||||||
|
# Filter / text pre-selects candidates; KNN ranks. No fusion.
|
||||||
|
pre = filter_expr
|
||||||
|
if query_text:
|
||||||
|
text_clause = self._build_text_clause(query_text)
|
||||||
|
if text_clause:
|
||||||
|
pre = f'{pre} {text_clause}'.strip() if pre else text_clause
|
||||||
|
pre = pre or '*'
|
||||||
|
query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]'
|
||||||
|
return await self._run_knn_search(client, index, query, query_embedding, k)
|
||||||
|
|
||||||
|
# Default: pure VECTOR search.
|
||||||
|
pre = filter_expr or '*'
|
||||||
|
query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]'
|
||||||
|
return await self._run_knn_search(client, index, query, query_embedding, k)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wrap_pre(pre: str) -> str:
|
||||||
|
"""Parenthesize a multi-condition pre-filter before the ``=>`` KNN clause.
|
||||||
|
|
||||||
|
When ``pre`` combines several terms (e.g. ``@file_id:{x} @document:term``)
|
||||||
|
the Valkey Search parser can otherwise mis-associate only the last term
|
||||||
|
with the KNN clause. Wrapping the whole expression forces correct
|
||||||
|
grouping. A bare ``*`` (match-all) and single-term expressions are left
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
if pre and pre != '*' and ' ' in pre.strip():
|
||||||
|
return f'({pre})'
|
||||||
|
return pre
|
||||||
|
|
||||||
|
async def _run_knn_search(
|
||||||
|
self,
|
||||||
|
client: GlideClient,
|
||||||
|
index: str,
|
||||||
|
query: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
options = FtSearchOptions(
|
||||||
|
params={'BLOB': self._pack_vector(list(query_embedding))},
|
||||||
|
return_fields=[
|
||||||
|
ReturnField(field_identifier=_VEC_SCORE_ALIAS, alias='distance'),
|
||||||
|
ReturnField(field_identifier=_FIELD_DOCUMENT),
|
||||||
|
ReturnField(field_identifier=_FIELD_METADATA),
|
||||||
|
],
|
||||||
|
limit=FtSearchLimit(0, k),
|
||||||
|
dialect=2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = await ft.search(client, index, query, options)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_missing_index_error(exc):
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
raise
|
||||||
|
return self._reply_to_chroma(index, reply, has_distance=True)
|
||||||
|
|
||||||
|
async def _run_text_search(
|
||||||
|
self,
|
||||||
|
client: GlideClient,
|
||||||
|
index: str,
|
||||||
|
query: str,
|
||||||
|
k: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
options = FtSearchOptions(
|
||||||
|
return_fields=[
|
||||||
|
ReturnField(field_identifier=_FIELD_DOCUMENT),
|
||||||
|
ReturnField(field_identifier=_FIELD_METADATA),
|
||||||
|
],
|
||||||
|
limit=FtSearchLimit(0, k),
|
||||||
|
dialect=2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = await ft.search(client, index, query, options)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_missing_index_error(exc):
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
raise
|
||||||
|
return self._reply_to_chroma(index, reply, has_distance=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_missing_index_error(exc: Exception) -> bool:
|
||||||
|
"""Return True if *exc* indicates the FT index does not exist.
|
||||||
|
|
||||||
|
``FT.DROPINDEX`` is applied eventually, so an index can briefly still
|
||||||
|
appear in ``FT._LIST`` after being dropped; a follow-up search then
|
||||||
|
fails with a "not found" error which we treat as an empty result.
|
||||||
|
"""
|
||||||
|
message = str(exc).lower()
|
||||||
|
return 'not found' in message and 'index' in message
|
||||||
|
|
||||||
|
def _iter_reply_docs(self, reply: Any, prefix: str):
|
||||||
|
"""Yield ``(doc_id, decoded_fields)`` pairs from an FT.SEARCH reply.
|
||||||
|
|
||||||
|
glide returns ``[total, {key: {field: value}, ...}]``. This shared
|
||||||
|
iterator decodes each key, strips the per-collection prefix to recover
|
||||||
|
the original document id, and decodes the field map — the logic both
|
||||||
|
``_reply_to_chroma`` and ``list_by_filter`` need.
|
||||||
|
"""
|
||||||
|
docs = reply[1] if reply and len(reply) >= 2 and isinstance(reply[1], dict) else {}
|
||||||
|
for key, fields in docs.items():
|
||||||
|
key_str = self._decode(key)
|
||||||
|
doc_id = key_str[len(prefix) :] if prefix and key_str.startswith(prefix) else key_str
|
||||||
|
decoded_fields = {self._decode(fk): fv for fk, fv in fields.items()} if isinstance(fields, dict) else {}
|
||||||
|
yield doc_id, decoded_fields
|
||||||
|
|
||||||
|
def _reply_to_chroma(self, index: str, reply: Any, has_distance: bool) -> dict[str, Any]:
|
||||||
|
"""Convert an FT.SEARCH reply into Chroma-style nested lists.
|
||||||
|
|
||||||
|
The KNN score field (aliased ``distance``) is a COSINE/L2 distance
|
||||||
|
directly, so no inversion is needed (unlike Qdrant).
|
||||||
|
"""
|
||||||
|
ids: list[str] = []
|
||||||
|
distances: list[float] = []
|
||||||
|
metadatas: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if not reply or len(reply) < 2:
|
||||||
|
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]}
|
||||||
|
|
||||||
|
prefix = self._key_prefix(index[len('idx:') :]) if index.startswith('idx:') else ''
|
||||||
|
|
||||||
|
for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix):
|
||||||
|
ids.append(doc_id)
|
||||||
|
|
||||||
|
if has_distance and 'distance' in decoded_fields:
|
||||||
|
try:
|
||||||
|
distances.append(float(self._decode(decoded_fields['distance'])))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
distances.append(0.0)
|
||||||
|
else:
|
||||||
|
distances.append(0.0)
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
raw_meta = decoded_fields.get(_FIELD_METADATA)
|
||||||
|
if raw_meta is not None:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(self._decode(raw_meta))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
metadata = {}
|
||||||
|
metadatas.append(metadata)
|
||||||
|
|
||||||
|
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]}
|
||||||
|
|
||||||
|
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
index = self._index_name(collection)
|
||||||
|
if not await self._index_exists(client, index):
|
||||||
|
self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = f'@{_FIELD_FILE_ID}:{{{self._encode_and_escape_tag(file_id)}}}'
|
||||||
|
keys = await self._search_keys(client, index, query)
|
||||||
|
if keys:
|
||||||
|
await client.delete(keys)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' with file_id: {file_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
index = self._index_name(collection)
|
||||||
|
if not await self._index_exists(client, index):
|
||||||
|
self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Guard against accidental mass deletion: a non-empty filter that maps
|
||||||
|
# to no usable (indexed) conditions must NOT fall back to match-all and
|
||||||
|
# wipe the whole collection. Skip instead (matching Milvus / pgvector).
|
||||||
|
query = self._triples_to_ft(filter)
|
||||||
|
if not query:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
"Valkey Search delete_by_filter on '%s': filter produced no usable conditions, skipping",
|
||||||
|
collection,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
keys = await self._search_keys(client, index, query)
|
||||||
|
if keys:
|
||||||
|
await client.delete(keys)
|
||||||
|
self.ap.logger.info(f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' by filter")
|
||||||
|
return len(keys)
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
index = self._index_name(collection)
|
||||||
|
if not await self._index_exists(client, index):
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
query = self._triples_to_ft(filter) or _MATCH_ALL
|
||||||
|
options = FtSearchOptions(
|
||||||
|
return_fields=[
|
||||||
|
ReturnField(field_identifier=_FIELD_DOCUMENT),
|
||||||
|
ReturnField(field_identifier=_FIELD_METADATA),
|
||||||
|
],
|
||||||
|
limit=FtSearchLimit(offset, limit),
|
||||||
|
dialect=2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = await ft.search(client, index, query, options)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_missing_index_error(exc):
|
||||||
|
return [], 0
|
||||||
|
raise
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
if reply:
|
||||||
|
try:
|
||||||
|
total = int(reply[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
prefix = self._key_prefix(collection)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix):
|
||||||
|
document = decoded_fields.get(_FIELD_DOCUMENT)
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
raw_meta = decoded_fields.get(_FIELD_METADATA)
|
||||||
|
if raw_meta is not None:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(self._decode(raw_meta))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'id': doc_id,
|
||||||
|
'document': self._decode(document) if document is not None else None,
|
||||||
|
'metadata': metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def delete_collection(self, collection: str):
|
||||||
|
client = await self._ensure_client()
|
||||||
|
index = self._index_name(collection)
|
||||||
|
self._ensured_indexes.discard(index)
|
||||||
|
|
||||||
|
if await self._index_exists(client, index):
|
||||||
|
try:
|
||||||
|
await ft.dropindex(client, index)
|
||||||
|
except RequestError:
|
||||||
|
# The index was already dropped (e.g. by a concurrent process)
|
||||||
|
# between the existence check and this call — benign. Other
|
||||||
|
# errors (connection / auth) must propagate so the caller knows
|
||||||
|
# the operation failed rather than silently SCAN-deleting next.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# DROPINDEX does not remove the underlying hashes; delete them too.
|
||||||
|
prefix = self._key_prefix(collection)
|
||||||
|
cursor = b'0'
|
||||||
|
deleted = 0
|
||||||
|
for _ in range(_MAX_SCAN_ROUNDS):
|
||||||
|
cursor, keys = await client.scan(cursor, match=f'{prefix}*', count=500)
|
||||||
|
if keys:
|
||||||
|
await client.delete(keys)
|
||||||
|
deleted += len(keys)
|
||||||
|
if cursor in (b'0', '0', 0):
|
||||||
|
break
|
||||||
|
self.ap.logger.info(f"Valkey Search collection '{collection}' deleted ({deleted} keys removed)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internal search helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def _index_exists(self, client: GlideClient, index: str) -> bool:
|
||||||
|
if index in self._ensured_indexes:
|
||||||
|
return True
|
||||||
|
# ft.info is O(1) and raises RequestError when the index does not
|
||||||
|
# exist, vs ft.list which is O(n) over every index on the server and
|
||||||
|
# was being paid on the first query to each collection.
|
||||||
|
try:
|
||||||
|
await ft.info(client, index)
|
||||||
|
self._ensured_indexes.add(index)
|
||||||
|
return True
|
||||||
|
except RequestError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _search_keys(self, client: GlideClient, index: str, query: str) -> list[str]:
|
||||||
|
"""Return all matching document keys for a query (NOCONTENT).
|
||||||
|
|
||||||
|
Paginates through the full result set in pages of ``_DELETE_SCAN_BATCH``
|
||||||
|
so that queries matching more than one page of chunks are fully
|
||||||
|
enumerated (avoids silently truncating deletes and leaving orphaned
|
||||||
|
vectors).
|
||||||
|
"""
|
||||||
|
keys: list[str] = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
options = FtSearchOptions(
|
||||||
|
nocontent=True,
|
||||||
|
limit=FtSearchLimit(offset, _DELETE_SCAN_BATCH),
|
||||||
|
dialect=2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = await ft.search(client, index, query, options)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_missing_index_error(exc):
|
||||||
|
return keys
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not reply or len(reply) < 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
# reply[0] is the total match count; reply[1] holds this page.
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
total = int(reply[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
docs = reply[1]
|
||||||
|
if isinstance(docs, dict):
|
||||||
|
page = [self._decode(k) for k in docs.keys()]
|
||||||
|
elif isinstance(docs, (list, tuple)):
|
||||||
|
page = [self._decode(k) for k in docs]
|
||||||
|
else:
|
||||||
|
page = []
|
||||||
|
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
keys.extend(page)
|
||||||
|
|
||||||
|
offset += len(page)
|
||||||
|
if offset >= total or len(page) < _DELETE_SCAN_BATCH:
|
||||||
|
break
|
||||||
|
|
||||||
|
return keys
|
||||||
@@ -87,6 +87,16 @@ vdb:
|
|||||||
database: 'langbot'
|
database: 'langbot'
|
||||||
user: 'postgres'
|
user: 'postgres'
|
||||||
password: 'postgres'
|
password: 'postgres'
|
||||||
|
valkey_search:
|
||||||
|
host: 'localhost'
|
||||||
|
port: 6379 # integration tests use 6380 -> valkey/valkey-bundle:9.1.0
|
||||||
|
db: 0
|
||||||
|
password: '' # optional (toB auth)
|
||||||
|
username: '' # optional (ACL user, toB)
|
||||||
|
tls: false # optional (toB/SaaS)
|
||||||
|
index_algorithm: 'HNSW' # HNSW | FLAT
|
||||||
|
distance_metric: 'COSINE' # COSINE | L2 | IP
|
||||||
|
request_timeout: 5000 # per-request timeout in ms (glide default 250ms is too low for KNN)
|
||||||
storage:
|
storage:
|
||||||
use: local
|
use: local
|
||||||
cleanup:
|
cleanup:
|
||||||
@@ -143,6 +153,15 @@ box:
|
|||||||
- './data/box'
|
- './data/box'
|
||||||
- '/tmp'
|
- '/tmp'
|
||||||
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
|
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
|
||||||
|
# Default nsjail cgroup memory limit for each MCP stdio server process, in MB.
|
||||||
|
# Node.js MCP servers (npx/bunx) need more memory than Python ones because V8
|
||||||
|
# and WebAssembly modules (e.g. undici llhttp) reserve large virtual address
|
||||||
|
# space at startup. Setting this too low causes processes to be killed with
|
||||||
|
# return_code=137 (OOM kill); the symptom is "Box managed process exited
|
||||||
|
# unexpectedly" in the logs. Raise on machines with ample RAM; lower only if
|
||||||
|
# you run exclusively Python (uvx) MCP servers.
|
||||||
|
# Can also be set via BOX__DEFAULT_MEMORY_MB. Default: 1536.
|
||||||
|
default_memory_mb: 1536
|
||||||
docker:
|
docker:
|
||||||
cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply.
|
cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply.
|
||||||
e2b:
|
e2b:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -104,6 +104,17 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
|
|||||||
'user': 'postgres',
|
'user': 'postgres',
|
||||||
'password': 'postgres',
|
'password': 'postgres',
|
||||||
},
|
},
|
||||||
|
'valkey_search': {
|
||||||
|
'host': 'localhost',
|
||||||
|
'port': 6379,
|
||||||
|
'db': 0,
|
||||||
|
'password': '',
|
||||||
|
'username': '',
|
||||||
|
'tls': False,
|
||||||
|
'index_algorithm': 'HNSW',
|
||||||
|
'distance_metric': 'COSINE',
|
||||||
|
'request_timeout': 5000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'storage': {
|
'storage': {
|
||||||
'use': 'local',
|
'use': 'local',
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Integration tests for the Valkey Search VDB backend.
|
||||||
|
|
||||||
|
These are SLOW, real-server tests. They are gated on ``TEST_VALKEY_URL`` and
|
||||||
|
skipped when it is unset (same precedent as the PostgreSQL migration tests).
|
||||||
|
|
||||||
|
Run locally against valkey/valkey-bundle:9.1.0::
|
||||||
|
|
||||||
|
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
|
||||||
|
TEST_VALKEY_URL=valkey://localhost:6380 \\
|
||||||
|
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
|
||||||
|
|
||||||
|
The default upstream fast CI lane (``-m "not slow"``) skips these; the local
|
||||||
|
supervisor validator MUST run them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_valkey_url(url: str) -> tuple[str, int, int]:
|
||||||
|
"""Parse ``valkey://host:port/db`` into ``(host, port, db)``."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or 'localhost'
|
||||||
|
port = parsed.port or 6379
|
||||||
|
db = 0
|
||||||
|
if parsed.path and parsed.path.strip('/'):
|
||||||
|
try:
|
||||||
|
db = int(parsed.path.strip('/'))
|
||||||
|
except ValueError:
|
||||||
|
db = 0
|
||||||
|
return host, port, db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valkey_config():
|
||||||
|
url = os.environ.get('TEST_VALKEY_URL')
|
||||||
|
if not url:
|
||||||
|
pytest.skip('TEST_VALKEY_URL not set')
|
||||||
|
host, port, db = _parse_valkey_url(url)
|
||||||
|
return {
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'db': db,
|
||||||
|
'password': '',
|
||||||
|
'username': '',
|
||||||
|
'tls': False,
|
||||||
|
'index_algorithm': 'HNSW',
|
||||||
|
'distance_metric': 'COSINE',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ap(valkey_config):
|
||||||
|
"""Build a minimal fake ``ap`` with the config + a no-op logger."""
|
||||||
|
logger = SimpleNamespace(
|
||||||
|
info=lambda *a, **k: None,
|
||||||
|
warning=lambda *a, **k: None,
|
||||||
|
error=lambda *a, **k: None,
|
||||||
|
debug=lambda *a, **k: None,
|
||||||
|
)
|
||||||
|
instance_config = SimpleNamespace(data={'vdb': {'valkey_search': valkey_config}})
|
||||||
|
return SimpleNamespace(instance_config=instance_config, logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def backend(valkey_config):
|
||||||
|
"""Create a Valkey Search backend, skip if module/server unavailable."""
|
||||||
|
from langbot.pkg.vector.vdbs.valkey_search import (
|
||||||
|
ValkeySearchVectorDatabase,
|
||||||
|
VALKEY_SEARCH_AVAILABLE,
|
||||||
|
)
|
||||||
|
from glide import ft
|
||||||
|
|
||||||
|
if not VALKEY_SEARCH_AVAILABLE:
|
||||||
|
pytest.skip('valkey-glide not installed')
|
||||||
|
|
||||||
|
ap = _make_ap(valkey_config)
|
||||||
|
db = ValkeySearchVectorDatabase(ap)
|
||||||
|
client = await db._ensure_client()
|
||||||
|
|
||||||
|
# Module-presence gate: FT.LIST must be available (Search module loaded).
|
||||||
|
try:
|
||||||
|
await ft.list(client)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await client.close()
|
||||||
|
pytest.skip(f'Valkey Search module not available: {exc}')
|
||||||
|
|
||||||
|
collection = f'test_{uuid.uuid4().hex[:12]}'
|
||||||
|
yield db, collection
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
await db.delete_collection(collection)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if db._client is not None:
|
||||||
|
await db._client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_until(coro_factory, predicate, timeout=5.0, interval=0.2):
|
||||||
|
"""Poll an async result until predicate is true (indexer is async)."""
|
||||||
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
|
result = await coro_factory()
|
||||||
|
while not predicate(result) and asyncio.get_event_loop().time() < deadline:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
result = await coro_factory()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_docs():
|
||||||
|
ids = ['d1', 'd2', 'd3']
|
||||||
|
embeddings = [
|
||||||
|
[1.0, 0.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0, 0.0],
|
||||||
|
[0.9, 0.1, 0.0, 0.0],
|
||||||
|
]
|
||||||
|
metadatas = [
|
||||||
|
{'file_id': 'fileA', 'topic': 'cats'},
|
||||||
|
{'file_id': 'fileB', 'topic': 'dogs'},
|
||||||
|
{'file_id': 'fileA', 'topic': 'cats'},
|
||||||
|
]
|
||||||
|
documents = [
|
||||||
|
'the quick brown fox',
|
||||||
|
'lazy dogs sleeping',
|
||||||
|
'foxes and cats playing',
|
||||||
|
]
|
||||||
|
return ids, embeddings, metadatas, documents
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_and_vector_search(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
|
||||||
|
result = await _poll_until(
|
||||||
|
lambda: db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector'),
|
||||||
|
lambda r: len(r['ids'][0]) >= 1,
|
||||||
|
)
|
||||||
|
assert len(result['ids'][0]) >= 1
|
||||||
|
# Closest to [1,0,0,0] should be d1.
|
||||||
|
assert result['ids'][0][0] == 'd1'
|
||||||
|
assert all(isinstance(d, float) for d in result['distances'][0])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_text_search(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
|
||||||
|
result = await _poll_until(
|
||||||
|
lambda: db.search(collection, [0.0, 0.0, 0.0, 0.0], k=5, search_type='full_text', query_text='dogs'),
|
||||||
|
lambda r: len(r['ids'][0]) >= 1,
|
||||||
|
)
|
||||||
|
assert 'd2' in result['ids'][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hybrid_filter_then_knn(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
|
||||||
|
result = await _poll_until(
|
||||||
|
lambda: db.search(
|
||||||
|
collection,
|
||||||
|
[1.0, 0.0, 0.0, 0.0],
|
||||||
|
k=5,
|
||||||
|
search_type='hybrid',
|
||||||
|
query_text='cats',
|
||||||
|
filter={'file_id': 'fileA'},
|
||||||
|
),
|
||||||
|
lambda r: len(r['ids'][0]) >= 1,
|
||||||
|
)
|
||||||
|
# Only fileA docs (d1, d3) should be candidates.
|
||||||
|
assert set(result['ids'][0]).issubset({'d1', 'd3'})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vector_weight_not_honored(backend):
|
||||||
|
"""Passing different vector_weight values must NOT change ranking."""
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
|
||||||
|
common = dict(
|
||||||
|
collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats'
|
||||||
|
)
|
||||||
|
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1)
|
||||||
|
|
||||||
|
r_low = await db.search(**common, vector_weight=0.1)
|
||||||
|
r_high = await db.search(**common, vector_weight=0.9)
|
||||||
|
assert r_low['ids'][0] == r_high['ids'][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_operators(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
|
||||||
|
# Wait for indexing.
|
||||||
|
await _poll_until(
|
||||||
|
lambda: db.list_by_filter(collection, limit=10),
|
||||||
|
lambda r: r[1] >= 3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# $eq
|
||||||
|
items, total = await db.list_by_filter(collection, filter={'file_id': 'fileA'})
|
||||||
|
assert total == 2
|
||||||
|
assert {it['id'] for it in items} == {'d1', 'd3'}
|
||||||
|
|
||||||
|
# $ne
|
||||||
|
items, total = await db.list_by_filter(collection, filter={'file_id': {'$ne': 'fileA'}})
|
||||||
|
assert {it['id'] for it in items} == {'d2'}
|
||||||
|
|
||||||
|
# $in
|
||||||
|
items, total = await db.list_by_filter(collection, filter={'file_id': {'$in': ['fileA', 'fileB']}})
|
||||||
|
assert total == 3
|
||||||
|
|
||||||
|
# $nin
|
||||||
|
items, total = await db.list_by_filter(collection, filter={'file_id': {'$nin': ['fileB']}})
|
||||||
|
assert {it['id'] for it in items} == {'d1', 'd3'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_file_id(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
|
||||||
|
|
||||||
|
await db.delete_by_file_id(collection, 'fileA')
|
||||||
|
items, total = await _poll_until(
|
||||||
|
lambda: db.list_by_filter(collection, limit=10),
|
||||||
|
lambda r: r[1] <= 1,
|
||||||
|
)
|
||||||
|
assert {it['id'] for it in items} == {'d2'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_filter_returns_count(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
|
||||||
|
|
||||||
|
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
|
||||||
|
assert deleted == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_by_filter_pagination(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
|
||||||
|
|
||||||
|
page1, total = await db.list_by_filter(collection, limit=2, offset=0)
|
||||||
|
assert total == 3
|
||||||
|
assert len(page1) == 2
|
||||||
|
|
||||||
|
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
|
||||||
|
assert total == 3
|
||||||
|
assert len(page2) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_collection(backend):
|
||||||
|
db, collection = backend
|
||||||
|
ids, embeddings, metadatas, documents = _sample_docs()
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
|
||||||
|
|
||||||
|
await db.delete_collection(collection)
|
||||||
|
|
||||||
|
# After dropping, search on a missing index returns empty.
|
||||||
|
result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector')
|
||||||
|
assert result['ids'][0] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adversarial_filter_and_query_input(backend):
|
||||||
|
"""Crafted FT special chars in file_id / query_text must not break out.
|
||||||
|
|
||||||
|
Guarantees locked in here:
|
||||||
|
* A file_id full of injection-style chars (quotes, parens, ``|``, ``@``,
|
||||||
|
``:``, spaces, dashes) only ever matches its own row — the payload is
|
||||||
|
escaped to literal TAG content, never interpreted as extra clauses.
|
||||||
|
* A query_text full of FT operators does not raise and does not widen the
|
||||||
|
result set.
|
||||||
|
* A file_id containing FT-unsafe chars (``{`` / ``}`` / ``*``) is
|
||||||
|
percent-encoded, so it round-trips correctly: an exact match returns ONLY
|
||||||
|
its own row and never widens to an unrelated row, and the query does not
|
||||||
|
raise.
|
||||||
|
"""
|
||||||
|
db, collection = backend
|
||||||
|
|
||||||
|
# Injection-style file_id WITHOUT FT-unsafe chars (the realistic surface).
|
||||||
|
injection_fid = 'evil") @file_id (".id|x-y:z'
|
||||||
|
# file_id WITH FT-unsafe chars that previously could not be queried.
|
||||||
|
brace_fid = 'x} @file_id:{*'
|
||||||
|
ids = ['adv1', 'benign2', 'brace3']
|
||||||
|
embeddings = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
|
||||||
|
metadatas = [{'file_id': injection_fid}, {'file_id': 'plainB'}, {'file_id': brace_fid}]
|
||||||
|
documents = ['payload row content', 'unrelated benign content', 'brace row content']
|
||||||
|
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
|
||||||
|
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
|
||||||
|
|
||||||
|
# Exact-match on the crafted file_id returns ONLY its own row.
|
||||||
|
items, total = await db.list_by_filter(collection, filter={'file_id': injection_fid})
|
||||||
|
assert total == 1
|
||||||
|
assert {it['id'] for it in items} == {'adv1'}
|
||||||
|
|
||||||
|
# A query_text packed with FT operators must not raise and must not match
|
||||||
|
# the benign row (escaped to literal terms, none of which it contains).
|
||||||
|
result = await db.search(
|
||||||
|
collection,
|
||||||
|
[0.0, 0.0, 0.0, 0.0],
|
||||||
|
k=5,
|
||||||
|
search_type='full_text',
|
||||||
|
query_text='@document:{*} | -()~ "evil"',
|
||||||
|
)
|
||||||
|
assert 'benign2' not in result['ids'][0]
|
||||||
|
|
||||||
|
# The brace/star-bearing file_id is encoded, so it round-trips: exact match
|
||||||
|
# returns ONLY its own row and never widens. No RequestError is raised.
|
||||||
|
b_items, b_total = await db.list_by_filter(collection, filter={'file_id': brace_fid})
|
||||||
|
assert b_total == 1
|
||||||
|
assert {it['id'] for it in b_items} == {'brace3'}
|
||||||
|
|
||||||
|
# And deletion by that file_id removes exactly its own row.
|
||||||
|
deleted = await db.delete_by_filter(collection, filter={'file_id': brace_fid})
|
||||||
|
assert deleted == 1
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
### 4. 向量数据库 (`vector/vdbs/`)
|
### 4. 向量数据库 (`vector/vdbs/`)
|
||||||
- **路径**: `src/langbot/pkg/vector/vdbs/`
|
- **路径**: `src/langbot/pkg/vector/vdbs/`
|
||||||
- **模块**: chroma, milvus, pgvector, qdrant, seekdb
|
- **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search
|
||||||
- **排除原因**: 需要真实向量数据库实例运行
|
- **排除原因**: 需要真实向量数据库实例运行
|
||||||
- **测试方式**: 需要 Docker 启动测试数据库或 mock
|
- **测试方式**: 需要 Docker 启动测试数据库或 mock
|
||||||
- **状态**: 后续可补充 mock 测试
|
- **状态**: 后续可补充 mock 测试
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for DingTalk adapter helper behavior."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.dingtalk import (
|
||||||
|
DingTalkAdapter,
|
||||||
|
_dingtalk_card_markdown,
|
||||||
|
_dingtalk_clean_form_content,
|
||||||
|
_dingtalk_completed_input_lines,
|
||||||
|
_dingtalk_extract_component_inputs,
|
||||||
|
_dingtalk_form_component_params,
|
||||||
|
_dingtalk_missing_completed_input_lines,
|
||||||
|
_dingtalk_pending_input_defs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_select_component_params_expose_options():
|
||||||
|
params = _dingtalk_form_component_params(
|
||||||
|
{
|
||||||
|
'_current_input_field': 'choice',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert params['select_visible'] == 'true'
|
||||||
|
assert params['select_placeholder'] == 'choice'
|
||||||
|
assert params['select_options'] == ['A', 'B']
|
||||||
|
assert [option['value'] for option in params['index_o']] == ['A', 'B']
|
||||||
|
assert [option['value'] for option in params['test_index']] == ['A', 'B']
|
||||||
|
assert params['index_o'][0]['text']['zh_CN'] == 'A'
|
||||||
|
assert params['index_o'][0]['text']['en_US'] == 'A'
|
||||||
|
assert params['select_index'] == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_extract_select_from_builtin_result_dict():
|
||||||
|
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
|
||||||
|
|
||||||
|
assert inputs == {'select': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_extract_select_from_template_param_string():
|
||||||
|
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
|
||||||
|
|
||||||
|
assert inputs == {'select': '{"index": 1, "value": "B"}'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_extract_input_and_select_together():
|
||||||
|
inputs = _dingtalk_extract_component_inputs(
|
||||||
|
{
|
||||||
|
'inputResult': {'value': 'looks good'},
|
||||||
|
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inputs == {'input': 'looks good', 'select': 'A'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_extract_component_inputs_strips_card_line_endings():
|
||||||
|
inputs = _dingtalk_extract_component_inputs(
|
||||||
|
{
|
||||||
|
'inputResult': {'value': '回复我测试\r\n'},
|
||||||
|
'selectResult': {'value': '1\r'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inputs == {'input': '回复我测试', 'select': '1'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_pending_input_defs_includes_file_fields():
|
||||||
|
pending = _dingtalk_pending_input_defs(
|
||||||
|
{
|
||||||
|
'input_defs': [
|
||||||
|
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'files', 'type': 'file-list'},
|
||||||
|
],
|
||||||
|
'inputs': {'comment': 'ready'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [field['output_variable_name'] for field in pending] == ['files']
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_completed_input_lines_include_text_and_select_values():
|
||||||
|
lines = _dingtalk_completed_input_lines(
|
||||||
|
{
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'inputs': {'comment': 'looks good', 'choice': 'B'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines == ['✅ comment:looks good', '✅ choice:B']
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_completed_inputs_are_not_repeated_when_already_interleaved():
|
||||||
|
form_data = {
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': '回复我测试\r', 'xiala': '1'},
|
||||||
|
}
|
||||||
|
form_content = '你好\n请输入你的问题\n✅ us_input:回复我测试\n请选择你的答案\n✅ xiala:1'
|
||||||
|
|
||||||
|
assert _dingtalk_missing_completed_input_lines(form_data, form_content) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_completed_inputs_are_appended_when_template_does_not_render_them():
|
||||||
|
form_data = {
|
||||||
|
'all_input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
||||||
|
'inputs': {'comment': 'ready'},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert _dingtalk_missing_completed_input_lines(form_data, 'Please review') == ['✅ comment:ready']
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_clean_form_content_uses_all_input_defs():
|
||||||
|
content = _dingtalk_clean_form_content(
|
||||||
|
{
|
||||||
|
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
|
||||||
|
'input_defs': [],
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'choice', 'type': 'select'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content == 'Hello'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values():
|
||||||
|
content = _dingtalk_clean_form_content(
|
||||||
|
{
|
||||||
|
'_current_input_field': 'choice',
|
||||||
|
'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
|
||||||
|
'form_content': 'Choose an answer',
|
||||||
|
'input_defs': [
|
||||||
|
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'choice', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'comment': 'hello'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert '{{#$output.' not in content
|
||||||
|
assert content.index('Question') < content.index('comment')
|
||||||
|
assert content.index('comment') < content.index('Choose an answer')
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values():
|
||||||
|
content = _dingtalk_clean_form_content(
|
||||||
|
{
|
||||||
|
'_action_select_only': True,
|
||||||
|
'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'choice', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'comment': 'hello', 'choice': 'B'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert '{{#$output.' not in content
|
||||||
|
assert content.startswith('11\nQuestion')
|
||||||
|
assert content.index('Question') < content.index('comment')
|
||||||
|
assert content.index('comment') < content.index('Choose an answer')
|
||||||
|
assert content.index('Choose an answer') < content.index('choice')
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_card_markdown_preserves_internal_line_breaks():
|
||||||
|
assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11<br>Question<br>Completed'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_card_action_adapter() -> DingTalkAdapter:
|
||||||
|
adapter = DingTalkAdapter.model_construct(
|
||||||
|
card_state={
|
||||||
|
'card-1': {
|
||||||
|
'session_key': 'group_group-1',
|
||||||
|
'launcher_type': 'group',
|
||||||
|
'launcher_id': 'group-1',
|
||||||
|
'sender_user_id': 'initiator-1',
|
||||||
|
'form_token': 'token-1',
|
||||||
|
'workflow_run_id': 'run-1',
|
||||||
|
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||||
|
'node_title': 'Review',
|
||||||
|
'form_content': 'Please review',
|
||||||
|
'input_defs': [],
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
active_turn_card={},
|
||||||
|
active_turn_text={},
|
||||||
|
)
|
||||||
|
adapter.logger = AsyncMock()
|
||||||
|
adapter.ap = MagicMock()
|
||||||
|
adapter.ap.platform_mgr.bots = []
|
||||||
|
adapter.ap.query_pool.add_query = AsyncMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dingtalk_group_card_action_uses_clicker_as_sender():
|
||||||
|
adapter = _build_card_action_adapter()
|
||||||
|
|
||||||
|
with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved:
|
||||||
|
await adapter._on_card_action(
|
||||||
|
{
|
||||||
|
'out_track_id': 'card-1',
|
||||||
|
'user_id': 'reviewer-2',
|
||||||
|
'action_id': 'approve',
|
||||||
|
'params': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
call = adapter.ap.query_pool.add_query.await_args
|
||||||
|
assert call.kwargs['launcher_id'] == 'group-1'
|
||||||
|
assert call.kwargs['sender_id'] == 'reviewer-2'
|
||||||
|
assert call.kwargs['message_event'].sender.id == 'reviewer-2'
|
||||||
|
mark_resolved.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dingtalk_unknown_card_action_is_rejected():
|
||||||
|
adapter = _build_card_action_adapter()
|
||||||
|
|
||||||
|
await adapter._on_card_action(
|
||||||
|
{
|
||||||
|
'out_track_id': 'card-1',
|
||||||
|
'user_id': 'reviewer-2',
|
||||||
|
'action_id': 'not-on-card',
|
||||||
|
'params': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.ap.query_pool.add_query.assert_not_awaited()
|
||||||
|
adapter.logger.warning.assert_awaited_once()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests for DingTalk API payload helpers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
||||||
|
params = _stringify_card_param_map(
|
||||||
|
{
|
||||||
|
'content': 'Pick one',
|
||||||
|
'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False),
|
||||||
|
'select_options': ['A', 'B'],
|
||||||
|
'index_o': [
|
||||||
|
{
|
||||||
|
'value': 'A',
|
||||||
|
'text': {'zh_CN': 'A', 'en_US': 'A'},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'test_index': [
|
||||||
|
{
|
||||||
|
'value': 'A',
|
||||||
|
'text': {'zh_CN': 'A', 'en_US': 'A'},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'select_index': -1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert params['content'] == 'Pick one'
|
||||||
|
assert params['btns'] == '[{"text": "OK"}]'
|
||||||
|
assert params['select_options'] == '["A", "B"]'
|
||||||
|
assert json.loads(params['index_o'])[0]['value'] == 'A'
|
||||||
|
assert json.loads(params['test_index'])[0]['value'] == 'A'
|
||||||
|
assert params['select_index'] == '-1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_card_param_map_stringifies_unregistered_structures():
|
||||||
|
params = _stringify_card_param_map({'other': ['A'], 'empty': None})
|
||||||
|
|
||||||
|
assert params['other'] == '["A"]'
|
||||||
|
assert params['empty'] == ''
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Tests for Lark adapter helper behavior."""
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.lark import (
|
||||||
|
LarkAdapter,
|
||||||
|
_lark_clean_form_content,
|
||||||
|
_lark_completed_input_lines,
|
||||||
|
_lark_current_input_defs,
|
||||||
|
_lark_extract_action_form_inputs,
|
||||||
|
_lark_should_update_stream_element,
|
||||||
|
_lark_visible_form_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_current_input_defs_only_returns_active_stage():
|
||||||
|
input_defs = [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _lark_current_input_defs(
|
||||||
|
{
|
||||||
|
'_current_input_field': 'xiala',
|
||||||
|
'input_defs': input_defs,
|
||||||
|
}
|
||||||
|
) == [input_defs[1]]
|
||||||
|
assert (
|
||||||
|
_lark_current_input_defs(
|
||||||
|
{
|
||||||
|
'_action_select_only': True,
|
||||||
|
'input_defs': input_defs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_form_field_elements_only_render_active_stage():
|
||||||
|
adapter = LarkAdapter.model_construct()
|
||||||
|
form_data = {
|
||||||
|
'_current_input_field': 'xiala',
|
||||||
|
'input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{
|
||||||
|
'output_variable_name': 'xiala',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
elements, input_name_map, file_help_lines = adapter._build_lark_form_field_elements(form_data)
|
||||||
|
|
||||||
|
assert len(elements) == 1
|
||||||
|
assert elements[0]['tag'] == 'select_static'
|
||||||
|
assert elements[0]['label']['content'] == 'xiala'
|
||||||
|
assert list(input_name_map.values()) == ['xiala']
|
||||||
|
assert file_help_lines == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_form_stage_skips_closed_streaming_element_update():
|
||||||
|
assert not _lark_should_update_stream_element(
|
||||||
|
resume_from=False,
|
||||||
|
form_data={'_current_input_field': 'xiala'},
|
||||||
|
msg_seq=1,
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
assert _lark_should_update_stream_element(
|
||||||
|
resume_from=False,
|
||||||
|
form_data=None,
|
||||||
|
msg_seq=1,
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_final_action_stage_interleaves_prompts_and_completed_values():
|
||||||
|
form_content = _lark_visible_form_content(
|
||||||
|
{
|
||||||
|
'_action_select_only': True,
|
||||||
|
'raw_form_content': ('11\nQuestion\n{{#$output.us_input#}}\nChoose an answer\n{{#$output.xiala#}}\n'),
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': 'hello', 'xiala': '2'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert '{{#$output.' not in form_content
|
||||||
|
assert form_content.startswith('11\nQuestion')
|
||||||
|
assert form_content.index('Question') < form_content.index('us_input')
|
||||||
|
assert form_content.index('us_input') < form_content.index('Choose an answer')
|
||||||
|
assert form_content.index('Choose an answer') < form_content.index('xiala')
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_completed_input_lines_include_text_select_and_files():
|
||||||
|
lines = _lark_completed_input_lines(
|
||||||
|
{
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
{'output_variable_name': 'files', 'type': 'file-list'},
|
||||||
|
],
|
||||||
|
'inputs': {
|
||||||
|
'us_input': '你好',
|
||||||
|
'xiala': 'or',
|
||||||
|
'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines == [
|
||||||
|
'✅ us_input:你好',
|
||||||
|
'✅ xiala:or',
|
||||||
|
'✅ files:2 file(s)',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_clean_form_content_removes_all_input_placeholders():
|
||||||
|
content = _lark_clean_form_content(
|
||||||
|
'人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n',
|
||||||
|
[
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content == '人工介入'
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_extract_action_form_inputs_from_json_form_value():
|
||||||
|
class Action:
|
||||||
|
form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}'
|
||||||
|
input_value = None
|
||||||
|
option = None
|
||||||
|
name = None
|
||||||
|
|
||||||
|
inputs = _lark_extract_action_form_inputs(
|
||||||
|
Action(),
|
||||||
|
{
|
||||||
|
'input_name_map': {
|
||||||
|
'Input_1_us_input_abcd12': 'us_input',
|
||||||
|
'Select_2_xiala_abcd12': 'xiala',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inputs == {'us_input': 'hello', 'xiala': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_extract_action_form_inputs_from_webhook_dict_action():
|
||||||
|
inputs = _lark_extract_action_form_inputs(
|
||||||
|
{
|
||||||
|
'form_value': {
|
||||||
|
'Input_1_us_input_abcd12': 'hello',
|
||||||
|
'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input_name_map': {
|
||||||
|
'Input_1_us_input_abcd12': 'us_input',
|
||||||
|
'Select_2_xiala_abcd12': 'xiala',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_extract_action_form_inputs_maps_dotted_component_names():
|
||||||
|
inputs = _lark_extract_action_form_inputs(
|
||||||
|
{
|
||||||
|
'form_value': {
|
||||||
|
'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input_name_map': {
|
||||||
|
'Input_1_us_input_abcd12': 'us_input',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inputs == {'us_input': 'hello'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_completed_input_lines_display_select_value_from_object():
|
||||||
|
lines = _lark_completed_input_lines(
|
||||||
|
{
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines == ['✅ xiala:B']
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Tests for QQ Official keyboard payload helpers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
from langbot.libs.qq_official_api.api import (
|
||||||
|
QQ_SELECT_ACTION_PREFIX,
|
||||||
|
build_keyboard_from_select_field,
|
||||||
|
get_select_field_options,
|
||||||
|
resolve_select_button_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_form_data() -> dict:
|
||||||
|
return {
|
||||||
|
'_current_input_field': 'choice',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_select_field_builds_callback_buttons():
|
||||||
|
keyboard = build_keyboard_from_select_field(_select_form_data(), buttons_per_row=2)
|
||||||
|
|
||||||
|
rows = keyboard['content']['rows']
|
||||||
|
assert [[button['render_data']['label'] for button in row['buttons']] for row in rows] == [
|
||||||
|
['A', 'B'],
|
||||||
|
['C'],
|
||||||
|
]
|
||||||
|
assert rows[0]['buttons'][0]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}0'
|
||||||
|
assert rows[0]['buttons'][1]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_select_button_resolves_field_and_value():
|
||||||
|
form_data = _select_form_data()
|
||||||
|
|
||||||
|
assert get_select_field_options(form_data) == ('choice', ['A', 'B', 'C'])
|
||||||
|
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}1') == ('choice', 'B')
|
||||||
|
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_select_keyboard_fits_twenty_five_options():
|
||||||
|
form_data = _select_form_data()
|
||||||
|
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
|
||||||
|
|
||||||
|
rows = build_keyboard_from_select_field(form_data)['content']['rows']
|
||||||
|
|
||||||
|
assert len(rows) == 5
|
||||||
|
assert all(len(row['buttons']) == 5 for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qq_non_select_field_does_not_build_keyboard():
|
||||||
|
form_data = {
|
||||||
|
'_current_input_field': 'comment',
|
||||||
|
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert build_keyboard_from_select_field(form_data)['content']['rows'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_test_adapter():
|
||||||
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|
||||||
|
adapter = QQOfficialAdapter.model_construct()
|
||||||
|
adapter.logger = AsyncMock()
|
||||||
|
adapter.bot = MagicMock()
|
||||||
|
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
||||||
|
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
||||||
|
adapter.ap = None
|
||||||
|
adapter._stream_ctx = {}
|
||||||
|
adapter._stream_ctx_ts = {}
|
||||||
|
adapter._fallback_text = {}
|
||||||
|
adapter._fallback_text_ts = {}
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter._stream_ctx['message-1'] = {
|
||||||
|
'user_openid': 'user-1',
|
||||||
|
'msg_id': 'source-1',
|
||||||
|
'stream_msg_id': None,
|
||||||
|
'msg_seq': 1,
|
||||||
|
'index': 0,
|
||||||
|
'last_update_ts': 0,
|
||||||
|
'accumulated_text': '',
|
||||||
|
'sent_length': 0,
|
||||||
|
'session_started': False,
|
||||||
|
}
|
||||||
|
adapter._stream_ctx_ts['message-1'] = time.time()
|
||||||
|
source = MagicMock()
|
||||||
|
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
source,
|
||||||
|
{'resp_message_id': 'message-1'},
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text='<think>one')]),
|
||||||
|
)
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
source,
|
||||||
|
{'resp_message_id': 'message-1'},
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text='<think>one two')]),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||||
|
'<think>one',
|
||||||
|
' two',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
source = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(QQOfficialAdapter, 'reply_message', new=AsyncMock()) as reply_message:
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
source,
|
||||||
|
{'resp_message_id': 'message-1'},
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text='Hel')]),
|
||||||
|
)
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
source,
|
||||||
|
{'resp_message_id': 'message-1'},
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text='Hello')]),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_chain = reply_message.await_args.args[1]
|
||||||
|
assert str(sent_chain) == 'Hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_text_field_prompt_keeps_form_content():
|
||||||
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter._pending_forms = {}
|
||||||
|
adapter._session_event_ids = {}
|
||||||
|
adapter._anchor_msg_seq = {}
|
||||||
|
source = MagicMock()
|
||||||
|
source.d_id = 'source-1'
|
||||||
|
source.t = 'C2C_MESSAGE_CREATE'
|
||||||
|
event = MagicMock()
|
||||||
|
event.source_platform_object = source
|
||||||
|
event.sender.id = 'user-1'
|
||||||
|
form_data = {
|
||||||
|
'_current_input_field': 'us_input',
|
||||||
|
'node_title': 'Manual input',
|
||||||
|
'form_content': '1234\nEnter your question',
|
||||||
|
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
||||||
|
'actions': [{'id': 'yes', 'title': 'yes'}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')):
|
||||||
|
await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data)
|
||||||
|
|
||||||
|
send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs
|
||||||
|
assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question'
|
||||||
|
assert send_call['keyboard'] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_select_click_enqueues_input_progress_query():
|
||||||
|
import langbot.pkg.core.app # noqa: F401
|
||||||
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|
||||||
|
adapter = QQOfficialAdapter.model_construct()
|
||||||
|
adapter.logger = AsyncMock()
|
||||||
|
adapter.bot = MagicMock()
|
||||||
|
adapter.bot.ack_interaction = AsyncMock()
|
||||||
|
adapter.ap = MagicMock()
|
||||||
|
adapter.ap.platform_mgr.bots = []
|
||||||
|
adapter.ap.query_pool.add_query = AsyncMock()
|
||||||
|
adapter._pending_forms = {
|
||||||
|
'group_group-1': {
|
||||||
|
'form_data': {
|
||||||
|
**_select_form_data(),
|
||||||
|
'form_token': 'token-1',
|
||||||
|
'workflow_run_id': 'run-1',
|
||||||
|
'node_title': 'Review',
|
||||||
|
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||||
|
},
|
||||||
|
'sender_id': 'initiator-1',
|
||||||
|
'posted_at': time.time(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adapter._session_event_ids = {}
|
||||||
|
adapter._anchor_msg_seq = {}
|
||||||
|
|
||||||
|
await adapter._handle_interaction_create(
|
||||||
|
{
|
||||||
|
'id': 'interaction-1',
|
||||||
|
'chat_type': 1,
|
||||||
|
'group_openid': 'group-1',
|
||||||
|
'member_openid': 'reviewer-2',
|
||||||
|
'data': {'resolved': {'button_data': f'{QQ_SELECT_ACTION_PREFIX}1'}},
|
||||||
|
},
|
||||||
|
ws_event_id='event-1',
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
call = adapter.ap.query_pool.add_query.await_args
|
||||||
|
form_action = call.kwargs['variables']['_dify_form_action']
|
||||||
|
assert call.kwargs['launcher_id'] == 'group-1'
|
||||||
|
assert call.kwargs['sender_id'] == 'reviewer-2'
|
||||||
|
assert form_action['action_id'] == ''
|
||||||
|
assert form_action['inputs'] == {'select': 'B'}
|
||||||
|
assert form_action['_current_input_field'] == 'choice'
|
||||||
|
assert form_action['_input_progress'] is True
|
||||||
|
adapter.bot.ack_interaction.assert_awaited_once_with('interaction-1', code=0)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for Telegram Dify form callback helpers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from telegram import ForceReply
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
from langbot.pkg.platform.sources.telegram import (
|
||||||
|
TelegramAdapter,
|
||||||
|
_telegram_form_action_from_callback,
|
||||||
|
_telegram_select_field_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_form_data() -> dict:
|
||||||
|
return {
|
||||||
|
'_current_input_field': 'choice',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_select_field_options_are_extracted():
|
||||||
|
assert _telegram_select_field_options(_select_form_data()) == ('choice', ['A', 'B', 'C'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_select_callback_becomes_input_progress():
|
||||||
|
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
||||||
|
'action_id': '',
|
||||||
|
'inputs': {'select': {'index': 1}},
|
||||||
|
'_input_progress': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_action_callback_remains_final_action():
|
||||||
|
assert _telegram_form_action_from_callback({'f': 1, 'a': 'approve'}) == {
|
||||||
|
'action_id': 'approve',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_invalid_select_callback_is_rejected():
|
||||||
|
assert _telegram_form_action_from_callback({'f': 1, 'x': -1}) is None
|
||||||
|
assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_form_callback_cache_consumes_the_whole_form_group():
|
||||||
|
adapter = TelegramAdapter.model_construct()
|
||||||
|
adapter._form_action_titles = {}
|
||||||
|
adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0)
|
||||||
|
|
||||||
|
assert adapter._take_form_action_title('callback-a', now=101.0) == 'A'
|
||||||
|
assert adapter._take_form_action_title('callback-a', now=101.0) is None
|
||||||
|
assert adapter._take_form_action_title('callback-b', now=101.0) is None
|
||||||
|
assert adapter._form_action_titles == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_form_callback_cache_prunes_expired_entries():
|
||||||
|
adapter = TelegramAdapter.model_construct()
|
||||||
|
adapter._form_action_titles = {}
|
||||||
|
adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0)
|
||||||
|
|
||||||
|
assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None
|
||||||
|
assert adapter._form_action_titles == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_form_callback_cache_preserves_pipeline_uuid():
|
||||||
|
adapter = TelegramAdapter.model_construct()
|
||||||
|
adapter._form_action_titles = {}
|
||||||
|
adapter._cache_form_action_titles(
|
||||||
|
{'callback-a': 'Approve'},
|
||||||
|
pipeline_uuid='pipeline-routed',
|
||||||
|
now=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert adapter._take_form_action_context('callback-a', now=101.0) == (
|
||||||
|
'Approve',
|
||||||
|
'pipeline-routed',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_telegram_select_field_sends_two_column_inline_keyboard():
|
||||||
|
bot = MagicMock()
|
||||||
|
bot.send_message = AsyncMock()
|
||||||
|
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
||||||
|
adapter._form_action_titles = {}
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.effective_chat.id = 123
|
||||||
|
update.effective_message.message_thread_id = None
|
||||||
|
event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
||||||
|
message_chain=platform_message.MessageChain([]),
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
form_data = {
|
||||||
|
**_select_form_data(),
|
||||||
|
'node_title': 'Review',
|
||||||
|
'form_content': 'Choose one',
|
||||||
|
'workflow_run_id': 'workflow-run-12345678',
|
||||||
|
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapter._send_form_action_buttons(event, form_data)
|
||||||
|
|
||||||
|
args = bot.send_message.await_args.kwargs
|
||||||
|
rows = args['reply_markup'].inline_keyboard
|
||||||
|
assert [[button.text for button in row] for row in rows] == [['A', 'B'], ['C']]
|
||||||
|
callback_data = rows[0][1].callback_data
|
||||||
|
assert len(callback_data.encode('utf-8')) <= 64
|
||||||
|
assert json.loads(callback_data)['x'] == 1
|
||||||
|
assert callback_data in adapter._form_action_titles
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_telegram_text_field_does_not_show_action_buttons():
|
||||||
|
bot = MagicMock()
|
||||||
|
bot.send_message = AsyncMock()
|
||||||
|
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
||||||
|
adapter._form_action_titles = {}
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.effective_chat.id = 123
|
||||||
|
update.effective_message.message_thread_id = None
|
||||||
|
event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
||||||
|
message_chain=platform_message.MessageChain([]),
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
form_data = {
|
||||||
|
'_current_input_field': 'us_input',
|
||||||
|
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': 'us_input (paragraph): reply "us_input: <value>"',
|
||||||
|
'workflow_run_id': 'workflow-run-12345678',
|
||||||
|
'actions': [{'id': 'yes', 'title': 'yes'}, {'id': 'no', 'title': 'no'}],
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapter._send_form_action_buttons(event, form_data)
|
||||||
|
|
||||||
|
args = bot.send_message.await_args.kwargs
|
||||||
|
assert isinstance(args['reply_markup'], ForceReply)
|
||||||
|
assert args['reply_markup'].selective is False
|
||||||
|
assert args['reply_markup'].input_field_placeholder == 'us_input'
|
||||||
|
assert 'Please reply' not in args['text']
|
||||||
|
assert args['text'].startswith('[人工介入]')
|
||||||
|
assert 'us_input (paragraph)' in args['text']
|
||||||
|
assert adapter._form_action_titles == {}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
logger_module = types.ModuleType('langbot.pkg.platform.logger')
|
||||||
|
logger_module.EventLogger = object
|
||||||
|
sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
|
||||||
|
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
|
||||||
|
WecomBotClient,
|
||||||
|
build_button_interaction_payload,
|
||||||
|
build_human_input_template_card_payload,
|
||||||
|
build_button_interaction_update_card,
|
||||||
|
build_multiple_interaction_update_card,
|
||||||
|
extract_template_card_event_payload,
|
||||||
|
extract_template_card_selections,
|
||||||
|
extract_wecom_event_type,
|
||||||
|
extract_template_card_action,
|
||||||
|
build_human_input_text_prompt,
|
||||||
|
parse_select_button_action,
|
||||||
|
)
|
||||||
|
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_template_card_action_supports_nested_button_key():
|
||||||
|
task_id, event_key, card_type = extract_template_card_action(
|
||||||
|
{
|
||||||
|
'taskId': 'task-1',
|
||||||
|
'cardType': 'button_interaction',
|
||||||
|
'button': {'key': 'approve'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task_id == 'task-1'
|
||||||
|
assert event_key == 'approve'
|
||||||
|
assert card_type == 'button_interaction'
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_wecom_event_type_supports_top_level_template_card_event():
|
||||||
|
payload = {
|
||||||
|
'eventtype': 'template_card_event',
|
||||||
|
'template_card_event': {
|
||||||
|
'TaskId': 'task-1',
|
||||||
|
'CardType': 'multiple_interaction',
|
||||||
|
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert extract_wecom_event_type(payload) == 'template_card_event'
|
||||||
|
assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields():
|
||||||
|
payload = {
|
||||||
|
'TaskId': 'task-1',
|
||||||
|
'CardType': 'button_interaction',
|
||||||
|
'EventKey': 'approve',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert extract_wecom_event_type(payload) == 'template_card_event'
|
||||||
|
assert extract_template_card_event_payload(payload)['EventKey'] == 'approve'
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_button_interaction_update_card_marks_clicked_button():
|
||||||
|
card = build_button_interaction_update_card(
|
||||||
|
{
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'form_content': 'Please choose one action.',
|
||||||
|
'actions': [
|
||||||
|
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
||||||
|
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
action_id='reject',
|
||||||
|
source={'desc': 'LangBot'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert card['main_title'] == {'title': 'Manual Review'}
|
||||||
|
assert card['sub_title_text'] == 'Please choose one action.'
|
||||||
|
assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'}
|
||||||
|
assert card['button_list'][1] == {
|
||||||
|
'text': '✅ Reject',
|
||||||
|
'style': 1,
|
||||||
|
'key': 'reject',
|
||||||
|
'replace_text': '✅ Reject',
|
||||||
|
}
|
||||||
|
assert card['source'] == {'desc': 'LangBot'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_button_interaction_payload_uses_preselected_button_styles_before_click():
|
||||||
|
payload = build_button_interaction_payload(
|
||||||
|
{
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'actions': [
|
||||||
|
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
||||||
|
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload['template_card']['button_list'] == [
|
||||||
|
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
||||||
|
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_payload_uses_multiple_interaction_for_pending_select_field():
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'inputs': {},
|
||||||
|
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
source={'desc': 'LangBot'},
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert card['card_type'] == 'multiple_interaction'
|
||||||
|
assert card['source'] == {'desc': 'LangBot'}
|
||||||
|
assert card['select_list'] == [
|
||||||
|
{
|
||||||
|
'question_key': 'choice',
|
||||||
|
'title': 'choice',
|
||||||
|
'selected_id': 'opt_1',
|
||||||
|
'option_list': [
|
||||||
|
{'id': 'opt_1', 'text': 'A'},
|
||||||
|
{'id': 'opt_2', 'text': 'B'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
|
||||||
|
form_data = {
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'inputs': {},
|
||||||
|
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||||
|
}
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
form_data,
|
||||||
|
task_id='task-1',
|
||||||
|
select_as_buttons=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert card['card_type'] == 'button_interaction'
|
||||||
|
assert card['button_list'][0]['text'] == 'A'
|
||||||
|
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt():
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'us_input',
|
||||||
|
'type': 'paragraph',
|
||||||
|
'label': '请输入你的问题',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'inputs': {},
|
||||||
|
'actions': [{'id': 'yes', 'title': 'yes'}],
|
||||||
|
'_current_input_field': 'us_input',
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert 'desc' not in card['main_title']
|
||||||
|
assert card['sub_title_text'] == '11\n请输入你的问题'
|
||||||
|
assert card['button_list'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_human_input_text_prompt_for_current_text_field():
|
||||||
|
prompt = build_human_input_text_prompt(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'us_input',
|
||||||
|
'type': 'paragraph',
|
||||||
|
'label': '请输入你的问题',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'_current_input_field': 'us_input',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prompt == '人工介入\n\n11\n请输入你的问题'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
||||||
|
client = WecomBotWsClient('bot-id', 'secret', object())
|
||||||
|
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
||||||
|
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_reply_text(req_id, content):
|
||||||
|
sent.append((req_id, content))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.reply_text = fake_reply_text
|
||||||
|
|
||||||
|
ok, stream_id, task_id = await client.push_form_pause(
|
||||||
|
'msg-1',
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'us_input',
|
||||||
|
'type': 'paragraph',
|
||||||
|
'label': '请输入你的问题',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'_current_input_field': 'us_input',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert stream_id == 'stream-1'
|
||||||
|
assert task_id is None
|
||||||
|
assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')]
|
||||||
|
assert client._pending_forms_by_task == {}
|
||||||
|
assert 'msg-1' not in client._stream_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
|
||||||
|
client = WecomBotWsClient('bot-id', 'secret', object())
|
||||||
|
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
||||||
|
client._stream_sessions['msg-1'] = {}
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
|
||||||
|
sent.append((req_id, stream_id, content, finish))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client.reply_stream = fake_reply_stream
|
||||||
|
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||||||
|
|
||||||
|
assert sent == [
|
||||||
|
('req-1', 'stream-1', '你', False),
|
||||||
|
('req-1', 'stream-1', '你好', False),
|
||||||
|
('req-1', 'stream-1', '你好', True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
|
||||||
|
client = WecomBotClient('', '', '', object(), unified_mode=True)
|
||||||
|
session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-1', 'chatid': '', 'from': {'userid': 'user-1'}})
|
||||||
|
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||||||
|
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||||||
|
|
||||||
|
chunks = [
|
||||||
|
await client.stream_sessions.consume(session.stream_id),
|
||||||
|
await client.stream_sessions.consume(session.stream_id),
|
||||||
|
await client.stream_sessions.consume(session.stream_id),
|
||||||
|
]
|
||||||
|
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
|
||||||
|
('你', False),
|
||||||
|
('你好', False),
|
||||||
|
('你好', True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_input_payload_keeps_action_select_stage_as_buttons():
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'inputs': {'choice': 'B'},
|
||||||
|
'actions': [
|
||||||
|
{'id': 'approve', 'title': 'Approve'},
|
||||||
|
{'id': 'reject', 'title': 'Reject'},
|
||||||
|
],
|
||||||
|
'_action_select_only': True,
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert card['card_type'] == 'button_interaction'
|
||||||
|
assert card['button_list'] == [
|
||||||
|
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
||||||
|
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_template_card_selections_maps_selected_id_to_option_text():
|
||||||
|
selections = extract_template_card_selections(
|
||||||
|
{
|
||||||
|
'SelectedItems': [
|
||||||
|
{'QuestionKey': 'choice', 'SelectedId': 'opt_2'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selections == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_template_card_selections_reads_nested_response_data_json():
|
||||||
|
selections = extract_template_card_selections(
|
||||||
|
{
|
||||||
|
'CardType': 'multiple_interaction',
|
||||||
|
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selections == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_template_card_selections_reads_response_data_direct_mapping():
|
||||||
|
selections = extract_template_card_selections(
|
||||||
|
{
|
||||||
|
'CardType': 'multiple_interaction',
|
||||||
|
'EventKey': 'submit_human_input',
|
||||||
|
'ResponseData': '{"choice":"opt_2"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selections == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text():
|
||||||
|
card = build_multiple_interaction_update_card(
|
||||||
|
{
|
||||||
|
'node_title': 'Manual Review',
|
||||||
|
'form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||||
|
'raw_form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'choice',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'_current_input_field': 'choice',
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
selections={'choice': 'B'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert card['card_type'] == 'multiple_interaction'
|
||||||
|
assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B'
|
||||||
|
assert card['submit_button']['text'] == '✅'
|
||||||
|
assert card['select_list'][0]['disable'] is True
|
||||||
|
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_stage_only_shows_current_prompt_in_a_separate_message():
|
||||||
|
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': '请选择你的答案\n{{#$output.xiala#}}',
|
||||||
|
'raw_form_content': raw_content,
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'xiala',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{
|
||||||
|
'output_variable_name': 'xiala',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': '你叫啥'},
|
||||||
|
'_current_input_field': 'xiala',
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload['template_card']['main_title']['desc'] == '请选择你的答案'
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
||||||
|
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作'
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': raw_content,
|
||||||
|
'raw_form_content': raw_content,
|
||||||
|
'input_defs': [],
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': '你叫啥', 'xiala': '2'},
|
||||||
|
'actions': [
|
||||||
|
{'id': 'yes', 'title': 'yes'},
|
||||||
|
{'id': 'no', 'title': 'no'},
|
||||||
|
],
|
||||||
|
'_action_select_only': True,
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert card['sub_title_text'] == '请选择操作'
|
||||||
|
assert '{{#$output.' not in card['sub_title_text']
|
||||||
|
assert [button['text'] for button in card['button_list']] == ['yes', 'no']
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -417,7 +417,7 @@ class TestBuildBoxSessionPayload:
|
|||||||
payload = s._build_box_session_payload('session-123')
|
payload = s._build_box_session_payload('session-123')
|
||||||
assert payload['image'] == 'node:20'
|
assert payload['image'] == 'node:20'
|
||||||
assert payload['cpus'] == 2.0
|
assert payload['cpus'] == 2.0
|
||||||
assert payload['memory_mb'] == 1024
|
assert payload["memory_mb"] == 1024
|
||||||
assert payload['pids_limit'] == 256
|
assert payload['pids_limit'] == 256
|
||||||
|
|
||||||
def test_none_fields_excluded(self, mcp_module):
|
def test_none_fields_excluded(self, mcp_module):
|
||||||
@@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict:
|
|||||||
assert info['box_session_id'] == 'mcp-shared'
|
assert info['box_session_id'] == 'mcp-shared'
|
||||||
assert info['box_enabled'] is True
|
assert info['box_enabled'] is True
|
||||||
|
|
||||||
def test_transient_test_session_is_isolated_from_shared(self, mcp_module):
|
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
|
||||||
"""A transient test session (config-page "test", no persisted UUID)
|
"""A transient config-page "test" now shares the same 'mcp-shared' Box
|
||||||
must NOT share the live "mcp-shared" Box session. Regression: a failing
|
session as live servers (so a test reuses the running container / live
|
||||||
test churned the shared session and tore down healthy live servers."""
|
process instead of a cold per-test session bootstrap). Isolation is at
|
||||||
|
the PROCESS level: the test runs under its own process_id and only ever
|
||||||
|
stops that process_id, so it cannot disturb another server's live
|
||||||
|
process or the shared session itself."""
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service.available = True
|
ap.box_service.available = True
|
||||||
transient = _make_session(
|
transient = _make_session(
|
||||||
@@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict:
|
|||||||
)
|
)
|
||||||
assert transient.is_transient is True
|
assert transient.is_transient is True
|
||||||
assert live.is_transient is False
|
assert live.is_transient is False
|
||||||
# Isolated session id for the test, shared for the live server.
|
# Both share ONE Box session ...
|
||||||
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123'
|
assert transient._build_box_session_id() == 'mcp-shared'
|
||||||
assert live._build_box_session_id() == 'mcp-shared'
|
assert live._build_box_session_id() == 'mcp-shared'
|
||||||
assert transient._build_box_session_id() != live._build_box_session_id()
|
assert transient._build_box_session_id() == live._build_box_session_id()
|
||||||
|
# ... but are isolated by distinct process_ids within that session.
|
||||||
|
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
||||||
|
|
||||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
||||||
"""Policy: when Box is configured but unavailable (disabled in config
|
"""Policy: when Box is configured but unavailable (disabled in config
|
||||||
@@ -824,3 +829,129 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
|
|||||||
assert process_payload['command'] == 'python'
|
assert process_payload['command'] == 'python'
|
||||||
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
||||||
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
|
||||||
|
"""During a slow (npx) cold start the handshake fails while the managed
|
||||||
|
process is still alive. initialize() must raise _ColdStartRetry (so the
|
||||||
|
outer lifecycle loop reuses the live process and retries without stopping it
|
||||||
|
or consuming the fatal budget), NOT a fatal error."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||||
|
|
||||||
|
class ColdClientSession:
|
||||||
|
def __init__(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
# Process still cold-starting: handshake fails.
|
||||||
|
raise Exception('Connection closed')
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_websocket_client(_url: str):
|
||||||
|
yield ('read-stream', 'write-stream')
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||||
|
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = True
|
||||||
|
ap.box_service.create_session = AsyncMock(return_value={})
|
||||||
|
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||||
|
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||||
|
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{
|
||||||
|
'name': 'slow',
|
||||||
|
'uuid': 'slow-uuid',
|
||||||
|
'mode': 'stdio',
|
||||||
|
'command': 'npx',
|
||||||
|
'args': ['-y', 'some-mcp'],
|
||||||
|
},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process is NOT exited (still cold-starting) and not yet running for reuse.
|
||||||
|
async def _not_exited():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_has_exited = _not_exited
|
||||||
|
|
||||||
|
async def _not_running():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||||
|
|
||||||
|
with pytest.raises(mcp_stdio_module._ColdStartRetry):
|
||||||
|
await session._init_box_stdio_server()
|
||||||
|
|
||||||
|
# Process was started exactly once (the retry will reuse it, not rebuild).
|
||||||
|
assert ap.box_service.start_managed_process.await_count == 1
|
||||||
|
await session.exit_stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
|
||||||
|
"""If the handshake fails AND the process has definitively exited, that is a
|
||||||
|
real failure — initialize() must NOT swallow it as a cold-start retry."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||||
|
|
||||||
|
class DeadClientSession:
|
||||||
|
def __init__(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
raise Exception('Connection closed')
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_websocket_client(_url: str):
|
||||||
|
yield ('read-stream', 'write-stream')
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||||
|
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||||
|
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = True
|
||||||
|
ap.box_service.create_session = AsyncMock(return_value={})
|
||||||
|
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||||
|
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||||
|
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _exited():
|
||||||
|
return True
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_has_exited = _exited
|
||||||
|
|
||||||
|
async def _not_running():
|
||||||
|
return False
|
||||||
|
|
||||||
|
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as ei:
|
||||||
|
await session._init_box_stdio_server()
|
||||||
|
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
|
||||||
|
await session.exit_stack.aclose()
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
from mcp import types as mcp_types
|
||||||
|
|
||||||
|
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
|
||||||
|
|
||||||
|
|
||||||
|
class _TransportProbe:
|
||||||
|
def __init__(self, streamable_status: int | None) -> None:
|
||||||
|
self.streamable_status = streamable_status
|
||||||
|
self.streamable_posts = 0
|
||||||
|
self.streamable_messages: list[str] = []
|
||||||
|
self.sse_gets = 0
|
||||||
|
self.sse_messages: list[str] = []
|
||||||
|
self.streamable_request_started = asyncio.Event()
|
||||||
|
self.release_streamable_request = asyncio.Event()
|
||||||
|
self._sse_response: web.StreamResponse | None = None
|
||||||
|
|
||||||
|
async def handle_mcp_endpoint(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
if request.method == 'POST':
|
||||||
|
self.streamable_posts += 1
|
||||||
|
self.streamable_request_started.set()
|
||||||
|
if self.streamable_status is None:
|
||||||
|
await self.release_streamable_request.wait()
|
||||||
|
return web.Response(status=204)
|
||||||
|
if self.streamable_status == 200:
|
||||||
|
message = await request.json()
|
||||||
|
method = message.get('method', '')
|
||||||
|
self.streamable_messages.append(method)
|
||||||
|
if method == 'initialize':
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': message['id'],
|
||||||
|
'result': {
|
||||||
|
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
|
||||||
|
'capabilities': {'tools': {}},
|
||||||
|
'serverInfo': {'name': 'streamable-test', 'version': '1.0.0'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if method == 'tools/list':
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': message['id'],
|
||||||
|
'result': {
|
||||||
|
'tools': [
|
||||||
|
{
|
||||||
|
'name': 'echo',
|
||||||
|
'description': 'Echo test input',
|
||||||
|
'inputSchema': {'type': 'object'},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return web.Response(status=202)
|
||||||
|
return web.Response(status=self.streamable_status)
|
||||||
|
|
||||||
|
self.sse_gets += 1
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await response.prepare(request)
|
||||||
|
self._sse_response = response
|
||||||
|
await response.write(b'event: endpoint\ndata: /messages?session_id=test-session\n\n')
|
||||||
|
try:
|
||||||
|
while request.transport is not None and not request.transport.is_closing():
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def handle_sse_message(self, request: web.Request) -> web.Response:
|
||||||
|
message = await request.json()
|
||||||
|
method = message.get('method', '')
|
||||||
|
self.sse_messages.append(method)
|
||||||
|
|
||||||
|
if method == 'initialize':
|
||||||
|
response_message = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': message['id'],
|
||||||
|
'result': {
|
||||||
|
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
|
||||||
|
'capabilities': {},
|
||||||
|
'serverInfo': {'name': 'legacy-sse-test', 'version': '1.0.0'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert self._sse_response is not None
|
||||||
|
payload = json.dumps(response_message, separators=(',', ':'))
|
||||||
|
await self._sse_response.write(f'event: message\ndata: {payload}\n\n'.encode())
|
||||||
|
|
||||||
|
return web.Response(status=202)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _transport_server(streamable_status: int | None):
|
||||||
|
probe = _TransportProbe(streamable_status)
|
||||||
|
application = web.Application()
|
||||||
|
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
||||||
|
application.router.add_post('/messages', probe.handle_sse_message)
|
||||||
|
runner = web.AppRunner(application, shutdown_timeout=0.1)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, '127.0.0.1', 0)
|
||||||
|
await site.start()
|
||||||
|
server = cast(asyncio.Server, site._server)
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
try:
|
||||||
|
yield probe, f'http://127.0.0.1:{port}/mcp'
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
|
||||||
|
app = cast(Any, SimpleNamespace(logger=Mock()))
|
||||||
|
return RuntimeMCPSession(
|
||||||
|
'remote-transport-test',
|
||||||
|
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
|
||||||
|
True,
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_http_status(exc: BaseException, status_code: int) -> bool:
|
||||||
|
return any(
|
||||||
|
isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == status_code
|
||||||
|
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_session(session: RuntimeMCPSession) -> None:
|
||||||
|
await session.exit_stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_transport_real_streamable_http_success_keeps_session_usable():
|
||||||
|
async with _transport_server(200) as (probe, url):
|
||||||
|
session = _session(url)
|
||||||
|
try:
|
||||||
|
await session._init_remote_server()
|
||||||
|
assert session.session is not None
|
||||||
|
tools = await session.session.list_tools()
|
||||||
|
assert [tool.name for tool in tools.tools] == ['echo']
|
||||||
|
assert probe.streamable_posts >= 2
|
||||||
|
assert probe.streamable_messages[:2] == ['initialize', 'notifications/initialized']
|
||||||
|
assert 'tools/list' in probe.streamable_messages
|
||||||
|
assert probe.sse_gets == 0
|
||||||
|
finally:
|
||||||
|
await _close_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('status_code', [400, 404, 405])
|
||||||
|
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
|
||||||
|
async with _transport_server(status_code) as (probe, url):
|
||||||
|
session = _session(url)
|
||||||
|
try:
|
||||||
|
await session._init_remote_server()
|
||||||
|
assert session.session is not None
|
||||||
|
assert probe.streamable_posts == 1
|
||||||
|
assert probe.sse_gets == 1
|
||||||
|
assert 'initialize' in probe.sse_messages
|
||||||
|
finally:
|
||||||
|
await _close_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('status_code', [401, 403, 406, 415, 429, 500])
|
||||||
|
async def test_remote_transport_real_non_compatibility_error_does_not_fallback(status_code: int):
|
||||||
|
async with _transport_server(status_code) as (probe, url):
|
||||||
|
session = _session(url)
|
||||||
|
try:
|
||||||
|
with pytest.raises(BaseException) as exc_info:
|
||||||
|
await session._init_remote_server()
|
||||||
|
assert _contains_http_status(exc_info.value, status_code)
|
||||||
|
assert probe.streamable_posts == 1
|
||||||
|
assert probe.sse_gets == 0
|
||||||
|
finally:
|
||||||
|
await _close_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_transport_real_timeout_does_not_fallback():
|
||||||
|
async with _transport_server(None) as (probe, url):
|
||||||
|
session = _session(url, timeout=0.05)
|
||||||
|
try:
|
||||||
|
with pytest.raises(BaseException) as exc_info:
|
||||||
|
await session._init_remote_server()
|
||||||
|
assert any(
|
||||||
|
isinstance(leaf, httpx.TimeoutException)
|
||||||
|
for leaf in RuntimeMCPSession._iter_exception_leaves(exc_info.value)
|
||||||
|
)
|
||||||
|
assert probe.streamable_posts == 1
|
||||||
|
assert probe.sse_gets == 0
|
||||||
|
finally:
|
||||||
|
probe.release_streamable_request.set()
|
||||||
|
await _close_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('error_type', [httpx.ConnectError, httpx.ConnectTimeout])
|
||||||
|
async def test_remote_transport_connection_errors_do_not_fallback(error_type: type[httpx.RequestError]):
|
||||||
|
request = httpx.Request('POST', 'https://unreachable.invalid/mcp')
|
||||||
|
error = error_type('connection failed', request=request)
|
||||||
|
session = _session(str(request.url))
|
||||||
|
session._init_streamable_http_server = AsyncMock(side_effect=error)
|
||||||
|
session._init_sse_server = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(type(error)) as exc_info:
|
||||||
|
await session._init_remote_server()
|
||||||
|
|
||||||
|
assert exc_info.value is error
|
||||||
|
session._init_sse_server.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fallback():
|
||||||
|
async with _transport_server(None) as (probe, url):
|
||||||
|
session = _session(url)
|
||||||
|
task = asyncio.create_task(session._init_remote_server())
|
||||||
|
await asyncio.wait_for(probe.streamable_request_started.wait(), timeout=2)
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert probe.sse_gets == 0
|
||||||
|
finally:
|
||||||
|
probe.release_streamable_request.set()
|
||||||
|
await _close_session(session)
|
||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
|
|
||||||
@@ -54,6 +55,50 @@ def _query() -> SimpleNamespace:
|
|||||||
return SimpleNamespace(variables={})
|
return SimpleNamespace(variables={})
|
||||||
|
|
||||||
|
|
||||||
|
def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
|
||||||
|
request = httpx.Request('POST', 'https://example.com/mcp')
|
||||||
|
response = httpx.Response(status_code, request=request)
|
||||||
|
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
|
||||||
|
session = RuntimeMCPSession(
|
||||||
|
'remote',
|
||||||
|
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
|
||||||
|
True,
|
||||||
|
_app(),
|
||||||
|
)
|
||||||
|
session._init_streamable_http_server = AsyncMock(
|
||||||
|
side_effect=ExceptionGroup('transport failed', [_http_status_error(405)])
|
||||||
|
)
|
||||||
|
session._init_sse_server = AsyncMock()
|
||||||
|
|
||||||
|
await session._init_remote_server()
|
||||||
|
|
||||||
|
session._init_streamable_http_server.assert_awaited_once()
|
||||||
|
session._init_sse_server.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_transport_does_not_fallback_for_auth_http_status():
|
||||||
|
session = RuntimeMCPSession(
|
||||||
|
'remote',
|
||||||
|
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
|
||||||
|
True,
|
||||||
|
_app(),
|
||||||
|
)
|
||||||
|
error = _http_status_error(403)
|
||||||
|
session._init_streamable_http_server = AsyncMock(side_effect=error)
|
||||||
|
session._init_sse_server = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await session._init_remote_server()
|
||||||
|
|
||||||
|
session._init_streamable_http_server.assert_awaited_once()
|
||||||
|
session._init_sse_server.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_read_resource_envelope_truncates_caches_and_records_trace():
|
async def test_read_resource_envelope_truncates_caches_and_records_trace():
|
||||||
session = _connected_session()
|
session = _connected_session()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestVectorDBManagerInitialization:
|
|||||||
mocks['langbot.pkg.core.app'] = MagicMock()
|
mocks['langbot.pkg.core.app'] = MagicMock()
|
||||||
|
|
||||||
# Mock all VDB backend implementations
|
# Mock all VDB backend implementations
|
||||||
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
|
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db', 'valkey_search']:
|
||||||
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
|
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
|
||||||
|
|
||||||
return mocks
|
return mocks
|
||||||
@@ -123,6 +123,25 @@ class TestVectorDBManagerInitialization:
|
|||||||
|
|
||||||
mock_seekdb_class.assert_called_once_with(mock_app)
|
mock_seekdb_class.assert_called_once_with(mock_app)
|
||||||
|
|
||||||
|
def test_initialize_valkey_search_backend(self):
|
||||||
|
"""Valkey Search config uses ValkeySearchVectorDatabase backend."""
|
||||||
|
vdb_config = {'use': 'valkey_search'}
|
||||||
|
mock_app = self._create_mock_app(vdb_config)
|
||||||
|
|
||||||
|
mocks = self._make_vector_import_mocks()
|
||||||
|
mock_valkey_class = MagicMock()
|
||||||
|
mocks['langbot.pkg.vector.vdbs.valkey_search'].ValkeySearchVectorDatabase = mock_valkey_class
|
||||||
|
|
||||||
|
with isolated_sys_modules(mocks):
|
||||||
|
from langbot.pkg.vector.mgr import VectorDBManager
|
||||||
|
|
||||||
|
mgr = VectorDBManager(mock_app)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||||
|
|
||||||
|
mock_valkey_class.assert_called_once_with(mock_app)
|
||||||
|
|
||||||
def test_initialize_milvus_backend_with_uri(self):
|
def test_initialize_milvus_backend_with_uri(self):
|
||||||
"""Milvus config with custom URI."""
|
"""Milvus config with custom URI."""
|
||||||
vdb_config = {
|
vdb_config = {
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Unit tests for the Valkey Search VDB backend's pure helpers.
|
||||||
|
|
||||||
|
These tests exercise the filter-to-FT mapping, float32 packing, tag/text
|
||||||
|
escaping, FT.SEARCH reply parsing and the import guard. They run in the fast
|
||||||
|
CI lane and require NO running Valkey server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
from importlib import import_module
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def get_valkey_module():
|
||||||
|
"""Lazy import of the valkey_search backend module."""
|
||||||
|
return import_module('langbot.pkg.vector.vdbs.valkey_search')
|
||||||
|
|
||||||
|
|
||||||
|
def make_backend():
|
||||||
|
"""Construct a backend instance without running its __init__.
|
||||||
|
|
||||||
|
The constructor needs a live ``ap`` + config; for pure-helper tests we
|
||||||
|
only need a bare instance with the attributes the helpers touch.
|
||||||
|
"""
|
||||||
|
mod = get_valkey_module()
|
||||||
|
backend = object.__new__(mod.ValkeySearchVectorDatabase)
|
||||||
|
# _ensure_client serializes creation through this lock; set it here since
|
||||||
|
# __init__ (which normally creates it) is bypassed.
|
||||||
|
backend._client_lock = asyncio.Lock()
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
class TestFloat32Packing:
|
||||||
|
"""Tests for _pack_vector little-endian float32 packing."""
|
||||||
|
|
||||||
|
def test_pack_round_trips(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
vec = [0.1, -2.5, 3.0, 4.25]
|
||||||
|
packed = mod.ValkeySearchVectorDatabase._pack_vector(vec)
|
||||||
|
assert isinstance(packed, bytes)
|
||||||
|
assert len(packed) == 4 * len(vec)
|
||||||
|
unpacked = list(struct.unpack(f'<{len(vec)}f', packed))
|
||||||
|
for original, restored in zip(vec, unpacked):
|
||||||
|
assert restored == pytest.approx(original, rel=1e-6)
|
||||||
|
|
||||||
|
def test_pack_is_little_endian(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
packed = mod.ValkeySearchVectorDatabase._pack_vector([1.0])
|
||||||
|
assert packed == struct.pack('<f', 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagEscaping:
|
||||||
|
"""Tests for _escape_tag."""
|
||||||
|
|
||||||
|
def test_escapes_special_chars(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
escaped = mod.ValkeySearchVectorDatabase._escape_tag('a-b c.d')
|
||||||
|
assert '\\-' in escaped
|
||||||
|
assert '\\ ' in escaped
|
||||||
|
assert '\\.' in escaped
|
||||||
|
|
||||||
|
def test_plain_value_unchanged(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
assert mod.ValkeySearchVectorDatabase._escape_tag('abc123') == 'abc123'
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileIdEncoding:
|
||||||
|
"""Tests for _encode_file_id (FT-unsafe char percent-encoding)."""
|
||||||
|
|
||||||
|
def test_uuid_is_noop(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
fid = '550e8400-e29b-41d4-a716-446655440000'
|
||||||
|
assert mod.ValkeySearchVectorDatabase._encode_file_id(fid) == fid
|
||||||
|
|
||||||
|
def test_encodes_braces_star_and_percent(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
enc = mod.ValkeySearchVectorDatabase._encode_file_id('a{b}c*d%e')
|
||||||
|
# '{'=7B '}'=7D '*'=2A '%'=25
|
||||||
|
assert enc == 'a%7Bb%7Dc%2Ad%25e'
|
||||||
|
# No raw FT-unsafe char survives.
|
||||||
|
assert all(ch not in enc for ch in '{}*') or '%' in enc
|
||||||
|
|
||||||
|
def test_encoding_is_deterministic_and_collision_safe(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
enc = mod.ValkeySearchVectorDatabase._encode_file_id
|
||||||
|
# A literal "%7B" must not collide with an encoded "{".
|
||||||
|
assert enc('{') != enc('%7B')
|
||||||
|
assert enc('{') == '%7B'
|
||||||
|
assert enc('%7B') == '%257B'
|
||||||
|
|
||||||
|
def test_filter_encodes_unsafe_chars_in_tag_query(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# The emitted TAG query must contain the encoded form, never raw braces.
|
||||||
|
frag = backend._triples_to_ft({'file_id': 'x}y{z*'})
|
||||||
|
assert '7D' in frag and '7B' in frag and '2A' in frag
|
||||||
|
# No raw '*' from the value, and exactly one opening/closing brace (the
|
||||||
|
# TAG-clause delimiters) — the value's own braces were encoded away.
|
||||||
|
assert '*' not in frag
|
||||||
|
assert frag.count('{') == 1 and frag.count('}') == 1
|
||||||
|
assert frag.startswith('@file_id:{') and frag.endswith('}')
|
||||||
|
|
||||||
|
def test_filter_in_operator_encodes_each_value(self):
|
||||||
|
backend = make_backend()
|
||||||
|
frag = backend._triples_to_ft({'file_id': {'$in': ['a*b', 'c}d']}})
|
||||||
|
assert '2A' in frag and '7D' in frag
|
||||||
|
assert '*' not in frag
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterToFt:
|
||||||
|
"""Tests for _triples_to_ft filter mapping (all 8 operators)."""
|
||||||
|
|
||||||
|
def test_empty_filter_returns_empty_string(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft(None) == ''
|
||||||
|
assert backend._triples_to_ft({}) == ''
|
||||||
|
|
||||||
|
def test_eq_tag(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft({'file_id': 'abc'}) == '@file_id:{abc}'
|
||||||
|
|
||||||
|
def test_explicit_eq_tag(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$eq': 'abc'}}) == '@file_id:{abc}'
|
||||||
|
|
||||||
|
def test_ne_tag(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$ne': 'abc'}}) == '-@file_id:{abc}'
|
||||||
|
|
||||||
|
def test_in_tag(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$in': ['a', 'b']}}) == '@file_id:{a|b}'
|
||||||
|
|
||||||
|
def test_nin_tag(self):
|
||||||
|
backend = make_backend()
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$nin': ['a', 'b']}}) == '-@file_id:{a|b}'
|
||||||
|
|
||||||
|
def test_numeric_range_operators(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# file_id is the only indexed field; numeric ops still render via the
|
||||||
|
# generic range fragment, so use file_id to keep the field supported.
|
||||||
|
# Values are cast to float (defensive against non-numeric input and a
|
||||||
|
# future NUMERIC field becoming an injection surface).
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$gt': 5}}) == '@file_id:[(5.0 +inf]'
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$gte': 5}}) == '@file_id:[5.0 +inf]'
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$lt': 5}}) == '@file_id:[-inf (5.0]'
|
||||||
|
assert backend._triples_to_ft({'file_id': {'$lte': 5}}) == '@file_id:[-inf 5.0]'
|
||||||
|
|
||||||
|
def test_numeric_range_rejects_non_numeric(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# A non-numeric range value fails closed rather than interpolating raw.
|
||||||
|
with pytest.raises((ValueError, TypeError)):
|
||||||
|
backend._triples_to_ft({'file_id': {'$gt': 'not-a-number'}})
|
||||||
|
|
||||||
|
def test_unsupported_field_dropped(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# Non-indexed fields are dropped (returns empty expression).
|
||||||
|
assert backend._triples_to_ft({'some_other_field': 'x'}) == ''
|
||||||
|
|
||||||
|
def test_multiple_supported_keys_anded(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# Two conditions on the same indexed field are joined with a space (AND).
|
||||||
|
result = backend._triples_to_ft({'file_id': {'$in': ['a', 'b']}})
|
||||||
|
assert result == '@file_id:{a|b}'
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextEscaping:
|
||||||
|
"""Tests for _escape_text full-text escaping."""
|
||||||
|
|
||||||
|
def test_escapes_ft_special_chars(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
escaped = mod.ValkeySearchVectorDatabase._escape_text('hello@world|test')
|
||||||
|
assert '\\@' in escaped
|
||||||
|
assert '\\|' in escaped
|
||||||
|
|
||||||
|
|
||||||
|
class TestReplyToChroma:
|
||||||
|
"""Tests for _reply_to_chroma FT.SEARCH reply parsing."""
|
||||||
|
|
||||||
|
def test_parses_knn_reply(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# glide returns [total, {key: {field: value}}]
|
||||||
|
reply = [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
b'kb:col1:id1': {
|
||||||
|
b'distance': b'0.10',
|
||||||
|
b'document': b'hello',
|
||||||
|
b'metadata_json': b'{"file_id": "f1"}',
|
||||||
|
},
|
||||||
|
b'kb:col1:id2': {
|
||||||
|
b'distance': b'0.25',
|
||||||
|
b'document': b'world',
|
||||||
|
b'metadata_json': b'{"file_id": "f2"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = backend._reply_to_chroma('idx:col1', reply, has_distance=True)
|
||||||
|
assert result['ids'][0] == ['id1', 'id2']
|
||||||
|
assert result['distances'][0] == [pytest.approx(0.10), pytest.approx(0.25)]
|
||||||
|
assert result['metadatas'][0][0] == {'file_id': 'f1'}
|
||||||
|
assert result['metadatas'][0][1] == {'file_id': 'f2'}
|
||||||
|
|
||||||
|
def test_empty_reply(self):
|
||||||
|
backend = make_backend()
|
||||||
|
result = backend._reply_to_chroma('idx:col1', [0, {}], has_distance=True)
|
||||||
|
assert result == {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
|
||||||
|
def test_malformed_reply(self):
|
||||||
|
backend = make_backend()
|
||||||
|
result = backend._reply_to_chroma('idx:col1', [], has_distance=True)
|
||||||
|
assert result == {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||||
|
|
||||||
|
def test_text_search_reply_no_distance(self):
|
||||||
|
backend = make_backend()
|
||||||
|
reply = [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
b'kb:col1:id1': {
|
||||||
|
b'document': b'hello',
|
||||||
|
b'metadata_json': b'{"file_id": "f1"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = backend._reply_to_chroma('idx:col1', reply, has_distance=False)
|
||||||
|
assert result['ids'][0] == ['id1']
|
||||||
|
assert result['distances'][0] == [0.0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportGuard:
|
||||||
|
"""Tests for the ImportError guard when glide is unavailable."""
|
||||||
|
|
||||||
|
def test_constructor_raises_when_unavailable(self, monkeypatch):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
monkeypatch.setattr(mod, 'VALKEY_SEARCH_AVAILABLE', False)
|
||||||
|
with pytest.raises(ImportError, match='valkey-glide'):
|
||||||
|
mod.ValkeySearchVectorDatabase(ap=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupportedSearchTypes:
|
||||||
|
"""Tests for supported_search_types."""
|
||||||
|
|
||||||
|
def test_supports_vector_full_text_hybrid(self):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
from langbot.pkg.vector.vdb import SearchType
|
||||||
|
|
||||||
|
types = mod.ValkeySearchVectorDatabase.supported_search_types()
|
||||||
|
assert SearchType.VECTOR in types
|
||||||
|
assert SearchType.FULL_TEXT in types
|
||||||
|
assert SearchType.HYBRID in types
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteByFilterGuard:
|
||||||
|
"""Regression tests for the delete_by_filter mass-deletion guard.
|
||||||
|
|
||||||
|
A non-empty filter referencing only non-indexed fields must NOT fall back
|
||||||
|
to match-all and wipe the whole collection: it must skip and return 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_unsupported_only_filter_skips_and_returns_zero(self):
|
||||||
|
backend = make_backend()
|
||||||
|
# Make the client/index lookups succeed without a real server.
|
||||||
|
backend._client = AsyncMock()
|
||||||
|
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
|
||||||
|
backend._ensure_client = AsyncMock(return_value=backend._client)
|
||||||
|
backend._index_exists = AsyncMock(return_value=True)
|
||||||
|
# _search_keys must never be reached for an unusable filter.
|
||||||
|
backend._search_keys = AsyncMock(
|
||||||
|
side_effect=AssertionError('_search_keys must not be called for an unusable filter')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter references only a non-indexed field -> maps to no FT conditions.
|
||||||
|
deleted = await backend.delete_by_filter('col1', {'some_other_field': 'x'})
|
||||||
|
|
||||||
|
assert deleted == 0
|
||||||
|
backend._client.delete.assert_not_called()
|
||||||
|
|
||||||
|
async def test_supported_filter_deletes_matching_keys(self):
|
||||||
|
backend = make_backend()
|
||||||
|
backend._client = AsyncMock()
|
||||||
|
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
|
||||||
|
backend._ensure_client = AsyncMock(return_value=backend._client)
|
||||||
|
backend._index_exists = AsyncMock(return_value=True)
|
||||||
|
backend._search_keys = AsyncMock(return_value=['kb:col1:id1', 'kb:col1:id2'])
|
||||||
|
|
||||||
|
deleted = await backend.delete_by_filter('col1', {'file_id': 'f1'})
|
||||||
|
|
||||||
|
assert deleted == 2
|
||||||
|
backend._client.delete.assert_awaited_once_with(['kb:col1:id1', 'kb:col1:id2'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestClose:
|
||||||
|
"""Tests for the close() teardown."""
|
||||||
|
|
||||||
|
async def test_close_resets_client_and_indexes(self):
|
||||||
|
backend = make_backend()
|
||||||
|
client = AsyncMock()
|
||||||
|
backend._client = client
|
||||||
|
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
|
||||||
|
backend._ensured_indexes = {'idx:col1'}
|
||||||
|
|
||||||
|
await backend.close()
|
||||||
|
|
||||||
|
client.close.assert_awaited_once()
|
||||||
|
assert backend._client is None
|
||||||
|
assert backend._ensured_indexes == set()
|
||||||
|
|
||||||
|
async def test_close_is_noop_when_no_client(self):
|
||||||
|
backend = make_backend()
|
||||||
|
backend._client = None
|
||||||
|
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
|
||||||
|
backend._ensured_indexes = set()
|
||||||
|
# Should not raise.
|
||||||
|
await backend.close()
|
||||||
|
assert backend._client is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCredentialsBuild:
|
||||||
|
"""Tests for the auth-credential construction in _ensure_client."""
|
||||||
|
|
||||||
|
def _prep_backend(self, mod, monkeypatch, *, username, password):
|
||||||
|
backend = make_backend()
|
||||||
|
backend._client = None
|
||||||
|
backend._host = 'localhost'
|
||||||
|
backend._port = 6379
|
||||||
|
backend._db = 0
|
||||||
|
backend._tls = False
|
||||||
|
backend._username = username
|
||||||
|
backend._password = password
|
||||||
|
backend._request_timeout = 5000
|
||||||
|
backend._ensured_indexes = set()
|
||||||
|
warnings: list[str] = []
|
||||||
|
backend.ap = type(
|
||||||
|
'Ap',
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
'logger': type(
|
||||||
|
'L', (), {'info': lambda self, *a, **k: None, 'warning': lambda s, m, *a, **k: warnings.append(m)}
|
||||||
|
)()
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
@staticmethod
|
||||||
|
async def create(conf):
|
||||||
|
created['conf'] = conf
|
||||||
|
return AsyncMock()
|
||||||
|
|
||||||
|
cred_calls: list[dict] = []
|
||||||
|
|
||||||
|
def _fake_credentials(**kwargs):
|
||||||
|
cred_calls.append(kwargs)
|
||||||
|
return ('CRED', kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, 'GlideClient', _FakeClient)
|
||||||
|
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials)
|
||||||
|
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw)
|
||||||
|
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k))
|
||||||
|
return backend, created, cred_calls, warnings
|
||||||
|
|
||||||
|
async def test_username_without_password_fails_closed(self, monkeypatch):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
backend, created, cred_calls, warnings = self._prep_backend(mod, monkeypatch, username='acluser', password=None)
|
||||||
|
|
||||||
|
# A username without a password must fail closed rather than silently
|
||||||
|
# connecting unauthenticated to a (potentially shared) Valkey instance.
|
||||||
|
with pytest.raises(ValueError, match='without a password'):
|
||||||
|
await backend._ensure_client()
|
||||||
|
|
||||||
|
assert cred_calls == [] # ServerCredentials NOT constructed
|
||||||
|
assert 'conf' not in created # client never created
|
||||||
|
|
||||||
|
async def test_password_builds_credentials(self, monkeypatch):
|
||||||
|
mod = get_valkey_module()
|
||||||
|
backend, created, cred_calls, warnings = self._prep_backend(
|
||||||
|
mod, monkeypatch, username='acluser', password='secret'
|
||||||
|
)
|
||||||
|
|
||||||
|
await backend._ensure_client()
|
||||||
|
|
||||||
|
assert len(cred_calls) == 1
|
||||||
|
assert cred_calls[0] == {'password': 'secret', 'username': 'acluser'}
|
||||||
|
assert created['conf']['credentials'] == ('CRED', {'password': 'secret', 'username': 'acluser'})
|
||||||
@@ -2084,6 +2084,7 @@ dependencies = [
|
|||||||
{ name = "tiktoken" },
|
{ name = "tiktoken" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
{ name = "uv" },
|
{ name = "uv" },
|
||||||
|
{ name = "valkey-glide" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2123,7 +2124,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.7" },
|
{ name = "langbot-plugin", specifier = "==0.4.13" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2172,6 +2173,7 @@ requires-dist = [
|
|||||||
{ name = "tiktoken", specifier = ">=0.9.0" },
|
{ name = "tiktoken", specifier = ">=0.9.0" },
|
||||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||||
{ name = "uv", specifier = ">=0.11.15" },
|
{ name = "uv", specifier = ">=0.11.15" },
|
||||||
|
{ name = "valkey-glide", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "websockets", specifier = ">=15.0.1" },
|
{ name = "websockets", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2187,7 +2189,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.7"
|
version = "0.4.13"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2208,9 +2210,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/00/6c/850a92cc05583ab71e6ac527ff50fa5f3db43cc16e86f4f6d5ac684da9a8/langbot_plugin-0.4.7.tar.gz", hash = "sha256:16b24d79fc55c0a6b15d901b1134de9e298e2cadab9a9b25c9231f731333ebe9", size = 333867, upload-time = "2026-07-02T16:49:46.258Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/f5/a00a929ffb4dcbd958e5f5249516f19ecc720f25f072c9bfda8bbb56851c/langbot_plugin-0.4.7-py3-none-any.whl", hash = "sha256:1364f80fcf448f4503e87d1367af60438abd2cf16e78be6807032cbdb4710a56", size = 220999, upload-time = "2026-07-02T16:49:44.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5984,6 +5986,39 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valkey-glide"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "protobuf" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152, upload-time = "2026-05-28T21:41:02.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601, upload-time = "2026-05-28T21:41:04.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977, upload-time = "2026-05-28T21:41:06.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446, upload-time = "2026-05-28T21:41:07.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646, upload-time = "2026-05-28T21:41:09.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375, upload-time = "2026-05-28T21:41:11.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469, upload-time = "2026-05-28T21:41:12.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744, upload-time = "2026-05-28T21:41:14.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972, upload-time = "2026-05-28T21:41:16.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012, upload-time = "2026-05-28T21:41:17.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842, upload-time = "2026-05-28T21:41:18.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919, upload-time = "2026-05-28T21:41:20.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906, upload-time = "2026-05-28T21:41:22.135Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495, upload-time = "2026-05-28T21:41:23.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720, upload-time = "2026-05-28T21:41:25.361Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318, upload-time = "2026-05-28T21:41:26.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155, upload-time = "2026-05-28T21:41:42.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571, upload-time = "2026-05-28T21:41:44.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021, upload-time = "2026-05-28T21:41:45.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093, upload-time = "2026-05-28T21:41:47.617Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.36.1"
|
version = "20.36.1"
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default function BotForm({
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
|
url: item.url,
|
||||||
|
download_filename: item.download_filename,
|
||||||
|
help_links: item.help_links,
|
||||||
|
help_label: item.help_label,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Globe,
|
||||||
|
Info,
|
||||||
|
QrCode,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -33,6 +41,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the value referenced by a `show_if.field` string.
|
* Resolve the value referenced by a `show_if.field` string.
|
||||||
@@ -291,6 +300,52 @@ function WebhookUrlField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DownloadLinkField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
helpUrl,
|
||||||
|
helpLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
url: string;
|
||||||
|
filename?: string;
|
||||||
|
helpUrl?: string | null;
|
||||||
|
helpLabel: string;
|
||||||
|
}) {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="min-w-0">
|
||||||
|
<FormLabel className="break-words">{label}</FormLabel>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<a href={downloadUrl} download={filename}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{helpUrl && (
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<a href={helpUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{helpLabel}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="max-w-2xl text-sm break-words text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-only component for `__system.*` fields (e.g. the deployment's
|
* Display-only component for `__system.*` fields (e.g. the deployment's
|
||||||
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
||||||
@@ -405,7 +460,7 @@ export default function DynamicFormComponent({
|
|||||||
}) {
|
}) {
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// Normalize a form value according to its field type.
|
// Normalize a form value according to its field type.
|
||||||
// This ensures legacy/malformed data (e.g. a plain string for
|
// This ensures legacy/malformed data (e.g. a plain string for
|
||||||
@@ -460,6 +515,7 @@ export default function DynamicFormComponent({
|
|||||||
item.type !== 'webhook-url' &&
|
item.type !== 'webhook-url' &&
|
||||||
item.type !== 'embed-code' &&
|
item.type !== 'embed-code' &&
|
||||||
item.type !== 'qr-code-login' &&
|
item.type !== 'qr-code-login' &&
|
||||||
|
item.type !== 'download-link' &&
|
||||||
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||||
),
|
),
|
||||||
[itemConfigList],
|
[itemConfigList],
|
||||||
@@ -777,6 +833,30 @@ export default function DynamicFormComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.type === 'download-link') {
|
||||||
|
if (!config.url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadLinkField
|
||||||
|
key={config.id}
|
||||||
|
label={extractI18nObject(config.label)}
|
||||||
|
description={
|
||||||
|
config.description
|
||||||
|
? extractI18nObject(config.description)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
url={config.url}
|
||||||
|
filename={config.download_filename}
|
||||||
|
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||||
|
helpLabel={
|
||||||
|
config.help_label
|
||||||
|
? extractI18nObject(config.help_label)
|
||||||
|
: t('bots.viewAdapterDocs')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||||
if (config.type === 'qr-code-login') {
|
if (config.type === 'qr-code-login') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
show_if?: IShowIfCondition;
|
show_if?: IShowIfCondition;
|
||||||
login_platform?: string;
|
login_platform?: string;
|
||||||
|
url?: string;
|
||||||
|
download_filename?: string;
|
||||||
|
help_links?: Record<string, string>;
|
||||||
|
help_label?: I18nObject;
|
||||||
|
|
||||||
constructor(params: IDynamicFormItemSchema) {
|
constructor(params: IDynamicFormItemSchema) {
|
||||||
this.id = params.id;
|
this.id = params.id;
|
||||||
@@ -30,6 +34,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
this.options = params.options;
|
this.options = params.options;
|
||||||
this.show_if = params.show_if;
|
this.show_if = params.show_if;
|
||||||
this.login_platform = params.login_platform;
|
this.login_platform = params.login_platform;
|
||||||
|
this.url = params.url;
|
||||||
|
this.download_filename = params.download_filename;
|
||||||
|
this.help_links = params.help_links;
|
||||||
|
this.help_label = params.help_label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,48 @@ function saveListExpansionState(state: SidebarListExpansionState) {
|
|||||||
|
|
||||||
// Maximum number of entity sub-items visible before "More" toggle
|
// Maximum number of entity sub-items visible before "More" toggle
|
||||||
const MAX_VISIBLE_ITEMS = 5;
|
const MAX_VISIBLE_ITEMS = 5;
|
||||||
|
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
|
||||||
|
const MCP_REFRESH_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMCPRefreshTask(taskId: number) {
|
||||||
|
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const task = await httpClient.getAsyncTask(taskId);
|
||||||
|
if (task.runtime.done) return task;
|
||||||
|
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEnabledMCPConnections() {
|
||||||
|
const resp = await httpClient.getMCPServers();
|
||||||
|
const enabledServers = resp.servers.filter((server) => server.enable);
|
||||||
|
if (enabledServers.length === 0) return;
|
||||||
|
|
||||||
|
const taskResults = await Promise.allSettled(
|
||||||
|
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
|
||||||
|
);
|
||||||
|
const taskIds: number[] = [];
|
||||||
|
|
||||||
|
for (const result of taskResults) {
|
||||||
|
if (
|
||||||
|
result.status === 'fulfilled' &&
|
||||||
|
typeof result.value.task_id === 'number'
|
||||||
|
) {
|
||||||
|
taskIds.push(result.value.task_id);
|
||||||
|
} else if (result.status === 'rejected') {
|
||||||
|
console.error('Failed to start MCP refresh task:', result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
|
||||||
|
}
|
||||||
|
|
||||||
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
||||||
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
||||||
@@ -352,11 +394,19 @@ function NavItems({
|
|||||||
if (extRefreshing) return;
|
if (extRefreshing) return;
|
||||||
setExtRefreshing(true);
|
setExtRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const results = await Promise.allSettled([
|
||||||
sidebarData.refreshPlugins(),
|
sidebarData.refreshPlugins(),
|
||||||
sidebarData.refreshMCPServers(),
|
|
||||||
sidebarData.refreshSkills(),
|
sidebarData.refreshSkills(),
|
||||||
|
refreshEnabledMCPConnections(),
|
||||||
]);
|
]);
|
||||||
|
const mcpRefreshResult = results[2];
|
||||||
|
if (mcpRefreshResult.status === 'rejected') {
|
||||||
|
console.error(
|
||||||
|
'Failed to refresh MCP connections:',
|
||||||
|
mcpRefreshResult.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sidebarData.refreshMCPServers();
|
||||||
} finally {
|
} finally {
|
||||||
setExtRefreshing(false);
|
setExtRefreshing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform =
|
||||||
|
| 'feishu'
|
||||||
|
| 'weixin'
|
||||||
|
| 'dingtalk'
|
||||||
|
| 'wecombot'
|
||||||
|
| 'qqofficial';
|
||||||
|
|
||||||
interface PlatformConfig {
|
interface PlatformConfig {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -29,6 +34,7 @@ interface PlatformConfig {
|
|||||||
apiBase: string;
|
apiBase: string;
|
||||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||||
successNoteKey?: string;
|
successNoteKey?: string;
|
||||||
|
boundByKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
@@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
|||||||
}),
|
}),
|
||||||
successNoteKey: 'wecombot.robotNameNote',
|
successNoteKey: 'wecombot.robotNameNote',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
titleKey: 'qqofficial.createBinding',
|
||||||
|
connectingKey: 'qqofficial.connecting',
|
||||||
|
scanQRCodeKey: 'qqofficial.scanQRCode',
|
||||||
|
waitingKey: 'qqofficial.waitingForScan',
|
||||||
|
successKey: 'qqofficial.bindSuccess',
|
||||||
|
failedKey: 'qqofficial.bindFailed',
|
||||||
|
retryKey: 'qqofficial.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
appid: data.appid,
|
||||||
|
secret: data.secret,
|
||||||
|
}),
|
||||||
|
successNoteKey: 'qqofficial.tokenNote',
|
||||||
|
boundByKey: 'qqofficial.boundBy',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QrCodeLoginDialogProps {
|
interface QrCodeLoginDialogProps {
|
||||||
@@ -118,6 +140,7 @@ export default function QrCodeLoginDialog({
|
|||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
const [expireIn, setExpireIn] = useState(0);
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [successMeta, setSuccessMeta] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -178,6 +201,7 @@ export default function QrCodeLoginDialog({
|
|||||||
setQrDataUrl('');
|
setQrDataUrl('');
|
||||||
setExpireIn(0);
|
setExpireIn(0);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
setSuccessMeta('');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
@@ -275,6 +299,13 @@ export default function QrCodeLoginDialog({
|
|||||||
sessionIdRef.current = null;
|
sessionIdRef.current = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
|
// Platform may return extra audit metadata (e.g. QQ Official returns
|
||||||
|
// the scanner's user_openid) — surface it briefly before the dialog closes.
|
||||||
|
if (rest.user_openid && cfg.boundByKey) {
|
||||||
|
setSuccessMeta(
|
||||||
|
tRef.current(cfg.boundByKey, { openid: rest.user_openid }),
|
||||||
|
);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||||
onOpenChangeRef.current(false);
|
onOpenChangeRef.current(false);
|
||||||
@@ -395,6 +426,11 @@ export default function QrCodeLoginDialog({
|
|||||||
<p className="text-sm text-green-600 font-medium">
|
<p className="text-sm text-green-600 font-medium">
|
||||||
{t(platformConfig.successKey)}
|
{t(platformConfig.successKey)}
|
||||||
</p>
|
</p>
|
||||||
|
{successMeta && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
|
||||||
|
{successMeta}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{platformConfig.successNoteKey && (
|
{platformConfig.successNoteKey && (
|
||||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||||
{t(platformConfig.successNoteKey)}
|
{t(platformConfig.successNoteKey)}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePersistedTestComplete = useCallback(async () => {
|
||||||
|
await refreshMCPServers();
|
||||||
|
}, [refreshMCPServers]);
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
httpClient
|
httpClient
|
||||||
.deleteMCPServer(id)
|
.deleteMCPServer(id)
|
||||||
@@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
onRuntimeInfoChange={(runtimeInfo) =>
|
onRuntimeInfoChange={(runtimeInfo) =>
|
||||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||||
}
|
}
|
||||||
|
onPersistedTestComplete={handlePersistedTestComplete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
|
||||||
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
||||||
import {
|
import {
|
||||||
MCPServerRuntimeInfo,
|
MCPServerRuntimeInfo,
|
||||||
@@ -487,6 +488,7 @@ interface MCPFormProps {
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onTestingChange?: (testing: boolean) => void;
|
onTestingChange?: (testing: boolean) => void;
|
||||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||||
|
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
|
||||||
/** Reported when the form cannot be saved because the current mode is
|
/** Reported when the form cannot be saved because the current mode is
|
||||||
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
||||||
* render the Save button outside this component should disable it. */
|
* render the Save button outside this component should disable it. */
|
||||||
@@ -511,6 +513,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onTestingChange,
|
onTestingChange,
|
||||||
onRuntimeInfoChange,
|
onRuntimeInfoChange,
|
||||||
|
onPersistedTestComplete,
|
||||||
onSaveBlockedChange,
|
onSaveBlockedChange,
|
||||||
layout = 'stacked',
|
layout = 'stacked',
|
||||||
sideHeader,
|
sideHeader,
|
||||||
@@ -822,6 +825,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
// are always current.
|
// are always current.
|
||||||
const serverName =
|
const serverName =
|
||||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
isEditMode && initServerName ? initServerName : form.getValues('name');
|
||||||
|
const shouldTestPersistedServer =
|
||||||
|
isEditMode && !!initServerName && !form.formState.isDirty;
|
||||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||||
const formStdioArgs = form.getValues('args') ?? [];
|
const formStdioArgs = form.getValues('args') ?? [];
|
||||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||||
@@ -844,12 +849,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { task_id } = await httpClient.testMCPServer('_', {
|
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
||||||
name: serverName,
|
const testPayload = shouldTestPersistedServer
|
||||||
mode,
|
? {}
|
||||||
enable: true,
|
: ({
|
||||||
extra_args: extraArgsData,
|
name: serverName,
|
||||||
} as MCPServer);
|
mode,
|
||||||
|
enable: true,
|
||||||
|
extra_args: extraArgsData,
|
||||||
|
} as MCPServer);
|
||||||
|
|
||||||
|
const { task_id } = await httpClient.testMCPServer(
|
||||||
|
testTarget,
|
||||||
|
testPayload,
|
||||||
|
);
|
||||||
|
|
||||||
if (!task_id) {
|
if (!task_id) {
|
||||||
throw new Error(t('mcp.noTaskId'));
|
throw new Error(t('mcp.noTaskId'));
|
||||||
@@ -875,14 +888,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
resource_count: 0,
|
resource_count: 0,
|
||||||
resources: [],
|
resources: [],
|
||||||
});
|
});
|
||||||
|
if (shouldTestPersistedServer) {
|
||||||
|
await onPersistedTestComplete?.(serverName);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isEditMode) {
|
if (shouldTestPersistedServer) {
|
||||||
await loadServerForEdit(serverName);
|
await loadServerForEdit(serverName);
|
||||||
|
await onPersistedTestComplete?.(serverName);
|
||||||
} else {
|
} else {
|
||||||
// Create mode has no persisted server to reload tools from.
|
// Transient tests have no persisted server to reload tools from.
|
||||||
// The backend stashes the discovered runtime info (status +
|
// The backend stashes the discovered runtime info (status +
|
||||||
// tools) in the test task's metadata before tearing the
|
// tools) in the task metadata before tearing the transient
|
||||||
// transient session down — surface it so a successful test
|
// session down — surface it so a successful test
|
||||||
// shows the tool list instead of "no tools found".
|
// shows the tool list instead of "no tools found".
|
||||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||||
@@ -1207,6 +1224,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
<TabsTrigger value="resources" className="flex-none px-4">
|
<TabsTrigger value="resources" className="flex-none px-4">
|
||||||
{resourcesTabLabel}
|
{resourcesTabLabel}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs" className="flex-none px-4">
|
||||||
|
{t('mcp.tabLogs')}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||||
<MCPReadme readme={readme} />
|
<MCPReadme readme={readme} />
|
||||||
@@ -1235,6 +1255,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
runtimePanel
|
runtimePanel
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
||||||
|
|
||||||
|
function levelClassName(level: string): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'CRITICAL':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'WARNING':
|
||||||
|
return 'text-amber-500';
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'text-gray-400 dark:text-gray-500';
|
||||||
|
default:
|
||||||
|
return 'text-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPLogs({ serverName }: { serverName: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [level, setLevel] = useState<string>('ALL');
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
httpClient
|
||||||
|
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
|
||||||
|
.then((res) => {
|
||||||
|
setLogs(res.logs ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLogs([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [serverName, level]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
// Auto-refresh poll loop.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const timer = setInterval(fetchLogs, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
// Keep view pinned to bottom when the user is already at the bottom.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el && atBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
|
||||||
|
<Select value={level} onValueChange={setLevel}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{t('mcp.logsRefresh')}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="mcp-logs-auto-refresh"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="mcp-logs-auto-refresh"
|
||||||
|
className="cursor-pointer text-sm font-normal text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t('mcp.logsAutoRefresh')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('mcp.logsEmpty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.ts}-${idx}`}
|
||||||
|
className={`whitespace-pre-wrap break-all ${levelClassName(
|
||||||
|
entry.level,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ export interface IDynamicFormItemSchema {
|
|||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accept?: string; // For file type: accepted MIME types
|
accept?: string; // For file type: accepted MIME types
|
||||||
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||||
|
url?: string; // For download-link type: relative or absolute download URL
|
||||||
|
download_filename?: string; // Optional filename for download-link type
|
||||||
|
help_links?: Record<string, string>; // Optional docs links for display-only fields
|
||||||
|
help_label?: I18nObject; // Optional label for help_links
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DynamicFormItemType {
|
export enum DynamicFormItemType {
|
||||||
@@ -72,6 +76,7 @@ export enum DynamicFormItemType {
|
|||||||
WEBHOOK_URL = 'webhook-url',
|
WEBHOOK_URL = 'webhook-url',
|
||||||
EMBED_CODE = 'embed-code',
|
EMBED_CODE = 'embed-code',
|
||||||
QR_CODE_LOGIN = 'qr-code-login',
|
QR_CODE_LOGIN = 'qr-code-login',
|
||||||
|
DOWNLOAD_LINK = 'download-link',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFileConfig {
|
export interface IFileConfig {
|
||||||
|
|||||||
@@ -671,6 +671,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMcpServerLogs(
|
||||||
|
serverName: string,
|
||||||
|
limit: number = 200,
|
||||||
|
level?: string,
|
||||||
|
): Promise<{ logs: PluginLogEntry[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
if (level) {
|
||||||
|
params.set('level', level);
|
||||||
|
}
|
||||||
|
return this.get(
|
||||||
|
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public getPluginAssetURL(
|
public getPluginAssetURL(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ export default function WizardPage() {
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
|
url: item.url,
|
||||||
|
download_filename: item.download_filename,
|
||||||
|
help_links: item.help_links,
|
||||||
|
help_label: item.help_label,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [adapters, selectedAdapter]);
|
}, [adapters, selectedAdapter]);
|
||||||
@@ -249,6 +253,10 @@ export default function WizardPage() {
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
|
url: item.url,
|
||||||
|
download_filename: item.download_filename,
|
||||||
|
help_links: item.help_links,
|
||||||
|
help_label: item.help_label,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [selectedRunnerConfigStage]);
|
}, [selectedRunnerConfigStage]);
|
||||||
|
|||||||
@@ -828,6 +828,12 @@ const enUS = {
|
|||||||
tabTools: 'Tools',
|
tabTools: 'Tools',
|
||||||
tabResources: 'Resources',
|
tabResources: 'Resources',
|
||||||
tabDocs: 'Docs',
|
tabDocs: 'Docs',
|
||||||
|
tabLogs: 'Logs',
|
||||||
|
logsLevelAll: 'All levels',
|
||||||
|
logsRefresh: 'Refresh',
|
||||||
|
logsAutoRefresh: 'Auto refresh',
|
||||||
|
logsEmpty:
|
||||||
|
'No logs yet. Runtime logs from the MCP server will appear here.',
|
||||||
noReadme: 'No documentation available',
|
noReadme: 'No documentation available',
|
||||||
parseResultFailed: 'Failed to parse test result',
|
parseResultFailed: 'Failed to parse test result',
|
||||||
noResultReturned: 'Test returned no result',
|
noResultReturned: 'Test returned no result',
|
||||||
@@ -1816,6 +1822,19 @@ const enUS = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'One-Click QR Binding for QQ Official Bot',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant',
|
||||||
|
waitingForScan: 'Waiting for scan',
|
||||||
|
bindSuccess: 'Bound successfully! AppID and Secret have been filled in',
|
||||||
|
bindFailed: 'Binding failed',
|
||||||
|
connecting: 'Connecting to QQ service...',
|
||||||
|
retry: 'Retry',
|
||||||
|
tokenNote:
|
||||||
|
'The Token field is not used by the current adapter — you can leave it blank.',
|
||||||
|
boundBy: 'Bound by QQ user {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||||
invalidPage: 'Invalid plugin page',
|
invalidPage: 'Invalid plugin page',
|
||||||
|
|||||||
@@ -842,6 +842,12 @@ const esES = {
|
|||||||
tabTools: 'Herramientas',
|
tabTools: 'Herramientas',
|
||||||
tabResources: 'Recursos',
|
tabResources: 'Recursos',
|
||||||
tabDocs: 'Documentación',
|
tabDocs: 'Documentación',
|
||||||
|
tabLogs: 'Registros',
|
||||||
|
logsLevelAll: 'Todos los niveles',
|
||||||
|
logsRefresh: 'Actualizar',
|
||||||
|
logsAutoRefresh: 'Actualización automática',
|
||||||
|
logsEmpty:
|
||||||
|
'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.',
|
||||||
noReadme: 'No hay documentación disponible',
|
noReadme: 'No hay documentación disponible',
|
||||||
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
||||||
noResultReturned: 'La prueba no devolvió resultados',
|
noResultReturned: 'La prueba no devolvió resultados',
|
||||||
@@ -1734,6 +1740,20 @@ const esES = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Vinculación QR con un clic para el bot oficial de QQ',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Esperando escaneo',
|
||||||
|
bindSuccess:
|
||||||
|
'¡Vinculación correcta! AppID y Secret se han rellenado automáticamente',
|
||||||
|
bindFailed: 'Error en la vinculación',
|
||||||
|
connecting: 'Conectando con el servicio de QQ...',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
tokenNote:
|
||||||
|
'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.',
|
||||||
|
boundBy: 'Vinculado por el usuario QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||||
invalidPage: 'Página de plugin no válida',
|
invalidPage: 'Página de plugin no válida',
|
||||||
|
|||||||
@@ -834,6 +834,11 @@ const jaJP = {
|
|||||||
tabTools: 'ツール',
|
tabTools: 'ツール',
|
||||||
tabResources: 'リソース',
|
tabResources: 'リソース',
|
||||||
tabDocs: 'ドキュメント',
|
tabDocs: 'ドキュメント',
|
||||||
|
tabLogs: 'ログ',
|
||||||
|
logsLevelAll: 'すべてのレベル',
|
||||||
|
logsRefresh: '更新',
|
||||||
|
logsAutoRefresh: '自動更新',
|
||||||
|
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
|
||||||
noReadme: 'ドキュメントがありません',
|
noReadme: 'ドキュメントがありません',
|
||||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||||
noResultReturned: 'テスト結果が返されませんでした',
|
noResultReturned: 'テスト結果が返されませんでした',
|
||||||
@@ -1728,6 +1733,19 @@ const jaJP = {
|
|||||||
retry: '再試行',
|
retry: '再試行',
|
||||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
|
||||||
|
scanQRCode:
|
||||||
|
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
|
||||||
|
waitingForScan: 'スキャン待ち',
|
||||||
|
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
|
||||||
|
bindFailed: 'バインド失敗',
|
||||||
|
connecting: 'QQ サービスに接続中...',
|
||||||
|
retry: '再試行',
|
||||||
|
tokenNote:
|
||||||
|
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
|
||||||
|
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||||
invalidPage: '無効なプラグインページ',
|
invalidPage: '無効なプラグインページ',
|
||||||
|
|||||||
@@ -839,6 +839,12 @@ const ruRU = {
|
|||||||
tabTools: 'Инструменты',
|
tabTools: 'Инструменты',
|
||||||
tabResources: 'Ресурсы',
|
tabResources: 'Ресурсы',
|
||||||
tabDocs: 'Документация',
|
tabDocs: 'Документация',
|
||||||
|
tabLogs: 'Журнал',
|
||||||
|
logsLevelAll: 'Все уровни',
|
||||||
|
logsRefresh: 'Обновить',
|
||||||
|
logsAutoRefresh: 'Автообновление',
|
||||||
|
logsEmpty:
|
||||||
|
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
|
||||||
noReadme: 'Документация отсутствует',
|
noReadme: 'Документация отсутствует',
|
||||||
parseResultFailed: 'Не удалось разобрать результат теста',
|
parseResultFailed: 'Не удалось разобрать результат теста',
|
||||||
noResultReturned: 'Тест не вернул результат',
|
noResultReturned: 'Тест не вернул результат',
|
||||||
@@ -1705,6 +1711,19 @@ const ruRU = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Привязка официального бота QQ по QR-коду',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически',
|
||||||
|
bindFailed: 'Не удалось выполнить привязку',
|
||||||
|
connecting: 'Подключение к сервису QQ...',
|
||||||
|
retry: 'Повторить',
|
||||||
|
tokenNote:
|
||||||
|
'Поле Token не используется текущим адаптером — его можно оставить пустым.',
|
||||||
|
boundBy: 'Привязано пользователем QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||||
invalidPage: 'Недопустимая страница плагина',
|
invalidPage: 'Недопустимая страница плагина',
|
||||||
|
|||||||
@@ -817,6 +817,11 @@ const thTH = {
|
|||||||
tabTools: 'เครื่องมือ',
|
tabTools: 'เครื่องมือ',
|
||||||
tabResources: 'ทรัพยากร',
|
tabResources: 'ทรัพยากร',
|
||||||
tabDocs: 'เอกสาร',
|
tabDocs: 'เอกสาร',
|
||||||
|
tabLogs: 'บันทึก',
|
||||||
|
logsLevelAll: 'ทุกระดับ',
|
||||||
|
logsRefresh: 'รีเฟรช',
|
||||||
|
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
|
||||||
|
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
|
||||||
noReadme: 'ไม่มีเอกสาร',
|
noReadme: 'ไม่มีเอกสาร',
|
||||||
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
||||||
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
||||||
@@ -1667,6 +1672,18 @@ const thTH = {
|
|||||||
retry: 'ลองใหม่',
|
retry: 'ลองใหม่',
|
||||||
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'กำลังรอสแกน',
|
||||||
|
bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว',
|
||||||
|
bindFailed: 'การผูกล้มเหลว',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ QQ...',
|
||||||
|
retry: 'ลองใหม่',
|
||||||
|
tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้',
|
||||||
|
boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||||
|
|||||||
@@ -832,6 +832,12 @@ const viVN = {
|
|||||||
tabTools: 'Công cụ',
|
tabTools: 'Công cụ',
|
||||||
tabResources: 'Tài nguyên',
|
tabResources: 'Tài nguyên',
|
||||||
tabDocs: 'Tài liệu',
|
tabDocs: 'Tài liệu',
|
||||||
|
tabLogs: 'Nhật ký',
|
||||||
|
logsLevelAll: 'Tất cả cấp độ',
|
||||||
|
logsRefresh: 'Làm mới',
|
||||||
|
logsAutoRefresh: 'Tự động làm mới',
|
||||||
|
logsEmpty:
|
||||||
|
'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.',
|
||||||
noReadme: 'Không có tài liệu',
|
noReadme: 'Không có tài liệu',
|
||||||
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
||||||
noResultReturned: 'Kiểm tra không trả về kết quả',
|
noResultReturned: 'Kiểm tra không trả về kết quả',
|
||||||
@@ -1695,6 +1701,19 @@ const viVN = {
|
|||||||
retry: 'Thử lại',
|
retry: 'Thử lại',
|
||||||
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Liên kết bot QQ Official bằng QR một chạm',
|
||||||
|
scanQRCode:
|
||||||
|
'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Đang chờ quét',
|
||||||
|
bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động',
|
||||||
|
bindFailed: 'Liên kết thất bại',
|
||||||
|
connecting: 'Đang kết nối tới dịch vụ QQ...',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
tokenNote:
|
||||||
|
'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.',
|
||||||
|
boundBy: 'Được liên kết bởi người dùng QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||||
invalidPage: 'Trang plugin không hợp lệ',
|
invalidPage: 'Trang plugin không hợp lệ',
|
||||||
|
|||||||
@@ -794,6 +794,11 @@ const zhHans = {
|
|||||||
tabTools: '工具',
|
tabTools: '工具',
|
||||||
tabResources: '资源',
|
tabResources: '资源',
|
||||||
tabDocs: '文档',
|
tabDocs: '文档',
|
||||||
|
tabLogs: '日志',
|
||||||
|
logsLevelAll: '全部级别',
|
||||||
|
logsRefresh: '刷新',
|
||||||
|
logsAutoRefresh: '自动刷新',
|
||||||
|
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
|
||||||
noReadme: '暂无文档',
|
noReadme: '暂无文档',
|
||||||
parseResultFailed: '解析测试结果失败',
|
parseResultFailed: '解析测试结果失败',
|
||||||
noResultReturned: '测试未返回结果',
|
noResultReturned: '测试未返回结果',
|
||||||
@@ -1736,6 +1741,17 @@ const zhHans = {
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: '一键扫码绑定 QQ 机器人',
|
||||||
|
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
|
||||||
|
bindFailed: '绑定失败',
|
||||||
|
connecting: '正在连接 QQ 服务...',
|
||||||
|
retry: '重试',
|
||||||
|
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
|
||||||
|
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||||
invalidPage: '无效的插件页面',
|
invalidPage: '无效的插件页面',
|
||||||
|
|||||||
@@ -793,6 +793,11 @@ const zhHant = {
|
|||||||
tabTools: '工具',
|
tabTools: '工具',
|
||||||
tabResources: '資源',
|
tabResources: '資源',
|
||||||
tabDocs: '文件',
|
tabDocs: '文件',
|
||||||
|
tabLogs: '日誌',
|
||||||
|
logsLevelAll: '全部級別',
|
||||||
|
logsRefresh: '重新整理',
|
||||||
|
logsAutoRefresh: '自動重新整理',
|
||||||
|
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
|
||||||
noReadme: '暫無文件',
|
noReadme: '暫無文件',
|
||||||
parseResultFailed: '解析測試結果失敗',
|
parseResultFailed: '解析測試結果失敗',
|
||||||
noResultReturned: '測試未返回結果',
|
noResultReturned: '測試未返回結果',
|
||||||
@@ -1645,6 +1650,17 @@ const zhHant = {
|
|||||||
retry: '重試',
|
retry: '重試',
|
||||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: '一鍵掃碼綁定 QQ 機器人',
|
||||||
|
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
|
||||||
|
waitingForScan: '等待掃碼中',
|
||||||
|
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
|
||||||
|
bindFailed: '綁定失敗',
|
||||||
|
connecting: '正在連線 QQ 服務...',
|
||||||
|
retry: '重試',
|
||||||
|
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
|
||||||
|
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||||
invalidPage: '無效的插件頁面',
|
invalidPage: '無效的插件頁面',
|
||||||
|
|||||||
Reference in New Issue
Block a user