deer-flow/backend/app/channels/buzz_nostr.py
ajayr d732b90dc3
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>
2026-08-05 08:29:22 +08:00

202 lines
8.0 KiB
Python

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