feat(channels): add Buzz (Nostr) channel connector (#4649)

* feat(channels): add Buzz (Nostr) channel connector

Adds a Buzz (https://github.com/block/buzz) channel so DeerFlow can join a
Nostr-relay workspace as a member: it answers @mentions in channels, replies
to DMs, and streams answers by editing one message in place.

  * app/channels/buzz_nostr.py — pure NIP-01 helpers: canonical event ids,
    BIP-340 signing/verification, chat/edit/auth builders, relay frames.
  * app/channels/buzz.py — BuzzChannel: one NIP-42-authenticated websocket,
    channel discovery (kind 39000) with one subscription per channel, live
    membership tracking (44100/44101), per-channel replay watermarks, and
    replies posted once then edited in place (kind 40003).
  * app/channels/buzz_run_policy.py — same-thread serialization, mirroring
    the Feishu precedent.

Inbound is gated in order: signature verification, self-drop, /connect
bind-and-return, pubkey allowlist, then mention / DM / mention-free /
thread-follow. Off by default; needs the new optional `buzz` extra
(coincurve, lazily imported), which detect_uv_extras resolves from
channels.buzz.enabled the same way it already handles channels.discord.

Two relay behaviours drove the design and are worth knowing when reviewing:
a global {"kinds":[9]} subscription receives nothing from buzz-relay and a
multi-value "#h" filter receives nothing either, so one REQ per channel is
required; and a single global `since` cursor skips quiet channels, so
watermarks are per channel.

Signed-off-by: Ajay R <ajayr@formbuddy.com>

* fix(channels): only publish assistant messages from the IM stream

`_accumulate_stream_text` decided what streamed `messages-tuple` payloads
become displayable text by rejecting ONLY payloads whose `type` contained
"tool", so it published everything else. DeerFlow writes hidden model
context into the messages channel as ordinary messages -- memory recall and
the rewritten user turn as hidden HumanMessages (DynamicContextMiddleware),
the `<durable_context_data>` block as another (DurableContextMiddleware) --
and LangGraph fans those state writes out on the messages stream, so they
reached every streaming IM channel as the assistant's reply.

Proved live on a Buzz relay: the connector published a `<memory>` fact block
and, in another run, a verbatim echo of the user's own inbound message.
Affects Feishu, Telegram, WeCom and Buzz; worst on Buzz, where each update
is an immutable public Nostr event that a corrective edit cannot unpublish.

Invert the filter to an allowlist of assistant message types. Two new pure
helpers keep it testable:

- `_stream_payload_type` resolves the type from both shapes the function
  already handles: the `model_dump()` shape the gateway emits, and
  LangChain's `to_json()` constructor shape whose own `type` is the literal
  "constructor" and whose class name is the tail of the `id` path.
- `_is_assistant_stream_type` matches "ai"/"assistant" by PREFIX, not
  substring -- ordinary words contain "ai" ("chain", "domain"), and a
  substring test would admit a foreign type name by accident.

The bare-`str` branch is removed: an untyped payload cannot be attributed to
the assistant, nothing in DeerFlow produces one (serialize_messages_tuple
always emits `[message_dict, metadata]`), and a runtime that emitted raw text
deltas would emit hidden context the same way. Per-message-id buffering and
merging are unchanged.

Tests pin both directions, including multi-chunk merging across one message
id, so the allowlist cannot silently kill streaming, plus an end-to-end
`_handle_streaming_chat` test asserting the live payload never reaches an
outbound message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ajay R <ajayr@formbuddy.com>

* chore(helm): bump config_version to 33 in chart values and README

config.example.yaml moved to 33 for the buzz channel block; the chart's
embedded config example and its README copy track it (config_version only
drives the outdated-config warning, per scripts/check_config_version.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ajay R <ajayr@formbuddy.com>

---------

Signed-off-by: Ajay R <ajayr@formbuddy.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ajayr 2026-08-05 01:29:22 +01:00 committed by GitHub
parent 61c153ff09
commit d732b90dc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4333 additions and 18 deletions

View File

@ -427,7 +427,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, or WeCom 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, 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 |
|---------|-----------|------------|
@ -437,6 +437,7 @@ DeerFlow can also expose user-owned IM channel connections in the workspace UI.
| WeChat | Tencent iLink (long-polling) | Moderate |
| WeCom | WebSocket | Moderate |
| DingTalk | Stream Push (WebSocket) | Moderate |
| Buzz | Nostr relay (WebSocket, NIP-42) | Moderate |
**Configuration in `config.yaml`:**

File diff suppressed because one or more lines are too long

1413
backend/app/channels/buzz.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,201 @@
"""Pure Nostr (NIP-01) helpers for the Buzz channel connector.
No I/O, no wall-clock: callers supply ``created_at``. BIP-340 signing is done via
``coincurve``, which ships in the optional ``buzz`` dependency extra and is imported
lazily so the rest of the app never requires it.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
COINCURVE_INSTALL_HINT = "The Buzz channel requires the 'buzz' extra: run `uv sync --extra buzz` (installs coincurve for BIP-340 signing)."
def _require_coincurve():
try:
import coincurve
except ImportError as exc: # pragma: no cover - exercised via BuzzChannel.start
raise RuntimeError(COINCURVE_INSTALL_HINT) from exc
return coincurve
@dataclass(frozen=True)
class NostrKeys:
secret: bytes
pubkey_hex: str
def _bech32_polymod(values: list[int]) -> int:
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for v in values:
b = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ v
for i in range(5):
chk ^= gen[i] if ((b >> i) & 1) else 0
return chk
def _bech32_decode(expected_hrp: str, value: str) -> bytes:
if "1" not in value:
raise ValueError(f"not bech32: {value!r}")
hrp, data_part = value.rsplit("1", 1)
if hrp != expected_hrp:
raise ValueError(f"expected {expected_hrp!r} bech32, got {hrp!r}")
try:
data = [_BECH32_CHARSET.index(c) for c in data_part]
except ValueError as exc:
raise ValueError(f"invalid bech32 character in {value!r}") from exc
hrp_expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
if _bech32_polymod(hrp_expanded + data) != 1:
raise ValueError(f"bad bech32 checksum in {value!r}")
acc = bits = 0
out = bytearray()
for v in data[:-6]:
acc = (acc << 5) | v
bits += 5
if bits >= 8:
bits -= 8
out.append((acc >> bits) & 0xFF)
if len(out) != 32:
raise ValueError(f"expected 32-byte payload in {value!r}")
return bytes(out)
def _parse_32_bytes(value: str, bech_hrp: str) -> bytes:
value = value.strip()
if value.lower().startswith(f"{bech_hrp}1"):
return _bech32_decode(bech_hrp, value.lower())
try:
raw = bytes.fromhex(value)
except ValueError as exc:
raise ValueError(f"expected 64-hex or {bech_hrp}1... value") from exc
if len(raw) != 32:
raise ValueError("expected exactly 32 bytes")
return raw
def parse_private_key(value: str) -> NostrKeys:
secret = _parse_32_bytes(value, "nsec")
coincurve = _require_coincurve()
pubkey = coincurve.PrivateKey(secret).public_key.format(compressed=True)[1:]
return NostrKeys(secret=secret, pubkey_hex=pubkey.hex())
def parse_pubkey(value: str) -> str:
return _parse_32_bytes(value, "npub").hex()
def event_id(pubkey_hex: str, created_at: int, kind: int, tags: list[list[str]], content: str) -> str:
payload = json.dumps([0, pubkey_hex, created_at, kind, tags, content], separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode()).hexdigest()
def sign_event(keys: NostrKeys, kind: int, tags: list[list[str]], content: str, created_at: int) -> dict:
coincurve = _require_coincurve()
eid = event_id(keys.pubkey_hex, created_at, kind, tags, content)
sig = coincurve.PrivateKey(keys.secret).sign_schnorr(bytes.fromhex(eid))
return {"id": eid, "pubkey": keys.pubkey_hex, "created_at": created_at, "kind": kind, "tags": tags, "content": content, "sig": sig.hex()}
KIND_CHAT = 9
KIND_EDIT = 40003
KIND_AUTH = 22242
KIND_CHANNEL_META = 39000
# Relay-signed membership notifications (buzz-core's KIND_MEMBER_ADDED_NOTIFICATION /
# KIND_MEMBER_REMOVED_NOTIFICATION). Each carries ``p`` = the affected member's pubkey
# and ``h`` = the channel uuid, which is how a connected client learns it was added to
# (or removed from) a channel without reconnecting.
KIND_MEMBER_ADDED = 44100
KIND_MEMBER_REMOVED = 44101
def build_auth_event(keys: NostrKeys, relay_url: str, challenge: str, created_at: int) -> dict:
return sign_event(keys, KIND_AUTH, [["relay", relay_url], ["challenge", challenge]], "", created_at)
def build_chat_event(keys: NostrKeys, channel_id: str, content: str, created_at: int, reply_to: str | None = None, mentions: tuple[str, ...] = ()) -> dict:
tags: list[list[str]] = [["h", channel_id]]
if reply_to:
tags.append(["e", reply_to])
tags.extend(["p", m] for m in mentions)
return sign_event(keys, KIND_CHAT, tags, content, created_at)
def build_edit_event(keys: NostrKeys, channel_id: str, target_event_id: str, content: str, created_at: int) -> dict:
return sign_event(keys, KIND_EDIT, [["h", channel_id], ["e", target_event_id]], content, created_at)
def verify_event(event: Any) -> bool:
"""True only when *event* carries a self-consistent id and a valid BIP-340 signature.
Two independent checks, both required:
1. The NIP-01 event id is RECOMPUTED from the event's own
``pubkey``/``created_at``/``kind``/``tags``/``content`` and must equal the
``id`` the sender claims -- so ``id`` cannot be borrowed from a different
(legitimately signed) event while the payload is swapped.
2. The Schnorr signature must verify against that id under the claimed
``pubkey``, which is what actually binds the payload to its author.
Relay input is untrusted, so this NEVER raises: any missing, mistyped,
non-hex, or wrong-length field -- or a payload that is not even a mapping --
is simply an event that fails to verify, and callers must be able to treat
"malformed" and "forged" identically without a try/except at every call site.
A missing ``coincurve`` (the optional ``buzz`` extra) also lands here and
fails closed; it is unreachable in practice because ``BuzzChannel.start()``
already parses its private key through ``coincurve`` and would have failed
with :data:`COINCURVE_INSTALL_HINT` long before any event arrived.
"""
try:
if not isinstance(event, dict):
return False
pubkey = event.get("pubkey")
sig = event.get("sig")
claimed_id = event.get("id")
content = event.get("content")
created_at = event.get("created_at")
kind = event.get("kind")
tags = event.get("tags")
# bool is an int subclass; a JSON `true` in either numeric field would
# otherwise serialize as "true" and silently change the canonical form.
if not isinstance(pubkey, str) or not isinstance(sig, str) or not isinstance(claimed_id, str) or not isinstance(content, str) or not isinstance(tags, list):
return False
if not isinstance(created_at, int) or isinstance(created_at, bool) or not isinstance(kind, int) or isinstance(kind, bool):
return False
if event_id(pubkey, created_at, kind, tags, content) != claimed_id:
return False
coincurve = _require_coincurve()
return bool(coincurve.PublicKeyXOnly(bytes.fromhex(pubkey)).verify(bytes.fromhex(sig), bytes.fromhex(claimed_id)))
except Exception:
return False
def req_frame(sub_id: str, *filters: dict) -> str:
return json.dumps(["REQ", sub_id, *filters], separators=(",", ":"))
def event_frame(event: dict) -> str:
return json.dumps(["EVENT", event], separators=(",", ":"))
def close_frame(sub_id: str) -> str:
"""NIP-01 ``CLOSE``: stop an individual subscription without dropping the socket.
Needed because chat subscriptions are per channel (the relay only fans kind-9
events out to ``#h``-scoped subscriptions), so being removed from a channel has
to unsubscribe exactly that one -- the other channels' subscriptions, the
discovery subscription, and the membership subscription all ride the same
connection and must survive.
"""
return json.dumps(["CLOSE", sub_id], separators=(",", ":"))
def tag_values(event: dict, name: str) -> list[str]:
return [t[1] for t in event.get("tags", []) if len(t) >= 2 and t[0] == name]

View File

@ -0,0 +1,12 @@
"""Run-policy registration for the Buzz channel (imported for side effect from manager.py)."""
from app.channels.run_policy import CHANNEL_RUN_POLICY, ChannelRunPolicy
def register_policy() -> None:
# Same-thread follow-ups queue instead of tripping the busy reply (Feishu precedent);
# the adapter-level pubkey allowlist is the identity gate, so no bound identity needed.
CHANNEL_RUN_POLICY["buzz"] = ChannelRunPolicy(serialize_thread_runs=True, requires_bound_identity=False)
register_policy()

View File

@ -18,6 +18,7 @@ from urllib.parse import quote
import httpx
from langgraph_sdk.errors import ConflictError
from app.channels import buzz_run_policy as _buzz_run_policy # noqa: F401
from app.channels import feishu_run_policy as _feishu_run_policy # noqa: F401
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.dedupe_store import InboundDedupeStore, MemoryInboundDedupeStore
@ -125,6 +126,7 @@ INBOUND_DEDUPE_METADATA_KEYS = ("event_id", "message_id", "msg_id")
CHAT_SCOPED_WORKSPACE_CHANNELS = frozenset({"telegram", "feishu", "wechat"})
CHANNEL_CAPABILITIES = {
"buzz": {"supports_streaming": True},
"dingtalk": {"supports_streaming": False},
"discord": {"supports_streaming": False},
"feishu": {"supports_streaming": True},
@ -515,12 +517,93 @@ def _extract_stream_message_id(payload: Any, metadata: Any) -> str | None:
return None
def _stream_payload_type(payload: Mapping[str, Any]) -> str:
"""Resolve the message ``type`` of one ``messages-tuple`` payload.
Two payload shapes reach this function and they name the message type in
different places:
* The shape DeerFlow's own gateway emits (``runtime/serialization.py``
calls ``model_dump()``): ``type`` is the LangChain literal directly --
``"ai"`` / ``"AIMessageChunk"`` / ``"human"`` / ``"tool"`` / ``"system"``.
* LangChain's ``to_json()`` constructor shape, which
``_extract_stream_message_id`` and the content extraction below already
accommodate: the wrapper's own ``type`` is the literal string
``"constructor"`` and the real class name is the last element of the
``id`` path (``["langchain", "schema", "messages", "AIMessageChunk"]``),
with the constructor kwargs under ``kwargs``.
Reading only the top level would classify every constructor-shaped payload
as ``"constructor"``, which an allowlist rejects (safe) but which would
also mean hidden context and assistant output are treated identically --
so the class name is resolved properly instead of guessed.
"""
raw_type = payload.get("type")
if isinstance(raw_type, str) and raw_type and raw_type != "constructor":
return raw_type
kwargs = payload.get("kwargs")
if isinstance(kwargs, Mapping):
nested = kwargs.get("type")
if isinstance(nested, str) and nested:
return nested
lc_path = payload.get("id")
if isinstance(lc_path, (list, tuple)) and lc_path:
tail = lc_path[-1]
if isinstance(tail, str) and tail:
return tail
return raw_type if isinstance(raw_type, str) else ""
def _is_assistant_stream_type(payload_type: str) -> bool:
"""Is this message type assistant output, i.e. displayable in an IM channel?
An ALLOWLIST, deliberately. The previous denylist ("reject anything whose
type contains 'tool'") published every other message type, and DeerFlow
writes hidden model context into the ``messages`` channel as ordinary
messages: ``DynamicContextMiddleware`` injects the ``<memory>`` block as a
hidden ``HumanMessage`` (``type == "human"``) and rewrites the user's own
turn into a new ``HumanMessage``, and ``DurableContextMiddleware`` injects a
hidden ``<durable_context_data>`` ``HumanMessage``. LangGraph fans state
writes out on the ``messages-tuple`` stream, so all of those reached the
channel as if they were the assistant's reply -- proved live on a Buzz
relay, where each streaming update is an immutable public Nostr event and a
later corrective edit cannot unpublish the leaked one.
The accepted spellings are the ones assistant output actually carries:
LangChain serializes ``AIMessage.type`` as ``"ai"`` and
``AIMessageChunk.type`` as ``"AIMessageChunk"``; ``"assistant"`` is the
OpenAI-style spelling a foreign runtime may use. Matching is by prefix
rather than substring because a substring test is not safe here -- ordinary
English words contain "ai" ("chain", "domain"), so ``"ai" in type`` would
admit a future/foreign type name by accident, which is exactly the class of
mistake this allowlist exists to prevent. No LangChain message type other
than the AI ones begins with "ai" or "assistant".
"""
normalized = payload_type.strip().lower()
return normalized.startswith(("ai", "assistant"))
def _accumulate_stream_text(
buffers: dict[str, str],
current_message_id: str | None,
event_data: Any,
) -> tuple[str | None, str | None]:
"""Convert a ``messages-tuple`` event into the latest displayable AI text."""
"""Convert a ``messages-tuple`` event into the latest displayable AI text.
Only assistant output is displayable. Hidden human/system context (memory
facts, durable context, the middleware-rewritten echo of the user's own
message) and tool traffic must never be published to an IM channel; see
:func:`_is_assistant_stream_type`.
A bare ``str`` payload -- previously accepted here and buffered under the
current message id -- carries no type information at all, so it cannot be
attributed to the assistant. Nothing in DeerFlow produces it (the gateway
always serializes a ``messages-tuple`` chunk as ``[message_dict, metadata]``
via ``runtime/serialization.py::serialize_messages_tuple``), and a runtime
that did emit raw text deltas would emit hidden context the same way, with
no way to tell them apart. Under an allowlist an unattributable payload is
dropped rather than published.
"""
payload = event_data
metadata: Any = None
if isinstance(event_data, (list, tuple)):
@ -529,16 +612,10 @@ def _accumulate_stream_text(
if len(event_data) > 1:
metadata = event_data[1]
if isinstance(payload, str):
message_id = current_message_id or "__default__"
buffers[message_id] = _merge_stream_text(buffers.get(message_id, ""), payload)
return buffers[message_id], message_id
if not isinstance(payload, Mapping):
return None, current_message_id
payload_type = str(payload.get("type", "")).lower()
if "tool" in payload_type:
if not _is_assistant_stream_type(_stream_payload_type(payload)):
return None, current_message_id
text = _extract_text_content(payload.get("content"))

View File

@ -23,6 +23,7 @@ if TYPE_CHECKING:
# Channel name → import path for lazy loading
_CHANNEL_REGISTRY: dict[str, str] = {
"buzz": "app.channels.buzz:BuzzChannel",
"dingtalk": "app.channels.dingtalk:DingTalkChannel",
"discord": "app.channels.discord:DiscordChannel",
"feishu": "app.channels.feishu:FeishuChannel",
@ -35,6 +36,7 @@ _CHANNEL_REGISTRY: dict[str, str] = {
# Keys that indicate a user has configured credentials for a channel.
_CHANNEL_CREDENTIAL_KEYS: dict[str, list[str]] = {
"buzz": ["private_key"],
"dingtalk": ["client_id", "client_secret"],
"discord": ["bot_token"],
"feishu": ["app_id", "app_secret"],

View File

@ -92,6 +92,7 @@ _PROVIDER_META: dict[str, dict[str, str]] = {
"dingtalk": {"display_name": "DingTalk", "auth_mode": "binding_code"},
"wechat": {"display_name": "WeChat", "auth_mode": "binding_code"},
"wecom": {"display_name": "WeCom", "auth_mode": "binding_code"},
"buzz": {"display_name": "Buzz", "auth_mode": "binding_code"},
}
_CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
@ -117,6 +118,10 @@ _CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
{"name": "bot_id", "label": "Bot ID", "type": "text"},
{"name": "bot_secret", "label": "Bot secret", "type": "password"},
),
"buzz": (
{"name": "relay_url", "label": "Relay URL", "type": "text"},
{"name": "private_key", "label": "Private key (hex or nsec)", "type": "password"},
),
}
_RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
@ -127,6 +132,7 @@ _RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
"dingtalk": ("client_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, and WeCom. 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, 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.
@ -293,6 +293,11 @@ channels:
enabled: true
bot_id: $WECOM_BOT_ID
bot_secret: $WECOM_BOT_SECRET
buzz:
enabled: true
relay_url: wss://buzz.example.com
private_key: $BUZZ_PRIVATE_KEY # hex or nsec1…
```
Then enable user bindings in `channel_connections`:
@ -327,6 +332,9 @@ channel_connections:
wecom:
enabled: true
buzz:
enabled: true
```
`channel_connections` does not duplicate provider secrets. It only controls the browser-facing connect UI and stores per-user binding records. Telegram needs `bot_username` only so the frontend can open a deep link.
@ -361,6 +369,57 @@ Feishu/Lark, DingTalk, WeChat, and WeCom:
- The UI shows `Send /connect <code> to the DeerFlow <Provider> bot.`
- The already-running long-connection or polling worker receives the message and binds the platform user/workspace identity to the current DeerFlow user.
Buzz:
- Unlike the bot/app credentials above, Buzz has no separate developer console: DeerFlow joins the relay as an ordinary member identity. Generate a Nostr keypair for that identity — everything below refers to its **hex public key**.
- **Onboarding takes two separate steps, and both are required.** Relay membership and channel membership are different things, and doing only the first produces a connector that connects and authenticates cleanly while receiving nothing:
1. **Register the pubkey as a relay member**`buzz-admin add-member --pubkey <hex>`. This is what lets the identity authenticate (NIP-42) and publish at all.
2. **Add it to each channel it should participate in**`buzz channels add-member --channel <uuid> --pubkey <hex> --role bot`. Chat events are only delivered to a channel's members, and the relay additionally **rejects** any message that `p`-mentions a non-member with `mentioned pubkeys are not channel members` — so without this step the connector can neither hear a mention nor answer one.
See the [Buzz project](https://github.com/block/buzz) for the admin tooling.
- **Channels are auto-discovered — you do not list them in `config.yaml`.** On every connection the connector asks the relay which channels this identity belongs to and subscribes to each one individually. Adding it to a new channel later takes effect **live**, without a restart or reconnect: the relay sends a membership notification and the connector starts listening immediately (and stops listening when it is removed). If you see `channel discovery returned no channels` in the logs, step 2 above has not been done.
- Configure `relay_url` and `private_key` (hex or `nsec1…`) under `channels.buzz`, then enable `channel_connections.buzz`.
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow Buzz bot.`
- The already-running Buzz relay-loop worker receives the message — sent as a DM or an @mention in a channel both parties belong to — and binds the sender's Nostr pubkey to the current DeerFlow user.
- Requires the `buzz` dependency extra (`uv sync --extra buzz`) for the `coincurve` library. `scripts/detect_uv_extras.py` (and Docker/production builds via `backend/Dockerfile`) auto-detect and preserve this extra when `channels.buzz.enabled: true` in `config.yaml`, the same way the `browser` extra is auto-detected for `browser_navigate`.
### Buzz subscription model
Buzz's relay only delivers chat events to **channel-scoped** subscriptions, which is why the connector's subscriptions look the way they do. A global `REQ {"kinds":[9]}` is accepted and answered with `EOSE`, but no chat event is ever fanned out to it, and a single subscription cannot cover several channels either (a multi-value `#h` matches nothing). So, on **every** connection, after NIP-42 auth completes:
| Subscription | Filter | Purpose |
|---|---|---|
| `buzz-discovery` | `{"kinds":[39000]}` | Historical query listing exactly the channels this identity is a member of (one stored event each, then `EOSE`). Supplies each channel's name and type, which is also what the DM mention-exemption reads. Do **not** narrow it with `#p` — that matches nothing. |
| `buzz-membership` | `{"kinds":[44100,44101], "#p":["<our pubkey>"], "since": …}` | **Live** membership notifications. `44100` (added) subscribes to the new channel immediately; `44101` (removed) closes that channel's subscription. This is what makes a newly added channel work without a restart. |
| `buzz-chat-<uuid>` | `{"kinds":[9], "#h":["<uuid>"], "since": …}` | One per discovered channel — the only shape that actually receives messages. |
Consequences worth knowing operationally:
- **Replay is tracked per channel.** Each channel carries its own `since` watermark, advanced only by events DeerFlow actually processed. A single shared watermark would let a busy channel drag the cursor past a quiet channel's unread messages and skip them after a reconnect; per-channel cursors can only ever cost duplicate delivery (which the manager's inbound dedupe absorbs), never a miss.
- **Membership is scoped to live events.** The relay *stores* 44100/44101 events, so without a `since` every connection replayed the whole membership history as if it had just happened — re-running channel discovery once per stored add (you would see several `channel discovery complete` lines for one connect, and channels logged as `<unnamed>`), re-subscribing channels you have since been removed from, and briefly unsubscribing channels you are still in. The subscription is therefore anchored at the moment the socket opened, minus 60s of slack so a membership change made *during* the connect/auth handshake — or a small relay clock skew — is still picked up.
- **The number of channel subscriptions is capped** (256). The channel list comes off the wire, so it is bounded like any other remote-fed state. At the cap, new channels are refused and named in a `per-channel subscription limit reached` warning rather than an existing, working subscription being evicted.
- **A subscription the relay closes is re-opened, up to 3 times per connection.** Every subscription on the socket fails *silently* when the relay drops it: a chat subscription deafens one channel, `buzz-membership` stops DeerFlow ever learning it was added to or removed from a channel, `buzz-discovery` kills the completeness sweep. So a `CLOSED` frame is recovered, not just noted, and the subscription that went quiet is always named at WARNING level. Recovery is skipped when the relay's stated reason says the subscription is not ours any more — a NIP-01/NIP-42 `auth-required:` / `restricted:` / `blocked:` / `invalid:` prefix, or buzz-relay's own revocation wording — because re-issuing the same REQ then just fights the relay. Any other reason (including a `CLOSED` with no reason at all) is treated as a hiccup and retried, with the 3-attempt budget as the backstop; after that it stays down until the next reconnect, which rebuilds everything from scratch.
- **Known bound: more than 2000 unread messages in one channel across a disconnect loses the oldest of them.** The relay caps historical delivery at 2000 events per subscription and serves them newest-first, even with a `since`. DeerFlow processes what it receives and the channel's watermark advances past the rest, so those older messages are never delivered and never retried. Every other gap in the design fails toward duplicate delivery (which is absorbed by inbound dedupe); this is the one remaining case that can skip, and it needs both a disconnect and a >2000-message backlog in a *single* channel to occur.
### Buzz trust model
On a team-run Buzz relay the relay operator is not necessarily the DeerFlow operator, so be precise about what the connector proves and what it takes on trust:
**Verified (cryptographically, on every inbound event):** DeerFlow recomputes each event's NIP-01 id from the delivered payload and verifies its BIP-340 Schnorr signature against the claimed `pubkey` before the event can influence anything. A relay therefore cannot rewrite a member's message, replay one author's signature onto another payload, or claim an allowlisted author it does not hold the key for. This applies to `/connect` binds as well as ordinary chat, so a relay cannot bind someone else's pubkey to an attacker's DeerFlow account. Events that fail verification are dropped with a warning.
**Trusted (not verified):** the *authorship* of kind-39000 channel metadata. Buzz publishes channel discovery events from the relay's own keypair, but nothing already configured identifies that key (`relay_url` is a network address, not a signing key), so DeerFlow only proves such an event was signed by *some* member. Because channel discovery and subscription are now driven by exactly these events, a forged kind-39000 has two effects, not one:
1. It can mark a channel `type: "dm"`, which relaxes the `require_mention` requirement for that channel.
2. It can make DeerFlow **open a chat subscription** for a channel of the forger's choosing, since the set of channels DeerFlow listens to is the set it holds metadata for.
Neither can make anything be *acted on*. The `allowed_users` allowlist and per-event signature verification are independent gates: an author who is not allowlisted is dropped regardless of channel type or how the subscription was opened. The blast radius of (2) is a relay reading its own traffic back to a subscriber that ignores it, bounded by the 256-subscription cap (which refuses new subscriptions rather than evicting working ones, so an induced subscription cannot displace a real channel). The same applies to a forged kind-44100 membership notification, except that its `p` tag is re-checked locally, so it must at least name this identity. If you need the mention requirement to be unforgeable on a relay whose members you do not all trust, keep those channels out of `mention_free_channels` and treat DM detection as convenience rather than a boundary.
**Deny-by-default allowlist:** unlike other providers (where an empty `allowed_users` means "allow everyone"), `channels.buzz.allowed_users` is deliberately deny-by-default — an empty list means *nobody* can trigger a run, and DeerFlow logs a startup warning saying so. Add each member pubkey (hex or `npub1…`) that should be able to reach the agent. Individual drops are logged at DEBUG level.
**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.
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

@ -50,6 +50,7 @@ class ChannelConnectionsConfig(BaseModel):
dingtalk: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
wechat: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
wecom: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
buzz: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
def provider_status(self, provider: str) -> dict[str, bool]:
config = getattr(self, provider, None)

View File

@ -28,6 +28,7 @@ dependencies = [
postgres = ["deerflow-harness[postgres]"]
redis = ["deerflow-harness[redis]"]
discord = ["discord.py>=2.7.0"]
buzz = ["coincurve>=20.0.0"]
monocle = ["deerflow-harness[monocle]"]
browser = ["deerflow-harness[browser]"]
memory-zh = ["deerflow-harness[memory-zh]"]
@ -69,9 +70,13 @@ index-url = "https://pypi.org/simple"
# breaking change is requiring Python >=3.10 (we require >=3.12), the sdk
# only imports `websockets.asyncio.client`/`websockets.exceptions` (both
# 16-compatible), and DeerFlow never uses the sdk's WebSocket transport
# (httpx/SSE only). Pin the exact pre-upgrade 16.0 for the IM channel
# integrations (dingtalk-stream, python-telegram-bot, etc.) that ran on it
# before. Remove once langgraph-sdk relaxes the pin upstream. Note: enabling
# (httpx/SSE only). DeerFlow does have one direct consumer of its own: the Buzz
# channel (`app/channels/buzz.py`) imports the top-level `websockets` package and
# calls `websockets.connect()`, which in 16.0 is the same asyncio client the sdk
# uses, re-exported at the package root -- so it is covered by the same
# compatibility argument. Pin the exact pre-upgrade 16.0 for that plus the IM
# channel integrations (dingtalk-stream, python-telegram-bot, etc.) that ran on
# it before. Remove once langgraph-sdk relaxes the pin upstream. Note: enabling
# the `openai[realtime]` or `slack-sdk[optional]` extras would conflict (they
# also cap websockets<16).
override-dependencies = ["websockets==16.0"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
"""Tests for the pure Nostr helpers behind the Buzz channel connector."""
import json
import pytest
coincurve = pytest.importorskip("coincurve")
from app.channels import buzz_nostr # noqa: E402
SK3_HEX = "0000000000000000000000000000000000000000000000000000000000000003"
SK3_NSEC = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqps52s3re"
PK3_HEX = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"
PK3_NPUB = "npub1lycg5qvjtrp3qjf5f7zl382j9x6nrjz9sdhenvyxq8c3808qxmus6gq266"
CHANNEL = "136852ee-63e1-49c2-8927-413b5ee8e5f7"
def test_parse_private_key_hex_derives_xonly_pubkey():
keys = buzz_nostr.parse_private_key(SK3_HEX)
assert keys.secret == bytes.fromhex(SK3_HEX)
assert keys.pubkey_hex == PK3_HEX
def test_parse_private_key_nsec_matches_hex():
assert buzz_nostr.parse_private_key(SK3_NSEC) == buzz_nostr.parse_private_key(SK3_HEX)
@pytest.mark.parametrize("bad", ["", "zz" * 32, "nsec1invalid", "npub1lycg5qvjtrp3qjf5f7zl382j9x6nrjz9sdhenvyxq8c3808qxmus6gq266"])
def test_parse_private_key_rejects_garbage(bad):
with pytest.raises(ValueError):
buzz_nostr.parse_private_key(bad)
def test_parse_pubkey_accepts_hex_and_npub():
assert buzz_nostr.parse_pubkey(PK3_HEX.upper()) == PK3_HEX
assert buzz_nostr.parse_pubkey(PK3_NPUB) == PK3_HEX
def test_event_id_matches_nip01_reference_vector():
eid = buzz_nostr.event_id(PK3_HEX, 1700000000, 9, [["h", CHANNEL]], "hello buzz")
assert eid == "6aa2ef0a72e39e52ac7c3680e6a76ed75c90340e684148da6221086b443d2089"
def test_sign_event_produces_valid_schnorr_signature():
keys = buzz_nostr.parse_private_key(SK3_HEX)
ev = buzz_nostr.sign_event(keys, 9, [["h", CHANNEL]], "hello buzz", created_at=1700000000)
assert ev["id"] == "6aa2ef0a72e39e52ac7c3680e6a76ed75c90340e684148da6221086b443d2089"
assert ev["pubkey"] == PK3_HEX and ev["kind"] == 9 and ev["tags"] == [["h", CHANNEL]]
xonly = coincurve.PublicKeyXOnly(bytes.fromhex(PK3_HEX))
assert xonly.verify(bytes.fromhex(ev["sig"]), bytes.fromhex(ev["id"]))
def _keys():
return buzz_nostr.parse_private_key(SK3_HEX)
def test_build_auth_event_carries_relay_and_challenge_tags():
ev = buzz_nostr.build_auth_event(_keys(), "wss://buzz.example.com", "abc123", created_at=1700000001)
assert ev["kind"] == 22242
assert ["relay", "wss://buzz.example.com"] in ev["tags"] and ["challenge", "abc123"] in ev["tags"]
def test_build_chat_event_tags_channel_reply_and_mentions():
ev = buzz_nostr.build_chat_event(_keys(), CHANNEL, "hi", created_at=1700000002, reply_to="ab" * 32, mentions=("cd" * 32,))
assert ev["kind"] == 9
assert ["h", CHANNEL] in ev["tags"] and ["e", "ab" * 32] in ev["tags"] and ["p", "cd" * 32] in ev["tags"]
def test_build_chat_event_minimal_has_only_channel_tag():
ev = buzz_nostr.build_chat_event(_keys(), CHANNEL, "hi", created_at=1700000002)
assert ev["tags"] == [["h", CHANNEL]]
def test_build_edit_event_targets_existing_message():
ev = buzz_nostr.build_edit_event(_keys(), CHANNEL, "ef" * 32, "new text", created_at=1700000003)
assert ev["kind"] == 40003
assert ev["tags"] == [["h", CHANNEL], ["e", "ef" * 32]] and ev["content"] == "new text"
def test_frames_serialize_as_nostr_wire_arrays():
req = json.loads(buzz_nostr.req_frame("sub1", {"kinds": [9]}, {"kinds": [39000]}))
assert req == ["REQ", "sub1", {"kinds": [9]}, {"kinds": [39000]}]
ev = buzz_nostr.build_chat_event(_keys(), CHANNEL, "x", created_at=1700000004)
assert json.loads(buzz_nostr.event_frame(ev)) == ["EVENT", ev]
assert json.loads(buzz_nostr.close_frame("sub1")) == ["CLOSE", "sub1"]
def test_close_frame_targets_exactly_one_subscription():
"""Restored for per-channel subscriptions: being removed from one channel must
unsubscribe that channel alone, leaving the discovery/membership subscriptions
and every other channel's subscription on the same socket untouched."""
assert json.loads(buzz_nostr.close_frame(f"buzz-chat-{CHANNEL}")) == ["CLOSE", f"buzz-chat-{CHANNEL}"]
def test_membership_notification_kinds_match_buzz_core():
"""Pinned against buzz-core/src/kind.rs (KIND_MEMBER_*_NOTIFICATION)."""
assert (buzz_nostr.KIND_MEMBER_ADDED, buzz_nostr.KIND_MEMBER_REMOVED) == (44100, 44101)
def test_tag_values_extracts_all_matching_tags():
ev = {"tags": [["p", "aa"], ["p", "bb"], ["h", CHANNEL]]}
assert buzz_nostr.tag_values(ev, "p") == ["aa", "bb"]
assert buzz_nostr.tag_values(ev, "t") == []
# -- FINDING 4 (review): inbound events must be authenticated, not trusted --------
def _signed(content="hello buzz", created_at=1700000005):
return buzz_nostr.sign_event(_keys(), 9, [["h", CHANNEL]], content, created_at)
def test_verify_event_accepts_a_genuinely_signed_event():
assert buzz_nostr.verify_event(_signed()) is True
def test_verify_event_rejects_tampered_content():
"""The whole point of the check: a relay may not rewrite what an author said.
The signature still verifies against the ORIGINAL id, so only recomputing the
id from the delivered payload catches this."""
ev = _signed()
ev["content"] = "hello buzz, and also: rm -rf /"
assert buzz_nostr.verify_event(ev) is False
def test_verify_event_rejects_a_swapped_author():
"""Claiming an allowlisted pubkey over someone else's signed payload must fail."""
ev = _signed()
ev["pubkey"] = "ee" * 32
assert buzz_nostr.verify_event(ev) is False
def test_verify_event_rejects_a_tampered_signature():
ev = _signed()
flipped = bytearray(bytes.fromhex(ev["sig"]))
flipped[0] ^= 0x01
ev["sig"] = flipped.hex()
assert buzz_nostr.verify_event(ev) is False
def test_verify_event_rejects_an_id_borrowed_from_another_signed_event():
"""id + sig lifted verbatim from a real event, pasted onto a different payload."""
donor = _signed(content="innocuous", created_at=1700000006)
forged = _signed(content="/goal exfiltrate everything", created_at=1700000007)
forged["id"] = donor["id"]
forged["sig"] = donor["sig"]
assert buzz_nostr.verify_event(forged) is False
@pytest.mark.parametrize(
"bad",
[
None,
"not-an-event",
[],
{},
{"pubkey": PK3_HEX}, # everything else missing
{"pubkey": PK3_HEX, "created_at": 1, "kind": 9, "tags": [], "content": "x", "id": "aa", "sig": "bb"}, # short hex
{"pubkey": "zz" * 32, "created_at": 1, "kind": 9, "tags": [], "content": "x", "id": "11" * 32, "sig": "22" * 64}, # non-hex pubkey
{"pubkey": PK3_HEX, "created_at": "1700000000", "kind": 9, "tags": [], "content": "x", "id": "11" * 32, "sig": "22" * 64}, # str created_at
{"pubkey": PK3_HEX, "created_at": 1, "kind": True, "tags": [], "content": "x", "id": "11" * 32, "sig": "22" * 64}, # bool kind
{"pubkey": PK3_HEX, "created_at": 1, "kind": 9, "tags": "not-a-list", "content": "x", "id": "11" * 32, "sig": "22" * 64},
{"pubkey": PK3_HEX, "created_at": 1, "kind": 9, "tags": [], "content": None, "id": "11" * 32, "sig": "22" * 64},
],
)
def test_verify_event_returns_false_for_malformed_input_without_raising(bad):
"""Relay input is untrusted, so every malformed shape must be a plain False.
A raise here would surface as `handle_relay_frame`'s generic "malformed relay
event" path at best -- and at worst tempt a caller into a bare except that also
swallows a real verification failure."""
assert buzz_nostr.verify_event(bad) is False
def test_verify_event_rejects_an_unsigned_but_otherwise_valid_event():
"""The pre-fix test fixtures' shape: correct id, no signature at all."""
ev = _signed()
ev.pop("sig")
assert buzz_nostr.verify_event(ev) is False

View File

@ -397,6 +397,7 @@ class TestChannelBase:
assert "prepare_inbound failed for msg_id=m1: boom" in caplog.text
def test_channel_capabilities_match_channel_defaults(self):
from app.channels.buzz import BuzzChannel
from app.channels.dingtalk import DingTalkChannel
from app.channels.discord import DiscordChannel
from app.channels.feishu import FeishuChannel
@ -409,6 +410,7 @@ class TestChannelBase:
bus = MessageBus()
defaults = {
"buzz": BuzzChannel(bus=bus, config={"relay_url": "wss://buzz.example.com"}).supports_streaming,
"dingtalk": DingTalkChannel(bus=bus, config={}).supports_streaming,
"discord": DiscordChannel(bus=bus, config={}).supports_streaming,
"feishu": FeishuChannel(bus=bus, config={}).supports_streaming,
@ -9649,3 +9651,254 @@ def test_merge_stream_text_newline_split():
def test_merge_stream_text_normal_append():
_merge = _get_merge_stream_text()
assert _merge("Hello ", "world") == "Hello world"
# ---------------------------------------------------------------------------
# LIVE-TEST FINDING 1 (critical, data disclosure): _accumulate_stream_text
# decided what streamed payloads become displayable assistant text by REJECTING
# only payloads whose ``type`` contained "tool". DeerFlow injects hidden
# context -- memory facts (DynamicContextMiddleware) and durable context
# (DurableContextMiddleware) -- as hidden HumanMessages whose ``type`` is
# "human", and DynamicContextMiddleware also rewrites the user's own turn into
# a fresh HumanMessage. All of those are written to the messages channel, so
# LangGraph fans them out on the ``messages-tuple`` stream and the old denylist
# accumulated them and published them to the IM channel as the assistant's
# reply. Proved live on a Buzz relay: the connector published
# "<memory>\nFacts:\n- [context | 0.70] ...\n</memory> ▉" and, in another run,
# a verbatim echo of the user's own inbound message.
#
# The filter is now an ALLOWLIST of assistant message types. These tests pin
# both directions: hidden context never accumulates, and assistant streaming --
# including multi-chunk merging across one message id -- is untouched.
# ---------------------------------------------------------------------------
def _get_accumulate_stream_text():
from app.channels.manager import _accumulate_stream_text
return _accumulate_stream_text
_MEMORY_LEAK_TEXT = "<memory>\nFacts:\n- [context | 0.70] User interacts with the assistant through the DeerFlow chat channel.\n</memory>"
def test_accumulate_stream_text_rejects_hidden_memory_human_message():
"""The exact live leak: a hidden HumanMessage carrying a <memory> block."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, message_id = _accumulate(
buffers,
None,
[
{
"id": "run-1__memory",
"type": "human",
"content": _MEMORY_LEAK_TEXT,
"additional_kwargs": {"hide_from_ui": True},
},
{"langgraph_node": "model_request"},
],
)
assert text is None
assert message_id is None
assert buffers == {}
def test_accumulate_stream_text_rejects_durable_context_human_message():
"""DurableContextMiddleware's hidden <durable_context_data> block."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, _ = _accumulate(
buffers,
None,
[
{"id": "dc-1", "type": "human", "content": "<durable_context_data>\n## Conversation summary so far\nsecret\n</durable_context_data>"},
{},
],
)
assert text is None
assert buffers == {}
def test_accumulate_stream_text_rejects_echo_of_plain_human_message():
"""DynamicContextMiddleware re-writes the user's own turn as a new
HumanMessage; echoing it back to the channel as the assistant's reply is
the second half of the same live leak."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, _ = _accumulate(buffers, None, [{"id": "u-1__user", "type": "human", "content": "what is my deploy status?"}, {}])
assert text is None
assert buffers == {}
def test_accumulate_stream_text_rejects_system_message():
"""The <system-reminder> SystemMessage is hidden context too."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, _ = _accumulate(buffers, None, [{"id": "s-1", "type": "system", "content": "<system-reminder>Today is 2026-08-01</system-reminder>"}, {}])
assert text is None
assert buffers == {}
def test_accumulate_stream_text_still_rejects_tool_payloads():
"""Pre-existing behavior: tool calls and tool results are never displayable."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
assert _accumulate(buffers, None, [{"id": "t-1", "type": "tool", "content": "bash output"}, {}]) == (None, None)
assert _accumulate(buffers, None, [{"id": "t-2", "type": "ToolMessageChunk", "content": "more output"}, {}]) == (None, None)
assert buffers == {}
def test_accumulate_stream_text_rejects_hidden_context_wrapped_in_kwargs_shape():
"""The LangChain ``to_json`` shape the function already reads content from:
the wrapper's own ``type`` is "constructor", so the real message type has to
be resolved from ``kwargs``/``id`` or hidden context walks straight through."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, _ = _accumulate(
buffers,
None,
[
{
"lc": 1,
"type": "constructor",
"id": ["langchain", "schema", "messages", "HumanMessage"],
"kwargs": {"id": "m-1", "content": _MEMORY_LEAK_TEXT},
},
{},
],
)
assert text is None
assert buffers == {}
def test_accumulate_stream_text_accepts_assistant_chunk_in_kwargs_shape():
"""...and the same shape must still stream an AIMessageChunk."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, message_id = _accumulate(
buffers,
None,
[
{
"lc": 1,
"type": "constructor",
"id": ["langchain", "schema", "messages", "AIMessageChunk"],
"kwargs": {"id": "ai-9", "content": "Hello"},
},
{},
],
)
assert text == "Hello"
assert message_id == "ai-9"
def test_accumulate_stream_text_rejects_untyped_bare_string_payload():
"""A bare ``str`` payload carries no type information at all, so it cannot be
attributed to the assistant. Under an allowlist an unattributable payload
must not be published -- hidden context arriving that way would be
indistinguishable from assistant output."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
assert _accumulate(buffers, None, "Hello") == (None, None)
assert _accumulate(buffers, "ai-1", ["raw text", {}]) == (None, "ai-1")
assert buffers == {}
@pytest.mark.parametrize("payload_type", ["ai", "AIMessageChunk", "AIMessage", "assistant"])
def test_accumulate_stream_text_accepts_every_assistant_type_spelling(payload_type):
"""The literal ``type`` values assistant output actually carries: LangChain
serializes AIMessage as "ai" and AIMessageChunk as "AIMessageChunk"; the
OpenAI-style "assistant" spelling is accepted for foreign runtimes."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
text, message_id = _accumulate(buffers, None, [{"id": "ai-1", "content": "Hi", "type": payload_type}, {"langgraph_node": "agent"}])
assert text == "Hi"
assert message_id == "ai-1"
def test_accumulate_stream_text_merges_multi_chunk_assistant_stream_across_one_message_id():
"""The function's entire purpose. Pinned hard so the allowlist can never
silently kill streaming for Feishu / Telegram / WeCom / Buzz."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
current: str | None = None
seen = []
for delta in ("Hello", " ", "world", "!"):
text, current = _accumulate(buffers, current, [{"id": "ai-1", "content": delta, "type": "AIMessageChunk"}, {"langgraph_node": "agent"}])
seen.append(text)
assert seen == ["Hello", "Hello ", "Hello world", "Hello world!"]
assert current == "ai-1"
assert buffers == {"ai-1": "Hello world!"}
def test_accumulate_stream_text_keeps_separate_buffers_per_message_id():
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
_accumulate(buffers, None, [{"id": "ai-1", "content": "first", "type": "AIMessageChunk"}, {}])
_accumulate(buffers, "ai-1", [{"id": "ai-2", "content": "second", "type": "AIMessageChunk"}, {}])
text, current = _accumulate(buffers, "ai-2", [{"id": "ai-1", "content": "-more", "type": "AIMessageChunk"}, {}])
assert text == "first-more"
assert current == "ai-1"
assert buffers == {"ai-1": "first-more", "ai-2": "second"}
def test_accumulate_stream_text_hidden_context_between_assistant_chunks_never_enters_the_buffer():
"""Interleaving is the realistic live shape: the middleware writes its hidden
HumanMessage in the middle of a turn. It must neither be published nor
corrupt the assistant buffer it sits between."""
_accumulate = _get_accumulate_stream_text()
buffers: dict[str, str] = {}
_, current = _accumulate(buffers, None, [{"id": "ai-1", "content": "Deploy ", "type": "AIMessageChunk"}, {}])
leaked, current_after = _accumulate(buffers, current, [{"id": "mem-1", "type": "human", "content": _MEMORY_LEAK_TEXT}, {}])
assert leaked is None
assert current_after == "ai-1" # the assistant message id is preserved
text, _ = _accumulate(buffers, current_after, [{"id": "ai-1", "content": "succeeded.", "type": "AIMessageChunk"}, {}])
assert text == "Deploy succeeded."
assert buffers == {"ai-1": "Deploy succeeded."}
def test_streaming_chat_never_publishes_hidden_memory_context(monkeypatch):
"""End-to-end through _handle_streaming_chat: the hidden <memory> HumanMessage
the live relay actually received must reach no outbound message at all."""
from app.channels.manager import ChannelManager
monkeypatch.setattr("app.channels.manager.STREAM_UPDATE_MIN_INTERVAL_SECONDS", 0.0)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
outbound_received: list[OutboundMessage] = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
stream_events = [
_make_stream_part("messages-tuple", [{"id": "u-1__user", "type": "human", "content": "what is my deploy status?"}, {}]),
_make_stream_part("messages-tuple", [{"id": "u-1__memory", "type": "human", "content": _MEMORY_LEAK_TEXT, "additional_kwargs": {"hide_from_ui": True}}, {}]),
_make_stream_part("messages-tuple", [{"id": "ai-1", "content": "All green.", "type": "AIMessageChunk"}, {"langgraph_node": "agent"}]),
_make_stream_part(
"values",
{"messages": [{"type": "human", "content": "what is my deploy status?"}, {"type": "ai", "content": "All green."}], "artifacts": []},
),
]
mock_client = _make_mock_langgraph_client()
mock_client.runs.stream = MagicMock(return_value=_make_async_iterator(stream_events))
manager._client = mock_client
await manager.start()
await bus.publish_inbound(InboundMessage(channel_name="buzz", chat_id="chan-1", user_id="pk-1", text="what is my deploy status?", thread_ts=None))
await _wait_for(lambda: any(m.is_final for m in outbound_received))
await manager.stop()
assert outbound_received, "expected at least the final outbound"
for published in outbound_received:
assert "<memory>" not in published.text
assert "what is my deploy status?" not in published.text
assert [m.text for m in outbound_received] == ["All green. ▉", "All green."]
_run(go())

View File

@ -173,6 +173,35 @@ def test_detect_from_config_ignores_commented_browser_tool(tmp_path):
assert detect.detect_from_config(cfg) == []
def test_detect_from_config_buzz_via_channels_enabled(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text(
"channels:\n buzz:\n enabled: true\n relay_url: wss://buzz.example.com\n",
)
assert detect.detect_from_config(cfg) == ["buzz"]
def test_detect_from_config_buzz_disabled_returns_no_extras(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("channels:\n buzz:\n enabled: false\n")
assert detect.detect_from_config(cfg) == []
def test_detect_from_config_no_channels_section_returns_no_buzz_extra(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("database:\n backend: sqlite\n")
assert detect.detect_from_config(cfg) == []
def test_detect_from_config_ignores_commented_buzz_block(tmp_path):
"""Mirrors the fully-commented example block shipped in config.example.yaml."""
cfg = tmp_path / "config.yaml"
cfg.write_text(
"# channels:\n# buzz:\n# enabled: true\n# relay_url: wss://buzz.example.com\ndatabase:\n backend: sqlite\n",
)
assert detect.detect_from_config(cfg) == []
def test_detect_from_config_memory_stream_bridge_returns_no_extras(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n")
@ -193,6 +222,13 @@ def test_detect_from_config_dedupes_when_both_present(tmp_path):
assert detect.detect_from_config(cfg) == ["postgres"]
def test_detect_from_config_combines_buzz_with_postgres(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("database:\n backend: postgres\nchannels:\n buzz:\n enabled: true\n")
# Existing postgres/redis/browser detection is unaffected by the new rule.
assert detect.detect_from_config(cfg) == ["buzz", "postgres"]
def test_detect_from_config_missing_file_returns_empty(tmp_path):
assert detect.detect_from_config(tmp_path / "does-not-exist.yaml") == []

34
backend/uv.lock generated
View File

@ -676,6 +676,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984, upload-time = "2024-06-01T18:11:07.911Z" },
]
[[package]]
name = "coincurve"
version = "21.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/a2/f2a38eb05b747ed3e54e1be33be339d4a14c1f5cc6a6e2b342b5e8160d51/coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5", size = 128986, upload-time = "2025-03-08T15:31:24.266Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/61/a2d9e109f99b6f5e65e653ac998b0944c5b82c568ac142fcbb381a4803be/coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c", size = 1391948, upload-time = "2025-03-08T15:30:32.899Z" },
{ url = "https://files.pythonhosted.org/packages/24/5a/2da75ee00a722ef1fa068ada3bc34c564595ead86fef573434e2f0cb0a5c/coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2", size = 1384958, upload-time = "2025-03-08T15:30:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/50/6bf0bf7e8a9a9dd419ecc1e479dcb9fbfe657029276ad703806a25a2bef2/coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b", size = 1606576, upload-time = "2025-03-08T15:30:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ab/9e89908fdd09ad522938085587aaa821b022f4def16c286c5580cfc85811/coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4", size = 1613642, upload-time = "2025-03-08T15:30:38.416Z" },
{ url = "https://files.pythonhosted.org/packages/b7/75/050b6fd08978de85a7b480f0f220ab6a30967c0910119f3096a8dd40befc/coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715", size = 1616974, upload-time = "2025-03-08T15:30:39.939Z" },
{ url = "https://files.pythonhosted.org/packages/d7/62/2740ba0cafebf45708633635fecadcbe582d7a3ed1ce8b4637921feceaf8/coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3", size = 1644133, upload-time = "2025-03-08T15:30:41.733Z" },
{ url = "https://files.pythonhosted.org/packages/94/14/1f27c3048c4084fa85ef65f42a4ca631f2b184336e6d9446fecec20e0a7f/coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9", size = 1619918, upload-time = "2025-03-08T15:30:43.284Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/7ec3ec4c8e7764daa25767d6674cb5741ea2d9b39ff758e9918d22a4b49b/coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8", size = 1645797, upload-time = "2025-03-08T15:30:44.974Z" },
{ url = "https://files.pythonhosted.org/packages/fb/60/87982b7499943ab12605df7b14f6001fff331aca0881b260682461e2309d/coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1", size = 1329255, upload-time = "2025-03-08T15:30:46.4Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/65b60b371579570931daca8a3f67debfc1482908b8ed03432297274a27da/coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d", size = 1325973, upload-time = "2025-03-08T15:30:48.056Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/cce55adaec37a588eb24b67da8eb68926546458e12ed2c4c2a21deb93d4c/coincurve-21.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b", size = 1391762, upload-time = "2025-03-08T15:30:49.586Z" },
{ url = "https://files.pythonhosted.org/packages/ca/7a/628a30281d246ce98aea56592e0c8e79b03a93ee8b85d688db3388130c2d/coincurve-21.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601", size = 1384921, upload-time = "2025-03-08T15:30:51.103Z" },
{ url = "https://files.pythonhosted.org/packages/61/cc/719c5da31e6ba07e438abcf962f7a365eb69a06a0621ca4f2a484f344e09/coincurve-21.0.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc", size = 1606559, upload-time = "2025-03-08T15:30:53.218Z" },
{ url = "https://files.pythonhosted.org/packages/b2/ee/dd14237013d732e7fc3248c0c33a1d36b88b5378dfa3e624a50a23fb6f19/coincurve-21.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a", size = 1613684, upload-time = "2025-03-08T15:30:55.087Z" },
{ url = "https://files.pythonhosted.org/packages/f0/05/eaa7f36a03376ced1c19e0cb563341cc83fe48f5734b2effe8f16d0ee0ab/coincurve-21.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7", size = 1617001, upload-time = "2025-03-08T15:30:57.036Z" },
{ url = "https://files.pythonhosted.org/packages/39/32/fc75f1dd914ac95eb2704425c7ca1a9f509f982e15d05e0ca895b9e6ea9c/coincurve-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93", size = 1643924, upload-time = "2025-03-08T15:30:58.737Z" },
{ url = "https://files.pythonhosted.org/packages/1a/4b/8c6e65b5755e26fc02077803879747615c1c327047328d1784bccb4ff4c3/coincurve-21.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51", size = 1619964, upload-time = "2025-03-08T15:31:00.275Z" },
{ url = "https://files.pythonhosted.org/packages/64/bc/d0a743305ff9fa26e72b4c77b534d5958ec8030b3772555a7172a0c134e5/coincurve-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3", size = 1645526, upload-time = "2025-03-08T15:31:01.952Z" },
{ url = "https://files.pythonhosted.org/packages/9d/44/ab082e2dc8c9a45774f1bb9961f58b43c0882b866f5c469ead932d45a35d/coincurve-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74", size = 1329285, upload-time = "2025-03-08T15:31:03.591Z" },
{ url = "https://files.pythonhosted.org/packages/f3/94/407f6fc811310f15b1fc7255f436f6a9040854213beeb10093f56b5b7fd3/coincurve-21.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18", size = 1326027, upload-time = "2025-03-08T15:31:05.318Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@ -801,6 +829,9 @@ dependencies = [
browser = [
{ name = "deerflow-harness", extra = ["browser"] },
]
buzz = [
{ name = "coincurve" },
]
discord = [
{ name = "discord-py" },
]
@ -834,6 +865,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "coincurve", marker = "extra == 'buzz'", specifier = ">=20.0.0" },
{ name = "deerflow-harness", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["browser"], marker = "extra == 'browser'", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["memory-zh"], marker = "extra == 'memory-zh'", editable = "packages/harness" },
@ -857,7 +889,7 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" },
]
provides-extras = ["postgres", "redis", "discord", "monocle", "browser", "memory-zh"]
provides-extras = ["postgres", "redis", "discord", "buzz", "monocle", "browser", "memory-zh"]
[package.metadata.requires-dev]
dev = [

View File

@ -15,7 +15,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 32
config_version: 33
# ============================================================================
# Logging
@ -2162,6 +2162,9 @@ run_ownership:
#
# wecom:
# enabled: false
#
# buzz:
# enabled: false
# ============================================================================
# IM Channels Configuration
@ -2279,6 +2282,20 @@ run_ownership:
# mention_only: false # If true, only respond when the bot is mentioned
# allowed_channels: [] # Optional: channel IDs exempt from mention_only (bot responds without mention)
# thread_mode: false # If true, group a channel conversation into a thread
#
# # Buzz (https://github.com/block/buzz) — Nostr-relay team workspace.
# # DeerFlow joins as a member identity; generate a keypair and register it as
# # a relay member, then @mention or DM DeerFlow in Buzz. Requires the `buzz`
# # dependency extra (uv sync --extra buzz).
# buzz:
# enabled: false
# relay_url: wss://buzz.example.com
# private_key: $BUZZ_PRIVATE_KEY # hex or nsec1…
# allowed_users: [] # pubkeys (hex or npub) allowed to trigger runs.
# # DENY-BY-DEFAULT (unlike other channels): empty
# # means nobody, and DeerFlow logs a startup warning.
# require_mention: true # @mention needed in channels
# mention_free_channels: [] # channel UUIDs that respond to every message
# ============================================================================
# Guardrails Configuration

View File

@ -124,7 +124,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 32
config_version: 33
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -240,7 +240,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: 32
config_version: 33
log_level: info
models: []

View File

@ -13,6 +13,7 @@ Order of resolution:
- stream_bridge.type == redis -> redis
- tools[].name == browser_navigate -> browser
- sandbox.ownership.type == redis -> redis
- channels.buzz.enabled == true -> buzz
3. Runtime environment toggles that enable optional backends:
- DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis
- DEER_FLOW_SANDBOX_OWNERSHIP_REDIS_URL -> redis
@ -267,6 +268,8 @@ def detect_from_config(path: Path) -> list[str]:
extras.add("redis")
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true":
extras.add("discord")
if (nested_section_value(lines, "channels.buzz", "enabled") or "").lower() == "true":
extras.add("buzz")
if tools_include_name(lines, "browser_navigate"):
extras.add("browser")
return sorted(extras)