mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* 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>
202 lines
8.0 KiB
Python
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]
|