Compare commits

..

2 Commits

Author SHA1 Message Date
dadachann 5208066df2 chore(deps): pin langbot-plugin 0.4.10 (per-process memory_mb fix) 2026-07-03 20:53:50 -04:00
dadachann 14ae46d178 fix(mcp): bump default memory to 1024MB for node (npx) stdio MCP servers
Node.js MCP servers (npx/bunx) were being OOM-killed (return_code=137) by the
default 512MB nsjail cgroup_mem_max. Node V8 reserves large virtual address
space and instantiates WebAssembly modules (undici llhttp) on startup, easily
exceeding 512MB resident. This caused every node-based MCP (memory,
sequential-thinking, filesystem, weather, docker, excel) to crash-loop.

Fix: when the stdio command is npx/bunx/pnpm, default memory_mb to 1024 unless
the operator explicitly set a value. Python/uvx servers keep the 512MB default.
2026-07-03 20:27:24 -04:00
69 changed files with 260 additions and 14534 deletions
+1 -1
View File
@@ -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)
📍 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/).
📍 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/).
---
+1 -1
View File
@@ -51,7 +51,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 实践指南:[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/)。
📍 实践指南:[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/)。
---
+1 -1
View File
@@ -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)
📍 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/).
📍 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/).
---
+1 -1
View File
@@ -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)
📍 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/).
📍 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/).
---
+1 -1
View File
@@ -50,7 +50,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
📍 実践ガイド: [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/)。
📍 実践ガイド: [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/)。
---
+1 -1
View File
@@ -50,7 +50,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
📍 실전 가이드: [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/).
📍 실전 가이드: [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/).
---
+1 -1
View File
@@ -50,7 +50,7 @@ LangBot — это **платформа с открытым исходным к
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 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/).
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 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/).
---
+1 -1
View File
@@ -52,7 +52,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 實踐指南:[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/)。
📍 實踐指南:[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/)。
---
+1 -1
View File
@@ -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)
📍 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/).
📍 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/).
---
-169
View File
@@ -1,169 +0,0 @@
# 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.
+1 -2
View File
@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.13",
"langbot-plugin==0.4.10",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -80,7 +80,6 @@ dependencies = [
"pgvector>=0.4.1",
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0",
]
keywords = [
"bot",
File diff suppressed because it is too large Load Diff
@@ -109,62 +109,6 @@ class AsyncDifyServiceClient:
if chunk.startswith('data:'):
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(
self,
file: httpx._types.FileTypes,
+24 -384
View File
@@ -1,48 +1,17 @@
import asyncio
import base64
import json
import logging
import os
import time
import typing
import uuid
import urllib.parse
from typing import Awaitable, Callable, Optional
from typing import Callable
import dingtalk_stream # type: ignore
import websockets
from .EchoHandler import EchoTextHandler
from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent
import httpx
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:
def __init__(
self,
@@ -52,7 +21,6 @@ class DingTalkClient:
robot_code: str,
markdown_card: bool,
logger: None,
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
):
"""初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret)
@@ -62,14 +30,6 @@ class DingTalkClient:
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
self.EchoTextHandler = EchoTextHandler(self)
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 = {
'example': [],
}
@@ -79,24 +39,8 @@ class DingTalkClient:
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
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
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):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
headers = {'Content-Type': 'application/json'}
@@ -485,35 +429,18 @@ class DingTalkClient:
'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 = {
'robotCode': robot_code,
'robotCode': self.robot_code,
'userIds': [target_id],
'msgKey': 'sampleText',
'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:
async with httpx.AsyncClient() as client:
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:
return
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()}')
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
@@ -529,7 +456,7 @@ class DingTalkClient:
}
data = {
'robotCode': self.robot_code or self.key,
'robotCode': self.robot_code,
'openConversationId': target_id,
'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}),
@@ -550,334 +477,47 @@ class DingTalkClient:
quote_origin: bool = False,
card_auto_layout: bool = False,
):
"""Create + deliver the streaming chat card for a chatbot reply.
card_data = {}
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
`(None, out_track_id)` to keep call sites compatible with the
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:
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
card_param_map = {'content': ''}
# 将用户的消息内容作为卡片的查询参数,方便后续处理
if incoming_message.message_type == 'text':
card_param_map['query'] = incoming_message.get_text_list()[0]
card_data['query'] = incoming_message.get_text_list()[0]
else:
card_param_map['query'] = '...'
card_data['query'] = '...'
await self.create_and_deliver_card(
card_template_id=temp_card_id,
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map=card_param_map,
card_data_config={'autoLayout': card_auto_layout},
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
card_instance_id = await card_instance.async_create_and_deliver_card(
temp_card_id,
card_data,
)
return None, out_track_id
return card_instance, card_instance_id
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
"""Stream a single chunk into an existing card's `content` field."""
content_key = 'content'
try:
await self.streaming_update_card(
out_track_id=card_instance_id,
content_key='content',
await card_instance.async_streaming(
card_instance_id,
content_key=content_key,
content_value=content,
append=False,
finished=is_final,
failed=False,
)
except Exception as e:
if self.logger:
self.logger.exception(e)
await self.streaming_update_card(
out_track_id=card_instance_id,
content_key='content',
self.logger.exception(e)
await card_instance.async_streaming(
card_instance_id,
content_key=content_key,
content_value='',
append=False,
finished=is_final,
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):
"""启动 WebSocket 连接,监听消息"""
self._stopped = False
@@ -1,106 +0,0 @@
"""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'
+9 -334
View File
@@ -12,142 +12,6 @@ import traceback
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:
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
self.unified_mode = unified_mode
@@ -166,10 +30,6 @@ class QQOfficialClient:
self.token = token
self.app_id = app_id
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.access_token = ''
self.access_token_expiry_time = None
@@ -247,23 +107,6 @@ class QQOfficialClient:
return response, 200
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)
if message_data:
event = QQOfficialEvent.from_payload(message_data)
@@ -290,21 +133,6 @@ class QQOfficialClient:
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):
"""处理消息事件"""
msg_type = event.t
@@ -349,20 +177,8 @@ class QQOfficialClient:
content_type = attachment.get('content_type', '')
return content_type.startswith('image/')
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.
"""
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
"""发送私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
@@ -372,15 +188,11 @@ class QQOfficialClient:
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
data: dict[str, Any] = {
data = {
'content': content,
'msg_type': 0,
'msg_seq': msg_seq,
'msg_id': msg_id,
}
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_data = response.json()
if response.status_code == 200:
@@ -389,19 +201,8 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response)
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).
"""
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
"""发送群聊消息"""
if not await self.check_access_token():
await self.get_access_token()
@@ -411,15 +212,11 @@ class QQOfficialClient:
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
data: dict[str, Any] = {
data = {
'content': content,
'msg_type': 0,
'msg_seq': msg_seq,
'msg_id': msg_id,
}
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)
if response.status_code == 200:
return
@@ -688,107 +485,6 @@ class QQOfficialClient:
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
return response.json()
async def send_markdown_keyboard(
self,
target_type: str,
target_id: str,
markdown_content: str,
keyboard: 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):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
@@ -957,12 +653,6 @@ class QQOfficialClient:
d = payload.get('d', {})
s = payload.get('s')
t = payload.get('t')
# Top-level event id, distinct from `d.id`. Per QQ
# spec this is the only value accepted as ``event_id``
# in subsequent passive-reply send-message calls
# (``d.id`` for INTERACTION_CREATE is the interaction
# id, used solely for PUT /interactions/{id} ack).
ws_event_id = payload.get('id')
if not isinstance(d, dict):
d = {}
@@ -1041,22 +731,7 @@ class QQOfficialClient:
else:
await self.logger.debug(f'Received event: {t}, seq={s}')
# INTERACTION_CREATE bypasses the regular
# on_event dispatcher so the adapter sees the
# top-level ws_event_id (needed as event_id
# for the resumed reply) — same shape as the
# webhook handler.
if t == 'INTERACTION_CREATE':
if self._interaction_handler:
try:
result = self._interaction_handler(d, ws_event_id)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(
f'Error in interaction handler (ws): {traceback.format_exc()}'
)
elif on_event:
if on_event:
try:
result = on_event(t, d)
if asyncio.iscoroutine(result):
File diff suppressed because it is too large Load Diff
+7 -286
View File
@@ -20,19 +20,7 @@ from typing import Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import (
parse_wecom_bot_message,
StreamSession,
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.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
@@ -55,10 +43,6 @@ def _generate_req_id(prefix: str) -> str:
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:
"""WeChat Work AI Bot WebSocket long connection client.
@@ -119,22 +103,6 @@ class WecomBotWsClient:
# msg_id -> feedback_id (for associating feedback with message)
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
# Dify human-input pause state for ws mode. Keys are task_id (echoed
# back in template_card_event.TaskId so we can rebuild the session
# context on click).
# task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id}
self._pending_forms_by_task: dict[str, dict] = {}
# Reverse: msg_id -> task_id (for cleanup when stream finishes).
self._task_id_by_msg: dict[str, str] = {}
# Optional card-action callback registered by the adapter.
# Signature mirrors the http-mode WecomBotClient:
# async def callback(session, action_id, task_id, raw_event) -> None
self._card_action_callback: Optional[Callable] = None
# 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 ──────────────────────────────────────────────────
async def connect(self):
@@ -268,132 +236,6 @@ class WecomBotWsClient:
}
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]:
"""Proactively send a message to a specified chat.
@@ -416,23 +258,6 @@ class WecomBotWsClient:
body['text'] = {'content': content}
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:
"""Push a streaming chunk for a given message ID.
@@ -451,31 +276,10 @@ class WecomBotWsClient:
return False
req_id, stream_id = key.split('|', 1)
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)
if not is_final and next_content == previous_content:
if not is_final and content == self._stream_last_content.get(msg_id):
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
feedback_id = ''
if is_final:
@@ -486,10 +290,8 @@ class WecomBotWsClient:
if session_info:
self._feedback_sessions[feedback_id] = session_info
# WeCom replaces the displayed stream content on each refresh, so
# 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
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
@@ -663,7 +465,7 @@ class WecomBotWsClient:
return
# Unknown frame
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
@@ -671,13 +473,6 @@ class WecomBotWsClient:
body = frame.get('body', {})
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
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
if not message_data:
@@ -711,12 +506,8 @@ class WecomBotWsClient:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
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}')
event_info = body.get('event', {})
event_type = event_info.get('eventtype', '')
message_data = {
'msgtype': 'event',
@@ -777,10 +568,6 @@ class WecomBotWsClient:
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
return
if event_type == 'template_card_event':
await self._handle_template_card_event_frame(frame, body)
return
event = wecombotevent.WecomBotEvent(message_data)
if event_type in self._message_handlers:
@@ -794,72 +581,6 @@ class WecomBotWsClient:
except Exception:
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):
"""Dispatch a message event to registered handlers with deduplication."""
try:
@@ -5,29 +5,6 @@ from ... import group
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')
class AdaptersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@@ -60,15 +37,6 @@ class AdaptersRouterGroup(group.RouterGroup):
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
_create_app_sessions: dict = {}
_SESSION_TTL = 900 # 15 minutes
@@ -682,220 +650,3 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# QQ Official QR Binding
# -----------------------------------------------------------------------
_qqofficial_sessions: dict = {}
_QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window)
def _cleanup_expired_qqofficial_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL
]
for sid in expired:
session = _qqofficial_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/qqofficial/bind', methods=['POST'])
async def _() -> str:
"""Start QQ Official QR binding. Returns session_id + QR URL.
Flow: generate a local AES-256 key, register it with
`q.qq.com/lite/create_bind_task`, then poll
`q.qq.com/lite/poll_bind_result` until the user authorizes the
bind inside the QQ Bot Assistant on mobile QQ. The encrypted
AppSecret returned by the poll endpoint is decrypted with the
same key. The key never leaves this process.
"""
import uuid
import time
import secrets
import base64
import aiohttp
QQ_BIND_BASE = 'https://q.qq.com'
_cleanup_expired_qqofficial_sessions()
bind_key_bytes = secrets.token_bytes(32)
bind_key = base64.b64encode(bind_key_bytes).decode('ascii')
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'appid': None,
'secret': None,
'user_openid': None,
'error': None,
'created_at': time.time(),
'task_id': None,
'bind_key_bytes': bind_key_bytes,
'interval': 2,
}
_qqofficial_sessions[session_id] = session
async def run_qr_binding():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: create_bind_task — register our AES key, get task_id
async with http.post(
f'{QQ_BIND_BASE}/lite/create_bind_task',
json={'key': bind_key},
headers={'Accept': 'application/json'},
) as resp:
try:
data = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from QQ bind service'
return
if int(data.get('retcode', -1)) != 0:
session['status'] = 'error'
session['error'] = (
data.get('msg') or data.get('message') or 'Failed to create bind task'
)
return
task_id = str((data.get('data') or {}).get('task_id') or '').strip()
if not task_id:
session['status'] = 'error'
session['error'] = 'Missing task_id in QQ response'
return
# The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant.
# `source=langbot` is a courtesy attribution parameter so Tencent
# can see LangBot adoption metrics, matching the convention used by
# other third-party integrations (e.g. hermes-agent uses `source=hermes`).
qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot'
session['task_id'] = task_id
session['qr_url'] = qr_url
session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL
session['status'] = 'waiting'
# Step 2: poll_bind_result until completed (status=2) or expired (3).
deadline = time.time() + _QQOFFICIAL_SESSION_TTL
while time.time() < deadline:
await asyncio.sleep(session['interval'])
async with http.post(
f'{QQ_BIND_BASE}/lite/poll_bind_result',
json={'task_id': task_id},
headers={'Accept': 'application/json'},
) as poll_resp:
try:
poll_data = await poll_resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
continue
if int(poll_data.get('retcode', -1)) != 0:
session['status'] = 'error'
session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed'
return
payload = poll_data.get('data') or {}
try:
raw_status = int(payload.get('status', 0))
except (TypeError, ValueError):
raw_status = 0
if raw_status == 2:
appid = str(payload.get('bot_appid') or '').strip()
encrypted = str(payload.get('bot_encrypt_secret') or '').strip()
if not appid or not encrypted:
session['status'] = 'error'
session['error'] = 'Incomplete credential payload'
return
try:
session['secret'] = _decrypt_qqofficial_secret(
encrypted,
bind_key_bytes,
)
except ValueError as exc:
session['status'] = 'error'
session['error'] = str(exc)
return
session['appid'] = appid
# The scanner's OpenID is returned alongside the credentials —
# surfaced to the dashboard for audit / "bound by" display.
session['user_openid'] = str(payload.get('user_openid') or '').strip() or None
session['status'] = 'success'
return
if raw_status == 3:
session['status'] = 'expired'
session['error'] = 'QR code expired'
return
# status 0 / 1: still pending, continue polling
session['status'] = 'expired'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_qr_binding())
session['task'] = task
# Wait up to 10s for the QR URL to be ready before responding.
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll QQ Official QR binding status."""
_cleanup_expired_qqofficial_sessions()
session = _qqofficial_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['appid'] = session['appid']
data['secret'] = session['secret']
if session.get('user_openid'):
data['user_openid'] = session['user_openid']
_qqofficial_sessions.pop(session_id, None)
elif session['status'] in ('error', 'expired'):
data['error'] = session['error']
_qqofficial_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a QQ Official QR binding session."""
session = _qqofficial_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
+1 -1
View File
@@ -168,7 +168,7 @@ class RuntimePipeline:
bot_message=query.resp_messages[-1],
message=result.user_notice,
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
is_final=[msg.is_final for msg in query.resp_messages][-1],
is_final=[msg.is_final for msg in query.resp_messages][0],
)
else:
await query.adapter.reply_message(
+1 -5
View File
@@ -42,13 +42,9 @@ class QueryPool:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
variables: typing.Optional[dict[str, typing.Any]] = None,
) -> pipeline_query.Query:
async with self.condition:
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(
bot_uuid=bot_uuid,
query_id=query_id,
@@ -57,7 +53,7 @@ class QueryPool:
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables=initial_variables,
variables={'_routed_by_rule': routed_by_rule},
resp_messages=[],
resp_message_chain=[],
adapter=adapter,
@@ -45,7 +45,7 @@ class SendResponseBackStage(stage.PipelineStage):
try:
if await query.adapter.is_stream_output_supported() and has_chunks:
is_final = [msg.is_final for msg in query.resp_messages][-1]
is_final = [msg.is_final for msg in query.resp_messages][0]
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
-2
View File
@@ -501,8 +501,6 @@ class PlatformManager:
bot_entity.adapter_config,
logger,
)
if hasattr(adapter_inst, 'ap'):
adapter_inst.ap = self.ap
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
File diff suppressed because it is too large Load Diff
@@ -103,41 +103,6 @@ spec:
type: string
required: true
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:
python:
path: ./dingtalk.py
+5 -518
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import discord
from discord import ui as discord_ui
import typing
import re
@@ -9,8 +8,6 @@ import base64
import uuid
import os
import datetime
import time
import traceback
# 使用BytesIO创建文件对象,避免路径问题
import io
@@ -827,69 +824,6 @@ 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):
bot: discord.Client = pydantic.Field(exclude=True)
@@ -903,10 +837,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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):
bot_account_id = config['client_id']
@@ -930,18 +860,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args = {}
# Proxy: config > env var > auto-detect.
# 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
if os.getenv('http_proxy'):
args['proxy'] = os.getenv('http_proxy')
bot = MyClient(intents=intents, **args)
@@ -955,19 +875,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**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
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
"""
@@ -1161,12 +1068,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
msg_to_send, files = await self.message_converter.yiri2target(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
assert isinstance(message_source.source_platform_object, discord.Message)
args = {
'content': msg_to_send,
@@ -1176,7 +1078,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args['files'] = files
if quote_origin:
args['reference'] = source
args['reference'] = message_source.source_platform_object
has_at = False
@@ -1188,422 +1090,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if has_at:
args['mention_author'] = True
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()}')
await message_source.source_platform_object.channel.send(**args)
async def is_muted(self, group_id: int) -> bool:
return False
File diff suppressed because it is too large Load Diff
+3 -582
View File
@@ -11,13 +11,7 @@ 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.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
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.api import QQOfficialClient
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
from ..logger import EventLogger
@@ -197,7 +191,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
enable_webhook: bool = False
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
ap: typing.Any = None
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -223,31 +216,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
self._stream_ctx_ts: dict[str, float] = {}
self._fallback_text: dict[str, str] = {}
self._fallback_text_ts: dict[str, float] = {}
# Dify form-action bookkeeping for the human-input button flow.
# session_key = "<scene>_<id>" where scene is c2c/group/channel and
# id is user_openid / group_openid / channel_id.
# session_key -> {form_data, msg_id, event_id, scene, target_id,
# sender_id, posted_at}
# Set when we send a markdown+keyboard card and consulted when:
# (a) INTERACTION_CREATE fires — we look up the form by
# session_key (button's `data` carries the action_id),
# (b) the resumed-workflow query needs to find a passive-reply
# event_id (INTERACTION_CREATE id, 30-min validity).
self._pending_forms: dict[str, dict] = {}
# session_key -> most recent ``INTERACTION_CREATE`` event_id, used
# as the passive event_id for the resumed query's LLM output.
self._session_event_ids: dict[str, dict] = {}
# Per-anchor msg_seq counter. QQ accepts up to 5 passive replies
# per (msg_id|event_id) within 60 min, but each reuse needs a
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
self._anchor_msg_seq: dict[str, int] = {}
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
# (ws mode is wired separately via on_event in _run_websocket so the
# raw payload bypasses get_message's message-only flattening.)
@self.bot.on_interaction()
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
await self._handle_interaction_create(event_data, interaction_id)
async def reply_message(
self,
@@ -259,13 +227,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
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)
# 确定 target_type 和 target_id
@@ -415,9 +376,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict):
# INTERACTION_CREATE is dispatched via bot.on_interaction()
# (registered in __init__) so we get the top-level ws_event_id
# — needed as the passive-reply event_id. It never reaches here.
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
message_event_types = {
'C2C_MESSAGE_CREATE',
@@ -479,36 +437,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def is_stream_output_supported(self) -> bool:
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:
source = event.source_platform_object
# Synthetic events (button-click resume) have no source object —
# they ride a cached INTERACTION_CREATE event_id, not a streamable
# msg_id. Skip stream setup; reply_message handles the one-shot
# send at is_final.
if source is None:
return False
# Streaming API only supports C2C private chat
if source.t != 'C2C_MESSAGE_CREATE':
return False
# 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 = {
'user_openid': source.user_openid,
'msg_id': source.d_id,
@@ -535,38 +469,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
):
# Periodically clean up stale stream contexts
await self._cleanup_stale_streams()
# Dify human-input pause: when the runner attaches `_form_data` to
# the final chunk, finalize any in-flight stream session and send
# a markdown + keyboard message instead. Plain-text content from
# earlier chunks is already on the stream; we close it cleanly
# and the buttons land as a separate reply.
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
if is_final:
_resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None
_open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None
if self.ap is not None:
self.ap.logger.info(
f'QQ Official reply_message_chunk final: '
f'type={type(bot_message).__name__} '
f'is_final={is_final} '
f'form_data_present={form_data is not None} '
f'resume_from_form={_resume} open_new_card={_open_new} '
f'content_len={len(getattr(bot_message, "content", "") or "")}'
)
if form_data and is_final:
await self._handle_form_chunk(message_source, message, form_data)
return
# 提取纯文本内容(当前 chunk 的文本)
text_parts = []
for msg in message:
if type(msg) is platform_message.Plain:
text_parts.append(msg.text)
chunk_text = '\n\n'.join(text_parts)
if self._is_form_placeholder_chunk(chunk_text):
await self.logger.debug('QQ Official: skipped invisible form placeholder chunk')
return
message_id = (
bot_message.get('resp_message_id')
@@ -576,8 +484,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
if not message_id or message_id not in self._stream_ctx:
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
if chunk_text:
# Chunks carry the latest full snapshot, not a text delta.
self._fallback_text[message_id] = chunk_text
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
@@ -590,7 +497,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 累积文本
if chunk_text:
ctx['accumulated_text'] = chunk_text
ctx['accumulated_text'] += chunk_text
# 未启动会话时,等第一个有内容的 chunk 来建立会话
if not ctx['session_started']:
@@ -650,489 +557,3 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
],
):
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,18 +31,6 @@ spec:
type: array[string]
required: false
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
label:
en_US: App ID
@@ -64,12 +52,8 @@ spec:
en_US: Token
zh_Hans: 令牌
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
required: false
required: true
default: ""
- name: enable-webhook
label:
+25 -427
View File
@@ -1,17 +1,15 @@
from __future__ import annotations
import time
import telegram
import telegram.ext
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
import telegramify_markdown
import typing
import traceback
import json
import base64
import time
import uuid
import pydantic
from langbot.pkg.utils import httpclient
@@ -22,61 +20,6 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
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):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
@@ -224,7 +167,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event.message.date.timestamp(),
source_platform_object=event,
)
elif event.effective_chat.type in ('group', 'supergroup'):
elif event.effective_chat.type == 'group' or 'supergroup':
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.effective_chat.id,
@@ -246,7 +189,6 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: telegram.Bot = 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()
event_converter: TelegramEventConverter = TelegramEventConverter()
@@ -262,48 +204,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.from_user.is_bot:
@@ -324,117 +224,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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__(
config=config,
logger=logger,
@@ -525,34 +314,23 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args['parse_mode'] = 'MarkdownV2'
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):
assert isinstance(event.source_platform_object, Update)
update = event.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
chat_type = update.effective_chat.type
message_thread_id = update.message.message_thread_id
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] = ('message', send_msg.message_id, False)
if chat_type == 'private':
draft_id = int(time.time() * 1000)
self.msg_stream_id[message_id] = ('private', draft_id)
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
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
@@ -569,15 +347,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
assert isinstance(message_source.source_platform_object, Update)
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
message_thread_id = update.message.message_thread_id
if message_id not in self.msg_stream_id:
return
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]
chat_mode, draft_id = self.msg_stream_id[message_id]
components = await TelegramMessageConverter.yiri2target(message, self.bot)
if not components or components[0]['type'] != 'text':
@@ -586,68 +361,17 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
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':
# Streaming via draft (ephemeral preview in the chat input area)
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)
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
if is_final and bot_message.tool_calls is None:
# Finalise: send the real message, discard the draft
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
del args['draft_id']
await self.bot.send_message(**args)
self.msg_stream_id.pop(message_id)
else:
# Streaming via edit_message_text (persistent message)
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:
stream_id = draft_id
if (msg_seq - 1) % 8 == 0 or is_final:
args = {
'message_id': stream_id,
'chat_id': chat_id,
@@ -655,137 +379,11 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
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)
await self.bot.edit_message_text(**args)
if is_final and bot_message.tool_calls is None:
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:
if not isinstance(event.source_platform_object, Update):
return None
+5 -424
View File
@@ -11,13 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
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.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
@@ -302,7 +296,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners: dict = {}
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
_STREAM_MAPPING_TTL = 600 # 10 minutes
ap: typing.Any = None
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -343,25 +336,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_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(
self,
message_source: platform_events.MessageEvent,
@@ -371,37 +345,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content = await self.message_converter.yiri2target(message)
_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:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
event = message_source.source_platform_object
req_id = event.get('req_id', '')
if req_id:
await self.bot.reply_text(req_id, content)
else:
await self.bot.set_message(event.message_id, content)
else:
await self.bot.set_message(event.message_id, content)
await self.bot.set_message(message_source.source_platform_object.message_id, content)
async def reply_message_chunk(
self,
@@ -412,56 +364,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
is_final: bool = False,
):
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)
# 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:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
@@ -480,142 +385,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Whether streaming output is enabled for this bot instance."""
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):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
@@ -762,191 +531,3 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def is_muted(self, group_id: int) -> bool:
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
+19 -67
View File
@@ -6,7 +6,7 @@ import json
import re
import time
import typing
from contextlib import AsyncExitStack, asynccontextmanager
from contextlib import AsyncExitStack
import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
@@ -18,7 +18,6 @@ from mcp import ClientSession, StdioServerParameters, types as mcp_types
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.exceptions import McpError
from pydantic import AnyUrl
from .. import loader
@@ -336,34 +335,23 @@ class RuntimeMCPSession:
await self.session.initialize()
@asynccontextmanager
async def _streamable_http_session(self) -> typing.AsyncIterator[ClientSession]:
"""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'],
http_client=http_client,
) as transport:
read, write, _ = transport
async with ClientSession(read, write) as session:
await session.initialize()
yield session
async def _init_streamable_http_server(self):
self.session = await self.exit_stack.enter_async_context(self._streamable_http_session())
transport = await self.exit_stack.enter_async_context(
streamable_http_client(
self.server_config['url'],
http_client=httpx.AsyncClient(
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
follow_redirects=True,
),
)
)
read, write, _ = transport
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
async def _init_remote_server(self):
"""Connect to a remote MCP server, auto-detecting the transport.
@@ -378,15 +366,9 @@ class RuntimeMCPSession:
await self._init_streamable_http_server()
return
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(
f'MCP server {self.server_name}: Streamable HTTP initialize failed with a compatible HTTP status '
f'({self._describe_exception(e)}), falling back to legacy SSE'
f'MCP server {self.server_name}: Streamable HTTP transport failed '
f'({self._describe_exception(e)}), falling back to SSE'
)
# The Streamable HTTP attempt may have partially entered the transport /
@@ -593,36 +575,6 @@ class RuntimeMCPSession:
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}'
@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_MAX_CONSECUTIVE_ERRORS = 3
@@ -57,23 +57,6 @@ class MCPSessionErrorPhase(enum.Enum):
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):
"""Structured configuration for running an MCP server inside a Box container."""
@@ -163,13 +146,11 @@ class BoxStdioSessionRuntime:
# 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)),
memory_mb=(
(self.config.memory_mb or 1024)
if self.server_config.get('command', '') in ('npx', 'bunx', 'pnpm')
else self.config.memory_mb
),
pids_limit=self.config.pids_limit,
persistent=True,
)
-6
View File
@@ -33,12 +33,6 @@ class VectorDBManager:
self.vector_db = SeekDBVectorDatabase(self.ap)
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':
from .vdbs.milvus import MilvusVectorDatabase
@@ -1,828 +0,0 @@
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
-19
View File
@@ -87,16 +87,6 @@ vdb:
database: 'langbot'
user: '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:
use: local
cleanup:
@@ -153,15 +143,6 @@ box:
- './data/box'
- '/tmp'
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:
cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply.
e2b:
File diff suppressed because one or more lines are too long
-11
View File
@@ -104,17 +104,6 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
'user': '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': {
'use': 'local',
@@ -1,343 +0,0 @@
"""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
+1 -1
View File
@@ -27,7 +27,7 @@
### 4. 向量数据库 (`vector/vdbs/`)
- **路径**: `src/langbot/pkg/vector/vdbs/`
- **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search
- **模块**: chroma, milvus, pgvector, qdrant, seekdb
- **排除原因**: 需要真实向量数据库实例运行
- **测试方式**: 需要 Docker 启动测试数据库或 mock
- **状态**: 后续可补充 mock 测试
@@ -1,254 +0,0 @@
"""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 == ['✅ commentlooks good', '✅ choiceB']
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✅ xiala1'
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') == ['✅ commentready']
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()
@@ -1,42 +0,0 @@
"""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'] == ''
@@ -1,196 +0,0 @@
"""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:你好',
'✅ xialaor',
'✅ files2 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 == ['✅ xialaB']
@@ -1,224 +0,0 @@
"""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)
@@ -1,158 +0,0 @@
"""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 == {}
@@ -1,475 +0,0 @@
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✅ choiceB'
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')
assert payload['image'] == 'node:20'
assert payload['cpus'] == 2.0
assert payload["memory_mb"] == 1024
assert payload['memory_mb'] == 1024
assert payload['pids_limit'] == 256
def test_none_fields_excluded(self, mcp_module):
@@ -1,244 +0,0 @@
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,7 +4,6 @@ import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from mcp import types as mcp_types
@@ -55,50 +54,6 @@ def _query() -> SimpleNamespace:
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
async def test_read_resource_envelope_truncates_caches_and_records_trace():
session = _connected_session()
+1 -20
View File
@@ -33,7 +33,7 @@ class TestVectorDBManagerInitialization:
mocks['langbot.pkg.core.app'] = MagicMock()
# Mock all VDB backend implementations
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db', 'valkey_search']:
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
return mocks
@@ -123,25 +123,6 @@ class TestVectorDBManagerInitialization:
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):
"""Milvus config with custom URI."""
vdb_config = {
@@ -1,388 +0,0 @@
"""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'})
Generated
+4 -39
View File
@@ -2084,7 +2084,6 @@ dependencies = [
{ name = "tiktoken" },
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide" },
{ name = "websockets" },
]
@@ -2124,7 +2123,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.4.13" },
{ name = "langbot-plugin", specifier = "==0.4.9" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2173,7 +2172,6 @@ requires-dist = [
{ name = "tiktoken", specifier = ">=0.9.0" },
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "uv", specifier = ">=0.11.15" },
{ name = "valkey-glide", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
@@ -2189,7 +2187,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.4.13"
version = "0.4.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2210,9 +2208,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/54/52/f85939d3be929696dc5f9f3369f18bd6652981a8985c6e6f0dfe48e04f75/langbot_plugin-0.4.9.tar.gz", hash = "sha256:1cc2882f1be96cd52be6c392c3925e44cf785c97c10f82a321339636da318c6e", size = 334682, upload-time = "2026-07-03T04:57:42.813Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/47/12/1cf535377a81cf01607bbee5e3f4745a8c9f6dbb561887467001a182a533/langbot_plugin-0.4.9-py3-none-any.whl", hash = "sha256:693225a089ba38bc8d0b58d9e0fdf07b027b5be6b5e858cf7b4e56d095b68ba5", size = 221722, upload-time = "2026-07-03T04:57:41.578Z" },
]
[[package]]
@@ -5986,39 +5984,6 @@ 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" },
]
[[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]]
name = "virtualenv"
version = "20.36.1"
@@ -269,10 +269,6 @@ export default function BotForm({
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
url: item.url,
download_filename: item.download_filename,
help_links: item.help_links,
help_label: item.help_label,
}),
),
);
@@ -24,15 +24,7 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Copy,
Check,
Globe,
Info,
QrCode,
Download,
ExternalLink,
} from 'lucide-react';
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
Tooltip,
@@ -41,7 +33,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -300,52 +291,6 @@ 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
* outbound IPs that the operator must add to a platform's trusted-IP list).
@@ -460,7 +405,7 @@ export default function DynamicFormComponent({
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation();
const { t } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
@@ -515,7 +460,6 @@ export default function DynamicFormComponent({
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login' &&
item.type !== 'download-link' &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
),
[itemConfigList],
@@ -833,30 +777,6 @@ 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)
if (config.type === 'qr-code-login') {
return (
@@ -18,10 +18,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
options?: IDynamicFormItemOption[];
show_if?: IShowIfCondition;
login_platform?: string;
url?: string;
download_filename?: string;
help_links?: Record<string, string>;
help_label?: I18nObject;
constructor(params: IDynamicFormItemSchema) {
this.id = params.id;
@@ -34,10 +30,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
this.options = params.options;
this.show_if = params.show_if;
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;
}
}
@@ -16,12 +16,7 @@ import {
} from 'lucide-react';
import QRCode from 'qrcode';
export type QrLoginPlatform =
| 'feishu'
| 'weixin'
| 'dingtalk'
| 'wecombot'
| 'qqofficial';
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
interface PlatformConfig {
titleKey: string;
@@ -34,7 +29,6 @@ interface PlatformConfig {
apiBase: string;
extractSuccess: (data: Record<string, string>) => Record<string, string>;
successNoteKey?: string;
boundByKey?: string;
}
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
@@ -98,22 +92,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
}),
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 {
@@ -140,7 +118,6 @@ export default function QrCodeLoginDialog({
const [qrDataUrl, setQrDataUrl] = useState('');
const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const [successMeta, setSuccessMeta] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -201,7 +178,6 @@ export default function QrCodeLoginDialog({
setQrDataUrl('');
setExpireIn(0);
setErrorMessage('');
setSuccessMeta('');
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
@@ -299,13 +275,6 @@ export default function QrCodeLoginDialog({
sessionIdRef.current = null;
cleanup();
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(() => {
onSuccessRef.current(cfg.extractSuccess(rest));
onOpenChangeRef.current(false);
@@ -426,11 +395,6 @@ export default function QrCodeLoginDialog({
<p className="text-sm text-green-600 font-medium">
{t(platformConfig.successKey)}
</p>
{successMeta && (
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
{successMeta}
</p>
)}
{platformConfig.successNoteKey && (
<p className="text-xs text-muted-foreground text-center max-w-xs">
{t(platformConfig.successNoteKey)}
@@ -44,10 +44,6 @@ export interface IDynamicFormItemSchema {
scopes?: string[];
accept?: string; // For file type: accepted MIME types
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 {
@@ -76,7 +72,6 @@ export enum DynamicFormItemType {
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
DOWNLOAD_LINK = 'download-link',
}
export interface IFileConfig {
-8
View File
@@ -229,10 +229,6 @@ export default function WizardPage() {
options: item.options,
show_if: item.show_if,
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]);
@@ -253,10 +249,6 @@ export default function WizardPage() {
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
url: item.url,
download_filename: item.download_filename,
help_links: item.help_links,
help_label: item.help_label,
}),
);
}, [selectedRunnerConfigStage]);
-13
View File
@@ -1822,19 +1822,6 @@ const enUS = {
robotNameNote:
'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: {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
-14
View File
@@ -1740,20 +1740,6 @@ const esES = {
robotNameNote:
'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: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
-13
View File
@@ -1733,19 +1733,6 @@ const jaJP = {
retry: '再試行',
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
},
qqofficial: {
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
scanQRCode:
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
waitingForScan: 'スキャン待ち',
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
bindFailed: 'バインド失敗',
connecting: 'QQ サービスに接続中...',
retry: '再試行',
tokenNote:
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
-13
View File
@@ -1711,19 +1711,6 @@ const ruRU = {
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: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
-12
View File
@@ -1672,18 +1672,6 @@ const thTH = {
retry: 'ลองใหม่',
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: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
-13
View File
@@ -1701,19 +1701,6 @@ const viVN = {
retry: 'Thử lại',
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: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
-11
View File
@@ -1741,17 +1741,6 @@ const zhHans = {
retry: '重试',
robotNameNote: '机器人名称无法自动获取,请手动填写。',
},
qqofficial: {
createBinding: '一键扫码绑定 QQ 机器人',
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
waitingForScan: '等待扫码中',
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
bindFailed: '绑定失败',
connecting: '正在连接 QQ 服务...',
retry: '重试',
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
-11
View File
@@ -1650,17 +1650,6 @@ const zhHant = {
retry: '重試',
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
},
qqofficial: {
createBinding: '一鍵掃碼綁定 QQ 機器人',
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
waitingForScan: '等待掃碼中',
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
bindFailed: '綁定失敗',
connecting: '正在連線 QQ 服務...',
retry: '重試',
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',