diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index 8bb57211c..c6a2aa18e 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -4,8 +4,10 @@ import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { nostrPlugin } from "./src/channel.js"; import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js"; import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js"; +import { createNostrBunkerHttpHandler } from "./src/nostr-bunker-http.js"; +import { createNostrAgentTools } from "./src/agent-tools.js"; import { resolveNostrAccount } from "./src/types.js"; -import type { NostrProfile } from "./src/config-schema.js"; +import type { NostrProfile, BunkerAccountConfig } from "./src/config-schema.js"; const plugin = { id: "nostr", @@ -63,6 +65,87 @@ const plugin = { }); api.registerHttpHandler(httpHandler); + + // Register HTTP handler for bunker management + const bunkerHttpHandler = createNostrBunkerHttpHandler({ + getBunkerAccounts: (accountId: string) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + const account = resolveNostrAccount({ cfg, accountId }); + return account.bunkerAccounts; + }, + updateBunkerAccount: async ( + accountId: string, + bunkerIndex: number, + update: Partial + ) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + const account = resolveNostrAccount({ cfg, accountId }); + + // Update the specific bunker account + const bunkerAccounts = [...account.bunkerAccounts]; + while (bunkerAccounts.length <= bunkerIndex) { + bunkerAccounts.push({ bunkerUrl: "" }); + } + bunkerAccounts[bunkerIndex] = { + ...bunkerAccounts[bunkerIndex], + ...update, + }; + + // Write back to config + const channels = (cfg.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; + await runtime.config.writeConfigFile({ + ...cfg, + channels: { + ...channels, + nostr: { + ...nostrConfig, + bunkerAccounts, + }, + }, + }); + }, + clearConfigBunkerUrl: async (accountId: string, bunkerIndex: number) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + const account = resolveNostrAccount({ cfg, accountId }); + + // Clear the specific bunker account + const bunkerAccounts = [...account.bunkerAccounts]; + if (bunkerIndex < bunkerAccounts.length) { + bunkerAccounts[bunkerIndex] = { + ...bunkerAccounts[bunkerIndex], + bunkerUrl: "", + userPubkey: undefined, + connectedAt: undefined, + }; + } + + // Write back to config + const channels = (cfg.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; + await runtime.config.writeConfigFile({ + ...cfg, + channels: { + ...channels, + nostr: { + ...nostrConfig, + bunkerAccounts, + }, + }, + }); + }, + log: api.logger, + }); + + api.registerHttpHandler(bunkerHttpHandler); + + // Register agent tools for bunker operations + for (const tool of createNostrAgentTools()) { + api.registerTool(tool); + } }, }; diff --git a/extensions/nostr/src/agent-tools.ts b/extensions/nostr/src/agent-tools.ts new file mode 100644 index 000000000..e5b0540b4 --- /dev/null +++ b/extensions/nostr/src/agent-tools.ts @@ -0,0 +1,1023 @@ +import { Type } from "@sinclair/typebox"; +import type { ChannelAgentTool } from "clawdbot/plugin-sdk"; +import { nip19 } from "nostr-tools"; +import { + connectBunker, + disconnectBunker, + getBunkerConnection, + getFirstBunkerConnection, + getAllBunkerConnections, + hasAnyBunkerConnected, + loadPersistedState, + stripBunkerSecret, + BunkerAuthUrlError, +} from "./bunker-store.js"; +import { + postNote, + postReaction, + postRepost, + fetchEvents, + postArticle, +} from "./bunker-actions.js"; +import { getSharedPool, normalizePubkey } from "./nostr-bus.js"; +import { getNostrRuntime } from "./runtime.js"; +import { resolveNostrAccount } from "./types.js"; + +/** Default account ID for single-account usage */ +const DEFAULT_ACCOUNT_ID = "default"; + +/** + * Normalize an event ID to hex format (accepts note1 bech32 or hex). + */ +function normalizeEventIdToHex(id: string): string { + const trimmed = id.trim(); + if (trimmed.startsWith("note1") || trimmed.startsWith("nevent1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type === "note") { + return decoded.data; + } + if (decoded.type === "nevent") { + return decoded.data.id; + } + throw new Error("Invalid note/nevent identifier"); + } + // Assume hex + if (!/^[a-f0-9]{64}$/i.test(trimmed)) { + throw new Error("Event ID must be 64 hex characters or note1/nevent1 format"); + } + return trimmed.toLowerCase(); +} + +// ============================================================================ +// Details types for structured tool results +// ============================================================================ + +interface NostrConnectDetails { + connected: boolean; + userPubkey?: string; + relays?: string[]; + error?: string; + authUrl?: string; +} + +interface NostrPostDetails { + posted: boolean; + eventId?: string; + pubkey?: string; + publishedTo?: string[]; + failedRelays?: Array<{ relay: string; error: string }>; + error?: string; + isReply?: boolean; +} + +interface NostrReactionDetails { + reacted: boolean; + eventId?: string; + pubkey?: string; + reaction?: string; + targetEventId?: string; + publishedTo?: string[]; + failedRelays?: Array<{ relay: string; error: string }>; + error?: string; +} + +interface NostrRepostDetails { + reposted: boolean; + eventId?: string; + pubkey?: string; + repostedEventId?: string; + kind?: number; + publishedTo?: string[]; + failedRelays?: Array<{ relay: string; error: string }>; + error?: string; +} + +interface NostrFetchDetails { + fetched: boolean; + events?: Array<{ + id: string; + pubkey: string; + content: string; + kind: number; + created_at: number; + tags: string[][]; + sig: string; + }>; + relaysQueried?: string[]; + error?: string; +} + +interface NostrArticleDetails { + posted: boolean; + eventId?: string; + pubkey?: string; + title?: string; + identifier?: string; + kind?: number; + publishedTo?: string[]; + failedRelays?: Array<{ relay: string; error: string }>; + error?: string; +} + +interface NostrDisconnectDetails { + wasConnected: boolean; +} + +interface NostrStatusDetails { + connected: boolean; + userPubkey?: string; + bunkerPubkey?: string; + relays?: string[]; + connectedAt?: number; + bunkerIndex?: number; +} + +// ============================================================================ +// Tool creation functions +// ============================================================================ + +export function createNostrAgentTools(): ChannelAgentTool[] { + return [ + createNostrConnectTool(), + createNostrPostTool(), + createNostrReactTool(), + createNostrRepostTool(), + createNostrFetchTool(), + createNostrArticleTool(), + createNostrDisconnectTool(), + createNostrStatusTool(), + ]; +} + +function createNostrConnectTool(): ChannelAgentTool { + return { + name: "nostr_connect", + label: "Nostr Connect", + description: + "Connect a Nostr identity via NIP-46 bunker URL. If no URL provided, uses the first bunkerAccount from config or reconnects using persisted state. User can provide bunker:// URL from their signer app (Amber, nsec.app, etc.).", + parameters: Type.Object({ + bunkerUrl: Type.Optional( + Type.String({ + description: + "bunker:// URL from the user's Nostr signer app (optional if configured in channel settings)", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to connect (default: 0)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { bunkerUrl: argBunkerUrl, bunkerIndex = 0 } = args as { + bunkerUrl?: string; + bunkerIndex?: number; + }; + + const accountId = DEFAULT_ACCOUNT_ID; + + try { + // Load persisted state first - this has the last successful connection + const persistedState = loadPersistedState(accountId, bunkerIndex); + + // Priority: arg > persisted state > config + let bunkerUrl = argBunkerUrl; + if (!bunkerUrl && persistedState?.lastBunkerUrl) { + bunkerUrl = persistedState.lastBunkerUrl; + } + if (!bunkerUrl) { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + const account = resolveNostrAccount({ cfg }); + bunkerUrl = account.bunkerAccounts[bunkerIndex]?.bunkerUrl; + } + + if (!bunkerUrl) { + return { + content: [ + { + type: "text" as const, + text: "No bunker URL provided and none configured. Please provide a bunker:// URL or configure one in channel settings.", + }, + ], + details: { + connected: false, + error: "No bunker URL available", + } satisfies NostrConnectDetails, + }; + } + + // Check if this is a reconnect to the same bunker (same URL minus secret) + const strippedUrl = stripBunkerSecret(bunkerUrl); + const isInitialConnection = + !persistedState?.lastBunkerUrl || strippedUrl !== persistedState.lastBunkerUrl; + + const pool = getSharedPool(); + const { connection: conn, isReconnect } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool, + isInitialConnection, + }); + + const reconnectMsg = isReconnect ? " (reconnected)" : ""; + + // Save bunkerUrl to main config for persistence + // Strip secret since it's one-time use (NIP-46) and already consumed + const urlWithoutSecret = stripBunkerSecret(bunkerUrl); + try { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + const account = resolveNostrAccount({ cfg }); + const bunkerAccounts = [...account.bunkerAccounts]; + + // Ensure bunkerIndex exists + while (bunkerAccounts.length <= bunkerIndex) { + bunkerAccounts.push({ bunkerUrl: "" }); + } + + // Update the specific bunker account + bunkerAccounts[bunkerIndex] = { + ...bunkerAccounts[bunkerIndex], + bunkerUrl: urlWithoutSecret, + userPubkey: conn.userPubkey, + connectedAt: conn.connectedAt, + }; + + // Write back to config + const channels = (cfg.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; + await runtime.config.writeConfigFile({ + ...cfg, + channels: { + ...channels, + nostr: { + ...nostrConfig, + bunkerAccounts, + }, + }, + }); + } catch { + // Config write is best-effort; connection still succeeded + } + + return { + content: [ + { + type: "text" as const, + text: `Connected to Nostr${reconnectMsg}! You can now post as ${conn.userPubkey.slice(0, 8)}... (${conn.relays.length} relay(s))`, + }, + ], + details: { + connected: true, + userPubkey: conn.userPubkey, + relays: conn.relays, + } satisfies NostrConnectDetails, + }; + } catch (err) { + // Special handling for auth_url - bunker needs user approval + if (err instanceof BunkerAuthUrlError) { + return { + content: [ + { + type: "text" as const, + text: `Your Nostr signer requires approval. Please open this URL to authorize the connection:\n\n${err.authUrl}\n\nAfter approving in your signer app, ask me to connect again.`, + }, + ], + details: { + connected: false, + error: "auth_url_required", + authUrl: err.authUrl, + } satisfies NostrConnectDetails, + }; + } + return { + content: [ + { + type: "text" as const, + text: `Failed to connect bunker: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + connected: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrConnectDetails, + }; + } + }, + }; +} + +function createNostrPostTool(): ChannelAgentTool { + return { + name: "nostr_post", + label: "Nostr Post", + description: + "Post a kind:1 note to Nostr using the connected bunker identity. Supports NIP-10 reply threading. Requires nostr_connect first.", + parameters: Type.Object({ + content: Type.String({ + description: "The text content of the note to post", + }), + relays: Type.Optional( + Type.Array(Type.String(), { + description: + "Optional relay URLs to publish to (defaults to bunker relays)", + }) + ), + // NIP-10 reply threading + replyTo: Type.Optional( + Type.String({ + description: "Event ID to reply to (hex or note1... format)", + }) + ), + replyToPubkey: Type.Optional( + Type.String({ + description: + "Pubkey of the reply target author (required if replyTo is set)", + }) + ), + rootEvent: Type.Optional( + Type.String({ + description: + "Root event ID of thread (if different from replyTo, for deep replies)", + }) + ), + rootPubkey: Type.Optional( + Type.String({ + description: "Pubkey of the root event author", + }) + ), + mentions: Type.Optional( + Type.Array(Type.String(), { + description: "Additional pubkeys to mention in the note", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to use (default: first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { + content, + relays, + replyTo, + replyToPubkey, + rootEvent, + rootPubkey, + mentions, + bunkerIndex, + } = args as { + content: string; + relays?: string[]; + replyTo?: string; + replyToPubkey?: string; + rootEvent?: string; + rootPubkey?: string; + mentions?: string[]; + bunkerIndex?: number; + }; + + try { + const pool = getSharedPool(); + const result = await postNote({ + accountId: DEFAULT_ACCOUNT_ID, + bunkerIndex, + content, + pool, + relays, + replyTo: replyTo ? normalizeEventIdToHex(replyTo) : undefined, + replyToPubkey: replyToPubkey ? normalizePubkey(replyToPubkey) : undefined, + rootEvent: rootEvent ? normalizeEventIdToHex(rootEvent) : undefined, + rootPubkey: rootPubkey ? normalizePubkey(rootPubkey) : undefined, + mentions: mentions?.map(normalizePubkey), + }); + + const successCount = result.publishedTo.length; + const failCount = result.failedRelays.length; + const isReply = Boolean(replyTo); + + return { + content: [ + { + type: "text" as const, + text: `Posted ${isReply ? "reply" : "note"} ${result.eventId.slice(0, 8)}... to ${successCount} relay(s)${failCount > 0 ? ` (${failCount} failed)` : ""}`, + }, + ], + details: { + posted: true, + eventId: result.eventId, + pubkey: result.pubkey, + publishedTo: result.publishedTo, + failedRelays: result.failedRelays, + isReply, + } satisfies NostrPostDetails, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Failed to post: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + posted: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrPostDetails, + }; + } + }, + }; +} + +function createNostrReactTool(): ChannelAgentTool { + return { + name: "nostr_react", + label: "Nostr React", + description: + 'React to a Nostr event with a like (+), dislike (-), or emoji. Creates a kind:7 reaction event per NIP-25. Requires nostr_connect first.', + parameters: Type.Object({ + eventId: Type.String({ + description: "Event ID to react to (hex or note1... format)", + }), + eventPubkey: Type.String({ + description: "Pubkey of the event author (required per NIP-25)", + }), + reaction: Type.Optional( + Type.String({ + description: + 'The reaction: "+" for like (default), "-" for dislike, or any emoji', + }) + ), + eventKind: Type.Optional( + Type.Number({ + description: "Kind of the event being reacted to (default: 1)", + }) + ), + relays: Type.Optional( + Type.Array(Type.String(), { + description: "Optional relay URLs to publish to", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to use (default: first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { eventId, eventPubkey, reaction, eventKind, relays, bunkerIndex } = args as { + eventId: string; + eventPubkey: string; + reaction?: string; + eventKind?: number; + relays?: string[]; + bunkerIndex?: number; + }; + + try { + const pool = getSharedPool(); + const result = await postReaction({ + accountId: DEFAULT_ACCOUNT_ID, + bunkerIndex, + eventId: normalizeEventIdToHex(eventId), + eventPubkey: normalizePubkey(eventPubkey), + eventKind, + reaction: reaction ?? "+", + pool, + relays, + }); + + const successCount = result.publishedTo.length; + const failCount = result.failedRelays.length; + + return { + content: [ + { + type: "text" as const, + text: `Reacted "${result.reaction}" to ${eventId.slice(0, 8)}... (${successCount} relay(s)${failCount > 0 ? `, ${failCount} failed` : ""})`, + }, + ], + details: { + reacted: true, + eventId: result.eventId, + pubkey: result.pubkey, + reaction: result.reaction, + targetEventId: result.targetEventId, + publishedTo: result.publishedTo, + failedRelays: result.failedRelays, + } satisfies NostrReactionDetails, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Failed to react: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + reacted: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrReactionDetails, + }; + } + }, + }; +} + +function createNostrRepostTool(): ChannelAgentTool { + return { + name: "nostr_repost", + label: "Nostr Repost", + description: + "Repost/boost a Nostr event. Creates a kind:6 repost (for notes) or kind:16 generic repost (for other kinds) per NIP-18. Requires nostr_connect first.", + parameters: Type.Object({ + eventId: Type.String({ + description: "Event ID to repost (hex or note1... format)", + }), + eventPubkey: Type.String({ + description: "Pubkey of the event author", + }), + eventKind: Type.Optional( + Type.Number({ + description: + "Kind of the event being reposted (default: 1). Determines if kind:6 or kind:16 is used.", + }) + ), + relays: Type.Optional( + Type.Array(Type.String(), { + description: "Optional relay URLs to publish to", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to use (default: first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { eventId, eventPubkey, eventKind, relays, bunkerIndex } = args as { + eventId: string; + eventPubkey: string; + eventKind?: number; + relays?: string[]; + bunkerIndex?: number; + }; + + try { + const pool = getSharedPool(); + const result = await postRepost({ + accountId: DEFAULT_ACCOUNT_ID, + bunkerIndex, + eventId: normalizeEventIdToHex(eventId), + eventPubkey: normalizePubkey(eventPubkey), + eventKind, + pool, + relays, + }); + + const successCount = result.publishedTo.length; + const failCount = result.failedRelays.length; + + return { + content: [ + { + type: "text" as const, + text: `Reposted ${eventId.slice(0, 8)}... as kind:${result.kind} (${successCount} relay(s)${failCount > 0 ? `, ${failCount} failed` : ""})`, + }, + ], + details: { + reposted: true, + eventId: result.eventId, + pubkey: result.pubkey, + repostedEventId: result.repostedEventId, + kind: result.kind, + publishedTo: result.publishedTo, + failedRelays: result.failedRelays, + } satisfies NostrRepostDetails, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Failed to repost: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + reposted: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrRepostDetails, + }; + } + }, + }; +} + +function createNostrFetchTool(): ChannelAgentTool { + return { + name: "nostr_fetch", + label: "Nostr Fetch", + description: + "Fetch and search Nostr events from relays. Can fetch by event ID, author pubkey, kinds, hashtags, mentions, or full-text search (NIP-50, relay dependent).", + parameters: Type.Object({ + eventId: Type.Optional( + Type.String({ + description: "Fetch specific event by ID (hex or note1... format)", + }) + ), + pubkey: Type.Optional( + Type.String({ + description: "Fetch events by author (npub or hex pubkey)", + }) + ), + kinds: Type.Optional( + Type.Array(Type.Number(), { + description: "Filter by event kinds (default: [1] for notes)", + }) + ), + search: Type.Optional( + Type.String({ + description: "NIP-50 full-text search query (relay dependent)", + }) + ), + hashtag: Type.Optional( + Type.String({ + description: 'Filter by hashtag (without # prefix, e.g., "nostr")', + }) + ), + mentions: Type.Optional( + Type.String({ + description: "Filter by mentioned pubkey", + }) + ), + limit: Type.Optional( + Type.Number({ + description: "Max events to return (default: 10)", + }) + ), + since: Type.Optional( + Type.Number({ + description: "Unix timestamp - only events after this time", + }) + ), + until: Type.Optional( + Type.Number({ + description: "Unix timestamp - only events before this time", + }) + ), + relays: Type.Optional( + Type.Array(Type.String(), { + description: "Optional relay URLs to query", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to use for relay list (default: first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { + eventId, + pubkey, + kinds, + search, + hashtag, + mentions, + limit, + since, + until, + relays, + bunkerIndex, + } = args as { + eventId?: string; + pubkey?: string; + kinds?: number[]; + search?: string; + hashtag?: string; + mentions?: string; + limit?: number; + since?: number; + until?: number; + relays?: string[]; + bunkerIndex?: number; + }; + + try { + const pool = getSharedPool(); + + // Build NIP-01 filter with normalized inputs + const filter: Record = {}; + + if (eventId) { + filter.ids = [normalizeEventIdToHex(eventId)]; + } + if (pubkey) { + filter.authors = [normalizePubkey(pubkey)]; + } + if (kinds && kinds.length > 0) { + filter.kinds = kinds; + } else if (!eventId) { + // Default to kind:1 notes if not fetching by ID + filter.kinds = [1]; + } + if (search) { + filter.search = search; + } + if (hashtag) { + filter["#t"] = [hashtag]; + } + if (mentions) { + filter["#p"] = [normalizePubkey(mentions)]; + } + if (since) { + filter.since = since; + } + if (until) { + filter.until = until; + } + filter.limit = limit ?? 10; + + const result = await fetchEvents({ + accountId: DEFAULT_ACCOUNT_ID, + bunkerIndex, + filter: filter as Parameters[0]["filter"], + pool, + relays, + }); + + const events = result.events.map((e) => ({ + id: e.id, + pubkey: e.pubkey, + content: e.content, + kind: e.kind, + created_at: e.created_at, + tags: e.tags, + sig: e.sig, + })); + + return { + content: [ + { + type: "text" as const, + text: `Fetched ${events.length} event(s) from ${result.relaysQueried.length} relay(s)\n\n${JSON.stringify(events, null, 2)}`, + }, + ], + details: { + fetched: true, + events, + relaysQueried: result.relaysQueried, + } satisfies NostrFetchDetails, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + fetched: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrFetchDetails, + }; + } + }, + }; +} + +function createNostrArticleTool(): ChannelAgentTool { + return { + name: "nostr_article", + label: "Nostr Article", + description: + "Post a long-form article (kind:30023) to Nostr per NIP-23. Supports markdown content, title, summary, image, and hashtags. Can also create drafts (kind:30024). Requires nostr_connect first.", + parameters: Type.Object({ + title: Type.String({ + description: "Article title", + }), + content: Type.String({ + description: "Article content in markdown format", + }), + identifier: Type.String({ + description: + 'd-tag identifier for the article (used for updates/replacements, e.g., "my-first-article")', + }), + summary: Type.Optional( + Type.String({ + description: "Short summary/excerpt of the article", + }) + ), + image: Type.Optional( + Type.String({ + description: "Header/cover image URL", + }) + ), + hashtags: Type.Optional( + Type.Array(Type.String(), { + description: 'Hashtags for the article (without # prefix)', + }) + ), + isDraft: Type.Optional( + Type.Boolean({ + description: + "If true, creates a draft (kind:30024) instead of published article (kind:30023)", + }) + ), + relays: Type.Optional( + Type.Array(Type.String(), { + description: "Optional relay URLs to publish to", + }) + ), + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to use (default: first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { title, content, identifier, summary, image, hashtags, isDraft, relays, bunkerIndex } = + args as { + title: string; + content: string; + identifier: string; + summary?: string; + image?: string; + hashtags?: string[]; + isDraft?: boolean; + relays?: string[]; + bunkerIndex?: number; + }; + + try { + const pool = getSharedPool(); + const result = await postArticle({ + accountId: DEFAULT_ACCOUNT_ID, + bunkerIndex, + title, + content, + identifier, + summary, + image, + hashtags, + isDraft, + pool, + relays, + }); + + const successCount = result.publishedTo.length; + const failCount = result.failedRelays.length; + const typeLabel = isDraft ? "draft" : "article"; + + return { + content: [ + { + type: "text" as const, + text: `Posted ${typeLabel} "${title}" (${result.eventId.slice(0, 8)}...) to ${successCount} relay(s)${failCount > 0 ? ` (${failCount} failed)` : ""}`, + }, + ], + details: { + posted: true, + eventId: result.eventId, + pubkey: result.pubkey, + title: result.title, + identifier: result.identifier, + kind: result.kind, + publishedTo: result.publishedTo, + failedRelays: result.failedRelays, + } satisfies NostrArticleDetails, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Failed to post article: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: { + posted: false, + error: err instanceof Error ? err.message : String(err), + } satisfies NostrArticleDetails, + }; + } + }, + }; +} + +function createNostrDisconnectTool(): ChannelAgentTool { + return { + name: "nostr_disconnect", + label: "Nostr Disconnect", + description: "Disconnect a Nostr bunker session. Defaults to first connected bunker.", + parameters: Type.Object({ + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to disconnect (default: 0)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { bunkerIndex = 0 } = args as { bunkerIndex?: number }; + + const wasConnected = hasAnyBunkerConnected(DEFAULT_ACCOUNT_ID); + await disconnectBunker(DEFAULT_ACCOUNT_ID, bunkerIndex); + + return { + content: [ + { + type: "text" as const, + text: wasConnected + ? `Disconnected bunker ${bunkerIndex} from Nostr.` + : "No bunker was connected.", + }, + ], + details: { + wasConnected, + } satisfies NostrDisconnectDetails, + }; + }, + }; +} + +function createNostrStatusTool(): ChannelAgentTool { + return { + name: "nostr_status", + label: "Nostr Status", + description: "Check the current Nostr bunker connection status.", + parameters: Type.Object({ + bunkerIndex: Type.Optional( + Type.Number({ + description: "Index of the bunker account to check (default: shows first connected)", + }) + ), + }), + execute: async (_toolCallId, args) => { + const { bunkerIndex } = args as { bunkerIndex?: number }; + + const conn = bunkerIndex !== undefined + ? getBunkerConnection(DEFAULT_ACCOUNT_ID, bunkerIndex) + : getFirstBunkerConnection(DEFAULT_ACCOUNT_ID); + + if (!conn) { + // Check if any bunkers are configured + const allConns = getAllBunkerConnections(DEFAULT_ACCOUNT_ID); + if (allConns.length > 0) { + const connList = allConns.map((c) => ` ${c.bunkerIndex}: ${c.userPubkey.slice(0, 8)}...`).join("\n"); + return { + content: [ + { + type: "text" as const, + text: `${allConns.length} bunker(s) connected:\n${connList}`, + }, + ], + details: { + connected: true, + userPubkey: allConns[0].userPubkey, + bunkerPubkey: allConns[0].bunkerPubkey, + relays: allConns[0].relays, + connectedAt: allConns[0].connectedAt, + bunkerIndex: allConns[0].bunkerIndex, + } satisfies NostrStatusDetails, + }; + } + + return { + content: [ + { + type: "text" as const, + text: "No Nostr bunker connected. Use nostr_connect to connect.", + }, + ], + details: { + connected: false, + } satisfies NostrStatusDetails, + }; + } + + const connectedAgo = Math.floor((Date.now() - conn.connectedAt) / 1000); + return { + content: [ + { + type: "text" as const, + text: `Connected as ${conn.userPubkey.slice(0, 8)}... via ${conn.relays.length} relay(s) (connected ${connectedAgo}s ago, bunker index: ${conn.bunkerIndex})`, + }, + ], + details: { + connected: true, + userPubkey: conn.userPubkey, + bunkerPubkey: conn.bunkerPubkey, + relays: conn.relays, + connectedAt: conn.connectedAt, + bunkerIndex: conn.bunkerIndex, + } satisfies NostrStatusDetails, + }; + }, + }; +} diff --git a/extensions/nostr/src/bunker-actions.test.ts b/extensions/nostr/src/bunker-actions.test.ts new file mode 100644 index 000000000..1ab96a76d --- /dev/null +++ b/extensions/nostr/src/bunker-actions.test.ts @@ -0,0 +1,531 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { buildReplyTags } from "./bunker-actions.js"; + +// Mock bunker-store +const mockSigner = { + signEvent: vi.fn(), +}; + +const mockBunkerConnection = { + signer: mockSigner, + userPubkey: "user123pubkey456", + bunkerPubkey: "bunker123pubkey", + relays: ["wss://relay.example.com"], + userWriteRelays: ["wss://write.relay.com"], + userReadRelays: ["wss://read.relay.com"], + connectedAt: Date.now(), + accountId: "test-account", + bunkerIndex: 0, +}; + +vi.mock("./bunker-store.js", () => ({ + getBunkerConnection: vi.fn(() => mockBunkerConnection), + getFirstBunkerConnection: vi.fn(() => mockBunkerConnection), +})); + +// Mock runtime +vi.mock("./runtime.js", () => ({ + getNostrRuntime: vi.fn(() => ({ + config: { + loadConfig: vi.fn(() => ({ + channels: { + nostr: { + relays: ["wss://config.relay.com"], + }, + }, + })), + }, + })), +})); + +// Mock types +vi.mock("./types.js", () => ({ + resolveNostrAccount: vi.fn(() => ({ + relays: ["wss://config.relay.com"], + })), +})); + +// Mock nostr-tools/kinds +vi.mock("nostr-tools/kinds", () => ({ + ShortTextNote: 1, + Reaction: 7, + Repost: 6, + GenericRepost: 16, + EventDeletion: 5, + LongFormArticle: 30023, +})); + +describe("bunker-actions", () => { + describe("buildReplyTags", () => { + it("builds correct tags for direct reply (reply to root)", () => { + const tags = buildReplyTags({ + replyToId: "event123", + replyToPubkey: "author123", + }); + + expect(tags).toEqual([ + ["e", "event123", "", "root"], + ["p", "author123"], + ]); + }); + + it("builds correct tags for deep reply (reply to non-root)", () => { + const tags = buildReplyTags({ + replyToId: "reply456", + replyToPubkey: "replier456", + rootId: "root123", + rootPubkey: "rootAuthor123", + }); + + expect(tags).toEqual([ + ["e", "root123", "", "root"], + ["e", "reply456", "", "reply"], + ["p", "rootAuthor123"], + ["p", "replier456"], + ]); + }); + + it("includes relay hint when provided", () => { + const tags = buildReplyTags({ + replyToId: "event123", + replyToPubkey: "author123", + relayHint: "wss://relay.example.com", + }); + + expect(tags).toEqual([ + ["e", "event123", "wss://relay.example.com", "root"], + ["p", "author123"], + ]); + }); + + it("includes additional mentions without duplicates", () => { + const tags = buildReplyTags({ + replyToId: "event123", + replyToPubkey: "author123", + mentions: ["mention1", "mention2", "author123"], // author123 should be deduplicated + }); + + expect(tags).toEqual([ + ["e", "event123", "", "root"], + ["p", "author123"], + ["p", "mention1"], + ["p", "mention2"], + // author123 not duplicated + ]); + }); + + it("handles deep reply with same author for root and reply", () => { + const tags = buildReplyTags({ + replyToId: "reply456", + replyToPubkey: "author123", // same as root + rootId: "root123", + rootPubkey: "author123", + }); + + expect(tags).toEqual([ + ["e", "root123", "", "root"], + ["e", "reply456", "", "reply"], + ["p", "author123"], + // No duplicate p tag + ]); + }); + + it("handles rootId same as replyToId (no separate reply tag)", () => { + const tags = buildReplyTags({ + replyToId: "event123", + replyToPubkey: "author123", + rootId: "event123", // same as replyToId + rootPubkey: "author123", + }); + + expect(tags).toEqual([ + ["e", "event123", "", "root"], + // No reply tag when rootId === replyToId + ["p", "author123"], + ]); + }); + }); + + describe("postNote", () => { + let mockPool: { publish: ReturnType; querySync: ReturnType }; + + beforeEach(async () => { + vi.clearAllMocks(); + mockPool = { + publish: vi.fn().mockReturnValue([Promise.resolve("ok")]), + querySync: vi.fn().mockResolvedValue([]), + }; + mockSigner.signEvent.mockResolvedValue({ + id: "signed-event-id", + pubkey: "user123pubkey456", + kind: 1, + content: "test content", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("posts a simple note", async () => { + const { postNote } = await import("./bunker-actions.js"); + + const result = await postNote({ + accountId: "test-account", + content: "Hello Nostr!", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.eventId).toBe("signed-event-id"); + expect(result.content).toBe("Hello Nostr!"); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 1, + content: "Hello Nostr!", + tags: [], + }) + ); + }); + + it("posts a reply with NIP-10 tags", async () => { + const { postNote } = await import("./bunker-actions.js"); + + await postNote({ + accountId: "test-account", + content: "This is a reply", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + replyTo: "target-event-id", + replyToPubkey: "target-author-pubkey", + }); + + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 1, + content: "This is a reply", + tags: expect.arrayContaining([ + ["e", "target-event-id", "", "root"], + ["p", "target-author-pubkey"], + ]), + }) + ); + }); + + it("throws when replyTo is set but replyToPubkey is missing", async () => { + const { postNote } = await import("./bunker-actions.js"); + + await expect( + postNote({ + accountId: "test-account", + content: "Invalid reply", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + replyTo: "target-event-id", + // replyToPubkey missing + }) + ).rejects.toThrow("replyToPubkey is required when replyTo is set"); + }); + }); + + describe("postReaction", () => { + let mockPool: { publish: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPool = { + publish: vi.fn().mockReturnValue([Promise.resolve("ok")]), + }; + mockSigner.signEvent.mockResolvedValue({ + id: "reaction-event-id", + pubkey: "user123pubkey456", + kind: 7, + content: "+", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + }); + + it("posts a like reaction", async () => { + const { postReaction } = await import("./bunker-actions.js"); + + const result = await postReaction({ + accountId: "test-account", + eventId: "target-event-id", + eventPubkey: "target-author-pubkey", + reaction: "+", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.eventId).toBe("reaction-event-id"); + expect(result.reaction).toBe("+"); + expect(result.targetEventId).toBe("target-event-id"); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 7, + content: "+", + tags: [ + ["e", "target-event-id", ""], + ["p", "target-author-pubkey"], + ["k", "1"], // Default kind + ], + }) + ); + }); + + it("includes k tag for non-note events", async () => { + const { postReaction } = await import("./bunker-actions.js"); + + await postReaction({ + accountId: "test-account", + eventId: "article-event-id", + eventPubkey: "article-author-pubkey", + eventKind: 30023, + reaction: "🔥", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 7, + content: "🔥", + tags: expect.arrayContaining([["k", "30023"]]), + }) + ); + }); + }); + + describe("postRepost", () => { + let mockPool: { publish: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPool = { + publish: vi.fn().mockReturnValue([Promise.resolve("ok")]), + }; + }); + + it("creates kind:6 repost for kind:1 notes", async () => { + mockSigner.signEvent.mockResolvedValue({ + id: "repost-event-id", + pubkey: "user123pubkey456", + kind: 6, + content: "", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + + const { postRepost } = await import("./bunker-actions.js"); + + const result = await postRepost({ + accountId: "test-account", + eventId: "note-event-id", + eventPubkey: "note-author-pubkey", + eventKind: 1, + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.kind).toBe(6); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 6, + tags: [ + ["e", "note-event-id", ""], + ["p", "note-author-pubkey"], + ], + }) + ); + }); + + it("creates kind:16 generic repost for non-note events", async () => { + mockSigner.signEvent.mockResolvedValue({ + id: "generic-repost-event-id", + pubkey: "user123pubkey456", + kind: 16, + content: "", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + + const { postRepost } = await import("./bunker-actions.js"); + + const result = await postRepost({ + accountId: "test-account", + eventId: "article-event-id", + eventPubkey: "article-author-pubkey", + eventKind: 30023, + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.kind).toBe(16); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 16, + tags: expect.arrayContaining([["k", "30023"]]), + }) + ); + }); + }); + + describe("fetchEvents", () => { + let mockPool: { querySync: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPool = { + querySync: vi.fn().mockResolvedValue([ + { + id: "event1", + pubkey: "pubkey1", + kind: 1, + content: "Hello", + tags: [], + created_at: 1234567890, + sig: "sig1", + }, + ]), + }; + }); + + it("fetches events with filter", async () => { + const { fetchEvents } = await import("./bunker-actions.js"); + + const result = await fetchEvents({ + accountId: "test-account", + filter: { kinds: [1], limit: 10 }, + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.events).toHaveLength(1); + expect(result.events[0].id).toBe("event1"); + expect(mockPool.querySync).toHaveBeenCalledWith( + expect.any(Array), + { kinds: [1], limit: 10 }, + expect.any(Object) + ); + }); + + it("uses explicit relays when provided", async () => { + const { fetchEvents } = await import("./bunker-actions.js"); + + await fetchEvents({ + accountId: "test-account", + filter: { kinds: [1] }, + pool: mockPool as unknown as import("nostr-tools").SimplePool, + relays: ["wss://explicit.relay.com"], + }); + + expect(mockPool.querySync).toHaveBeenCalledWith( + ["wss://explicit.relay.com"], + expect.any(Object), + expect.any(Object) + ); + }); + }); + + describe("postArticle", () => { + let mockPool: { publish: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPool = { + publish: vi.fn().mockReturnValue([Promise.resolve("ok")]), + }; + mockSigner.signEvent.mockResolvedValue({ + id: "article-event-id", + pubkey: "user123pubkey456", + kind: 30023, + content: "# My Article", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + }); + + it("posts a published article (kind:30023)", async () => { + const { postArticle } = await import("./bunker-actions.js"); + + const result = await postArticle({ + accountId: "test-account", + title: "My Article", + content: "# My Article\n\nContent here", + identifier: "my-article", + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.eventId).toBe("article-event-id"); + expect(result.title).toBe("My Article"); + expect(result.identifier).toBe("my-article"); + expect(result.kind).toBe(30023); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 30023, + content: "# My Article\n\nContent here", + tags: expect.arrayContaining([ + ["d", "my-article"], + ["title", "My Article"], + ]), + }) + ); + }); + + it("posts a draft article (kind:30024)", async () => { + mockSigner.signEvent.mockResolvedValue({ + id: "draft-event-id", + pubkey: "user123pubkey456", + kind: 30024, + content: "Draft content", + tags: [], + created_at: Math.floor(Date.now() / 1000), + sig: "signature", + }); + + const { postArticle } = await import("./bunker-actions.js"); + + const result = await postArticle({ + accountId: "test-account", + title: "Draft Article", + content: "Draft content", + identifier: "draft-article", + isDraft: true, + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(result.kind).toBe(30024); + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 30024, + }) + ); + }); + + it("includes optional metadata in tags", async () => { + const { postArticle } = await import("./bunker-actions.js"); + + await postArticle({ + accountId: "test-account", + title: "Full Article", + content: "Content", + identifier: "full-article", + summary: "A summary", + image: "https://example.com/image.jpg", + hashtags: ["nostr", "test"], + pool: mockPool as unknown as import("nostr-tools").SimplePool, + }); + + expect(mockSigner.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.arrayContaining([ + ["summary", "A summary"], + ["image", "https://example.com/image.jpg"], + ["t", "nostr"], + ["t", "test"], + ]), + }) + ); + }); + }); +}); diff --git a/extensions/nostr/src/bunker-actions.ts b/extensions/nostr/src/bunker-actions.ts new file mode 100644 index 000000000..e933e9f68 --- /dev/null +++ b/extensions/nostr/src/bunker-actions.ts @@ -0,0 +1,515 @@ +import type { SimplePool, EventTemplate, Event, Filter } from "nostr-tools"; +import { + ShortTextNote, + Reaction, + Repost, + GenericRepost, + LongFormArticle, +} from "nostr-tools/kinds"; +import { getFirstBunkerConnection, getBunkerConnection, type BunkerConnection } from "./bunker-store.js"; +import { getNostrRuntime } from "./runtime.js"; +import { resolveNostrAccount } from "./types.js"; + +// Kind 30024 (draft long-form) is not exported from nostr-tools/kinds +const DraftLong = 30024; + +// ============================================================================ +// Result Types +// ============================================================================ + +export interface PostNoteResult { + eventId: string; + pubkey: string; + content: string; + publishedTo: string[]; + failedRelays: Array<{ relay: string; error: string }>; +} + +export interface PostReactionResult { + eventId: string; + pubkey: string; + reaction: string; + targetEventId: string; + publishedTo: string[]; + failedRelays: Array<{ relay: string; error: string }>; +} + +export interface PostRepostResult { + eventId: string; + pubkey: string; + repostedEventId: string; + kind: number; + publishedTo: string[]; + failedRelays: Array<{ relay: string; error: string }>; +} + +export interface FetchEventsResult { + events: Event[]; + relaysQueried: string[]; +} + +export interface PostArticleResult { + eventId: string; + pubkey: string; + title: string; + identifier: string; + kind: number; + publishedTo: string[]; + failedRelays: Array<{ relay: string; error: string }>; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** Relay publish timeout in ms */ +const RELAY_PUBLISH_TIMEOUT_MS = 5000; + +/** Timeout for fetching events (ms) */ +const RELAY_FETCH_TIMEOUT_MS = 5000; + +// ============================================================================ +// Helper Functions (must be defined before they're used) +// ============================================================================ + +/** + * Get relays from the Nostr channel configuration. + * Returns empty array if runtime not available. + */ +function getConfiguredRelays(): string[] { + try { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + const account = resolveNostrAccount({ cfg }); + return account.relays; + } catch { + return []; + } +} + +/** + * Get target relays from explicit opts or connection + config. + */ +function getTargetRelays( + explicitRelays: string[] | undefined, + conn: BunkerConnection +): string[] { + if (explicitRelays && explicitRelays.length > 0) { + return explicitRelays; + } + const configRelays = getConfiguredRelays(); + const combined = new Set([ + ...conn.relays, + ...conn.userWriteRelays, + ...configRelays, + ]); + return Array.from(combined); +} + +/** + * Get target relays for reading (fetching events). + */ +function getReadRelays( + explicitRelays: string[] | undefined, + conn: BunkerConnection | null +): string[] { + if (explicitRelays && explicitRelays.length > 0) { + return explicitRelays; + } + const configRelays = getConfiguredRelays(); + if (conn) { + const combined = new Set([ + ...conn.relays, + ...conn.userReadRelays, + ...configRelays, + ]); + return Array.from(combined); + } + return configRelays; +} + +/** + * Publish a signed event to multiple relays. + */ +async function publishToRelays( + pool: SimplePool, + signed: Event, + targetRelays: string[] +): Promise<{ + publishedTo: string[]; + failedRelays: Array<{ relay: string; error: string }>; +}> { + const publishedTo: string[] = []; + const failedRelays: Array<{ relay: string; error: string }> = []; + + const publishPromises = targetRelays.map(async (relay) => { + try { + const publishPromise = pool.publish([relay], signed)[0]; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS); + }); + await Promise.race([publishPromise, timeoutPromise]); + publishedTo.push(relay); + } catch (err) { + failedRelays.push({ + relay, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + await Promise.all(publishPromises); + return { publishedTo, failedRelays }; +} + +/** + * Build NIP-10 compliant reply tags for threading. + * @see https://github.com/nostr-protocol/nips/blob/master/10.md + */ +export function buildReplyTags(opts: { + replyToId: string; + replyToPubkey: string; + rootId?: string; + rootPubkey?: string; + mentions?: string[]; + relayHint?: string; +}): string[][] { + const tags: string[][] = []; + const relay = opts.relayHint ?? ""; + + // Determine root event + const rootEventId = opts.rootId ?? opts.replyToId; + const rootPubkey = opts.rootPubkey ?? opts.replyToPubkey; + + // Root tag (e tag with "root" marker) + tags.push(["e", rootEventId, relay, "root"]); + + // Reply tag (e tag with "reply" marker) - only if different from root + if (opts.rootId && opts.rootId !== opts.replyToId) { + tags.push(["e", opts.replyToId, relay, "reply"]); + } + + // P tags for threading + tags.push(["p", rootPubkey]); + if (opts.replyToPubkey !== rootPubkey) { + tags.push(["p", opts.replyToPubkey]); + } + + // Additional mentions + for (const pubkey of opts.mentions ?? []) { + if (pubkey !== rootPubkey && pubkey !== opts.replyToPubkey) { + tags.push(["p", pubkey]); + } + } + + return tags; +} + +// ============================================================================ +// Action Functions +// ============================================================================ + +export interface PostNoteOpts { + accountId: string; + bunkerIndex?: number; + content: string; + pool: SimplePool; + relays?: string[]; // Override relays (defaults to bunker relays) + tags?: string[][]; // Optional tags (mentions, hashtags, etc.) + // NIP-10 reply threading + replyTo?: string; // Event ID to reply to + replyToPubkey?: string; // Pubkey of reply target (required if replyTo set) + rootEvent?: string; // Root of thread (if different from replyTo) + rootPubkey?: string; // Pubkey of root event author + mentions?: string[]; // Additional pubkeys to mention +} + +/** + * Post a short text note (kind:1). + * Supports NIP-10 reply threading when replyTo is set. + */ +export async function postNote(opts: PostNoteOpts): Promise { + const conn = opts.bunkerIndex !== undefined + ? getBunkerConnection(opts.accountId, opts.bunkerIndex) + : getFirstBunkerConnection(opts.accountId); + if (!conn) { + throw new Error("No bunker connected. Use nostr_connect first."); + } + + // Build tags - start with explicit tags or empty array + let tags = opts.tags ? [...opts.tags] : []; + + // Add NIP-10 reply tags if this is a reply + if (opts.replyTo) { + if (!opts.replyToPubkey) { + throw new Error("replyToPubkey is required when replyTo is set"); + } + const replyTags = buildReplyTags({ + replyToId: opts.replyTo, + replyToPubkey: opts.replyToPubkey, + rootId: opts.rootEvent, + rootPubkey: opts.rootPubkey, + mentions: opts.mentions, + }); + tags = [...tags, ...replyTags]; + } + + const event: EventTemplate = { + kind: ShortTextNote, + content: opts.content, + tags, + created_at: Math.floor(Date.now() / 1000), + }; + + const signed = await conn.signer.signEvent(event); + + // Determine target relays + const targetRelays = getTargetRelays(opts.relays, conn); + + const { publishedTo, failedRelays } = await publishToRelays( + opts.pool, + signed, + targetRelays + ); + + return { + eventId: signed.id, + pubkey: signed.pubkey, + content: opts.content, + publishedTo, + failedRelays, + }; +} + +export interface PostReactionOpts { + accountId: string; + bunkerIndex?: number; + eventId: string; + eventPubkey: string; + eventKind?: number; + reaction: string; // "+" for like, "-" for dislike, or emoji + relayHint?: string; + pool: SimplePool; + relays?: string[]; +} + +/** + * Post a reaction (kind:7) to an event. + * @see NIP-25 https://github.com/nostr-protocol/nips/blob/master/25.md + */ +export async function postReaction(opts: PostReactionOpts): Promise { + const conn = opts.bunkerIndex !== undefined + ? getBunkerConnection(opts.accountId, opts.bunkerIndex) + : getFirstBunkerConnection(opts.accountId); + if (!conn) { + throw new Error("No bunker connected. Use nostr_connect first."); + } + + const eventKind = opts.eventKind ?? ShortTextNote; + + // Build reaction event template (NIP-25 tag structure) + const event: EventTemplate = { + kind: Reaction, // 7 + content: opts.reaction, + tags: [ + ["e", opts.eventId, opts.relayHint ?? ""], + ["p", opts.eventPubkey], + ["k", String(eventKind)], + ], + created_at: Math.floor(Date.now() / 1000), + }; + + const signed = await conn.signer.signEvent(event); + + // Determine target relays + const targetRelays = getTargetRelays(opts.relays, conn); + + const { publishedTo, failedRelays } = await publishToRelays( + opts.pool, + signed, + targetRelays + ); + + return { + eventId: signed.id, + pubkey: signed.pubkey, + reaction: opts.reaction, + targetEventId: opts.eventId, + publishedTo, + failedRelays, + }; +} + +export interface PostRepostOpts { + accountId: string; + bunkerIndex?: number; + eventId: string; + eventPubkey: string; + eventKind?: number; + eventContent?: string; // JSON of original event (optional) + relayHint?: string; + pool: SimplePool; + relays?: string[]; +} + +/** + * Post a repost (kind:6 for notes, kind:16 for other kinds). + * @see NIP-18 https://github.com/nostr-protocol/nips/blob/master/18.md + */ +export async function postRepost(opts: PostRepostOpts): Promise { + const conn = opts.bunkerIndex !== undefined + ? getBunkerConnection(opts.accountId, opts.bunkerIndex) + : getFirstBunkerConnection(opts.accountId); + if (!conn) { + throw new Error("No bunker connected. Use nostr_connect first."); + } + + const eventKind = opts.eventKind ?? ShortTextNote; + + // Use kind 6 for kind 1 notes, kind 16 for others + const repostKind = eventKind === ShortTextNote ? Repost : GenericRepost; + + const tags: string[][] = [ + ["e", opts.eventId, opts.relayHint ?? ""], + ["p", opts.eventPubkey], + ]; + + // For generic reposts (non-kind-1), include the original kind + if (repostKind === GenericRepost) { + tags.push(["k", String(eventKind)]); + } + + const event: EventTemplate = { + kind: repostKind, + content: opts.eventContent ?? "", // Can include JSON of original event + tags, + created_at: Math.floor(Date.now() / 1000), + }; + + const signed = await conn.signer.signEvent(event); + + // Determine target relays + const targetRelays = getTargetRelays(opts.relays, conn); + + const { publishedTo, failedRelays } = await publishToRelays( + opts.pool, + signed, + targetRelays + ); + + return { + eventId: signed.id, + pubkey: signed.pubkey, + repostedEventId: opts.eventId, + kind: repostKind, + publishedTo, + failedRelays, + }; +} + +export interface FetchEventsOpts { + accountId: string; + bunkerIndex?: number; + filter: Filter; + pool: SimplePool; + relays?: string[]; + maxWait?: number; +} + +/** + * Fetch events from relays using a filter. + * @see NIP-01 for filter format + */ +export async function fetchEvents(opts: FetchEventsOpts): Promise { + const conn = opts.bunkerIndex !== undefined + ? getBunkerConnection(opts.accountId, opts.bunkerIndex) + : getFirstBunkerConnection(opts.accountId); + + // Get relays to query - use READ relays for fetching (NIP-65) + const queryRelays = getReadRelays(opts.relays, conn); + + if (queryRelays.length === 0) { + throw new Error("No relays available for query"); + } + + // Use pool.querySync for one-shot fetch with EOSE + const events = await opts.pool.querySync(queryRelays, opts.filter, { + maxWait: opts.maxWait ?? RELAY_FETCH_TIMEOUT_MS, + }); + + return { + events, + relaysQueried: queryRelays, + }; +} + +export interface PostArticleOpts { + accountId: string; + bunkerIndex?: number; + title: string; + content: string; // Markdown content + identifier: string; // d-tag for addressable event + summary?: string; + image?: string; // Header image URL + hashtags?: string[]; + publishedAt?: number; // Original pub timestamp + isDraft?: boolean; // Use kind 30024 instead + pool: SimplePool; + relays?: string[]; +} + +/** + * Post a long-form article (kind:30023 or draft kind:30024). + * @see NIP-23 https://github.com/nostr-protocol/nips/blob/master/23.md + */ +export async function postArticle(opts: PostArticleOpts): Promise { + const conn = opts.bunkerIndex !== undefined + ? getBunkerConnection(opts.accountId, opts.bunkerIndex) + : getFirstBunkerConnection(opts.accountId); + if (!conn) { + throw new Error("No bunker connected. Use nostr_connect first."); + } + + const now = Math.floor(Date.now() / 1000); + const tags: string[][] = [ + ["d", opts.identifier], + ["title", opts.title], + ]; + + if (opts.summary) tags.push(["summary", opts.summary]); + if (opts.image) tags.push(["image", opts.image]); + tags.push(["published_at", String(opts.publishedAt ?? now)]); + for (const tag of opts.hashtags ?? []) { + tags.push(["t", tag]); + } + + const articleKind = opts.isDraft ? DraftLong : LongFormArticle; + + const event: EventTemplate = { + kind: articleKind, + content: opts.content, + tags, + created_at: now, + }; + + const signed = await conn.signer.signEvent(event); + + // Determine target relays + const targetRelays = getTargetRelays(opts.relays, conn); + + const { publishedTo, failedRelays } = await publishToRelays( + opts.pool, + signed, + targetRelays + ); + + return { + eventId: signed.id, + pubkey: signed.pubkey, + title: opts.title, + identifier: opts.identifier, + kind: articleKind, + publishedTo, + failedRelays, + }; +} diff --git a/extensions/nostr/src/bunker-store.test.ts b/extensions/nostr/src/bunker-store.test.ts new file mode 100644 index 000000000..8716337ec --- /dev/null +++ b/extensions/nostr/src/bunker-store.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + connectBunker, + disconnectBunker, + getBunkerConnection, + getFirstBunkerConnection, + getAllBunkerConnections, + isBunkerConnected, + hasAnyBunkerConnected, + resetClientSecretKey, + loadPersistedState, + savePersistedState, + stripBunkerSecret, + getClientSecretKey, + clearPersistedState, +} from "./bunker-store.js"; + +// Mock runtime +vi.mock("./runtime.js", () => ({ + getNostrRuntime: vi.fn(() => ({ + state: { + resolveStateDir: vi.fn(() => "/tmp/test-clawdbot"), + }, + })), +})); + +// Mock nostr-tools/nip46 +vi.mock("nostr-tools/nip46", () => { + const mockSigner = { + connect: vi.fn().mockResolvedValue(undefined), + sendRequest: vi.fn().mockResolvedValue("ack"), + getPublicKey: vi.fn().mockResolvedValue("user123pubkey456"), + close: vi.fn().mockResolvedValue(undefined), + signEvent: vi.fn(), + }; + + return { + parseBunkerInput: vi.fn(), + BunkerSigner: { + fromBunker: vi.fn().mockReturnValue(mockSigner), + }, + }; +}); + +// Mock nostr-tools +vi.mock("nostr-tools", () => ({ + generateSecretKey: vi.fn().mockReturnValue(new Uint8Array(32).fill(1)), + SimplePool: vi.fn().mockImplementation(() => ({ + publish: vi.fn().mockReturnValue([Promise.resolve("ok")]), + get: vi.fn().mockResolvedValue(null), // Default: no relay list found + })), +})); + +// Mock nostr-tools/kinds +vi.mock("nostr-tools/kinds", () => ({ + RelayList: 10002, +})); + +// Mock fs +vi.mock("fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +describe("bunker-store", () => { + let mockParseBunkerInput: ReturnType; + let mockBunkerSigner: { + fromBunker: ReturnType; + }; + let mockSignerInstance: { + connect: ReturnType; + sendRequest: ReturnType; + getPublicKey: ReturnType; + close: ReturnType; + signEvent: ReturnType; + }; + + const accountId = "test-account"; + const bunkerIndex = 0; + + beforeEach(async () => { + // Reset state between tests + await disconnectBunker(accountId, bunkerIndex); + resetClientSecretKey(accountId, bunkerIndex); + vi.clearAllMocks(); + + // Get mocked modules + const nip46 = await import("nostr-tools/nip46"); + mockParseBunkerInput = nip46.parseBunkerInput as ReturnType; + mockBunkerSigner = nip46.BunkerSigner as unknown as { + fromBunker: ReturnType; + }; + mockSignerInstance = mockBunkerSigner.fromBunker() as { + connect: ReturnType; + sendRequest: ReturnType; + getPublicKey: ReturnType; + close: ReturnType; + signEvent: ReturnType; + }; + }); + + afterEach(async () => { + await disconnectBunker(accountId, bunkerIndex); + }); + + describe("connectBunker", () => { + it("connects successfully with valid bunker URL", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123"; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "fa984bd7deb8", + relays: ["wss://relay.example.com"], + secret: "abc123", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456"); + + const { connection: conn, isReconnect } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool: mockPool, + }); + + expect(conn.userPubkey).toBe("user123pubkey456"); + expect(conn.bunkerPubkey).toBe("fa984bd7deb8"); + expect(conn.relays).toEqual(["wss://relay.example.com"]); + expect(conn.userWriteRelays).toEqual([]); // No relay list found + expect(conn.connectedAt).toBeLessThanOrEqual(Date.now()); + expect(conn.accountId).toBe(accountId); + expect(conn.bunkerIndex).toBe(bunkerIndex); + expect(isReconnect).toBe(false); + }); + + it("fetches user write relays from NIP-65 relay list", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue({ + kind: 10002, + tags: [ + ["r", "wss://write.relay.com", "write"], + ["r", "wss://both.relay.com"], // no marker = both read/write + ["r", "wss://read.relay.com", "read"], // read-only, should be excluded from write + ], + }), + } as unknown as import("nostr-tools").SimplePool; + const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123"; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "fa984bd7deb8", + relays: ["wss://relay.example.com"], + secret: "abc123", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456"); + + const { connection: conn } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool: mockPool, + }); + + expect(conn.userWriteRelays).toEqual([ + "wss://write.relay.com", + "wss://both.relay.com", + ]); + expect(conn.userReadRelays).toEqual([ + "wss://both.relay.com", + "wss://read.relay.com", + ]); + }); + + it("throws on invalid bunker URL (parseBunkerInput returns null)", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue(null); + + await expect( + connectBunker({ accountId, bunkerIndex, bunkerUrl: "invalid-url", pool: mockPool }) + ).rejects.toThrow("Invalid bunker URL format"); + }); + + it("throws when bunker URL has no relays", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "fa984bd7deb8", + relays: [], + secret: "abc123", + }); + + await expect( + connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://fa984bd7deb8", pool: mockPool }) + ).rejects.toThrow("No relays in bunker URL"); + }); + + it("handles 'already connected' error gracefully", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123"; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "fa984bd7deb8", + relays: ["wss://relay.example.com"], + secret: "abc123", + }); + // First call with secret fails with "already connected" + mockSignerInstance.sendRequest + .mockRejectedValueOnce(new Error("already connected")) + .mockResolvedValueOnce("ack"); // Retry without secret succeeds + mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456"); + + const { connection: conn, isReconnect } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool: mockPool, + }); + + expect(conn.userPubkey).toBe("user123pubkey456"); + expect(isReconnect).toBe(true); // Should be marked as reconnect + // Should have been called twice: once with secret, once without + expect(mockSignerInstance.sendRequest).toHaveBeenCalledTimes(2); + }); + + it("retries without secret on 'invalid secret' error", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123"; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "fa984bd7deb8", + relays: ["wss://relay.example.com"], + secret: "abc123", + }); + // First call with secret fails with "invalid secret" + mockSignerInstance.sendRequest + .mockRejectedValueOnce(new Error("invalid secret")) + .mockResolvedValueOnce("ack"); // Retry without secret succeeds + mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456"); + + const { connection: conn, isReconnect } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool: mockPool, + }); + + expect(conn.userPubkey).toBe("user123pubkey456"); + expect(isReconnect).toBe(true); + expect(mockSignerInstance.sendRequest).toHaveBeenCalledTimes(2); + }); + }); + + describe("getBunkerConnection", () => { + it("returns null when not connected", () => { + expect(getBunkerConnection(accountId, bunkerIndex)).toBeNull(); + }); + + it("returns connection after connecting", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "test-bunker", + relays: ["wss://relay.example.com"], + secret: "secret", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey"); + + await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool }); + + const conn = getBunkerConnection(accountId, bunkerIndex); + expect(conn).not.toBeNull(); + expect(conn?.userPubkey).toBe("test-user-pubkey"); + }); + }); + + describe("getFirstBunkerConnection", () => { + it("returns null when no bunker connected", () => { + expect(getFirstBunkerConnection(accountId)).toBeNull(); + }); + + it("returns the first connected bunker", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "test-bunker", + relays: ["wss://relay.example.com"], + secret: "secret", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey"); + + await connectBunker({ accountId, bunkerIndex: 0, bunkerUrl: "bunker://test", pool: mockPool }); + + const conn = getFirstBunkerConnection(accountId); + expect(conn).not.toBeNull(); + expect(conn?.bunkerIndex).toBe(0); + }); + }); + + describe("getAllBunkerConnections", () => { + it("returns empty array when no bunkers connected", () => { + expect(getAllBunkerConnections(accountId)).toEqual([]); + }); + }); + + describe("disconnectBunker", () => { + it("returns false when no connection exists", async () => { + const result = await disconnectBunker(accountId, bunkerIndex); + expect(result).toBe(false); + }); + + it("returns true and cleans up when connected", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "test-bunker", + relays: ["wss://relay.example.com"], + secret: "secret", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey"); + + await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool }); + expect(isBunkerConnected(accountId, bunkerIndex)).toBe(true); + + const result = await disconnectBunker(accountId, bunkerIndex); + + expect(result).toBe(true); + expect(isBunkerConnected(accountId, bunkerIndex)).toBe(false); + expect(getBunkerConnection(accountId, bunkerIndex)).toBeNull(); + expect(mockSignerInstance.close).toHaveBeenCalled(); + }); + }); + + describe("isBunkerConnected", () => { + it("returns false when not connected", () => { + expect(isBunkerConnected(accountId, bunkerIndex)).toBe(false); + }); + + it("returns true when connected", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "test-bunker", + relays: ["wss://relay.example.com"], + secret: "secret", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey"); + + await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool }); + + expect(isBunkerConnected(accountId, bunkerIndex)).toBe(true); + }); + }); + + describe("hasAnyBunkerConnected", () => { + it("returns false when no bunker connected", () => { + expect(hasAnyBunkerConnected(accountId)).toBe(false); + }); + + it("returns true when any bunker connected", async () => { + const mockPool = { + publish: vi.fn(), + get: vi.fn().mockResolvedValue(null), + } as unknown as import("nostr-tools").SimplePool; + + mockParseBunkerInput.mockResolvedValue({ + pubkey: "test-bunker", + relays: ["wss://relay.example.com"], + secret: "secret", + }); + mockSignerInstance.sendRequest.mockResolvedValue("ack"); + mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey"); + + await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool }); + + expect(hasAnyBunkerConnected(accountId)).toBe(true); + }); + }); + + describe("stripBunkerSecret", () => { + it("strips secret from bunker URL", () => { + const url = "bunker://abc123?relay=wss://r.com&secret=mysecret"; + const result = stripBunkerSecret(url); + // Secret should be removed, relay should remain + expect(result).not.toContain("secret"); + expect(result).toContain("relay="); + expect(result).toContain("bunker://abc123"); + }); + + it("returns URL without secret if no secret present", () => { + const url = "bunker://abc123?relay=wss://r.com"; + const result = stripBunkerSecret(url); + // Should be essentially unchanged (maybe URL-normalized) + expect(result).not.toContain("secret"); + expect(result).toContain("relay="); + expect(result).toContain("bunker://abc123"); + }); + + it("handles invalid URLs gracefully", () => { + const url = "not-a-valid-url"; + expect(stripBunkerSecret(url)).toBe("not-a-valid-url"); + }); + }); +}); diff --git a/extensions/nostr/src/bunker-store.ts b/extensions/nostr/src/bunker-store.ts new file mode 100644 index 000000000..f7246c8ee --- /dev/null +++ b/extensions/nostr/src/bunker-store.ts @@ -0,0 +1,397 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"; +import { join, dirname } from "path"; +import { generateSecretKey } from "nostr-tools"; +import { bytesToHex, hexToBytes } from "nostr-tools/utils"; +import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46"; +import { RelayList } from "nostr-tools/kinds"; +import type { SimplePool } from "nostr-tools"; +import os from "node:os"; + +import { getNostrRuntime } from "./runtime.js"; + +// ============================================================================ +// Persistence - client key survives process restarts to maintain bunker session +// ============================================================================ + +const BUNKER_STATE_DIR = "nostr"; + +export interface PersistedBunkerState { + clientSecretKeyHex: string; + lastBunkerUrl?: string; // Without secret +} + +/** + * Generate a unique key for the bunker connection map. + */ +function makeBunkerKey(accountId: string, bunkerIndex: number): string { + return `${accountId}-${bunkerIndex}`; +} + +function getBunkerStatePath(accountId: string, bunkerIndex: number): string { + const stateDir = getNostrRuntime().state.resolveStateDir(process.env, os.homedir); + return join(stateDir, BUNKER_STATE_DIR, `bunker-state-${accountId}-${bunkerIndex}.json`); +} + +export function loadPersistedState(accountId: string, bunkerIndex: number): PersistedBunkerState | null { + try { + const path = getBunkerStatePath(accountId, bunkerIndex); + if (existsSync(path)) { + return JSON.parse(readFileSync(path, "utf-8")); + } + } catch { + // Fail silently - will generate new key + } + return null; +} + +export function savePersistedState(accountId: string, bunkerIndex: number, state: PersistedBunkerState): void { + try { + const path = getBunkerStatePath(accountId, bunkerIndex); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2)); + } catch { + // Fail silently - persistence is best-effort + } +} + +/** + * Strip the one-time secret from a bunker URL for storage/comparison. + * Bunker secrets are single-use, so we don't persist them. + */ +export function stripBunkerSecret(url: string): string { + try { + const parsed = new URL(url); + parsed.searchParams.delete("secret"); + return parsed.toString(); + } catch { + return url; + } +} + +// ============================================================================ +// Connection types +// ============================================================================ + +export interface BunkerConnection { + signer: BunkerSigner; + userPubkey: string; // The user's pubkey (from getPublicKey, NOT bunker pubkey) + bunkerPubkey: string; // The bunker's pubkey (from bunker URL) + relays: string[]; // From bunker URL + userWriteRelays: string[]; // From user's kind:10002 (NIP-65) - for publishing + userReadRelays: string[]; // From user's kind:10002 (NIP-65) - for fetching + connectedAt: number; + accountId: string; + bunkerIndex: number; +} + +// Per-account bunker connections +const activeBunkers = new Map(); + +// Per-account client secret keys +const clientSecretKeys = new Map(); + +/** + * Get or generate the client secret key for a specific bunker. + * Persists key to disk so reconnects use the same identity (bunker sees same client). + */ +export function getClientSecretKey(accountId: string, bunkerIndex: number): Uint8Array { + const key = makeBunkerKey(accountId, bunkerIndex); + let secretKey = clientSecretKeys.get(key); + if (!secretKey) { + const persisted = loadPersistedState(accountId, bunkerIndex); + if (persisted?.clientSecretKeyHex) { + secretKey = hexToBytes(persisted.clientSecretKeyHex); + } else { + secretKey = generateSecretKey(); + savePersistedState(accountId, bunkerIndex, { clientSecretKeyHex: bytesToHex(secretKey) }); + } + clientSecretKeys.set(key, secretKey); + } + return secretKey; +} + +/** Connection timeout (ms) - increased to 90s because auth_url flows require user interaction in signer app */ +const BUNKER_CONNECT_TIMEOUT_MS = 90000; + +/** Timeout for fetching user's relay list (ms) */ +const RELAY_FETCH_TIMEOUT_MS = 5000; + +/** + * Fetch user's relays from their NIP-65 relay list (kind:10002). + * Returns both read and write relays separately. + */ +async function fetchUserRelays( + pool: SimplePool, + pubkey: string, + queryRelays: string[] +): Promise<{ readRelays: string[]; writeRelays: string[] }> { + try { + // Query bunker relays for the user's kind:10002 event + const relayListEvent = await Promise.race([ + pool.get(queryRelays, { + kinds: [RelayList], // 10002 + authors: [pubkey], + }), + new Promise((resolve) => + setTimeout(() => resolve(null), RELAY_FETCH_TIMEOUT_MS) + ), + ]); + + if (!relayListEvent) return { readRelays: [], writeRelays: [] }; + + // Parse "r" tags + const readRelays: string[] = []; + const writeRelays: string[] = []; + for (const tag of relayListEvent.tags) { + if (tag[0] !== "r" || !tag[1]) continue; + const relay = tag[1]; + const marker = tag[2]; + // No marker = both read and write + if (!marker) { + readRelays.push(relay); + writeRelays.push(relay); + } else if (marker === "read") { + readRelays.push(relay); + } else if (marker === "write") { + writeRelays.push(relay); + } + } + return { readRelays, writeRelays }; + } catch { + return { readRelays: [], writeRelays: [] }; // Fail silently - relay list is optional + } +} + +export interface ConnectBunkerResult { + connection: BunkerConnection; + isReconnect: boolean; +} + +/** + * Custom error thrown when bunker requires auth_url approval. + * The user needs to open the URL to approve the connection in their signer app. + */ +export class BunkerAuthUrlError extends Error { + constructor(public readonly authUrl: string) { + super(`Bunker requires approval. Open this URL in your browser: ${authUrl}`); + this.name = "BunkerAuthUrlError"; + } +} + +export async function connectBunker(opts: { + accountId: string; + bunkerIndex: number; + bunkerUrl: string; + pool: SimplePool; + /** If false, treats this as a reconnect and won't send the secret */ + isInitialConnection?: boolean; +}): Promise { + const key = makeBunkerKey(opts.accountId, opts.bunkerIndex); + + // Disconnect existing local connection first + const existingConnection = activeBunkers.get(key); + if (existingConnection) { + await disconnectBunker(opts.accountId, opts.bunkerIndex); + } + + // parseBunkerInput returns null on error (no throw) + const bp = await parseBunkerInput(opts.bunkerUrl); + if (!bp) { + throw new Error("Invalid bunker URL format"); + } + if (bp.relays.length === 0) { + throw new Error("No relays in bunker URL"); + } + + // Track if we received an auth_url during connection + let pendingAuthUrl: string | null = null; + + // fromBunker() is SYNCHRONOUS - returns immediately + const signer = BunkerSigner.fromBunker(getClientSecretKey(opts.accountId, opts.bunkerIndex), bp, { + pool: opts.pool, + onauth: (authUrl: string) => { + // Bunker is requesting authorization - store the URL + pendingAuthUrl = authUrl; + }, + }); + + // Helper to send connect request with timeout + const connectWithSecret = async (secret: string | null) => { + const connectPromise = signer.sendRequest("connect", [bp.pubkey, secret || ""]); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + // If we have an auth_url pending, throw that instead of generic timeout + if (pendingAuthUrl) { + reject(new BunkerAuthUrlError(pendingAuthUrl)); + } else { + reject(new Error("Bunker connection timeout")); + } + }, BUNKER_CONNECT_TIMEOUT_MS); + }); + await Promise.race([connectPromise, timeoutPromise]); + }; + + // Determine if this is initial connection or reconnect + const isInitial = opts.isInitialConnection !== false; + const secret = isInitial ? bp.secret : null; + let isReconnect = !isInitial; + + try { + await connectWithSecret(secret); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + + // Retry without secret (for one-time secrets already consumed, or reconnects) + // Pidgeon pattern: try without secret if the bunker rejects + if (secret && (msg.includes("already connected") || msg.includes("invalid secret"))) { + try { + await connectWithSecret(null); + } catch (retryErr) { + const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); + // If retry also says "already connected", that's fine - bunker still knows us + if (!retryMsg.includes("already connected")) { + throw retryErr; + } + } + isReconnect = true; // If we retried without secret, it's effectively a reconnect + } else if (msg.includes("already connected")) { + // Bunker has existing session with this client pubkey - that's OK, continue + isReconnect = true; + } else { + throw err; + } + } + + // getPublicKey() returns the USER's pubkey (cached after first call) + const userPubkey = await signer.getPublicKey(); + + // Fetch user's NIP-65 relay list for better publishing/fetching coverage + const { readRelays, writeRelays } = await fetchUserRelays( + opts.pool, + userPubkey, + bp.relays + ); + + const connection: BunkerConnection = { + signer, + userPubkey, + bunkerPubkey: bp.pubkey, + relays: bp.relays, + userWriteRelays: writeRelays, + userReadRelays: readRelays, + connectedAt: Date.now(), + accountId: opts.accountId, + bunkerIndex: opts.bunkerIndex, + }; + + activeBunkers.set(key, connection); + + // Persist state after successful connect (strip secret from URL) + const strippedUrl = stripBunkerSecret(opts.bunkerUrl); + savePersistedState(opts.accountId, opts.bunkerIndex, { + clientSecretKeyHex: bytesToHex(getClientSecretKey(opts.accountId, opts.bunkerIndex)), + lastBunkerUrl: strippedUrl, + }); + + return { connection, isReconnect }; +} + +export function getBunkerConnection(accountId: string, bunkerIndex: number): BunkerConnection | null { + return activeBunkers.get(makeBunkerKey(accountId, bunkerIndex)) ?? null; +} + +/** + * Get the first connected bunker for an account (convenience for single-bunker usage). + */ +export function getFirstBunkerConnection(accountId: string): BunkerConnection | null { + // Check bunker index 0 first (most common case) + const first = activeBunkers.get(makeBunkerKey(accountId, 0)); + if (first) return first; + + // Search for any connected bunker for this account + for (const [key, conn] of activeBunkers.entries()) { + if (key.startsWith(`${accountId}-`)) { + return conn; + } + } + return null; +} + +/** + * Get all connected bunkers for an account. + */ +export function getAllBunkerConnections(accountId: string): BunkerConnection[] { + const connections: BunkerConnection[] = []; + for (const [key, conn] of activeBunkers.entries()) { + if (key.startsWith(`${accountId}-`)) { + connections.push(conn); + } + } + return connections.sort((a, b) => a.bunkerIndex - b.bunkerIndex); +} + +export async function disconnectBunker(accountId: string, bunkerIndex: number): Promise { + const key = makeBunkerKey(accountId, bunkerIndex); + const connection = activeBunkers.get(key); + if (!connection) return false; + await connection.signer.close(); + activeBunkers.delete(key); + return true; +} + +/** + * Disconnect all bunkers for an account. + */ +export async function disconnectAllBunkers(accountId: string): Promise { + const keysToDelete: string[] = []; + for (const [key, conn] of activeBunkers.entries()) { + if (key.startsWith(`${accountId}-`)) { + keysToDelete.push(key); + await conn.signer.close(); + } + } + for (const key of keysToDelete) { + activeBunkers.delete(key); + } + return keysToDelete.length; +} + +export function isBunkerConnected(accountId: string, bunkerIndex: number): boolean { + return activeBunkers.has(makeBunkerKey(accountId, bunkerIndex)); +} + +/** + * Check if any bunker is connected for an account. + */ +export function hasAnyBunkerConnected(accountId: string): boolean { + for (const key of activeBunkers.keys()) { + if (key.startsWith(`${accountId}-`)) { + return true; + } + } + return false; +} + +/** + * Reset the client secret key for a specific bunker - useful for testing + */ +export function resetClientSecretKey(accountId: string, bunkerIndex: number): void { + clientSecretKeys.delete(makeBunkerKey(accountId, bunkerIndex)); +} + +/** + * Clear all persisted bunker state for a specific bunker and reset in-memory client key. + * This is a full reset - next connect will generate a fresh identity. + */ +export function clearPersistedState(accountId: string, bunkerIndex: number): void { + try { + const path = getBunkerStatePath(accountId, bunkerIndex); + if (existsSync(path)) { + unlinkSync(path); + } + } catch { + // Fail silently + } + // Also reset the in-memory client key so next connect generates fresh identity + clientSecretKeys.delete(makeBunkerKey(accountId, bunkerIndex)); +} diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 2087c77a1..05b00d079 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -53,6 +53,22 @@ export const NostrProfileSchema = z.object({ export type NostrProfile = z.infer; +/** + * Bunker account entry for NIP-46 remote signing + */ +export const BunkerAccountSchema = z.object({ + /** Display name for this bunker account */ + name: z.string().optional(), + /** bunker:// URL (secret stripped after connection) */ + bunkerUrl: z.string(), + /** Cached user pubkey after successful connection */ + userPubkey: z.string().optional(), + /** Connection timestamp */ + connectedAt: z.number().optional(), +}); + +export type BunkerAccountConfig = z.infer; + /** * Zod schema for channels.nostr.* configuration */ @@ -72,6 +88,9 @@ export const NostrConfigSchema = z.object({ /** WebSocket relay URLs to connect to */ relays: z.array(z.string()).optional(), + /** Bunker accounts for NIP-46 remote signing */ + bunkerAccounts: z.array(BunkerAccountSchema).optional(), + /** DM access policy: pairing, allowlist, open, or disabled */ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), diff --git a/extensions/nostr/src/nostr-bunker-http.ts b/extensions/nostr/src/nostr-bunker-http.ts new file mode 100644 index 000000000..1006b0d8c --- /dev/null +++ b/extensions/nostr/src/nostr-bunker-http.ts @@ -0,0 +1,355 @@ +/** + * Nostr Bunker HTTP Handler + * + * Handles HTTP requests for bunker management: + * - GET /api/channels/nostr/:accountId/bunker - List all bunker account statuses + * - GET /api/channels/nostr/:accountId/bunker/:index - Get specific bunker status + * - POST /api/channels/nostr/:accountId/bunker/:index - Connect specific bunker + * - DELETE /api/channels/nostr/:accountId/bunker/:index - Disconnect specific bunker + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { + isBunkerConnected, + loadPersistedState, + clearPersistedState, + disconnectBunker, + connectBunker, + getBunkerConnection, + getAllBunkerConnections, + BunkerAuthUrlError, + stripBunkerSecret, +} from "./bunker-store.js"; +import { getSharedPool } from "./nostr-bus.js"; +import type { BunkerAccountConfig } from "./config-schema.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrBunkerHttpContext { + /** Get bunker accounts from config */ + getBunkerAccounts: (accountId: string) => BunkerAccountConfig[]; + /** Update a bunker account in config (after successful connect) */ + updateBunkerAccount: ( + accountId: string, + bunkerIndex: number, + update: Partial + ) => Promise; + /** Clear bunkerUrl from config for a specific bunker */ + clearConfigBunkerUrl: (accountId: string, bunkerIndex: number) => Promise; + /** Logger */ + log?: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; +} + +// ============================================================================ +// Request Helpers +// ============================================================================ + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: IncomingMessage, maxBytes = 16 * 1024): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error("Invalid JSON")); + } + }); + + req.on("error", reject); + }); +} + +interface BunkerPathParams { + accountId: string; + bunkerIndex?: number; +} + +function parseBunkerPathParams(pathname: string): BunkerPathParams | null { + // Match: /api/channels/nostr/:accountId/bunker/:index (specific bunker) + const indexMatch = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/bunker\/(\d+)$/); + if (indexMatch) { + return { + accountId: indexMatch[1], + bunkerIndex: parseInt(indexMatch[2], 10), + }; + } + + // Match: /api/channels/nostr/:accountId/bunker (all bunkers) + const allMatch = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/bunker$/); + if (allMatch) { + return { + accountId: allMatch[1], + }; + } + + return null; +} + +// ============================================================================ +// HTTP Handler +// ============================================================================ + +export function createNostrBunkerHttpHandler( + ctx: NostrBunkerHttpContext +): (req: IncomingMessage, res: ServerResponse) => Promise { + return async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + // Only handle /api/channels/nostr/:accountId/bunker paths + if (!url.pathname.includes("/bunker")) { + return false; + } + + const params = parseBunkerPathParams(url.pathname); + if (!params) { + return false; + } + + const { accountId, bunkerIndex } = params; + + // Handle different HTTP methods + try { + if (req.method === "GET") { + if (bunkerIndex !== undefined) { + return handleGetBunkerStatus(accountId, bunkerIndex, ctx, res); + } else { + return handleGetAllBunkerStatus(accountId, ctx, res); + } + } + + if (req.method === "POST" && bunkerIndex !== undefined) { + return await handleConnectBunker(accountId, bunkerIndex, ctx, req, res); + } + + if (req.method === "DELETE" && bunkerIndex !== undefined) { + return await handleDisconnectBunker(accountId, bunkerIndex, ctx, res); + } + + // Method not allowed + sendJson(res, 405, { ok: false, error: "Method not allowed" }); + return true; + } catch (err) { + ctx.log?.error(`Bunker HTTP error: ${String(err)}`); + sendJson(res, 500, { ok: false, error: "Internal server error" }); + return true; + } + }; +} + +// ============================================================================ +// GET /api/channels/nostr/:accountId/bunker - List all bunkers +// ============================================================================ + +function handleGetAllBunkerStatus( + accountId: string, + ctx: NostrBunkerHttpContext, + res: ServerResponse +): true { + const bunkerAccounts = ctx.getBunkerAccounts(accountId); + const connections = getAllBunkerConnections(accountId); + + const bunkers = bunkerAccounts.map((account, index) => { + const connected = isBunkerConnected(accountId, index); + const connection = connections.find((c) => c.bunkerIndex === index); + const persistedState = loadPersistedState(accountId, index); + + return { + index, + name: account.name, + bunkerUrl: account.bunkerUrl ? stripBunkerSecret(account.bunkerUrl) : null, + connected, + userPubkey: connection?.userPubkey ?? account.userPubkey ?? null, + connectedAt: connection?.connectedAt ?? account.connectedAt ?? null, + lastBunkerUrl: persistedState?.lastBunkerUrl ?? null, + }; + }); + + sendJson(res, 200, { + ok: true, + bunkers, + }); + return true; +} + +// ============================================================================ +// GET /api/channels/nostr/:accountId/bunker/:index - Get specific bunker status +// ============================================================================ + +function handleGetBunkerStatus( + accountId: string, + bunkerIndex: number, + ctx: NostrBunkerHttpContext, + res: ServerResponse +): true { + const bunkerAccounts = ctx.getBunkerAccounts(accountId); + const account = bunkerAccounts[bunkerIndex]; + + if (!account) { + sendJson(res, 404, { ok: false, error: `Bunker index ${bunkerIndex} not found` }); + return true; + } + + const connected = isBunkerConnected(accountId, bunkerIndex); + const connection = getBunkerConnection(accountId, bunkerIndex); + const persistedState = loadPersistedState(accountId, bunkerIndex); + + sendJson(res, 200, { + ok: true, + index: bunkerIndex, + name: account.name, + bunkerUrl: account.bunkerUrl ? stripBunkerSecret(account.bunkerUrl) : null, + connected, + userPubkey: connection?.userPubkey ?? account.userPubkey ?? null, + bunkerPubkey: connection?.bunkerPubkey ?? null, + relays: connection?.relays ?? [], + userWriteRelays: connection?.userWriteRelays ?? [], + userReadRelays: connection?.userReadRelays ?? [], + connectedAt: connection?.connectedAt ?? account.connectedAt ?? null, + lastBunkerUrl: persistedState?.lastBunkerUrl ?? null, + }); + return true; +} + +// ============================================================================ +// POST /api/channels/nostr/:accountId/bunker/:index - Connect bunker +// ============================================================================ + +async function handleConnectBunker( + accountId: string, + bunkerIndex: number, + ctx: NostrBunkerHttpContext, + req: IncomingMessage, + res: ServerResponse +): Promise { + // Parse request body + let body: { bunkerUrl?: string }; + try { + body = (await readJsonBody(req)) as { bunkerUrl?: string }; + } catch (err) { + sendJson(res, 400, { ok: false, error: String(err) }); + return true; + } + + // Get bunker URL from request or config + const bunkerAccounts = ctx.getBunkerAccounts(accountId); + const account = bunkerAccounts[bunkerIndex]; + + const bunkerUrl = body.bunkerUrl ?? account?.bunkerUrl; + if (!bunkerUrl) { + sendJson(res, 400, { ok: false, error: "No bunker URL provided" }); + return true; + } + + // Check if this is a reconnect (same URL minus secret) + const persistedState = loadPersistedState(accountId, bunkerIndex); + const strippedUrl = stripBunkerSecret(bunkerUrl); + const isInitialConnection = + !persistedState?.lastBunkerUrl || strippedUrl !== persistedState.lastBunkerUrl; + + ctx.log?.info(`[${accountId}] Connecting bunker ${bunkerIndex}${isInitialConnection ? "" : " (reconnect)"}`); + + try { + const pool = getSharedPool(); + const { connection, isReconnect } = await connectBunker({ + accountId, + bunkerIndex, + bunkerUrl, + pool, + isInitialConnection, + }); + + // Update config with user pubkey and connected timestamp + const urlWithoutSecret = stripBunkerSecret(bunkerUrl); + await ctx.updateBunkerAccount(accountId, bunkerIndex, { + bunkerUrl: urlWithoutSecret, + userPubkey: connection.userPubkey, + connectedAt: connection.connectedAt, + }); + + ctx.log?.info( + `[${accountId}] Bunker ${bunkerIndex} connected${isReconnect ? " (reconnected)" : ""} as ${connection.userPubkey.slice(0, 8)}...` + ); + + sendJson(res, 200, { + ok: true, + userPubkey: connection.userPubkey, + bunkerPubkey: connection.bunkerPubkey, + relays: connection.relays, + userWriteRelays: connection.userWriteRelays, + userReadRelays: connection.userReadRelays, + connectedAt: connection.connectedAt, + isReconnect, + }); + } catch (err) { + if (err instanceof BunkerAuthUrlError) { + ctx.log?.info(`[${accountId}] Bunker ${bunkerIndex} requires auth_url approval`); + sendJson(res, 200, { + ok: false, + needsAuth: true, + authUrl: err.authUrl, + error: "Bunker requires approval", + }); + } else { + ctx.log?.error(`[${accountId}] Bunker ${bunkerIndex} connect error: ${String(err)}`); + sendJson(res, 400, { + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return true; +} + +// ============================================================================ +// DELETE /api/channels/nostr/:accountId/bunker/:index - Disconnect bunker +// ============================================================================ + +async function handleDisconnectBunker( + accountId: string, + bunkerIndex: number, + ctx: NostrBunkerHttpContext, + res: ServerResponse +): Promise { + ctx.log?.info(`[${accountId}] Disconnecting bunker ${bunkerIndex}`); + + // Disconnect in-memory connection + const wasConnected = await disconnectBunker(accountId, bunkerIndex); + + // Clear persisted state file (clientSecretKeyHex + lastBunkerUrl) + clearPersistedState(accountId, bunkerIndex); + + // Clear bunkerUrl from config + await ctx.clearConfigBunkerUrl(accountId, bunkerIndex); + + ctx.log?.info(`[${accountId}] Bunker ${bunkerIndex} disconnected and state cleared`); + + sendJson(res, 200, { ok: true, wasConnected }); + return true; +} diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 0f38ce73c..40764c0eb 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -40,6 +40,23 @@ import { export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; +// ============================================================================ +// Shared Pool (singleton for bunker tools) +// ============================================================================ + +let sharedPool: SimplePool | null = null; + +/** + * Get a shared SimplePool instance for bunker tools. + * Uses a singleton pattern to reuse connections. + */ +export function getSharedPool(): SimplePool { + if (!sharedPool) { + sharedPool = new SimplePool(); + } + return sharedPool; +} + // ============================================================================ // Constants // ============================================================================ @@ -855,8 +872,13 @@ export function normalizePubkey(input: string): string { if (decoded.type !== "npub") { throw new Error("Invalid npub key"); } - // Convert Uint8Array to hex string - return Array.from(decoded.data) + // In nostr-tools v2+, decoded.data is already a hex string + // In older versions, it was a Uint8Array + if (typeof decoded.data === "string") { + return decoded.data.toLowerCase(); + } + // Fallback for Uint8Array (older nostr-tools) + return Array.from(decoded.data as Uint8Array) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 0db50add0..b455257f0 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -1,13 +1,14 @@ import type { MoltbotConfig } from "clawdbot/plugin-sdk"; import { getPublicKeyFromPrivate, type DmProtocol } from "./nostr-bus.js"; import { DEFAULT_RELAYS } from "./nostr-bus.js"; -import type { NostrProfile } from "./config-schema.js"; +import type { NostrProfile, BunkerAccountConfig } from "./config-schema.js"; export interface NostrAccountConfig { enabled?: boolean; name?: string; privateKey?: string; relays?: string[]; + bunkerAccounts?: BunkerAccountConfig[]; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; dmProtocol?: DmProtocol; allowFrom?: Array; @@ -22,6 +23,7 @@ export interface ResolvedNostrAccount { privateKey: string; publicKey: string; relays: string[]; + bunkerAccounts: BunkerAccountConfig[]; dmProtocol: DmProtocol; profile?: NostrProfile; config: NostrAccountConfig; @@ -87,6 +89,7 @@ export function resolveNostrAccount(opts: { privateKey, publicKey, relays: nostrCfg?.relays ?? DEFAULT_RELAYS, + bunkerAccounts: nostrCfg?.bunkerAccounts ?? [], dmProtocol: nostrCfg?.dmProtocol ?? "dual", profile: nostrCfg?.profile, config: { @@ -94,6 +97,7 @@ export function resolveNostrAccount(opts: { name: nostrCfg?.name, privateKey: nostrCfg?.privateKey, relays: nostrCfg?.relays, + bunkerAccounts: nostrCfg?.bunkerAccounts, dmPolicy: nostrCfg?.dmPolicy, dmProtocol: nostrCfg?.dmProtocol, allowFrom: nostrCfg?.allowFrom,