mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
feat(channel): add QQ WebSocket channel (#6081)
* feat: add QQ WebSocket channel * fix: address QQ channel review feedback * docs: trim inherited agent guidance below CI limit * fix: align QQ gateway and passive reply limits with protocol * docs: keep merged channel guidance within CI budget
This commit is contained in:
parent
6e76c14384
commit
9b17bef630
@ -1,10 +1,8 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding agents (Claude Code, Codex, and others) when working with code in this repository. It is the source of truth; the sibling `CLAUDE.md` imports it via `@AGENTS.md`.
|
||||
This is the source of truth for repository agent guidance; `CLAUDE.md` imports it via `@AGENTS.md`.
|
||||
|
||||
It is the **monorepo orientation layer**: it maps the whole repo and points to the
|
||||
module guides that own the depth. For anything inside a module, read that module's
|
||||
guide rather than expecting full detail here:
|
||||
This **monorepo orientation layer** maps the repo. For module details, read its guide:
|
||||
|
||||
- **[backend/AGENTS.md](backend/AGENTS.md)** — backend depth: harness/app split, agent &
|
||||
middleware chain, sandbox, MCP, skills, memory, IM channels, persistence/migrations,
|
||||
|
||||
@ -708,7 +708,7 @@ cleanup when migrating from legacy metadata credentials.
|
||||
|
||||
DeerFlow supports receiving tasks from messaging apps. Channels auto-start when configured — no public IP required for any of them.
|
||||
|
||||
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, or Buzz from the sidebar / Settings > Channels. It reuses the existing outbound `channels.*` transports, so no public IP or provider callback URL is required. Incoming IM messages then run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for setup and security notes.
|
||||
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, QQ, or Buzz from the sidebar / Settings > Channels. It reuses the existing outbound `channels.*` transports, so no public IP or provider callback URL is required. Incoming IM messages then run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for setup and security notes.
|
||||
|
||||
| Channel | Transport | Difficulty |
|
||||
|---------|-----------|------------|
|
||||
@ -717,6 +717,7 @@ DeerFlow can also expose user-owned IM channel connections in the workspace UI.
|
||||
| Feishu / Lark | WebSocket | Moderate |
|
||||
| WeChat | Tencent iLink (long-polling) | Moderate |
|
||||
| WeCom | WebSocket | Moderate |
|
||||
| QQ | WebSocket (text-only C2C and group @mentions; four/five passive replies per source) | Moderate |
|
||||
| DingTalk | Stream Push (WebSocket) | Moderate |
|
||||
| Buzz | Nostr relay (WebSocket, NIP-42) | Moderate |
|
||||
|
||||
@ -753,6 +754,12 @@ channels:
|
||||
# domain: https://open.feishu.cn # China (default)
|
||||
# domain: https://open.larksuite.com # International
|
||||
|
||||
qq:
|
||||
enabled: true
|
||||
app_id: $QQ_APP_ID
|
||||
client_secret: $QQ_CLIENT_SECRET
|
||||
allowed_users: [] # QQ OpenIDs, not QQ account numbers
|
||||
|
||||
wecom:
|
||||
enabled: true
|
||||
bot_id: $WECOM_BOT_ID
|
||||
|
||||
@ -296,7 +296,7 @@ Voir le [Guide MCP Server](backend/docs/MCP_SERVER.md) pour les instructions dé
|
||||
|
||||
DeerFlow peut recevoir des tâches depuis des applications de messagerie. Les canaux démarrent automatiquement une fois configurés — aucune IP publique n'est requise.
|
||||
|
||||
DeerFlow peut aussi exposer des connexions de canaux IM appartenant à l'utilisateur dans l'UI du workspace. Quand `channel_connections` est activé, les utilisateurs connectés peuvent lier Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat ou WeCom depuis la barre latérale / Settings > Channels. Cela réutilise les transports sortants `channels.*` existants, donc aucune IP publique ni URL de callback provider n'est requise. Les messages IM entrants s'exécutent ensuite sous le compte utilisateur DeerFlow connecté. Voir [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) pour la configuration et les notes de sécurité.
|
||||
DeerFlow peut aussi exposer des connexions de canaux IM appartenant à l'utilisateur dans l'UI du workspace. Quand `channel_connections` est activé, les utilisateurs connectés peuvent lier Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, QQ ou Buzz depuis la barre latérale / Settings > Channels. Cela réutilise les transports sortants `channels.*` existants, donc aucune IP publique ni URL de callback provider n'est requise. Les messages IM entrants s'exécutent ensuite sous le compte utilisateur DeerFlow connecté. Voir [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) pour la configuration et les notes de sécurité.
|
||||
|
||||
| Canal | Transport | Difficulté |
|
||||
|---------|-----------|------------|
|
||||
@ -306,6 +306,8 @@ DeerFlow peut aussi exposer des connexions de canaux IM appartenant à l'utilisa
|
||||
| WeChat | Tencent iLink (long-polling) | Modérée |
|
||||
| WeCom | WebSocket | Modérée |
|
||||
| DingTalk | Stream Push (WebSocket) | Modérée |
|
||||
| QQ | WebSocket (texte : messages privés et mentions de groupe) | Modérée |
|
||||
| Buzz | Relais Nostr (WebSocket, NIP-42) | Modérée |
|
||||
|
||||
**Configuration dans `config.yaml` :**
|
||||
|
||||
|
||||
@ -283,7 +283,7 @@ HTTP/SSE MCPサーバーでは、OAuthトークンフロー(`client_credential
|
||||
|
||||
DeerFlowはメッセージングアプリからのタスク受信をサポートしています。チャネルは設定時に自動的に開始されます。いずれもパブリックIPは不要です。
|
||||
|
||||
DeerFlowはワークスペースUIでユーザー所有のIMチャネル接続を公開することもできます。`channel_connections`を有効にすると、ログイン済みユーザーはサイドバー / Settings > ChannelsからTelegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat、WeComをバインドできます。これは既存の`channels.*`送信トランスポートを再利用するため、パブリックIPやプロバイダーのコールバックURLは不要です。受信したIMメッセージは接続したDeerFlowユーザーアカウントの下で実行されます。セットアップとセキュリティ上の注意は[IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)をご覧ください。
|
||||
DeerFlowはワークスペースUIでユーザー所有のIMチャネル接続を公開することもできます。`channel_connections`を有効にすると、ログイン済みユーザーはサイドバー / Settings > ChannelsからTelegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat、WeCom、QQ、Buzzをバインドできます。これは既存の`channels.*`送信トランスポートを再利用するため、パブリックIPやプロバイダーのコールバックURLは不要です。受信したIMメッセージは接続したDeerFlowユーザーアカウントの下で実行されます。セットアップとセキュリティ上の注意は[IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)をご覧ください。
|
||||
|
||||
| チャネル | トランスポート | 難易度 |
|
||||
|---------|-----------|------------|
|
||||
@ -293,6 +293,8 @@ DeerFlowはワークスペースUIでユーザー所有のIMチャネル接続
|
||||
| WeChat | Tencent iLink(ロングポーリング) | 中程度 |
|
||||
| WeCom | WebSocket | 中程度 |
|
||||
| DingTalk | Stream Push(WebSocket) | 中程度 |
|
||||
| QQ | WebSocket(テキストのみ:個別チャットとグループ @) | 中程度 |
|
||||
| Buzz | Nostr relay(WebSocket、NIP-42) | 中程度 |
|
||||
|
||||
**`config.yaml`での設定:**
|
||||
|
||||
|
||||
@ -285,7 +285,7 @@ DeerFlow поддерживает настраиваемые MCP-серверы
|
||||
|
||||
DeerFlow принимает задачи прямо из мессенджеров. Каналы запускаются автоматически при настройке, публичный IP не нужен.
|
||||
|
||||
DeerFlow может также предоставлять в workspace UI пользовательские подключения IM-каналов. Когда включён `channel_connections`, вошедшие в систему пользователи могут привязать Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat или WeCom из боковой панели / Settings > Channels. Это переиспользует существующие исходящие транспорты `channels.*`, поэтому публичный IP или URL обратного вызова провайдера не требуются. Входящие IM-сообщения выполняются от имени подключённого пользователя DeerFlow. Настройки и вопросы безопасности описаны в [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md).
|
||||
DeerFlow может также предоставлять в workspace UI пользовательские подключения IM-каналов. Когда включён `channel_connections`, вошедшие в систему пользователи могут привязать Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, QQ или Buzz из боковой панели / Settings > Channels. Это переиспользует существующие исходящие транспорты `channels.*`, поэтому публичный IP или URL обратного вызова провайдера не требуются. Входящие IM-сообщения выполняются от имени подключённого пользователя DeerFlow. Настройки и вопросы безопасности описаны в [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md).
|
||||
|
||||
| Канал | Транспорт | Сложность |
|
||||
|-------|-----------|-----------|
|
||||
@ -295,6 +295,8 @@ DeerFlow может также предоставлять в workspace UI пол
|
||||
| WeChat | Tencent iLink (long-polling) | Средне |
|
||||
| WeCom | WebSocket | Средне |
|
||||
| DingTalk | Stream Push (WebSocket) | Средне |
|
||||
| QQ | WebSocket (текст: личные сообщения и упоминания в группе) | Средняя |
|
||||
| Buzz | Nostr relay (WebSocket, NIP-42) | Средняя |
|
||||
|
||||
**Конфигурация в `config.yaml`:**
|
||||
|
||||
|
||||
@ -392,7 +392,7 @@ DeerFlow 支持可配置的 MCP Server 和 skills,用来扩展能力。
|
||||
|
||||
DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应渠道会自动启动,而且都不需要公网 IP。
|
||||
|
||||
DeerFlow 还可以在 workspace UI 里暴露用户自有的 IM 渠道连接。启用 `channel_connections` 后,已登录用户可以从侧边栏 / Settings > Channels 绑定 Telegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat 或 WeCom。它复用现有的 `channels.*` 出站传输,因此不需要公网 IP 或 provider 回调地址。入站 IM 消息会以所连接的 DeerFlow 用户身份运行。设置和安全注意事项参见 [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)。
|
||||
DeerFlow 还可以在 workspace UI 里暴露用户自有的 IM 渠道连接。启用 `channel_connections` 后,已登录用户可以从侧边栏 / Settings > Channels 绑定 Telegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat、WeCom、QQ 或 Buzz。它复用现有的 `channels.*` 出站传输,因此不需要公网 IP 或 provider 回调地址。入站 IM 消息会以所连接的 DeerFlow 用户身份运行。设置和安全注意事项参见 [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)。
|
||||
|
||||
| 渠道 | 传输方式 | 上手难度 |
|
||||
|---------|-----------|------------|
|
||||
@ -402,6 +402,8 @@ DeerFlow 还可以在 workspace UI 里暴露用户自有的 IM 渠道连接。
|
||||
| WeChat | Tencent iLink(long-polling) | 中等 |
|
||||
| 企业微信智能机器人 | WebSocket | 中等 |
|
||||
| 钉钉 | Stream Push(WebSocket) | 中等 |
|
||||
| QQ | WebSocket(仅文本私聊及群 @;每条来源消息最多回复 4 / 5 条) | 中等 |
|
||||
| Buzz | Nostr relay(WebSocket,NIP-42) | 中等 |
|
||||
|
||||
**`config.yaml` 中的配置示例:**
|
||||
|
||||
|
||||
@ -77,3 +77,25 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
|
||||
- Outbound is **log-only** by design: each agent posts its own reply mid-run via the `gh` CLI from its sandbox, so the manager uses `fire_and_forget=True` and `runs.create()` returns once pending.
|
||||
- **Follow-up buffering while busy** (issue #4121): because outbound is log-only, the pre-existing `THREAD_BUSY_MESSAGE` reply on a `ConflictError` was invisible to the commenter — a comment posted while a run was already active looked like it had been silently ignored. When `ChannelRunPolicy.buffer_followups_on_busy=True` (GitHub's default), a `ConflictError` on `runs.create()` now also appends the triggering message to a per-thread, in-memory buffer (`ChannelManager._followup_buffers`) — deduped by GitHub delivery id, capped at `FOLLOWUP_BUFFER_MAX_PER_THREAD` (20, oldest dropped with a WARNING log on overflow). The first successful `runs.create()` on a thread now captures its `run_id` and spawns a background watcher that subscribes to that run's `StreamBridge` stream; once it observes `END_SENTINEL`, the watcher drains up to `FOLLOWUP_DRAIN_BATCH_SIZE` (10) buffered entries into one `<followups-while-busy>`-wrapped input and fires a follow-up `runs.create()` — itself watched the same way, so a backlog deeper than one batch chains into further drain cycles instead of growing one unbounded input. If that follow-up `runs.create()` itself hits `ConflictError` (e.g. a manual Web UI turn or a scheduled run raced onto the same thread), the batch is requeued rather than lost, and is retried whenever this manager next successfully creates and watches a run on that thread. Reactions/acknowledgment (e.g. GitHub's `eyes`/`confused` reaction API) on buffered comments are intentionally **out of scope** for this mechanism and left to a follow-up — comments are coalesced silently. **Plumbing**: the watcher needs the Gateway's `StreamBridge` singleton, which `ChannelManager` did not previously have access to; it is threaded from `app.py`'s lifespan (where `app.state.stream_bridge` is already set by `langgraph_runtime`) through `start_channel_service(get_stream_bridge=...)` → `ChannelService.__init__` → `ChannelManager.__init__`, as a zero-arg closure mirroring the existing `launch_run=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs)` pattern used for `ScheduledTaskService` in the same lifespan function. A `ChannelManager` constructed without it (e.g. directly in a test) still buffers safely — it just has no watcher to auto-drain. **Scope limitation**: the buffer and watcher state are per-process, in-memory. Under `GATEWAY_WORKERS>1` or multi-pod, a follow-up comment routed to a different worker process than the one running the busy thread's agent will not see that buffer. This is a known, deliberately deferred limitation with the same shape as the cross-pod gap described for issue #4120 (a shared buffer store or IM-leader election would be needed to close it) — single-process/single-pod deployments, the safe default, see no correctness issue from this, only the documented per-process scope.
|
||||
- See [backend/docs/GITHUB_AGENTS.md](../../docs/GITHUB_AGENTS.md) for the architecture diagrams: webhook → fan-out → `InboundMessage` dispatch, `preferred_thread_id = UUID5(repo, number, agent_name)` thread determinism, mention-handle precedence chain, GH token lifecycle via `GH_TOKEN`/`GITHUB_TOKEN` per-call `extra_env`, and the narrow `ConflictError` (HTTP 409) thread-create race recovery.
|
||||
|
||||
|
||||
### QQ WebSocket channel
|
||||
|
||||
`qq.py` handles text C2C/group @mentions via httpx/websockets and the shared
|
||||
non-streaming manager. Keep service, provider, credential and config registration
|
||||
aligned. Binding scopes: `<app_id>:c2c` or `<app_id>:group:<group_openid>`; topics
|
||||
retain the sender. Bind commands precede allowlists; reserve intake before identity lookup.
|
||||
|
||||
Start waits for READY; stop drains tasks/HTTP. Keep reads/heartbeats independent
|
||||
of intake and SSL/client setup off-loop. Accept only TLS/443 gateways at
|
||||
`api.bot.qq.com` or the `sgroup.qq.com` family; reject URL credentials/redirects.
|
||||
Logs exclude secrets, content, provider bodies and raw exceptions.
|
||||
|
||||
`thread_ts` is the source message ID. Lock each source; preserve expiry, sequence
|
||||
and quota (C2C: four/60 min; group: five/5 min) across redelivery. Ignore progress;
|
||||
reuse sequences only after definite auth failure, never ambiguous I/O. Unknown
|
||||
timestamps use a full advisory window with content-free, rate-limited warnings.
|
||||
Evict expired contexts first; caches are bounded/process-local. One worker per bot.
|
||||
|
||||
Tests: `test_qq_{channel,lifecycle,binding}.py`, connection router and blocking-I/O.
|
||||
Setup: [IM_CHANNEL_CONNECTIONS.md](../../docs/IM_CHANNEL_CONNECTIONS.md#qq-websocket-setup-mvp).
|
||||
|
||||
493
backend/app/channels/qq.py
Normal file
493
backend/app/channels/qq.py
Normal file
@ -0,0 +1,493 @@
|
||||
"""QQ Open Platform text channel over an outbound WebSocket connection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import ssl
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
from websockets.asyncio.client import connect
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import extract_connect_code, is_known_channel_command
|
||||
from app.channels.connection_identity import attach_connection_identity
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
|
||||
from deerflow.utils.file_io import await_drained
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"
|
||||
API_URL = "https://api.sgroup.qq.com"
|
||||
GROUP_AND_C2C_EVENT = 1 << 25
|
||||
# Conservative adapter budget, not a claim about the platform's maximum.
|
||||
MAX_TEXT_BYTES = 4000
|
||||
C2C_MAX_REPLIES = 4
|
||||
GROUP_MAX_REPLIES = 5
|
||||
TIMESTAMP_WARNING_INTERVAL = 60.0
|
||||
MAX_CACHED_MESSAGES = 2048
|
||||
INBOUND_BUFFER_SIZE = 64
|
||||
START_TIMEOUT = 30.0
|
||||
RECONNECT_DELAY = 1.0
|
||||
TRUNCATION_MARKER = "\n[Reply truncated; see DeerFlow for the full response.]"
|
||||
|
||||
|
||||
class QQAPIError(RuntimeError):
|
||||
"""A sanitized provider failure, safe for shared channel error logging."""
|
||||
|
||||
|
||||
def validate_gateway_url(url: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
host = parsed.hostname or ""
|
||||
valid = (
|
||||
parsed.scheme == "wss" and (host == "api.bot.qq.com" or host == "sgroup.qq.com" or host.endswith(".sgroup.qq.com")) and parsed.port in (None, 443) and parsed.username is None and parsed.password is None and not parsed.fragment
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
valid = False
|
||||
if not valid:
|
||||
raise QQAPIError("QQ returned an untrusted gateway URL")
|
||||
return url
|
||||
|
||||
|
||||
class _GatewayConnect(connect):
|
||||
def process_redirect(self, exc: Exception) -> Exception:
|
||||
# Never forward Identify credentials through a redirected socket.
|
||||
return QQAPIError("QQ gateway redirects are not supported")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ReplyContext:
|
||||
path: str
|
||||
expires_at: float
|
||||
max_replies: int
|
||||
sequence: int = 0
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
class QQChannel(Channel):
|
||||
def __init__(self, bus: MessageBus, config: dict[str, Any]):
|
||||
super().__init__("qq", bus, config)
|
||||
self._app_id = str(config.get("app_id") or "").strip()
|
||||
self._client_secret = str(config.get("client_secret") or "").strip()
|
||||
self._allowed_users = set(config.get("allowed_users") or [])
|
||||
self._api_url = "https://sandbox.api.sgroup.qq.com" if config.get("sandbox", False) else API_URL
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._ssl: ssl.SSLContext | None = None
|
||||
self._access_token = ""
|
||||
self._token_expires_at = 0.0
|
||||
self._token_lock = asyncio.Lock()
|
||||
self._listener: asyncio.Task | None = None
|
||||
self._worker: asyncio.Task | None = None
|
||||
self._ready = asyncio.Event()
|
||||
self._authenticated = asyncio.Event()
|
||||
self._events: asyncio.Queue = asyncio.Queue(maxsize=INBOUND_BUFFER_SIZE)
|
||||
self._session_id: str | None = None
|
||||
self._sequence: int | None = None
|
||||
self._bot_id = ""
|
||||
self._replies: OrderedDict[tuple[str, str], _ReplyContext] = OrderedDict()
|
||||
self._next_timestamp_warning_at = 0.0
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return bool(self._running and self._ready.is_set() and self._listener and not self._listener.done())
|
||||
|
||||
async def _setup_transport(self) -> None:
|
||||
# Both SSL trust loading and httpx initialization can read local files.
|
||||
self._ssl = await asyncio.to_thread(ssl.create_default_context)
|
||||
if self._http is None:
|
||||
self._http = await asyncio.to_thread(httpx.AsyncClient, timeout=15.0, follow_redirects=False, trust_env=False)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.is_running:
|
||||
return
|
||||
if self._listener is not None:
|
||||
raise QQAPIError("QQ channel startup is already in progress")
|
||||
if not self._app_id or not self._client_secret:
|
||||
raise QQAPIError("QQ requires app_id and client_secret")
|
||||
try:
|
||||
await await_drained(self._setup_transport())
|
||||
self._running = True
|
||||
self._ready.clear()
|
||||
self.bus.subscribe_outbound(self._on_outbound)
|
||||
self._worker = asyncio.create_task(self._consume_events(), name="qq-inbound")
|
||||
self._listener = asyncio.create_task(self._listen(), name="qq-websocket")
|
||||
await asyncio.wait_for(self._ready.wait(), timeout=START_TIMEOUT)
|
||||
except BaseException:
|
||||
await self.stop()
|
||||
raise
|
||||
|
||||
async def stop(self) -> None:
|
||||
await await_drained(self._stop())
|
||||
|
||||
async def _stop(self) -> None:
|
||||
self._running = False
|
||||
self.bus.unsubscribe_outbound(self._on_outbound)
|
||||
tasks = [task for task in (self._listener, self._worker) if task is not None]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._listener = self._worker = None
|
||||
if self._http is not None:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
self._ready.clear()
|
||||
self._session_id = None
|
||||
self._authenticated.clear()
|
||||
self._sequence = None
|
||||
self._access_token = ""
|
||||
self._token_expires_at = 0.0
|
||||
self._events = asyncio.Queue(maxsize=INBOUND_BUFFER_SIZE)
|
||||
self._replies.clear()
|
||||
self._next_timestamp_warning_at = 0.0
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
async with self._token_lock:
|
||||
if self._access_token and time.monotonic() < self._token_expires_at:
|
||||
return self._access_token
|
||||
if self._http is None:
|
||||
raise QQAPIError("QQ HTTP transport is not available")
|
||||
try:
|
||||
response = await self._http.post(
|
||||
TOKEN_URL,
|
||||
json={"appId": self._app_id, "clientSecret": self._client_secret},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
token = payload["access_token"]
|
||||
lifetime = float(payload["expires_in"])
|
||||
if not isinstance(token, str) or not token or not math.isfinite(lifetime) or lifetime <= 0:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
raise QQAPIError("QQ access token request failed") from None
|
||||
self._access_token = token
|
||||
self._token_expires_at = time.monotonic() + lifetime - min(60, lifetime / 10)
|
||||
return token
|
||||
|
||||
async def _api_request(self, method: str, path: str, **kwargs: Any) -> dict:
|
||||
for attempt in range(2):
|
||||
token = await self._get_access_token()
|
||||
try:
|
||||
response = await self._http.request(
|
||||
method,
|
||||
self._api_url + path,
|
||||
headers={
|
||||
"Authorization": f"QQBot {token}",
|
||||
"X-Union-Appid": self._app_id,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
except Exception:
|
||||
# An ambiguous timeout must not trigger another passive reply.
|
||||
raise QQAPIError("QQ API transport failed") from None
|
||||
if response.status_code == 401 and attempt == 0:
|
||||
if token == self._access_token:
|
||||
self._token_expires_at = 0
|
||||
continue
|
||||
if not response.is_success:
|
||||
raise QQAPIError(f"QQ API request failed (HTTP {response.status_code})")
|
||||
try:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict) or payload.get("code", 0) not in (
|
||||
0,
|
||||
None,
|
||||
):
|
||||
raise ValueError
|
||||
return payload
|
||||
except (ValueError, TypeError):
|
||||
raise QQAPIError("QQ API returned an invalid response") from None
|
||||
raise QQAPIError("QQ API authentication failed")
|
||||
|
||||
async def _listen(self) -> None:
|
||||
delay = RECONNECT_DELAY
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
gateway = await self._api_request("GET", "/gateway")
|
||||
url = validate_gateway_url(gateway.get("url", ""))
|
||||
await self._run_connection(url)
|
||||
delay = RECONNECT_DELAY
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ConnectionClosed as exc:
|
||||
code = exc.rcvd.code if exc.rcvd is not None else None
|
||||
# 4009 explicitly permits Resume; rejected authentication,
|
||||
# sessions and sequence numbers require a fresh Identify.
|
||||
if code in range(4000, 4009):
|
||||
self._session_id = None
|
||||
self._sequence = None
|
||||
if code == 4004:
|
||||
self._token_expires_at = 0
|
||||
logger.warning("[qq] gateway closed; reconnecting (code=%s)", code)
|
||||
except Exception:
|
||||
# Provider exceptions may contain tokens or raw frames.
|
||||
logger.warning("[qq] gateway connection interrupted; reconnecting")
|
||||
if self._running:
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(max(delay * 2, RECONNECT_DELAY), 30.0)
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
async def _run_connection(self, url: str) -> None:
|
||||
self._authenticated.clear()
|
||||
async with _GatewayConnect(
|
||||
url,
|
||||
ssl=self._ssl if url.startswith("wss:") else None,
|
||||
proxy=None,
|
||||
ping_interval=None,
|
||||
open_timeout=15,
|
||||
close_timeout=5,
|
||||
max_size=256 * 1024,
|
||||
) as socket:
|
||||
hello = json.loads(await asyncio.wait_for(socket.recv(), timeout=15))
|
||||
interval = float(hello.get("d", {}).get("heartbeat_interval", 0)) / 1000
|
||||
if hello.get("op") != 10 or not math.isfinite(interval) or interval <= 0:
|
||||
raise QQAPIError("QQ gateway sent an invalid HELLO")
|
||||
token = await self._get_access_token()
|
||||
if self._session_id is not None and self._sequence is not None:
|
||||
auth = {
|
||||
"op": 6,
|
||||
"d": {
|
||||
"token": f"QQBot {token}",
|
||||
"session_id": self._session_id,
|
||||
"seq": self._sequence,
|
||||
},
|
||||
}
|
||||
else:
|
||||
auth = {
|
||||
"op": 2,
|
||||
"d": {
|
||||
"token": f"QQBot {token}",
|
||||
"intents": GROUP_AND_C2C_EVENT,
|
||||
"shard": [0, 1],
|
||||
},
|
||||
}
|
||||
await socket.send(json.dumps(auth))
|
||||
ack = asyncio.Event()
|
||||
ack.set()
|
||||
heartbeat = asyncio.create_task(self._heartbeat(socket, interval, ack), name="qq-heartbeat")
|
||||
reader = asyncio.create_task(self._read_frames(socket, ack), name="qq-reader")
|
||||
try:
|
||||
done, _ = await asyncio.wait((heartbeat, reader), return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in done:
|
||||
task.result()
|
||||
finally:
|
||||
heartbeat.cancel()
|
||||
reader.cancel()
|
||||
await asyncio.gather(heartbeat, reader, return_exceptions=True)
|
||||
|
||||
async def _heartbeat(self, socket: Any, interval: float, ack: asyncio.Event) -> None:
|
||||
# QQ must finish Identify/Resume before the first periodic heartbeat.
|
||||
# Sending a null sequence immediately after Identify isn't acknowledged
|
||||
# by the live gateway, even though permissive test servers accept it.
|
||||
await asyncio.wait_for(self._authenticated.wait(), timeout=15)
|
||||
while True:
|
||||
if not ack.is_set():
|
||||
raise QQAPIError("QQ heartbeat acknowledgement timed out")
|
||||
ack.clear()
|
||||
await socket.send(json.dumps({"op": 1, "d": self._sequence}))
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _read_frames(self, socket: Any, ack: asyncio.Event) -> None:
|
||||
async for raw in socket:
|
||||
frame = json.loads(raw)
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
op = frame.get("op")
|
||||
if op == 11:
|
||||
ack.set()
|
||||
elif op == 1:
|
||||
await socket.send(json.dumps({"op": 1, "d": self._sequence}))
|
||||
elif op == 7:
|
||||
return
|
||||
elif op == 9:
|
||||
if frame.get("d") is not True:
|
||||
self._session_id = None
|
||||
self._sequence = None
|
||||
self._token_expires_at = 0
|
||||
return
|
||||
elif op == 0:
|
||||
sequence = frame.get("s")
|
||||
if isinstance(sequence, int) and not isinstance(sequence, bool):
|
||||
self._sequence = sequence
|
||||
kind = frame.get("t")
|
||||
if kind == "READY":
|
||||
data = frame.get("d") or {}
|
||||
session = data.get("session_id")
|
||||
if not isinstance(session, str) or not session:
|
||||
raise QQAPIError("QQ gateway sent an invalid READY")
|
||||
self._session_id = session
|
||||
self._bot_id = str((data.get("user") or {}).get("id") or "")
|
||||
self._authenticated.set()
|
||||
self._ready.set()
|
||||
elif kind == "RESUMED":
|
||||
self._authenticated.set()
|
||||
self._ready.set()
|
||||
elif kind in ("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"):
|
||||
try:
|
||||
self._events.put_nowait(frame)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("[qq] inbound buffer full; dropping message")
|
||||
|
||||
async def _consume_events(self) -> None:
|
||||
while True:
|
||||
frame = await self._events.get()
|
||||
try:
|
||||
await self._handle_inbound(frame)
|
||||
except Exception:
|
||||
logger.warning("[qq] inbound message handling failed")
|
||||
finally:
|
||||
self._events.task_done()
|
||||
|
||||
async def _handle_inbound(self, frame: dict) -> None:
|
||||
kind = frame.get("t")
|
||||
if kind not in ("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"):
|
||||
return
|
||||
data = frame.get("d")
|
||||
if not isinstance(data, dict) or not isinstance(data.get("author"), dict):
|
||||
return
|
||||
group = kind == "GROUP_AT_MESSAGE_CREATE"
|
||||
sender = data["author"].get("member_openid" if group else "user_openid")
|
||||
target = data.get("group_openid") if group else sender
|
||||
message_id, text = data.get("id"), data.get("content")
|
||||
if not all(isinstance(value, str) and value for value in (sender, target, message_id, text)):
|
||||
return
|
||||
text = text.strip()
|
||||
if group and self._bot_id:
|
||||
for mention in (f"<@!{self._bot_id}>", f"<@{self._bot_id}>"):
|
||||
if text.startswith(mention):
|
||||
text = text[len(mention) :].strip()
|
||||
break
|
||||
if not text:
|
||||
return
|
||||
scope = "group" if group else "c2c"
|
||||
chat_id = f"{scope}:{target}"
|
||||
workspace = f"{self._app_id}:group:{target}" if group else f"{self._app_id}:c2c"
|
||||
now = time.monotonic()
|
||||
window = 300 if group else 3600
|
||||
timestamp = data.get("timestamp")
|
||||
if timestamp is not None:
|
||||
try:
|
||||
created = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
if created.tzinfo is None:
|
||||
raise ValueError
|
||||
window -= max(0, time.time() - created.timestamp())
|
||||
except (TypeError, ValueError, AttributeError, OverflowError):
|
||||
# The window is advisory; QQ enforces expiry server-side.
|
||||
# Unknown timestamp formats must not silently disable intake.
|
||||
if now >= self._next_timestamp_warning_at:
|
||||
logger.warning("[qq] message timestamp invalid or missing timezone; using full passive reply window")
|
||||
self._next_timestamp_warning_at = now + TIMESTAMP_WARNING_INTERVAL
|
||||
if window <= 0:
|
||||
return
|
||||
code = self._pending_connect_code(text)
|
||||
# Binding codes are always control traffic, even without a repository.
|
||||
if extract_connect_code(text) and code is None:
|
||||
return
|
||||
if not code and self._allowed_users and sender not in self._allowed_users:
|
||||
return
|
||||
key = (chat_id, message_id)
|
||||
self._replies.setdefault(
|
||||
key,
|
||||
_ReplyContext(
|
||||
f"/v2/{'groups' if group else 'users'}/{quote(target, safe='')}/messages",
|
||||
now + window,
|
||||
GROUP_MAX_REPLIES if group else C2C_MAX_REPLIES,
|
||||
),
|
||||
)
|
||||
if len(self._replies) > MAX_CACHED_MESSAGES:
|
||||
expired = [key for key, context in self._replies.items() if context.expires_at <= now]
|
||||
for expired_key in expired:
|
||||
del self._replies[expired_key]
|
||||
while len(self._replies) > MAX_CACHED_MESSAGES:
|
||||
self._replies.popitem(last=False)
|
||||
if code:
|
||||
state = await self._connection_repo.consume_oauth_state(provider="qq", state=code)
|
||||
reply = "QQ connection code is invalid or expired."
|
||||
if state is not None:
|
||||
await self._connection_repo.upsert_connection(
|
||||
owner_user_id=state["owner_user_id"],
|
||||
provider="qq",
|
||||
external_account_id=sender,
|
||||
workspace_id=workspace,
|
||||
metadata={"chat_type": scope},
|
||||
status="connected",
|
||||
)
|
||||
reply = "QQ connected to DeerFlow."
|
||||
await self.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id=chat_id,
|
||||
thread_id="",
|
||||
thread_ts=message_id,
|
||||
text=reply,
|
||||
)
|
||||
)
|
||||
return
|
||||
inbound = self._make_inbound(
|
||||
chat_id,
|
||||
sender,
|
||||
text,
|
||||
msg_type=InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT,
|
||||
thread_ts=message_id,
|
||||
metadata={"message_id": message_id},
|
||||
)
|
||||
inbound.topic_id = f"{self._app_id}:{sender}"
|
||||
inbound.workspace_id = workspace
|
||||
reservation = self._reserve_inbound(inbound)
|
||||
if reservation is None:
|
||||
return
|
||||
try:
|
||||
await attach_connection_identity(
|
||||
inbound,
|
||||
repo=self._connection_repo,
|
||||
provider="qq",
|
||||
workspace_id=workspace,
|
||||
)
|
||||
self._commit_reserved_inbound(reservation, inbound)
|
||||
finally:
|
||||
reservation.release()
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
if not msg.is_final or not msg.text:
|
||||
return
|
||||
context = self._replies.get((msg.chat_id, msg.thread_ts))
|
||||
if context is None:
|
||||
raise QQAPIError("QQ requires an unexpired source message for a passive reply")
|
||||
async with context.lock:
|
||||
remaining = context.max_replies - context.sequence
|
||||
if remaining <= 0:
|
||||
raise QQAPIError("QQ passive reply quota exhausted")
|
||||
raw = msg.text.encode("utf-8")
|
||||
for index in range(remaining):
|
||||
if context.expires_at <= time.monotonic():
|
||||
raise QQAPIError("QQ passive reply window expired")
|
||||
if index == remaining - 1 and len(raw) > MAX_TEXT_BYTES:
|
||||
marker = TRUNCATION_MARKER.encode("utf-8")
|
||||
chunk = raw[: max(0, MAX_TEXT_BYTES - len(marker))].decode("utf-8", errors="ignore") + TRUNCATION_MARKER
|
||||
raw = b""
|
||||
else:
|
||||
chunk = raw[:MAX_TEXT_BYTES].decode("utf-8", errors="ignore")
|
||||
raw = raw[len(chunk.encode("utf-8")) :]
|
||||
# Spend the sequence before I/O: a timeout may still deliver.
|
||||
context.sequence += 1
|
||||
await self._api_request(
|
||||
"POST",
|
||||
context.path,
|
||||
json={
|
||||
"msg_type": 0,
|
||||
"content": chunk,
|
||||
"msg_id": msg.thread_ts,
|
||||
"msg_seq": context.sequence,
|
||||
},
|
||||
)
|
||||
if not raw:
|
||||
break
|
||||
@ -31,6 +31,7 @@ _CHANNEL_REGISTRY: dict[str, str] = {
|
||||
"discord": "app.channels.discord:DiscordChannel",
|
||||
"feishu": "app.channels.feishu:FeishuChannel",
|
||||
"github": "app.channels.github:GitHubChannel",
|
||||
"qq": "app.channels.qq:QQChannel",
|
||||
"slack": "app.channels.slack:SlackChannel",
|
||||
"telegram": "app.channels.telegram:TelegramChannel",
|
||||
"wechat": "app.channels.wechat:WechatChannel",
|
||||
@ -43,6 +44,7 @@ _CHANNEL_CREDENTIAL_KEYS: dict[str, list[str]] = {
|
||||
"dingtalk": ["client_id", "client_secret"],
|
||||
"discord": ["bot_token"],
|
||||
"feishu": ["app_id", "app_secret"],
|
||||
"qq": ["app_id", "client_secret"],
|
||||
"slack": ["bot_token", "app_token"],
|
||||
"telegram": ["bot_token"],
|
||||
"wecom": ["bot_id", "bot_secret"],
|
||||
|
||||
@ -110,6 +110,7 @@ _PROVIDER_META: dict[str, dict[str, str]] = {
|
||||
"wechat": {"display_name": "WeChat", "auth_mode": "binding_code"},
|
||||
"wecom": {"display_name": "WeCom", "auth_mode": "binding_code"},
|
||||
"buzz": {"display_name": "Buzz", "auth_mode": "binding_code"},
|
||||
"qq": {"display_name": "QQ", "auth_mode": "binding_code"},
|
||||
}
|
||||
|
||||
_CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
|
||||
@ -126,6 +127,10 @@ _CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
|
||||
{"name": "app_id", "label": "App ID", "type": "text"},
|
||||
{"name": "app_secret", "label": "App secret", "type": "password"},
|
||||
),
|
||||
"qq": (
|
||||
{"name": "app_id", "label": "App ID", "type": "text"},
|
||||
{"name": "client_secret", "label": "Client secret", "type": "password"},
|
||||
),
|
||||
"dingtalk": (
|
||||
{"name": "client_id", "label": "Client ID", "type": "text"},
|
||||
{"name": "client_secret", "label": "Client secret", "type": "password"},
|
||||
@ -147,6 +152,7 @@ _RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
|
||||
"discord": ("bot_token",),
|
||||
"feishu": ("app_id", "app_secret"),
|
||||
"dingtalk": ("client_id", "client_secret"),
|
||||
"qq": ("app_id", "client_secret"),
|
||||
"wechat": ("bot_token",),
|
||||
"wecom": ("bot_id", "bot_secret"),
|
||||
"buzz": ("relay_url", "private_key"),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# IM Channel Connections
|
||||
|
||||
DeerFlow supports user-owned IM channel bindings for Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, and Buzz. The feature reuses the existing `channels.*` runtime configuration, so it works in local and private deployments with the same outbound transports already supported by DeerFlow.
|
||||
DeerFlow supports user-owned IM channel bindings for Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, WeCom, QQ, and Buzz. The feature reuses the existing `channels.*` runtime configuration, so it works in local and private deployments with the same outbound transports already supported by DeerFlow.
|
||||
|
||||
No public IP, OAuth callback URL, or provider webhook is required in this implementation.
|
||||
|
||||
@ -448,6 +448,86 @@ Neither can make anything be *acted on*. The `allowed_users` allowlist and per-e
|
||||
|
||||
**Bound identity:** once a pubkey completes `/connect`, its inbound messages resolve to that connection and run under the bound DeerFlow user (memory, files, and artifacts land in that user's buckets). Bindings are scoped to the relay host, so the same pubkey on a different relay is a different identity and must bind separately.
|
||||
|
||||
### QQ WebSocket setup (MVP)
|
||||
|
||||
QQ uses an outbound WebSocket connection for events and the QQ Open Platform
|
||||
HTTPS API for replies. No public server, webhook route, callback URL, or tunnel
|
||||
is required. This transport requires a bot whose developer console offers
|
||||
WebSocket access; availability should be checked for your bot.
|
||||
|
||||
1. Create a bot in the QQ Open Platform console and select **WebSocket** under
|
||||
development settings. Enable the private-message and group @mention
|
||||
capabilities you intend to use. Configure permitted test accounts/groups in
|
||||
the console when the bot is in testing; publishing and platform approval
|
||||
requirements are separate from DeerFlow configuration.
|
||||
2. Supply `QQ_APP_ID` and `QQ_CLIENT_SECRET` to the Gateway process, or save the
|
||||
App ID and Client secret through the administrator's Channels UI. Do not
|
||||
commit credentials. A frontend `.env.local` file is not a substitute for
|
||||
supplying the Gateway's environment.
|
||||
3. Configure the transport and, for authenticated users, the binding UI:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
qq:
|
||||
enabled: true
|
||||
app_id: $QQ_APP_ID
|
||||
client_secret: $QQ_CLIENT_SECRET
|
||||
allowed_users: []
|
||||
sandbox: false
|
||||
|
||||
channel_connections:
|
||||
enabled: true
|
||||
require_bound_identity: true
|
||||
qq:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
4. Restart the Gateway after editing YAML. In **Settings > Channels > QQ**, get
|
||||
a one-time binding code and send `/connect <code>` to the bot. For a group
|
||||
binding, @mention the bot before the command in that group. Then send a
|
||||
private text message, or @mention the bot with text in the bound group.
|
||||
|
||||
`allowed_users` contains QQ OpenIDs, not numeric QQ account numbers. An empty
|
||||
list admits ordinary messages from all senders, subject to the shared bound
|
||||
identity policy. As with other channels, a valid binding code is consumed
|
||||
before this allowlist. Treat the code as confidential; only bind in trusted
|
||||
groups. Private identities are scoped to the bot App ID; group identities are
|
||||
scoped to both the App ID and group OpenID. Do not assume private and group
|
||||
OpenIDs represent the same identity. Bind each context separately. Group
|
||||
conversation histories are isolated by sender, but replies remain visible to
|
||||
everyone in the group.
|
||||
|
||||
The MVP supports text-only `C2C_MESSAGE_CREATE` and `GROUP_AT_MESSAGE_CREATE`
|
||||
events and final text replies. It does not download/upload attachments, stream
|
||||
partial answers, handle guild/channel messages, or proactively push scheduled
|
||||
results. Replies require the original message ID: C2C replies have a 60-minute
|
||||
window with at most four replies per source message; group replies have a
|
||||
5-minute window with at most five replies. Long answers are split at UTF-8 character boundaries with a
|
||||
conservative 4,000-byte adapter budget per message; overflow is marked as
|
||||
truncated in the last permitted reply. Unknown or timezone-naive message
|
||||
timestamps use the full advisory window and emit a rate-limited warning without
|
||||
message content; QQ still enforces reply expiry server-side.
|
||||
Runs that finish after the platform's reply window remain available
|
||||
in DeerFlow but cannot be delivered through that source message. QQ may reject
|
||||
links that are not approved in the bot's URL allowlist.
|
||||
|
||||
Use one active Gateway channel worker per bot for this MVP. Token refresh,
|
||||
heartbeat monitoring, reconnect backoff, and session Resume are automatic.
|
||||
Invalid sessions/sequence numbers trigger a fresh Identify. Bindings persist;
|
||||
session state, duplicate suppression and passive-reply contexts are bounded
|
||||
in-memory state and do not survive a process restart. The `sandbox` option
|
||||
changes only the REST API host; test-account/group membership is controlled
|
||||
separately in the QQ console.
|
||||
|
||||
For an opt-in transport check, supply the two QQ credential environment
|
||||
variables, set `DEER_FLOW_RUN_LIVE_TESTS=1`, and run
|
||||
`uv run pytest -m live tests/test_qq_channel_live.py` from `backend/`.
|
||||
It checks READY, two heartbeat acknowledgements and shutdown; it does not send
|
||||
chat messages or establish end-to-end agent or group-message compatibility.
|
||||
|
||||
Protocol references: [QQ event subscriptions](https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html)
|
||||
and [message sending rules](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/send-receive/send.html).
|
||||
|
||||
Codes use 128 bits of randomness, expire after 10 minutes, and are single-use.
|
||||
|
||||
For providers with an `allowed_users` allowlist (Telegram, Slack, DingTalk, WeChat, …), a valid `/connect <code>` (or Telegram `/start <code>`) is consumed **before** the allowlist is checked. This is intentional: a user who is not yet on the allowlist — and whose platform identity the bot has therefore never seen — can still complete their first browser-initiated bind. After binding, `allowed_users` continues to gate ordinary (non-bind) messages as before.
|
||||
|
||||
@ -51,6 +51,7 @@ class ChannelConnectionsConfig(BaseModel):
|
||||
wechat: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
|
||||
wecom: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
|
||||
buzz: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
|
||||
qq: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
|
||||
|
||||
def provider_status(self, provider: str) -> dict[str, bool]:
|
||||
config = getattr(self, provider, None)
|
||||
|
||||
@ -15,6 +15,7 @@ dependencies = [
|
||||
"deerflow-extension-api",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.28.0",
|
||||
"websockets>=16.0,<17",
|
||||
"python-multipart>=0.0.31",
|
||||
"sse-starlette>=2.1.0",
|
||||
# Direct dependency on purpose, even though FastAPI already pulls it in:
|
||||
|
||||
54
backend/tests/blocking_io/test_qq_channel.py
Normal file
54
backend/tests/blocking_io/test_qq_channel.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""QQ transport setup, token requests and message routing must stay async."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.channels.message_bus import MessageBus, OutboundMessage
|
||||
from app.channels.qq import QQChannel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qq_transport_and_message_path_do_not_block(monkeypatch):
|
||||
channel = QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
||||
try:
|
||||
# Real SSL trust loading and httpx initialization; only network replies
|
||||
# are replaced. This fails if initialization moves onto the event loop.
|
||||
await channel._setup_transport()
|
||||
monkeypatch.setattr(
|
||||
channel._http,
|
||||
"post",
|
||||
AsyncMock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"access_token": "fixture-token", "expires_in": 7200},
|
||||
request=httpx.Request("POST", "https://bots.qq.com/app/getAppAccessToken"),
|
||||
)
|
||||
),
|
||||
)
|
||||
request = AsyncMock(return_value=httpx.Response(200, json={"id": "reply"}))
|
||||
monkeypatch.setattr(channel._http, "request", request)
|
||||
await channel._handle_inbound(
|
||||
{
|
||||
"t": "C2C_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": "source",
|
||||
"content": "hello",
|
||||
"author": {"user_openid": "alice"},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert channel.bus.get_inbound_nowait().user_id == "alice"
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="source",
|
||||
text="reply",
|
||||
)
|
||||
)
|
||||
request.assert_awaited_once()
|
||||
finally:
|
||||
await channel.stop()
|
||||
@ -62,3 +62,13 @@ def test_provider_status_reports_disabled_and_unknown_providers():
|
||||
assert config.provider_status("wechat") == {"enabled": False, "configured": False}
|
||||
assert config.provider_status("wecom") == {"enabled": False, "configured": False}
|
||||
assert config.provider_status("unknown") == {"enabled": False, "configured": False}
|
||||
|
||||
|
||||
def test_qq_connection_configuration_is_opt_in():
|
||||
assert ChannelConnectionsConfig().provider_status("qq") == {
|
||||
"enabled": False,
|
||||
"configured": False,
|
||||
}
|
||||
configured = ChannelConnectionsConfig.model_validate({"enabled": True, "qq": {"enabled": True}})
|
||||
assert configured.provider_status("qq") == {"enabled": True, "configured": True}
|
||||
assert configured.require_bound_identity is True
|
||||
|
||||
@ -1490,3 +1490,78 @@ def test_manual_wechat_setup_remains_available_with_multiple_workers(monkeypatch
|
||||
assert result.status_code == 200
|
||||
assert app.state.channels_config["wechat"]["bot_token"] == "manual-token"
|
||||
restart.assert_awaited_once()
|
||||
|
||||
|
||||
def test_qq_provider_exposes_binding_flow_and_masks_secret(tmp_path):
|
||||
import anyio
|
||||
|
||||
repo = anyio.run(_make_repo, tmp_path)
|
||||
config = ChannelConnectionsConfig.model_validate({"enabled": True, "qq": {"enabled": True}})
|
||||
app = _make_app(
|
||||
config,
|
||||
repo,
|
||||
{
|
||||
"qq": {
|
||||
"enabled": True,
|
||||
"app_id": "fixture-app",
|
||||
"client_secret": "fixture-private-secret",
|
||||
}
|
||||
},
|
||||
)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/channels/providers")
|
||||
assert response.status_code == 200
|
||||
providers = response.json()["providers"]
|
||||
assert len(providers) == 1
|
||||
provider = providers[0]
|
||||
assert provider["provider"] == "qq"
|
||||
assert provider["display_name"] == "QQ"
|
||||
assert provider["auth_mode"] == "binding_code"
|
||||
assert provider["configured"] is True
|
||||
assert provider["connectable"] is True
|
||||
assert "fixture-private-secret" not in response.text
|
||||
fields = {field["name"]: field for field in provider["credential_fields"]}
|
||||
assert fields["app_id"]["type"] == "text"
|
||||
assert fields["client_secret"]["type"] == "password"
|
||||
connected = client.post("/api/channels/qq/connect")
|
||||
assert connected.status_code == 200
|
||||
body = connected.json()
|
||||
assert body["mode"] == "binding_code"
|
||||
assert body["url"] is None
|
||||
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow QQ bot."
|
||||
|
||||
async def consume():
|
||||
return await repo.consume_oauth_state(provider="qq", state=body["code"])
|
||||
|
||||
state = anyio.run(consume)
|
||||
assert state["owner_user_id"] == str(_user().id)
|
||||
assert anyio.run(consume) is None
|
||||
finally:
|
||||
anyio.run(repo.close)
|
||||
|
||||
|
||||
def test_qq_runtime_credentials_can_be_configured_from_browser(tmp_path):
|
||||
import anyio
|
||||
|
||||
repo = anyio.run(_make_repo, tmp_path)
|
||||
config = ChannelConnectionsConfig.model_validate({"enabled": True, "qq": {"enabled": True}})
|
||||
app = _make_app(config, repo, {})
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/channels/qq/runtime-config",
|
||||
json={
|
||||
"values": {
|
||||
"app_id": "fixture-app",
|
||||
"client_secret": "fixture-private-secret",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["configured"] is True
|
||||
assert "fixture-private-secret" not in response.text
|
||||
assert app.state.channels_config["qq"]["client_secret"] == "fixture-private-secret"
|
||||
assert client.post("/api/channels/qq/connect").status_code == 200
|
||||
finally:
|
||||
anyio.run(repo.close)
|
||||
|
||||
116
backend/tests/test_qq_binding.py
Normal file
116
backend/tests/test_qq_binding.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""QQ ownership integration against the real SQLite connection repository."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import MessageBus
|
||||
from app.channels.qq import QQChannel
|
||||
from app.channels.store import ChannelStore
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
await init_engine(
|
||||
"sqlite",
|
||||
url=f"sqlite+aiosqlite:///{tmp_path / 'qq.db'}",
|
||||
sqlite_dir=str(tmp_path),
|
||||
)
|
||||
try:
|
||||
yield ChannelConnectionRepository(get_session_factory())
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
def incoming(message_id, text="hello", group=None):
|
||||
data = {
|
||||
"id": message_id,
|
||||
"content": text,
|
||||
"author": {"user_openid": "alice", "member_openid": "alice"},
|
||||
}
|
||||
if group:
|
||||
data["group_openid"] = group
|
||||
return {
|
||||
"t": "GROUP_AT_MESSAGE_CREATE" if group else "C2C_MESSAGE_CREATE",
|
||||
"d": data,
|
||||
}
|
||||
|
||||
|
||||
async def bind(repo, channel, owner, code, message_id, group=None):
|
||||
await repo.create_oauth_state(
|
||||
owner_user_id=owner,
|
||||
provider="qq",
|
||||
state=code,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
await channel._handle_inbound(incoming(message_id, f"/connect {code}", group))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binding_transfer_rejects_queued_old_owner(repo, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "0")
|
||||
bus = MessageBus()
|
||||
channel = QQChannel(bus, {"app_id": "app1", "client_secret": "fixture", "connection_repo": repo})
|
||||
channel.send = AsyncMock()
|
||||
manager = ChannelManager(
|
||||
bus,
|
||||
ChannelStore(tmp_path / "threads.json"),
|
||||
connection_repo=repo,
|
||||
require_bound_identity=True,
|
||||
)
|
||||
try:
|
||||
await bind(repo, channel, "owner-a", "code-a", "bind-a")
|
||||
await channel._handle_inbound(incoming("queued-a"))
|
||||
queued = bus.get_inbound_nowait()
|
||||
assert queued.owner_user_id == "owner-a"
|
||||
assert await manager._get_bound_identity_rejection(queued) is None
|
||||
|
||||
await bind(repo, channel, "owner-b", "code-b", "bind-b")
|
||||
assert await manager._get_bound_identity_rejection(queued) is not None
|
||||
await channel._handle_inbound(incoming("fresh-b"))
|
||||
fresh = bus.get_inbound_nowait()
|
||||
assert fresh.owner_user_id == "owner-b"
|
||||
assert await manager._get_bound_identity_rejection(fresh) is None
|
||||
|
||||
# A used browser code cannot transfer the identity back.
|
||||
await channel._handle_inbound(incoming("replay-a", "/connect code-a"))
|
||||
await channel._handle_inbound(incoming("after-replay"))
|
||||
assert bus.get_inbound_nowait().owner_user_id == "owner-b"
|
||||
assert "expired" in channel.send.await_args.args[0].text
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_group_and_app_bindings_do_not_fall_back(repo):
|
||||
channel = QQChannel(
|
||||
MessageBus(),
|
||||
{"app_id": "app1", "client_secret": "fixture", "connection_repo": repo},
|
||||
)
|
||||
other_app = QQChannel(
|
||||
MessageBus(),
|
||||
{"app_id": "app2", "client_secret": "fixture", "connection_repo": repo},
|
||||
)
|
||||
channel.send = AsyncMock()
|
||||
try:
|
||||
await bind(repo, channel, "private-owner", "private-code", "private-bind")
|
||||
await channel._handle_inbound(incoming("unbound-group", group="group1"))
|
||||
assert channel.bus.get_inbound_nowait().owner_user_id is None
|
||||
await other_app._handle_inbound(incoming("other-app"))
|
||||
assert other_app.bus.get_inbound_nowait().owner_user_id is None
|
||||
|
||||
await bind(repo, channel, "group-owner", "group-code", "group-bind", group="group1")
|
||||
await channel._handle_inbound(incoming("bound-group", group="group1"))
|
||||
assert channel.bus.get_inbound_nowait().owner_user_id == "group-owner"
|
||||
await channel._handle_inbound(incoming("another-group", group="group2"))
|
||||
assert channel.bus.get_inbound_nowait().owner_user_id is None
|
||||
await channel._handle_inbound(incoming("still-private"))
|
||||
assert channel.bus.get_inbound_nowait().owner_user_id == "private-owner"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await other_app.stop()
|
||||
444
backend/tests/test_qq_channel.py
Normal file
444
backend/tests/test_qq_channel.py
Normal file
@ -0,0 +1,444 @@
|
||||
"""QQ channel contracts: offline synthetic events and HTTP responses only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.channels import qq
|
||||
from app.channels.message_bus import MessageBus, OutboundMessage
|
||||
|
||||
|
||||
def event(kind="c2c", sender="alice", target="group1", message_id="message1", text="hello"):
|
||||
data = {
|
||||
"id": message_id,
|
||||
"content": text,
|
||||
"author": {"user_openid": sender, "member_openid": sender},
|
||||
}
|
||||
if kind == "group":
|
||||
data["group_openid"] = target
|
||||
return {
|
||||
"op": 0,
|
||||
"s": 2,
|
||||
"id": "event-" + message_id,
|
||||
"t": "C2C_MESSAGE_CREATE" if kind == "c2c" else "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": data,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def channel():
|
||||
instance = qq.QQChannel(bus=MessageBus(), config={"app_id": "app1", "client_secret": "fixture-secret"})
|
||||
instance._bot_id = "99"
|
||||
yield instance
|
||||
await instance.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_c2c_preserves_reply_and_stable_identity(channel):
|
||||
await channel._handle_inbound(event())
|
||||
first = channel.bus.get_inbound_nowait()
|
||||
await channel._handle_inbound(event(message_id="message2"))
|
||||
second = channel.bus.get_inbound_nowait()
|
||||
assert first.channel_name == "qq"
|
||||
assert first.chat_id == second.chat_id == "c2c:alice"
|
||||
assert first.user_id == "alice"
|
||||
assert first.thread_ts == "message1"
|
||||
assert first.topic_id == second.topic_id
|
||||
assert first.workspace_id == "app1:c2c"
|
||||
assert first.metadata["message_id"] == "message1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_members_have_distinct_topics_and_group_scope(channel):
|
||||
await channel._handle_inbound(event("group", text="<@!99> hello"))
|
||||
alice = channel.bus.get_inbound_nowait()
|
||||
await channel._handle_inbound(event("group", sender="bob", message_id="message2"))
|
||||
bob = channel.bus.get_inbound_nowait()
|
||||
assert alice.chat_id == bob.chat_id == "group:group1"
|
||||
assert alice.topic_id != bob.topic_id
|
||||
assert alice.workspace_id == "app1:group:group1"
|
||||
assert alice.text == "hello"
|
||||
await channel._handle_inbound(event("group", target="group2", message_id="message3"))
|
||||
other = channel.bus.get_inbound_nowait()
|
||||
assert other.workspace_id != alice.workspace_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_sender_and_non_message_events_are_ignored(channel):
|
||||
malformed = event()
|
||||
malformed["d"]["author"] = {}
|
||||
for incoming in (malformed, {"t": "FRIEND_ADD", "d": {}}, event(text="")):
|
||||
await channel._handle_inbound(incoming)
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_events_are_forwarded_to_manager_dedupe(channel):
|
||||
await channel._handle_inbound(event())
|
||||
await channel._handle_inbound(event())
|
||||
# Provider adapters must not own the retry policy: ChannelManager records
|
||||
# the message id and releases it when downstream handling fails.
|
||||
assert channel.bus.inbound_queue.qsize() == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_users_gate_precedes_identity_query(channel):
|
||||
channel._allowed_users = {"bob"}
|
||||
channel._connection_repo = AsyncMock()
|
||||
await channel._handle_inbound(event())
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
channel._connection_repo.find_connection_by_external_identity.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binding_precedes_allowlist_and_never_enters_agent(channel):
|
||||
repo = AsyncMock()
|
||||
repo.consume_oauth_state.return_value = {"owner_user_id": "owner-a"}
|
||||
channel._connection_repo = repo
|
||||
channel._allowed_users = {"bob"}
|
||||
channel.send = AsyncMock()
|
||||
await channel._handle_inbound(event(text="/connect one-time-fixture"))
|
||||
repo.consume_oauth_state.assert_awaited_once_with(provider="qq", state="one-time-fixture")
|
||||
kwargs = repo.upsert_connection.await_args.kwargs
|
||||
assert kwargs["provider"] == "qq"
|
||||
assert kwargs["owner_user_id"] == "owner-a"
|
||||
assert kwargs["external_account_id"] == "alice"
|
||||
assert kwargs["workspace_id"] == "app1:c2c"
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
assert "one-time-fixture" not in channel.send.await_args.args[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_binding_code_is_consumed_locally(channel):
|
||||
channel._connection_repo = AsyncMock()
|
||||
channel._connection_repo.consume_oauth_state.return_value = None
|
||||
channel.send = AsyncMock()
|
||||
await channel._handle_inbound(event(text="/connect expired-fixture"))
|
||||
channel._connection_repo.upsert_connection.assert_not_awaited()
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
assert "expired" in channel.send.await_args.args[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bound_owner_is_attached_with_exact_workspace(channel):
|
||||
repo = AsyncMock()
|
||||
repo.find_connection_by_external_identity.return_value = {
|
||||
"id": "connection-a",
|
||||
"owner_user_id": "owner-a",
|
||||
"workspace_id": "app1:group:group1",
|
||||
}
|
||||
channel._connection_repo = repo
|
||||
await channel._handle_inbound(event("group"))
|
||||
inbound = channel.bus.get_inbound_nowait()
|
||||
assert inbound.owner_user_id == "owner-a"
|
||||
assert inbound.connection_id == "connection-a"
|
||||
repo.find_connection_by_external_identity.assert_awaited_once_with(provider="qq", external_account_id="alice", workspace_id="app1:group:group1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identity_failure_allows_provider_redelivery(channel):
|
||||
repo = AsyncMock()
|
||||
repo.find_connection_by_external_identity.side_effect = [RuntimeError("temporary"), None]
|
||||
channel._connection_repo = repo
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await channel._handle_inbound(event())
|
||||
await channel._handle_inbound(event())
|
||||
|
||||
assert channel.bus.inbound_queue.qsize() == 1
|
||||
assert repo.find_connection_by_external_identity.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_intake_does_not_query_identity(channel):
|
||||
channel.bus = MessageBus(inbound_queue_maxsize=1)
|
||||
await channel._handle_inbound(event())
|
||||
channel._connection_repo = AsyncMock()
|
||||
await channel._handle_inbound(event(message_id="message2"))
|
||||
channel._connection_repo.find_connection_by_external_identity.assert_not_awaited()
|
||||
assert channel.bus.inbound_queue.qsize() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresh_is_shared_across_concurrent_requests(channel):
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={"access_token": "fixture-token", "expires_in": 7200})
|
||||
|
||||
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
values = await asyncio.gather(*(channel._get_access_token() for _ in range(8)))
|
||||
assert values == ["fixture-token"] * 8
|
||||
assert len(calls) == 1
|
||||
assert json.loads(calls[0].content) == {
|
||||
"appId": "app1",
|
||||
"clientSecret": "fixture-secret",
|
||||
}
|
||||
channel._token_expires_at = 0
|
||||
await channel._get_access_token()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_refresh_reuses_reply_sequence(channel):
|
||||
tokens, replies = [], []
|
||||
|
||||
def handler(request):
|
||||
if request.url.host == "bots.qq.com":
|
||||
tokens.append(request)
|
||||
return httpx.Response(200, json={"access_token": f"token-{len(tokens)}", "expires_in": 7200})
|
||||
replies.append(json.loads(request.content))
|
||||
return httpx.Response(401 if len(replies) == 1 else 200, json={"id": "reply"})
|
||||
|
||||
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await channel._handle_inbound(event())
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="message1",
|
||||
text="reply",
|
||||
)
|
||||
)
|
||||
assert len(tokens) == 2
|
||||
assert replies[0] == replies[1]
|
||||
assert replies[0]["msg_seq"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("kind", "quota"), [("c2c", 4), ("group", 5)])
|
||||
async def test_send_splits_utf8_and_preserves_passive_reply_budget(channel, monkeypatch, kind, quota):
|
||||
monkeypatch.setattr(qq, "MAX_TEXT_BYTES", 100)
|
||||
calls = []
|
||||
|
||||
async def send_api(method, path, **kwargs):
|
||||
assert kwargs["json"]["msg_seq"] <= quota
|
||||
calls.append((path, kwargs["json"]))
|
||||
return {"id": "reply"}
|
||||
|
||||
channel._api_request = send_api
|
||||
await channel._handle_inbound(event(kind))
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="group:group1" if kind == "group" else "c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="message1",
|
||||
text="中文🙂" * 300,
|
||||
)
|
||||
)
|
||||
assert len(calls) == quota
|
||||
expected_path = "/v2/groups/group1/messages" if kind == "group" else "/v2/users/alice/messages"
|
||||
assert all(path == expected_path for path, _ in calls)
|
||||
assert all(len(payload["content"].encode("utf-8")) <= 100 for _, payload in calls)
|
||||
assert [payload["msg_seq"] for _, payload in calls] == list(range(1, len(calls) + 1))
|
||||
assert all(payload["msg_id"] == "message1" for _, payload in calls)
|
||||
assert "truncated" in calls[-1][1]["content"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_updates_do_not_spend_reply_quota(channel):
|
||||
channel._api_request = AsyncMock()
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
text="thinking",
|
||||
is_final=False,
|
||||
)
|
||||
)
|
||||
channel._api_request.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_without_source_message_fails_closed(channel):
|
||||
channel._api_request = AsyncMock()
|
||||
with pytest.raises(qq.QQAPIError):
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
text="unsolicited",
|
||||
)
|
||||
)
|
||||
channel._api_request.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_reply_context_does_not_send(channel):
|
||||
channel._api_request = AsyncMock()
|
||||
await channel._handle_inbound(event())
|
||||
channel._replies[("c2c:alice", "message1")].expires_at = 0
|
||||
with pytest.raises(qq.QQAPIError):
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="message1",
|
||||
text="late",
|
||||
)
|
||||
)
|
||||
channel._api_request.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_failure_never_exposes_provider_body(channel, caplog):
|
||||
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json={"message": "secret-response-body"})))
|
||||
with pytest.raises(qq.QQAPIError) as caught:
|
||||
await channel._get_access_token()
|
||||
assert "secret-response-body" not in str(caught.value) + caplog.text
|
||||
assert "fixture-secret" not in str(caught.value) + caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gateway",
|
||||
[
|
||||
"ws://api.sgroup.qq.com/ws",
|
||||
"wss://qq.com.attacker.invalid/ws",
|
||||
"wss://user:pass@api.sgroup.qq.com/ws",
|
||||
"wss://api.bot.qq.com.attacker.invalid/websocket/",
|
||||
"wss://attacker.bot.qq.com/websocket/",
|
||||
"ws://api.bot.qq.com/websocket/",
|
||||
"wss://api.bot.qq.com:444/websocket/",
|
||||
"wss://user:pass@api.bot.qq.com/websocket/",
|
||||
"wss://api.bot.qq.com/websocket/#fragment",
|
||||
],
|
||||
)
|
||||
def test_untrusted_gateway_rejected(gateway):
|
||||
with pytest.raises(qq.QQAPIError):
|
||||
qq.validate_gateway_url(gateway)
|
||||
|
||||
|
||||
def test_valid_gateway_accepted():
|
||||
assert qq.validate_gateway_url("wss://api.sgroup.qq.com/websocket/") == "wss://api.sgroup.qq.com/websocket/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_official_gateway_discovery_reaches_connection(channel):
|
||||
channel._api_request = AsyncMock(return_value={"url": "wss://api.bot.qq.com/websocket/"})
|
||||
|
||||
async def connection(url):
|
||||
channel._running = False
|
||||
|
||||
channel._run_connection = AsyncMock(side_effect=connection)
|
||||
channel._running = True
|
||||
await asyncio.wait_for(channel._listen(), timeout=0.1)
|
||||
channel._run_connection.assert_awaited_once_with("wss://api.bot.qq.com/websocket/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("timestamp", ["sensitive-invalid-timestamp", "2026-10-03T10:00:00", 123, {}])
|
||||
async def test_unknown_timestamp_uses_full_reply_window(channel, timestamp, monkeypatch, caplog):
|
||||
monkeypatch.setattr(qq.time, "monotonic", lambda: 1000)
|
||||
incoming = event(text="sensitive-message-content")
|
||||
incoming["d"]["timestamp"] = timestamp
|
||||
await channel._handle_inbound(incoming)
|
||||
assert channel.bus.get_inbound_nowait().text == "sensitive-message-content"
|
||||
assert channel._replies[("c2c:alice", "message1")].expires_at == 4600
|
||||
assert "using full passive reply window" in caplog.text
|
||||
assert "sensitive" not in caplog.text
|
||||
await channel._handle_inbound(incoming)
|
||||
assert caplog.text.count("using full passive reply window") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_cache_evicts_expired_context_before_active_turn(channel, monkeypatch):
|
||||
monkeypatch.setattr(qq, "MAX_CACHED_MESSAGES", 2)
|
||||
await channel._handle_inbound(event(message_id="active"))
|
||||
active = channel._replies[("c2c:alice", "active")]
|
||||
active.sequence = 1
|
||||
await channel._handle_inbound(event(message_id="expired"))
|
||||
channel._replies[("c2c:alice", "expired")].expires_at = 0
|
||||
await channel._handle_inbound(event(message_id="new"))
|
||||
assert list(channel._replies) == [("c2c:alice", "active"), ("c2c:alice", "new")]
|
||||
await channel._handle_inbound(event(message_id="active"))
|
||||
assert channel._replies[("c2c:alice", "active")] is active
|
||||
assert active.sequence == 1
|
||||
await channel._handle_inbound(event(message_id="newest"))
|
||||
assert list(channel._replies) == [("c2c:alice", "new"), ("c2c:alice", "newest")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("text", "expected_type"), [("/help", "command"), ("/new", "command"), ("/agent list", "command"), ("/goal implement a feature", "command"), ("/custom-skill", "chat")])
|
||||
async def test_known_commands_use_shared_dispatch(channel, text, expected_type):
|
||||
await channel._handle_inbound(event(text=text))
|
||||
assert channel.bus.get_inbound_nowait().msg_type == expected_type
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("kind", "quota"), [("c2c", 4), ("group", 5)])
|
||||
async def test_concurrent_replies_share_one_source_quota(channel, kind, quota):
|
||||
channel._api_request = AsyncMock(return_value={"id": "reply"})
|
||||
await channel._handle_inbound(event(kind))
|
||||
msg = OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="group:group1" if kind == "group" else "c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="message1",
|
||||
text="reply",
|
||||
)
|
||||
results = await asyncio.gather(*(channel.send(msg) for _ in range(8)), return_exceptions=True)
|
||||
assert sum(isinstance(result, qq.QQAPIError) for result in results) == 8 - quota
|
||||
assert [call.kwargs["json"]["msg_seq"] for call in channel._api_request.await_args_list] == list(range(1, quota + 1))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_http_timeout_is_not_retried_or_reused(channel):
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(json.loads(request.content))
|
||||
if len(calls) == 1:
|
||||
raise httpx.ReadTimeout("fixture-sensitive-provider-detail")
|
||||
return httpx.Response(200, json={"id": "reply"})
|
||||
|
||||
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
channel._get_access_token = AsyncMock(return_value="fixture-token")
|
||||
await channel._handle_inbound(event())
|
||||
msg = OutboundMessage(
|
||||
channel_name="qq",
|
||||
chat_id="c2c:alice",
|
||||
thread_id="thread",
|
||||
thread_ts="message1",
|
||||
text="reply",
|
||||
)
|
||||
with pytest.raises(qq.QQAPIError) as caught:
|
||||
await channel.send(msg)
|
||||
assert "fixture-sensitive" not in str(caught.value)
|
||||
assert len(calls) == 1
|
||||
await channel.send(msg)
|
||||
assert [payload["msg_seq"] for payload in calls] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("kind", "age"), [("c2c", 3601), ("group", 301)])
|
||||
async def test_replayed_expired_messages_do_not_create_runs(channel, kind, age):
|
||||
incoming = event(kind)
|
||||
incoming["d"]["timestamp"] = (datetime.now(UTC) - timedelta(seconds=age)).isoformat()
|
||||
await channel._handle_inbound(incoming)
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
assert not channel._replies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_code_without_repository_never_reaches_agent(channel):
|
||||
await channel._handle_inbound(event(text="/connect fixture-private-code"))
|
||||
assert channel.bus.inbound_queue.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_the_actual_bot_mention_is_removed(channel):
|
||||
await channel._handle_inbound(event("group", text="<@!someone-else> hello"))
|
||||
assert channel.bus.get_inbound_nowait().text == "<@!someone-else> hello"
|
||||
56
backend/tests/test_qq_channel_live.py
Normal file
56
backend/tests/test_qq_channel_live.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Opt-in QQ transport verification; does not send messages or invoke agents."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels.message_bus import MessageBus
|
||||
from app.channels.qq import QQChannel
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.asyncio]
|
||||
|
||||
|
||||
async def test_qq_websocket_ready_and_heartbeat_acknowledgements():
|
||||
if os.getenv("DEER_FLOW_RUN_LIVE_TESTS") != "1":
|
||||
pytest.skip("Set DEER_FLOW_RUN_LIVE_TESTS=1 to enable live QQ verification")
|
||||
app_id = os.getenv("QQ_APP_ID")
|
||||
secret = os.getenv("QQ_CLIENT_SECRET")
|
||||
if not app_id or not secret:
|
||||
pytest.skip("QQ_APP_ID and QQ_CLIENT_SECRET are required")
|
||||
|
||||
acknowledged = asyncio.Event()
|
||||
count = 0
|
||||
|
||||
class ObservedSocket:
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
async def send(self, data):
|
||||
await self.socket.send(data)
|
||||
|
||||
async def __aiter__(self):
|
||||
nonlocal count
|
||||
async for raw in self.socket:
|
||||
if json.loads(raw).get("op") == 11:
|
||||
count += 1
|
||||
if count >= 2:
|
||||
acknowledged.set()
|
||||
yield raw
|
||||
|
||||
class ObservedChannel(QQChannel):
|
||||
async def _read_frames(self, socket, ack):
|
||||
await super()._read_frames(ObservedSocket(socket), ack)
|
||||
|
||||
channel = ObservedChannel(MessageBus(), {"app_id": app_id, "client_secret": secret})
|
||||
try:
|
||||
async with asyncio.timeout(150):
|
||||
await channel.start()
|
||||
assert channel.is_running
|
||||
await acknowledged.wait()
|
||||
assert channel.is_running
|
||||
finally:
|
||||
await channel.stop()
|
||||
assert count >= 2
|
||||
assert channel._listener is channel._worker is channel._http is None
|
||||
357
backend/tests/test_qq_lifecycle.py
Normal file
357
backend/tests/test_qq_lifecycle.py
Normal file
@ -0,0 +1,357 @@
|
||||
"""Exercise the adapter against real loopback WebSocket connections."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from websockets.asyncio.server import serve
|
||||
|
||||
from app.channels import qq
|
||||
from app.channels.message_bus import MessageBus
|
||||
|
||||
|
||||
def ready(sequence=1):
|
||||
return {
|
||||
"op": 0,
|
||||
"s": sequence,
|
||||
"t": "READY",
|
||||
"d": {"session_id": "fixture-session", "user": {"id": "99"}},
|
||||
}
|
||||
|
||||
|
||||
def message():
|
||||
return {
|
||||
"op": 0,
|
||||
"s": 2,
|
||||
"t": "C2C_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": "fixture-message",
|
||||
"content": "hello",
|
||||
"author": {"user_openid": "alice"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def acknowledge(socket):
|
||||
async for raw in socket:
|
||||
if json.loads(raw).get("op") == 1:
|
||||
await socket.send(json.dumps({"op": 11}))
|
||||
|
||||
|
||||
def adapter(monkeypatch, port):
|
||||
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
||||
channel._api_request = AsyncMock(return_value={"url": f"ws://127.0.0.1:{port}/"})
|
||||
channel._get_access_token = AsyncMock(return_value="fixture-token")
|
||||
# Only test-local discovery may use plaintext loopback.
|
||||
monkeypatch.setattr(qq, "validate_gateway_url", lambda url: url)
|
||||
monkeypatch.setattr(qq, "RECONNECT_DELAY", 0.001)
|
||||
return channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_resume_invalid_session_reidentify_and_deduplicate(monkeypatch):
|
||||
authentications = []
|
||||
recovered = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
||||
authentications.append(json.loads(await socket.recv()))
|
||||
attempt = len(authentications)
|
||||
if attempt == 1:
|
||||
await socket.send(json.dumps(ready()))
|
||||
await socket.send(json.dumps(message()))
|
||||
await socket.send(json.dumps({"op": 7}))
|
||||
elif attempt == 2:
|
||||
await socket.send(json.dumps({"op": 9, "d": False}))
|
||||
else:
|
||||
await socket.send(json.dumps(ready()))
|
||||
await socket.send(json.dumps(message()))
|
||||
recovered.set()
|
||||
await acknowledge(socket)
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(recovered.wait(), 3)
|
||||
await asyncio.wait_for(channel._events.join(), 3)
|
||||
assert channel.is_running
|
||||
assert [auth["op"] for auth in authentications] == [2, 6, 2]
|
||||
assert authentications[0]["d"]["intents"] == 1 << 25
|
||||
assert authentications[1]["d"]["session_id"] == "fixture-session"
|
||||
assert authentications[1]["d"]["seq"] == 2
|
||||
# Duplicate delivery is handled by ChannelManager's failure-aware
|
||||
# dedupe in production; this lifecycle fixture only checks that a
|
||||
# recovered event reaches the bus.
|
||||
assert channel.bus.inbound_queue.qsize() == 1
|
||||
assert channel.bus.get_inbound_nowait().thread_ts == "fixture-message"
|
||||
finally:
|
||||
await channel.stop()
|
||||
assert not channel.is_running
|
||||
assert channel._listener is channel._worker is channel._http is None
|
||||
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith("qq-")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_heartbeat_ack_reconnects(monkeypatch):
|
||||
authentications = []
|
||||
reconnected = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 30}}))
|
||||
authentications.append(json.loads(await socket.recv()))
|
||||
await socket.send(json.dumps(ready()))
|
||||
if len(authentications) == 1:
|
||||
await socket.wait_closed()
|
||||
else:
|
||||
reconnected.set()
|
||||
await acknowledge(socket)
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(reconnected.wait(), 3)
|
||||
assert authentications[1]["op"] == 6
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_timeout_closes_socket_and_owned_tasks(monkeypatch):
|
||||
closed = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
try:
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
||||
await acknowledge(socket)
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
monkeypatch.setattr(qq, "START_TIMEOUT", 0.1)
|
||||
with pytest.raises(TimeoutError):
|
||||
await channel.start()
|
||||
await asyncio.wait_for(closed.wait(), 3)
|
||||
assert channel._listener is channel._worker is channel._http is None
|
||||
assert not channel.is_running
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_cancellation_drains_transport_setup(monkeypatch):
|
||||
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
client = AsyncMock()
|
||||
|
||||
async def setup():
|
||||
started.set()
|
||||
await release.wait()
|
||||
channel._http = client
|
||||
|
||||
monkeypatch.setattr(channel, "_setup_transport", setup)
|
||||
task = asyncio.create_task(channel.start())
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
client.aclose.assert_awaited_once()
|
||||
assert channel._http is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("close_code", "expected_op"), [(4004, 2), (4006, 2), (4007, 2), (4009, 6)])
|
||||
async def test_provider_close_codes_choose_identify_or_resume(monkeypatch, close_code, expected_op):
|
||||
authentications = []
|
||||
recovered = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
||||
authentications.append(json.loads(await socket.recv()))
|
||||
await socket.send(json.dumps(ready()))
|
||||
if len(authentications) == 1:
|
||||
await socket.close(code=close_code)
|
||||
else:
|
||||
recovered.set()
|
||||
await acknowledge(socket)
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(recovered.wait(), 3)
|
||||
assert authentications[1]["op"] == expected_op
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_heartbeat_uses_ready_sequence(monkeypatch):
|
||||
heartbeats = []
|
||||
observed = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
||||
assert json.loads(await socket.recv())["op"] == 2
|
||||
await socket.send(json.dumps(ready()))
|
||||
async for raw in socket:
|
||||
frame = json.loads(raw)
|
||||
if frame.get("op") == 1:
|
||||
heartbeats.append(frame["d"])
|
||||
await socket.send(json.dumps({"op": 11}))
|
||||
observed.set()
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(observed.wait(), 3)
|
||||
assert heartbeats[0] == 1
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_resume_restarts_heartbeats(monkeypatch):
|
||||
connections = 0
|
||||
resumed_heartbeat = asyncio.Event()
|
||||
|
||||
async def gateway(socket):
|
||||
nonlocal connections
|
||||
connections += 1
|
||||
attempt = connections
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
||||
authentication = json.loads(await socket.recv())
|
||||
if attempt == 1:
|
||||
assert authentication["op"] == 2
|
||||
await socket.send(json.dumps(ready()))
|
||||
await socket.send(json.dumps({"op": 7}))
|
||||
else:
|
||||
assert authentication["op"] == 6
|
||||
await socket.send(json.dumps({"op": 0, "s": 3, "t": "RESUMED", "d": {}}))
|
||||
async for raw in socket:
|
||||
frame = json.loads(raw)
|
||||
if frame.get("op") == 1:
|
||||
await socket.send(json.dumps({"op": 11}))
|
||||
if attempt > 1:
|
||||
assert frame["d"] == 3
|
||||
resumed_heartbeat.set()
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(resumed_heartbeat.wait(), 3)
|
||||
assert channel.is_running
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_inbound_work_does_not_block_heartbeats(monkeypatch):
|
||||
entered, release, heartbeats_received = (
|
||||
asyncio.Event(),
|
||||
asyncio.Event(),
|
||||
asyncio.Event(),
|
||||
)
|
||||
count = 0
|
||||
|
||||
async def gateway(socket):
|
||||
nonlocal count
|
||||
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 30}}))
|
||||
await socket.recv()
|
||||
await socket.send(json.dumps(ready()))
|
||||
await socket.send(json.dumps(message()))
|
||||
async for raw in socket:
|
||||
if json.loads(raw).get("op") == 1:
|
||||
await socket.send(json.dumps({"op": 11}))
|
||||
count += 1
|
||||
if count >= 3:
|
||||
heartbeats_received.set()
|
||||
|
||||
async def slow_inbound(frame):
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
async with serve(gateway, "127.0.0.1", 0) as server:
|
||||
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
||||
monkeypatch.setattr(channel, "_handle_inbound", slow_inbound)
|
||||
try:
|
||||
await channel.start()
|
||||
await asyncio.wait_for(entered.wait(), 3)
|
||||
await asyncio.wait_for(heartbeats_received.wait(), 3)
|
||||
assert not release.is_set()
|
||||
assert channel.is_running
|
||||
finally:
|
||||
release.set()
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_stop_drains_http_client_cleanup():
|
||||
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def close():
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
client = AsyncMock()
|
||||
client.aclose.side_effect = close
|
||||
channel._http = client
|
||||
task = asyncio.create_task(channel.stop())
|
||||
await entered.wait()
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
client.aclose.assert_awaited_once()
|
||||
assert channel._http is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_registers_starts_and_disposes_qq(tmp_path, monkeypatch):
|
||||
from app.channels import service as service_module
|
||||
from app.channels.store import ChannelStore
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
monkeypatch.setattr(service_module, "ChannelStore", lambda: store)
|
||||
config = {
|
||||
"app_id": "fixture-app",
|
||||
"client_secret": "fixture-secret",
|
||||
"enabled": True,
|
||||
}
|
||||
app_config = AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}})
|
||||
repository = object()
|
||||
service = service_module.ChannelService({"qq": config}, connection_repo=repository, app_config=app_config)
|
||||
|
||||
async def connected(self, url):
|
||||
self._ready.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(
|
||||
qq.QQChannel,
|
||||
"_api_request",
|
||||
AsyncMock(return_value={"url": "wss://api.sgroup.qq.com/websocket/"}),
|
||||
)
|
||||
monkeypatch.setattr(qq.QQChannel, "_run_connection", connected)
|
||||
try:
|
||||
assert await service._start_channel("qq", config)
|
||||
channel = service.get_channel("qq")
|
||||
assert isinstance(channel, qq.QQChannel)
|
||||
assert channel.is_running
|
||||
assert channel.bus is service.bus
|
||||
assert channel._connection_repo is repository
|
||||
assert not channel.supports_streaming
|
||||
finally:
|
||||
channel = service.get_channel("qq")
|
||||
if channel is not None:
|
||||
await service._stop_and_discard_channel("qq", channel)
|
||||
assert service.get_channel("qq") is None
|
||||
assert channel._listener is channel._worker is channel._http is None
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@ -824,6 +824,7 @@ dependencies = [
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
{ name = "wecom-aibot-python-sdk" },
|
||||
]
|
||||
|
||||
@ -896,6 +897,7 @@ requires-dist = [
|
||||
{ name = "sse-starlette", specifier = ">=2.1.0" },
|
||||
{ name = "starlette", specifier = ">=1.3.1,<2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||
{ name = "websockets", specifier = ">=16.0,<17" },
|
||||
{ name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" },
|
||||
]
|
||||
provides-extras = ["postgres", "redis", "discord", "buzz", "monocle", "ollama", "browser", "memory-zh"]
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
# ============================================================================
|
||||
# Bump this number when the config schema changes.
|
||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||
config_version: 50
|
||||
config_version: 51
|
||||
|
||||
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
|
||||
# lead_prompt_overlay:
|
||||
@ -2833,7 +2833,7 @@ run_ownership:
|
||||
# - Provider bot/app credentials stay under `channels.*`.
|
||||
# - `channel_connections` stores per-user bindings and one-time connect codes.
|
||||
# - Telegram uses a deep link when `bot_username` is configured.
|
||||
# - Slack, Discord, Feishu, DingTalk, WeChat, and WeCom use `/connect <code>`
|
||||
# - Slack, Discord, Feishu, DingTalk, WeChat, WeCom, and QQ use `/connect <code>`
|
||||
# through the already-running bot/app.
|
||||
# - When enabled (and channels are running), scheduled-task completions/failures
|
||||
# also enqueue IM pushes to the owner's connected identities (see scheduler
|
||||
@ -2869,6 +2869,9 @@ run_ownership:
|
||||
#
|
||||
# buzz:
|
||||
# enabled: false
|
||||
#
|
||||
# qq:
|
||||
# enabled: false
|
||||
|
||||
# ============================================================================
|
||||
# IM Channels Configuration
|
||||
@ -2910,6 +2913,15 @@ run_ownership:
|
||||
# is_plan_mode: false
|
||||
# subagent_enabled: false
|
||||
#
|
||||
# # QQ Open Platform bot; choose WebSocket in the developer console.
|
||||
# # Text-only C2C and group @mentions; no public callback URL required.
|
||||
# qq:
|
||||
# enabled: false
|
||||
# app_id: $QQ_APP_ID
|
||||
# client_secret: $QQ_CLIENT_SECRET
|
||||
# allowed_users: [] # QQ OpenIDs, not numeric QQ account numbers; empty allows all
|
||||
# sandbox: false # REST sandbox host only; console sandbox membership is separate
|
||||
#
|
||||
# feishu:
|
||||
# enabled: false
|
||||
# app_id: $FEISHU_APP_ID
|
||||
|
||||
@ -263,7 +263,7 @@ ingress:
|
||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||
# inline literal secret values here. The default enables provisioner sandbox.
|
||||
config: |
|
||||
config_version: 50
|
||||
config_version: 51
|
||||
log_level: info
|
||||
recursion_limit: 100
|
||||
max_recursion_limit: 1000
|
||||
|
||||
@ -18,6 +18,31 @@ export function ChannelProviderIcon({
|
||||
}: ChannelProviderIconProps) {
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
|
||||
if (normalizedProvider === "qq") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn("size-5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ellipse cx="8" cy="21" rx="4" ry="2" fill="#F5B83D" />
|
||||
<ellipse cx="16" cy="21" rx="4" ry="2" fill="#F5B83D" />
|
||||
<path
|
||||
d="M5 12C5 4 8 1 12 1s7 3 7 11c3 5 2 7 0 6-1 3-3 4-7 4s-6-1-7-4c-2 1-3-1 0-6Z"
|
||||
fill="#252525"
|
||||
/>
|
||||
<ellipse cx="12" cy="16" rx="5" ry="5" fill="#FFFFFF" />
|
||||
<ellipse cx="10" cy="7" rx="1.4" ry="2" fill="#FFFFFF" />
|
||||
<ellipse cx="14" cy="7" rx="1.4" ry="2" fill="#FFFFFF" />
|
||||
<circle cx="10.4" cy="7.5" r=".6" fill="#252525" />
|
||||
<circle cx="13.6" cy="7.5" r=".6" fill="#252525" />
|
||||
<ellipse cx="12" cy="10" rx="3" ry="1" fill="#F5B83D" />
|
||||
<path d="M5 12h14v3H9v3H6v-3H5Z" fill="#EB3434" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedProvider === "buzz") {
|
||||
return <BuzzProviderIcon className={cn("size-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
@ -1220,6 +1220,7 @@ export const enUS: Translations = {
|
||||
saveChanges: "Save changes",
|
||||
descriptions: {
|
||||
buzz: "Buzz channels and direct messages through your DeerFlow agent.",
|
||||
qq: "QQ direct messages and group @mentions over WebSocket.",
|
||||
telegram: "Telegram direct messages through your DeerFlow bot.",
|
||||
slack: "Slack workspace messages and mentions.",
|
||||
discord: "Discord server messages through your DeerFlow bot.",
|
||||
|
||||
@ -1141,6 +1141,7 @@ export const zhCN: Translations = {
|
||||
saveChanges: "保存修改",
|
||||
descriptions: {
|
||||
buzz: "通过 DeerFlow 智能体接收 Buzz 频道消息和私聊。",
|
||||
qq: "通过 WebSocket 接收 QQ 私聊和群聊 @机器人消息。",
|
||||
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
|
||||
slack: "接收 Slack 工作区消息和提及。",
|
||||
discord: "通过 DeerFlow Bot 接收 Discord 服务器消息。",
|
||||
|
||||
@ -116,6 +116,7 @@ const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
||||
dingtalk: "DingTalk",
|
||||
discord: "Discord",
|
||||
feishu: "Feishu",
|
||||
qq: "QQ",
|
||||
slack: "Slack",
|
||||
telegram: "Telegram",
|
||||
wechat: "WeChat",
|
||||
|
||||
@ -5,6 +5,21 @@ import { ChannelProviderIcon } from "@/components/workspace/channels/channel-pro
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders QQ with caller-provided accessible label and styling", () => {
|
||||
const { getByRole } = render(
|
||||
<ChannelProviderIcon
|
||||
provider="QQ"
|
||||
role="img"
|
||||
aria-label="QQ"
|
||||
aria-hidden={false}
|
||||
className="size-8"
|
||||
/>,
|
||||
);
|
||||
expect(getByRole("img", { name: "QQ" }).getAttribute("class")).toContain(
|
||||
"size-8",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders collision-safe official Buzz marks", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
|
||||
@ -156,6 +156,14 @@ test("formats the Buzz channel source label", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("formats the QQ channel source label", () => {
|
||||
expect(
|
||||
channelSourceOfThread({
|
||||
metadata: { channel_source: { type: "im_channel", provider: "qq" } },
|
||||
}),
|
||||
).toMatchObject({ provider: "qq", label: "QQ" });
|
||||
});
|
||||
|
||||
test("ignores threads without valid IM channel source metadata", () => {
|
||||
expect(channelSourceOfThread({ metadata: {} })).toBeNull();
|
||||
expect(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user