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:
Xuehao Xu 2026-10-03 14:32:44 +08:00 committed by GitHub
parent 6e76c14384
commit 9b17bef630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1808 additions and 13 deletions

View File

@ -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,

View File

@ -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

View File

@ -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` :**

View File

@ -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`での設定:**

View File

@ -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`:**

View File

@ -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` 中的配置示例:**

View File

@ -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
View 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

View File

@ -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"],

View File

@ -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"),

View File

@ -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.

View File

@ -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)

View File

@ -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:

View 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()

View File

@ -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

View File

@ -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)

View 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()

View 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"

View 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

View 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
View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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} />;
}

View File

@ -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.",

View File

@ -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 服务器消息。",

View File

@ -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",

View File

@ -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(
<>

View File

@ -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(