From 3130c65e74216926e99d66b5c28958dc070bf2b6 Mon Sep 17 00:00:00 2001 From: Patrick Ulrich Date: Mon, 26 Jan 2026 09:31:19 -0500 Subject: [PATCH] feat(nostr): add NIP-17 private direct messages support - Add NIP-17 (Private Direct Messages) with NIP-59 (Gift Wrap) support - Subscribe to both kind:4 (NIP-04) and kind:1059 (GiftWrap) events - Use 48-hour lookback for NIP-17 due to randomized timestamps - Add dmProtocol config option: "dual" (default), "nip17", or "nip04" - Send replies using NIP-17 when dmProtocol is "dual" or "nip17" - Pass raw message text without envelope formatting - Fix pool.publish() calls (returns array of promises) Co-Authored-By: Claude Opus 4.5 --- extensions/nostr/src/channel.ts | 95 ++++++-- extensions/nostr/src/config-schema.ts | 8 + extensions/nostr/src/nostr-bus.ts | 311 +++++++++++++++++++------- extensions/nostr/src/nostr-profile.ts | 3 +- extensions/nostr/src/types.ts | 6 +- 5 files changed, 320 insertions(+), 103 deletions(-) diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index ac006ec57..3330a18e1 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -32,7 +32,7 @@ export const nostrPlugin: ChannelPlugin = { selectionLabel: "Nostr", docsPath: "/channels/nostr", docsLabel: "nostr", - blurb: "Decentralized DMs via Nostr relays (NIP-04)", + blurb: "Decentralized DMs via Nostr relays (NIP-04/NIP-17)", order: 100, }, capabilities: { @@ -218,21 +218,88 @@ export const nostrPlugin: ChannelPlugin = { accountId: account.accountId, privateKey: account.privateKey, relays: account.relays, - onMessage: async (senderPubkey, text, reply) => { + dmProtocol: account.dmProtocol, + onMessage: async (senderPubkey, text, replyFn) => { ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`); - // Forward to moltbot's message pipeline - await runtime.channel.reply.handleInboundMessage({ - channel: "nostr", - accountId: account.accountId, - senderId: senderPubkey, - chatType: "direct", - chatId: senderPubkey, // For DMs, chatId is the sender's pubkey - text, - reply: async (responseText: string) => { - await reply(responseText); - }, - }); + try { + // Load config for routing and session handling + const config = runtime.config.loadConfig(); + + // Resolve agent route for this DM + const route = runtime.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "nostr", + accountId: account.accountId, + peer: { kind: "dm", id: senderPubkey }, + }); + + // Resolve store path for session recording + const storePath = runtime.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + + // Build the inbound context payload (no envelope - pass raw text) + const fromLabel = `nostr:${senderPubkey.slice(0, 8)}`; + const ctxPayload = runtime.channel.reply.finalizeInboundContext({ + Body: text, + RawBody: text, + CommandBody: text, + From: `nostr:${senderPubkey}`, + To: `nostr:${account.publicKey}`, + SessionKey: route.sessionKey, + AccountId: account.accountId, + ChatType: "direct", + ConversationLabel: fromLabel, + SenderName: undefined, + SenderId: senderPubkey, + Provider: "nostr" as const, + Surface: "nostr" as const, + MessageSid: `nostr-${Date.now()}`, + OriginatingChannel: "nostr" as const, + OriginatingTo: `nostr:${account.publicKey}`, + }); + + // Record the inbound session + await runtime.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + ctx.log?.error(`[${account.accountId}] Failed to record session: ${String(err)}`); + }, + }); + + // Resolve table mode for reply formatting + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "nostr", + accountId: account.accountId, + }); + + // Dispatch the reply through the agent pipeline + await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + const replyText = runtime.channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode + ); + if (replyText.trim()) { + await replyFn(replyText); + ctx.setStatus({ lastOutboundAt: Date.now() }); + } + }, + onError: (err, info) => { + ctx.log?.error(`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`); + }, + }, + }); + } catch (err) { + ctx.log?.error(`[${account.accountId}] onMessage error: ${String(err)}`); + } }, onError: (error, context) => { ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`); diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 08ac773b0..2087c77a1 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -75,6 +75,14 @@ export const NostrConfigSchema = z.object({ /** DM access policy: pairing, allowlist, open, or disabled */ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + /** + * DM protocol preference: + * - "dual" (default): Accept both NIP-04 and NIP-17, send using NIP-17 + * - "nip17": NIP-17 only (private DMs with gift wrap) + * - "nip04": NIP-04 only (legacy encrypted DMs) + */ + dmProtocol: z.enum(["dual", "nip17", "nip04"]).optional(), + /** Allowed sender pubkeys (npub or hex format) */ allowFrom: z.array(allowFromEntry).optional(), diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 25ae6f082..0f38ce73c 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -5,8 +5,17 @@ import { verifyEvent, nip19, type Event, + type Filter, } from "nostr-tools"; import { decrypt, encrypt } from "nostr-tools/nip04"; +import { + EncryptedDirectMessage, + GiftWrap, + PrivateDirectMessage, + DirectMessageRelaysList, +} from "nostr-tools/kinds"; +import { unwrapEvent, type Rumor } from "nostr-tools/nip59"; +import * as nip17 from "nostr-tools/nip17"; import { readNostrBusState, @@ -55,6 +64,9 @@ const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats // Types // ============================================================================ +/** DM protocol preference */ +export type DmProtocol = "dual" | "nip17" | "nip04"; + export interface NostrBusOptions { /** Private key in hex or nsec format */ privateKey: string; @@ -62,6 +74,8 @@ export interface NostrBusOptions { relays?: string[]; /** Account ID for state persistence (optional, defaults to pubkey prefix) */ accountId?: string; + /** DM protocol: "dual" (default - both NIP-04 and NIP-17), "nip17" (NIP-17 only), or "nip04" (NIP-04 only) */ + dmProtocol?: DmProtocol; /** Called when a DM is received */ onMessage: ( pubkey: string, @@ -344,6 +358,7 @@ export async function startNostrBus( const { privateKey, relays = DEFAULT_RELAYS, + dmProtocol = "dual", onMessage, onError, onEose, @@ -420,7 +435,7 @@ export async function startNostrBus( const inflight = new Set(); - // Event handler + // Event handler - supports both NIP-04 (kind 4) and NIP-17 (kind 1059) async function handleEvent(event: Event): Promise { try { metrics.emit("event.received"); @@ -432,14 +447,10 @@ export async function startNostrBus( } inflight.add(event.id); - // Self-message loop prevention: skip our own messages - if (event.pubkey === pk) { - metrics.emit("event.rejected.self_message"); - return; - } - // Skip events older than our `since` (relay may ignore filter) - if (event.created_at < since) { + // IMPORTANT: For NIP-17 GiftWrap events, timestamps are randomized up to 2 days in the past + // to protect metadata, so we skip the stale check for kind 1059 + if (event.kind !== GiftWrap && event.created_at < since) { metrics.emit("event.rejected.stale"); return; } @@ -468,35 +479,87 @@ export async function startNostrBus( seen.add(event.id); metrics.emit("memory.seen_tracker_size", seen.size()); - // Decrypt the message + // Variables to be set by protocol-specific handling let plaintext: string; - try { - plaintext = await decrypt(sk, event.pubkey, event.content); + let senderPubkey: string; + + // Handle based on event kind + if (event.kind === GiftWrap) { + // NIP-17: Gift wrap containing sealed private DM + let rumor: Rumor; + try { + rumor = unwrapEvent(event, sk); + } catch (err) { + metrics.emit("event.rejected.decrypt_failed"); + onError?.(err as Error, `unwrap gift wrap from ${event.pubkey}`); + return; + } + + // Validate the unwrapped rumor is a private DM + if (rumor.kind !== PrivateDirectMessage) { + metrics.emit("event.rejected.wrong_kind"); + onError?.( + new Error(`Unexpected rumor kind ${rumor.kind}, expected ${PrivateDirectMessage}`), + `event ${event.id}` + ); + return; + } + + // For NIP-17, the actual sender is in the rumor, not the gift wrap + senderPubkey = rumor.pubkey; + plaintext = rumor.content; + + // Self-message check on rumor.pubkey (the real sender) + if (senderPubkey === pk) { + metrics.emit("event.rejected.self_message"); + return; + } + metrics.emit("decrypt.success"); - } catch (err) { - metrics.emit("decrypt.failure"); - metrics.emit("event.rejected.decrypt_failed"); - onError?.(err as Error, `decrypt from ${event.pubkey}`); + } else if (event.kind === EncryptedDirectMessage) { + // NIP-04: Legacy encrypted DM + // Self-message loop prevention for NIP-04 + if (event.pubkey === pk) { + metrics.emit("event.rejected.self_message"); + return; + } + + senderPubkey = event.pubkey; + try { + plaintext = await decrypt(sk, event.pubkey, event.content); + metrics.emit("decrypt.success"); + } catch (err) { + metrics.emit("decrypt.failure"); + metrics.emit("event.rejected.decrypt_failed"); + onError?.(err as Error, `decrypt from ${event.pubkey}`); + return; + } + } else { + // Unknown kind - shouldn't happen with our filter, but be defensive + metrics.emit("event.rejected.wrong_kind"); return; } // Create reply function (try relays by health score) + // Reply uses the same protocol as the incoming message + const replyProtocol = event.kind === GiftWrap ? "nip17" : "nip04"; const replyTo = async (text: string): Promise => { await sendEncryptedDm( pool, sk, - event.pubkey, + senderPubkey, text, relays, metrics, circuitBreakers, healthTracker, - onError + onError, + replyProtocol ); }; // Call the message handler - await onMessage(event.pubkey, plaintext, replyTo); + await onMessage(senderPubkey, plaintext, replyTo); // Mark as processed metrics.emit("event.processed"); @@ -510,33 +573,76 @@ export async function startNostrBus( } } - const sub = pool.subscribeMany( - relays, - [{ kinds: [4], "#p": [pk], since }], - { - onevent: handleEvent, - oneose: () => { - // EOSE handler - called when all stored events have been received - for (const relay of relays) { - metrics.emit("relay.message.eose", 1, { relay }); - } - onEose?.(relays.join(", ")); - }, - onclose: (reason) => { - // Handle subscription close - for (const relay of relays) { - metrics.emit("relay.message.closed", 1, { relay }); - options.onDisconnect?.(relay); - } - onError?.( - new Error(`Subscription closed: ${reason}`), - "subscription" - ); - }, - } - ); + // Build subscription filters based on dmProtocol setting + // NIP-17 gift wraps have randomized timestamps up to 2 days in the past, + // so we need a much older `since` for kind 1059 to avoid relay filtering + const NIP17_LOOKBACK_SEC = 48 * 60 * 60; // 48 hours + const nip17Since = Math.floor(Date.now() / 1000) - NIP17_LOOKBACK_SEC; - // Public sendDm function + const subscriptions: Array<{ close: (reason?: string) => void }> = []; + + // Subscribe to NIP-04 (kind 4) with normal since + if (dmProtocol !== "nip17") { + const nip04Filter: Filter = { + kinds: [EncryptedDirectMessage], + "#p": [pk], + since, + }; + const nip04Sub = pool.subscribeMany( + relays, + nip04Filter, + { + onevent: handleEvent, + oneose: () => { + for (const relay of relays) { + metrics.emit("relay.message.eose", 1, { relay }); + } + }, + onclose: (reason) => { + for (const relay of relays) { + metrics.emit("relay.message.closed", 1, { relay }); + options.onDisconnect?.(relay); + } + onError?.(new Error(`NIP-04 subscription closed: ${reason}`), "subscription"); + }, + } + ); + subscriptions.push(nip04Sub); + } + + // Subscribe to NIP-17 gift wrap (kind 1059) with extended lookback + if (dmProtocol !== "nip04") { + const nip17Filter: Filter = { + kinds: [GiftWrap], + "#p": [pk], + since: nip17Since, + }; + const nip17Sub = pool.subscribeMany( + relays, + nip17Filter, + { + onevent: handleEvent, + oneose: () => { + for (const relay of relays) { + metrics.emit("relay.message.eose", 1, { relay }); + } + // Only call onEose once all subscriptions have received EOSE + onEose?.(relays.join(", ")); + }, + onclose: (reason) => { + for (const relay of relays) { + metrics.emit("relay.message.closed", 1, { relay }); + options.onDisconnect?.(relay); + } + onError?.(new Error(`NIP-17 subscription closed: ${reason}`), "subscription"); + }, + } + ); + subscriptions.push(nip17Sub); + } + + // Public sendDm function - uses NIP-17 by default, or NIP-04 if dmProtocol is "nip04" + const defaultSendProtocol = dmProtocol === "nip04" ? "nip04" : "nip17"; const sendDm = async (toPubkey: string, text: string): Promise => { await sendEncryptedDm( pool, @@ -547,7 +653,8 @@ export async function startNostrBus( metrics, circuitBreakers, healthTracker, - onError + onError, + defaultSendProtocol ); }; @@ -592,7 +699,10 @@ export async function startNostrBus( return { close: () => { - sub.close(); + // Close all active subscriptions + for (const sub of subscriptions) { + sub.close(); + } seen.stop(); // Flush pending state write synchronously on close if (pendingWrite) { @@ -618,7 +728,7 @@ export async function startNostrBus( // ============================================================================ /** - * Send an encrypted DM to a pubkey + * Send an encrypted DM to a pubkey using the specified protocol */ async function sendEncryptedDm( pool: SimplePool, @@ -629,56 +739,83 @@ async function sendEncryptedDm( metrics: NostrMetrics, circuitBreakers: Map, healthTracker: RelayHealthTracker, - onError?: (error: Error, context: string) => void + onError?: (error: Error, context: string) => void, + protocol: "nip04" | "nip17" = "nip17" ): Promise { - const ciphertext = await encrypt(sk, toPubkey, text); - const reply = finalizeEvent( - { - kind: 4, - content: ciphertext, - tags: [["p", toPubkey]], - created_at: Math.floor(Date.now() / 1000), - }, - sk - ); + // Build the event(s) to publish based on protocol + let eventsToPublish: Event[]; + + if (protocol === "nip17") { + // NIP-17: Create gift-wrapped private DM + // wrapManyEvents returns [senderCopy, recipientCopy] + const wrappedEvents = nip17.wrapManyEvents( + sk, + [{ publicKey: toPubkey }], + text + ); + // Publish both sender copy (for our inbox) and recipient copy + eventsToPublish = wrappedEvents; + } else { + // NIP-04: Legacy encrypted DM + const ciphertext = await encrypt(sk, toPubkey, text); + const reply = finalizeEvent( + { + kind: EncryptedDirectMessage, + content: ciphertext, + tags: [["p", toPubkey]], + created_at: Math.floor(Date.now() / 1000), + }, + sk + ); + eventsToPublish = [reply]; + } // Sort relays by health score (best first) const sortedRelays = healthTracker.getSortedRelays(relays); - // Try relays in order of health, respecting circuit breakers - let lastError: Error | undefined; - for (const relay of sortedRelays) { - const cb = circuitBreakers.get(relay); + // Publish all events - for NIP-17 this includes both sender and recipient copies + for (const eventToPublish of eventsToPublish) { + // Try relays in order of health, respecting circuit breakers + let lastError: Error | undefined; + let published = false; - // Skip if circuit breaker is open - if (cb && !cb.canAttempt()) { - continue; + for (const relay of sortedRelays) { + const cb = circuitBreakers.get(relay); + + // Skip if circuit breaker is open + if (cb && !cb.canAttempt()) { + continue; + } + + const startTime = Date.now(); + try { + // pool.publish returns Promise[], await the first promise + await pool.publish([relay], eventToPublish)[0]; + const latency = Date.now() - startTime; + + // Record success + cb?.recordSuccess(); + healthTracker.recordSuccess(relay, latency); + + published = true; + break; // Success - move to next event + } catch (err) { + lastError = err as Error; + const latency = Date.now() - startTime; + + // Record failure + cb?.recordFailure(); + healthTracker.recordFailure(relay); + metrics.emit("relay.error", 1, { relay, latency }); + + onError?.(lastError, `publish to ${relay}`); + } } - const startTime = Date.now(); - try { - await pool.publish([relay], reply); - const latency = Date.now() - startTime; - - // Record success - cb?.recordSuccess(); - healthTracker.recordSuccess(relay, latency); - - return; // Success - exit early - } catch (err) { - lastError = err as Error; - const latency = Date.now() - startTime; - - // Record failure - cb?.recordFailure(); - healthTracker.recordFailure(relay); - metrics.emit("relay.error", 1, { relay, latency }); - - onError?.(lastError, `publish to ${relay}`); + if (!published) { + throw new Error(`Failed to publish to any relay: ${lastError?.message}`); } } - - throw new Error(`Failed to publish to any relay: ${lastError?.message}`); } // ============================================================================ diff --git a/extensions/nostr/src/nostr-profile.ts b/extensions/nostr/src/nostr-profile.ts index 1dba8e100..20dc13101 100644 --- a/extensions/nostr/src/nostr-profile.ts +++ b/extensions/nostr/src/nostr-profile.ts @@ -150,7 +150,8 @@ export async function publishProfileEvent( setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS); }); - await Promise.race([pool.publish([relay], event), timeoutPromise]); + // pool.publish returns Promise[], get the first promise + await Promise.race([pool.publish([relay], event)[0], timeoutPromise]); successes.push(relay); } catch (err) { diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 03f5a8d8b..0db50add0 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -1,5 +1,5 @@ import type { MoltbotConfig } from "clawdbot/plugin-sdk"; -import { getPublicKeyFromPrivate } from "./nostr-bus.js"; +import { getPublicKeyFromPrivate, type DmProtocol } from "./nostr-bus.js"; import { DEFAULT_RELAYS } from "./nostr-bus.js"; import type { NostrProfile } from "./config-schema.js"; @@ -9,6 +9,7 @@ export interface NostrAccountConfig { privateKey?: string; relays?: string[]; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + dmProtocol?: DmProtocol; allowFrom?: Array; profile?: NostrProfile; } @@ -21,6 +22,7 @@ export interface ResolvedNostrAccount { privateKey: string; publicKey: string; relays: string[]; + dmProtocol: DmProtocol; profile?: NostrProfile; config: NostrAccountConfig; } @@ -85,6 +87,7 @@ export function resolveNostrAccount(opts: { privateKey, publicKey, relays: nostrCfg?.relays ?? DEFAULT_RELAYS, + dmProtocol: nostrCfg?.dmProtocol ?? "dual", profile: nostrCfg?.profile, config: { enabled: nostrCfg?.enabled, @@ -92,6 +95,7 @@ export function resolveNostrAccount(opts: { privateKey: nostrCfg?.privateKey, relays: nostrCfg?.relays, dmPolicy: nostrCfg?.dmPolicy, + dmProtocol: nostrCfg?.dmProtocol, allowFrom: nostrCfg?.allowFrom, profile: nostrCfg?.profile, },