feat(nostr): add NIP-29 group chat support for Buzz channels - #1168
feat(nostr): add NIP-29 group chat support for Buzz channels#1168penso wants to merge 16 commits into
Conversation
Block's Buzz (https://github.com/block/buzz) is a Nostr relay where AI agents and humans collaborate in group "channels". Its primary API is NIP-29 group chat (kind:9 events scoped by an `h` tag) over a NIP-42 authenticated connection. The existing moltis-nostr channel only spoke NIP-04/NIP-59 encrypted DMs, so it could not join those groups. Extend the Nostr channel to speak plain NIP-29 — the same wire protocol any NIP-29 client uses, so it works against Buzz relays and generic NIP-29 relays alike: - groups.rs: kind:9 helpers, `h`-tag extraction/building, `p`-tag mention detection, group access control, and plaintext group message sending. - bus.rs: subscribe to kind:9 filtered by the configured groups' `h` tags and route inbound group messages (dedup, self/stale filtering, group policy + mention-mode gating) with the group id as the session/chat id. - outbound.rs: route replies to a group (kind:9 + `h` tag + NIP-10 `e` reply tag) or a DM (gift wrap) based on whether the target matches a configured group. - plugin.rs: enable NIP-42 automatic authentication so the bot can join relays that challenge every connection (Buzz relays do). New account config fields: `groups`, `group_policy`, and `group_mention_mode` (default `mention`, so the bot only replies when @-tagged). Empty `groups` keeps the account in DM-only mode, so existing configs are unaffected. Group membership is fixed at connect, so changing `groups` requires an account restart. Web UI gains dedicated group fields in the Add-Nostr modal (plus the existing advanced-JSON escape hatch); config template, docs, README, and tests updated.
Clippy needless_option_as_deref: Tag::content() already returns Option<&str>, so the .as_deref() calls were no-ops.
Greptile SummaryAdds NIP-29/Buzz group-chat support to the Nostr channel.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; current outbound routing fails closed when a group is removed and preserves the group-versus-DM distinction for pubkey-shaped group IDs.
|
| Filename | Overview |
|---|---|
| crates/nostr/src/outbound.rs | Routes explicitly prefixed group targets through a current-membership check, preventing removed groups from receiving replies or being reinterpreted as DM recipients. |
| crates/nostr/src/groups.rs | Introduces NIP-29/Buzz wire-format helpers, access checks, target typing, reactions, and group-message construction. |
| crates/nostr/src/bus.rs | Adds group subscriptions and inbound processing with joined-group enforcement, mention filtering, command dispatch, and reply-context capture. |
| crates/nostr/src/config.rs | Adds group membership, mention mode, message dialect, and acknowledgement-reaction configuration. |
| crates/nostr/src/reply_ctx.rs | Tracks bounded reply and reaction context needed for threaded, dialect-preserving group responses. |
| crates/web/ui/src/pages/channels/modals/AddNostrModal.tsx | Exposes the new Nostr group-chat settings in the account configuration UI. |
Sequence Diagram
sequenceDiagram
participant Relay as Nostr/Buzz relay
participant Bus as Nostr subscription loop
participant Gateway
participant Agent
participant Outbound as Nostr outbound
Relay->>Bus: kind 9/40002 event with h tag
Bus->>Bus: Verify configured group and mention policy
Bus->>Gateway: Dispatch with group-prefixed reply target
Gateway->>Agent: Run channel session
Agent-->>Gateway: Stream/final response
Gateway->>Outbound: Send to group-prefixed target
Outbound->>Outbound: Re-check current group membership
Outbound-->>Relay: Mirrored group-message kind and reply tags
Reviews (4): Last reviewed commit: "fix(nostr): make group reply targets sel..." | Re-trigger Greptile
Merging this PR will not alter performance
Comparing Footnotes
|
Self-review of the NIP-29 support found that the group gate trusted the relay. Two problems, one of them a prompt-injection hole: 1. `group_policy` defaulted to `open`, and `check_group_access` returned Ok for *any* group under that policy. A group's `h` tag is an unauthenticated label — an event signature proves who wrote the event, not which group it belongs to — and `nostr-sdk` leaves `verify_subscriptions` off by default, so it never checks that delivered events match the filters we subscribed with. A hostile or buggy relay could therefore push a kind:9 carrying any `h` tag and have its content dispatched straight to the agent. 2. Under `allowlist`, an empty list fell through to `gating::is_allowed`, whose documented convention is "empty allowlist allows everyone" — exactly backwards here, where the list defines membership rather than filtering within it. Make the configured `groups` list authoritative: it is the only set we subscribe to, so it is also the only set we accept, and empty now denies instead of allowing. Matching is exact — an `h` tag is an opaque id, not a user-facing handle, so no globbing or case folding. This makes `open` and `allowlist` indistinguishable, so drop the `group_policy` field rather than keep a knob that suggests protection it does not provide. `groups` (join list) plus `group_mention_mode` (when to reply, including `none`) already cover the real cases; the derived `ChannelConfigView::group_policy()` reports Allowlist/Disabled so the cross-channel view stays consistent. Also build the client with `verify_subscriptions(true)` so the SDK drops off-filter events for DMs as well as groups, and correct the NIP-42 comment: auto-authentication was already the upstream default, so the previous call was a no-op rather than the fix it claimed to be. It is now set explicitly alongside the verify option to pin both behaviours.
Automated review flagged that outbound group sends did not honour a disabled group policy: `resolve` keyed only on membership in the configured list, so with `group_policy = "disabled"` an inbound message was blocked while a queued or agent-initiated send could still publish a kind:9 event to the group. Removing `group_policy` in favour of a single authoritative `groups` list closed that gap — disabling group chat now means clearing `groups`, and outbound reads the same list — but nothing pinned the behaviour. Cover `resolve` directly: a configured id routes as a group send, a pubkey routes as a DM, an unknown account is unavailable, and a cleared group fails closed rather than falling through to the DM path (a group id is not a valid pubkey).
|
Re the Greptile finding on The finding was correct: Rather than add a policy check on the send path, I removed So
Generated by Claude Code |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Buzz defines two chat kinds and posts channel messages as kind:40002 (KIND_STREAM_MESSAGE_V2), reserving kind:9 for standard NIP-29. Its architecture notes are explicit that "clients filtering by kind 9 alone would not receive kind 40002 messages, and vice versa" — so the previous kind:9-only implementation would have been mutually invisible on a real Buzz relay: it would neither see channel traffic nor have its replies rendered. That made the integration Buzz-shaped but not Buzz-compatible. Read both kinds and answer in the dialect we were addressed in: - groups.rs: add the kind:40002 constant, `group_message_kinds()` for the subscription filter, and `is_group_message_kind()` for the inbound gate. `send_group_message` now takes the kind to publish. - reply_ctx.rs (new): bounded per-event store of the author and kind of each accepted group message, plus the last kind seen per group. Outbound only receives a chat id and the event id being replied to, so neither is otherwise recoverable there. - outbound.rs: resolve the dialect from the message being answered, else the group's observed dialect, else the configured default — and `p`-tag the author being replied to, which is what turns a NIP-29 reply into a notification for them. - bus.rs: subscribe to and accept both kinds, record reply context, and intercept slash commands so `/help` behaves in a Buzz channel as it does in a DM instead of being sent to the model. New `group_message_kind` config (`nip29` | `buzz_v2`, default `nip29`) only picks the dialect for bot-initiated messages in a group nothing has been received from yet; replies and learned groups never consult it, so Buzz works without configuration. Also covers the previously untested inbound gate in bus.rs with a recording ChannelEventSink: unjoined groups are dropped, mention modes are honoured, self-messages and replays do not loop, and accepted messages record their reply context.
…ions Reading Buzz's SDK (crates/buzz-sdk/src/builders.rs) and kind registry (crates/buzz-core/src/kind.rs) turned up three things the integration was getting wrong or leaving on the table. Threading was wrong. Buzz's build_message emits NIP-10 markers — ["e", <id>, "", "reply"] — while we emitted a bare ["e", <id>], which is not recognised as a threaded reply. Replies now carry the marker. Replies were not live. Buzz defines KIND_STREAM_MESSAGE_EDIT (kind:40003) with tags ["h", <channel>] and ["e", <target>], so a published message can be revised in place. Group replies are now published as soon as the first tokens arrive and edited as the rest stream in, so a channel sees the answer forming instead of waiting out the whole turn. Edits are throttled to ~1/second: a long answer should not become hundreds of signed events in the relay's audit log. Editing is gated on the Buzz dialect — plain NIP-29 has no edit kind, and gift-wrapped DMs have no edit path, so both keep collecting and sending once. Messages were never acknowledged. Buzz uses standard NIP-25 reactions (kind:7, content is the emoji, tag ["e", <target>]), and the gateway already drives 👀-on-receipt and ✅/❌-on-completion for any channel that implements add_reaction and sets ack_message_id. Implement both, mapping the gateway's Slack-style shortcodes to the glyphs NIP-25 wants. Retracting the 👀 needs a NIP-09 deletion referencing the reaction event, so reaction ids are tracked alongside the reply context. Reactions are group-only: reacting to a gift-wrapped DM would reveal that a conversation happened. New `group_ack_reactions` config (default true) gates the acknowledgement behaviour, with a matching web UI toggle.
The Buzz kinds and tag shapes this branch emits were read out of Buzz's own source — crates/buzz-core/src/kind.rs for the kind numbers and crates/buzz-sdk/src/builders.rs for the tag arrays. Reading them is not the same as proving we emit them, and a wrong kind integer or a missing NIP-10 marker is invisible in unit tests while making the bot silently malformed in a real channel. Add nostr-relay-builder as a dev-dependency and round-trip every published event through an in-process relay, asserting on what comes back off the wire: - a Buzz reply is kind:40002 with ["h", channel], ["e", parent, "", "reply"] and a ["p", author] mention; - a plain NIP-29 reply is kind:9 with only the channel tag; - an edit is kind:40003 with ["h", channel] and a bare ["e", target] — Buzz's build_edit uses no marker there, unlike build_message; - a reaction is NIP-25 kind:7 carrying the emoji glyph and only ["e", target]; - retracting one publishes a NIP-09 kind:5 referencing the reaction event rather than the message it was attached to; - an h-tag subscription returns both dialects and excludes other channels. The same events are then fed back through `extract_group_id` and `is_bot_mentioned` so the inbound gate is shown to read what the outbound path writes. MockRelay binds to loopback, so these need no network and run by default, unlike the existing live-relay tests which stay #[ignore]d.
Codecov flagged outbound.rs as the weakest-covered file, and the gap was almost entirely the streaming edit loop — the newest logic on this branch and the one with the most branching (publish-on-first-chunk, throttled edits, final edit when the last chunks were throttled, and the collect-and-send fallback for dialects that cannot edit). Drive it through the mock relay and assert what the channel would actually see: - a Buzz stream publishes one kind:40002 carrying the first chunk, then a kind:40003 edit targeting it with the complete text. Chunks arriving faster than the throttle collapse into that single final edit, which is the case that would otherwise strand the reply truncated; - a plain NIP-29 stream posts exactly one kind:9 with the whole answer and emits no edit kinds at all, since publishing a partial message on a relay that cannot edit would leave it permanently incomplete; - a stream that errors before producing any text still says something, rather than leaving the channel silent.
The PR claims `/help` and friends behave in a Buzz channel the way they do
in a DM, but nothing verified it — a regression would have quietly started
feeding commands to the model instead.
Assert both directions: a known channel command never reaches the agent,
and a slash that is not a command ("/notacommand hello") still does, so the
interception cannot silently swallow ordinary text that happens to start
with a slash.
|
@greptileai review The branch has moved on substantially since the last review ( Generated by Claude Code |
The streamed-reply path incremented MESSAGES_SENT_TOTAL after the match on whether anything had been published, which was wrong in two of the three arms: the fallback arm calls send_text, which already records the metric, so a non-Buzz streamed reply counted twice; and the empty-stream arm counted a message that was never sent at all. Move the increment into the arm that actually needs it. A streamed Buzz reply publishes through send_group_message and then edits, neither of which records metrics, and all of it is one logical message — so it is counted exactly once there, and not at all when nothing was published.
Three statements in the Nostr page were written when the integration spoke only kind:9 and were never revisited when Buzz's kind:40002 dialect landed, so the page now contradicted the code: - the Buzz overview described channel messages as "kind:9 chat events", when kind:40002 is what a Buzz relay actually carries; - the reply description claimed replies are posted as kind:9, when they mirror the kind of the message they answer and also carry a `p` tag notifying its author; - the security warning said only inbound kind:9 is re-checked against `groups`, understating the gate, which covers both kinds. A reader following the old text would have concluded the bot cannot see or be seen in a Buzz channel, which is the opposite of what it does.
Three defects found in an adversarial review of the group chat path. Relay rejection was reported as success. `RelayPool::send_event_to` returns `Ok` even when every relay answered `OK false` — the rejections are filed into `output.failed` and the call still succeeds. Nothing inspected that. This matters far more for group chat than for DMs: a NIP-29 relay refusing a post from a pubkey it has not admitted to the group is the single most likely misconfiguration of this feature, and the bot would have logged "sent group message", counted it, and let the gateway stamp a ✅ on the user's message while the channel saw nothing. Streaming made it worse — `published` was set to an id the relay never stored, so every following kind:40003 edit targeted a phantom event and the whole turn vanished with no warning. All four publish sites now fail when no relay accepted, keeping the relay's reason so the cause is diagnosable. A failed throttled edit truncated the reply permanently. `pending_edit` means "the buffer has moved on since what the relay holds", but it was cleared on an *attempted* edit rather than a successful one, so a single transient edit failure skipped the final edit and left the channel showing a partial answer forever — the exact invariant the surrounding comment claims to guarantee. It now stays set when the edit fails, and the failure logs at warn rather than debug. Remembered reaction ids grew without bound. Eviction only ever pruned `entries`. The gateway retracts the 👀 it adds on receipt, but the terminal ✅/❌ is never retracted, so the map gained an entry per completed group turn for the life of the process, at a rate set by remote traffic. It is now bounded like `entries`; losing an old id only costs a best-effort retraction nothing waits on.
NIP-29 relays route and authorize group events by the `h` tag, so an unscoped write is refused. Buzz's own build_reaction omits `h` and gets away with it because Buzz does not enforce it, but copying that leaves acknowledgement reactions — and the NIP-09 deletions that retract them — failing on every other NIP-29 relay. Now that a relay rejection surfaces as an error rather than being swallowed, that would have turned into a visible error on every acknowledged group message outside Buzz. Add the tag: it is what the spec asks for, and an extra tag is inert on Buzz.
Group chat deliberately has no per-sender allowlist: NIP-29 makes the relay the authority on who is in a channel. That is the right model for conversation, but two channel commands are not conversation. `/sh` turns subsequent messages in the session into shell commands, and `/sandbox` can move execution off the sandbox onto the host — so anyone the relay admitted to a joined channel could obtain host execution. The gateway gates `/approve`, `/deny` and `/update` centrally via `is_sender_on_allowlist`, but `/sh` and `/sandbox` are dispatched without a sender at all. That is longstanding behaviour shared with the other group channels, so rather than change the shared dispatcher underneath Slack and Discord, gate them where the new exposure is: the Nostr group path. Operators are the account's existing `allowed_pubkeys` — the keys the owner already nominated as trusted for DMs — so no new configuration appears. An empty list means nobody qualifies, so these commands fail closed. A refused command is answered in the channel rather than dropped, per the project's no-silent-failures rule. Fixing this properly for every channel belongs in the gateway dispatcher and should be its own change.
|
@greptileai review Head is now Since then a self-review and an adversarial pass fixed five further defects worth re-checking:
Also added: Buzz's Generated by Claude Code |
…ecome DMs Outbound classified a reply target by asking "is this id currently in `groups`?" and treated anything else parseable as a public key. A NIP-29 group id is an opaque string, and nothing stops it being 64 hex characters — which is exactly what a Nostr public key looks like. Buzz uses UUID channel ids, but a generic NIP-29 relay need not. The reply target is persisted with the session (`_channel_reply_target`), so it outlives the config that produced it. Remove such a group while a turn is queued or resumable and the next send falls through to the DM path, parses the former group id as a pubkey, and gift-wraps the channel's reply to whoever owns that key — disclosing channel content to an unintended recipient. The earlier claim that this "fails closed because a group id is not a valid pubkey" was only true for UUID-shaped ids. Make the target carry its own kind: group reply targets are now prefixed (`group:<id>`), so classification never consults mutable config. Membership is still re-checked afterwards, so a removed group fails closed instead of being delivered anywhere. A DM target is a bare pubkey and can never collide with the prefix. Tests cover the exact scenario: a group whose id is a real public key is refused once removed, and still routes as a group while configured.
|
@greptileai review Head Two tests pin the reported scenario specifically — a group whose id is a real generated public key is refused once removed, and still routes as a group while configured. Generated by Claude Code |
The h-tag fix added the NIP-29 scoping tag to reactions and deletions, but only the deletion test gained the matching assertion — the reaction one was edited against text the formatter had already reflowed, so the replacement silently did nothing and the test kept asserting the pre-fix shape. Its doc comment still claimed reactions carry "only an `e` tag". Assert the tag and correct the comment, so a regression that dropped group scoping from reactions would fail rather than pass quietly.
Summary
Buzz is Block's open-source workspace where AI agents and humans are equal members of team channels. It is a Nostr relay you self-host: its API is NIP-29 group chat over a NIP-42-authenticated connection.
moltis-nostrpreviously spoke only NIP-04/NIP-59 encrypted DMs, so it could not join those channels. This makes a moltis agent a first-class Buzz channel member: it reads channel traffic, replies in-thread, streams its answer live, and acknowledges work with reactions.Wire details were taken from Buzz's own source —
crates/buzz-core/src/kind.rsfor kind numbers andcrates/buzz-sdk/src/builders.rsfor exact tag arrays — not inferred.h-tag subscription, NIP-42 authkind:9and Buzzkind:40002["e", <id>, "", "reply"]marker +p-tag on the authorkind:40003), throttled ~1/skind:7👀 → ✅/❌, retracted via NIP-09kind:5/helpetc. handled in-channel, as in DMsTwo message kinds
Buzz posts channel messages as
kind:40002(KIND_STREAM_MESSAGE_V2); plain NIP-29 useskind:9. Buzz's architecture notes are explicit that "clients filtering by kind 9 alone would not receive kind 40002 messages, and vice versa" — an earlier revision of this branch spoke onlykind:9and would have been mutually invisible on a real Buzz relay.Both kinds are now read, and replies mirror the kind they answer, so Buzz works with no configuration.
group_message_kindonly picks the dialect for bot-initiated messages in a channel nothing has been received from yet.Config
groups,group_mention_mode(mentiondefault /always/none),group_message_kind(nip29/buzz_v2),group_ack_reactions(defaulttrue). Emptygroupskeeps the account DM-only, so existing configs are unaffected. Group subscriptions are fixed at connect, so changinggroupsneeds an account restart.Security
groupsis the entire group access model — both the set subscribed to and the set accepted from. A group'shtag is an unauthenticated label: the signature proves who wrote an event, not which group it belongs to, andnostr-sdkleavesverify_subscriptionsoff by default. Without an explicit re-check a hostile or buggy relay could push a message carrying anyhtag straight into the agent.An earlier revision had exactly that hole (a
group_policy = "open"default). That knob is gone: once access is bounded by the joined set,openandallowlistare indistinguishable, so keeping it would have advertised protection it did not provide. Matching is exact. The client also setsverify_subscriptions(true).Group chat has no sender allowlist by design: in NIP-29 the relay owns membership, so anyone it admits to a joined channel can address the bot — the same trust model as a Slack channel, and a reason to prefer a relay you host. Reactions are group-only; reacting to a gift-wrapped DM would reveal that a conversation happened.
Validation
Completed
cargo fmt --all -- --checkcargo clippy -p moltis-nostr --all-targets --all-features(clean)cargo test -p moltis-nostr— 82 unit tests, covering the inbound gate (unjoined groups dropped, mention modes, no self-reply loops, replay dedup), dialect selection, NIP-10 marker shape, ack shortcode→glyph mapping, reaction-id retraction, and outbound group-vs-DM routingcargo doc -p moltis-nostr --no-deps(no broken intra-doc links)cd crates/web/ui && npx biome ci --diagnostic-level=error src/ e2e/cd crates/web/ui && npx tsc --noEmit(strict, 0 errors)cd crates/web/ui && npm run buildnpx playwright test e2e/specs/channels-nostr.spec.js— 6/6 against a freshly builttarget/debug/moltis./scripts/check-file-size.sh,check-install-package-names.sh,check-install-docs.sh,./scripts/i18n-check.sh,cargo fetch --lockedRemaining
./scripts/local-validate.sh 1168— must run from a maintainer machine. The redfmt/clippy/test/biome/i18nchecks do not run any checks; they poll for thelocal/*commit statuses that script publishes (Waiting for local/lint=success (current: missing)).--all-featuresclippy/test —llama-cpp-sys-2needs a native toolchain unavailable in the dev containerManual QA
Requires a self-hosted Buzz relay.
npubto a channel.subscribing to group chat (kind:9 + kind:40002).mentionmode).group_ack_reactions = false, restart → no reactions./help→ command output, not a model answer.groups, restart, post there → dropped withgroup message rejected: group not joined.Web UI
Settings → Channels → Connect Nostr exposes Groups, Group Mention Mode, Group Message Kind, and the ack-reactions toggle, plus the advanced-JSON escape hatch.
Notes / follow-ups
kind:20002) is defined in Buzz'skind.rsbut has no builder inbuzz-sdk, so its tag shape is unspecified — deliberately not guessed.40100), forum threads (45001/45003), workflows (46001–46012), job requests (43001), Blossom media, and git are separate subsystems, not chat.groupsrequires an account restart; live re-subscription would be a natural improvement.