import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, type ChannelPlugin, } from "clawdbot/plugin-sdk"; import { NostrConfigSchema } from "./config-schema.js"; import { getNostrRuntime } from "./runtime.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount, type ResolvedNostrAccount, } from "./types.js"; import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import type { NostrProfile } from "./config-schema.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; // Store active bus handles per account const activeBuses = new Map(); // Store metrics snapshots per account (for status reporting) const metricsSnapshots = new Map(); export const nostrPlugin: ChannelPlugin = { id: "nostr", meta: { id: "nostr", label: "Nostr", selectionLabel: "Nostr", docsPath: "/channels/nostr", docsLabel: "nostr", blurb: "Decentralized DMs via Nostr relays (NIP-04)", order: 100, }, capabilities: { chatTypes: ["direct"], // DMs only for MVP media: false, // No media for MVP }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), config: { listAccountIds: (cfg) => listNostrAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg), isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, publicKey: account.publicKey, }), resolveAllowFrom: ({ cfg, accountId }) => (resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry) ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => { if (entry === "*") return "*"; try { return normalizePubkey(entry); } catch { return entry; // Keep as-is if normalization fails } }) .filter(Boolean), }, pairing: { idLabel: "nostrPubkey", normalizeAllowEntry: (entry) => { try { return normalizePubkey(entry.replace(/^nostr:/i, "")); } catch { return entry; } }, notifyApproval: async ({ id }) => { // Get the default account's bus and send approval message const bus = activeBuses.get(DEFAULT_ACCOUNT_ID); if (bus) { await bus.sendDm(id, "Your pairing request has been approved!"); } }, }, security: { resolveDmPolicy: ({ account }) => { return { policy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom ?? [], policyPath: "channels.nostr.dmPolicy", allowFromPath: "channels.nostr.allowFrom", approveHint: formatPairingApproveHint("nostr"), normalizeEntry: (raw) => { try { return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); } catch { return raw.trim(); } }, }; }, }, messaging: { normalizeTarget: (target) => { // Strip nostr: prefix if present const cleaned = target.replace(/^nostr:/i, "").trim(); try { return normalizePubkey(cleaned); } catch { return cleaned; } }, targetResolver: { looksLikeId: (input) => { const trimmed = input.trim(); return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed); }, hint: "", }, }, outbound: { deliveryMode: "direct", textChunkLimit: 4000, sendText: async ({ to, text, accountId }) => { const aid = accountId ?? DEFAULT_ACCOUNT_ID; const bus = activeBuses.get(aid); if (!bus) { throw new Error(`Nostr bus not running for account ${aid}`); } const normalizedTo = normalizePubkey(to); await bus.sendDm(normalizedTo, text); return { channel: "nostr", to: normalizedTo }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, collectStatusIssues: (accounts) => accounts.flatMap((account) => { const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; if (!lastError) return []; return [ { channel: "nostr", accountId: account.accountId, kind: "runtime" as const, message: `Channel error: ${lastError}`, }, ]; }), buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, publicKey: snapshot.publicKey ?? null, running: snapshot.running ?? false, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, }), buildAccountSnapshot: ({ account, runtime }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, publicKey: account.publicKey, profile: account.profile, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { startAccount: async (ctx) => { const account = ctx.account; ctx.setStatus({ accountId: account.accountId, publicKey: account.publicKey, }); ctx.log?.info(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`); if (!account.configured) { throw new Error("Nostr private key not configured"); } const runtime = getNostrRuntime(); // Track bus handle for metrics callback let busHandle: NostrBusHandle | null = null; const bus = await startNostrBus({ accountId: account.accountId, privateKey: account.privateKey, relays: account.relays, onMessage: async (senderPubkey, text, reply) => { ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`); // Forward to clawdbot's message pipeline await runtime.channel.reply.handleInboundMessage({ channel: "nostr", accountId: account.accountId, senderId: senderPubkey, chatType: "direct", chatId: senderPubkey, // For DMs, chatId is the sender's pubkey text, reply: async (responseText: string) => { await reply(responseText); }, }); }, onError: (error, context) => { ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`); }, onConnect: (relay) => { ctx.log?.debug(`[${account.accountId}] Connected to relay: ${relay}`); }, onDisconnect: (relay) => { ctx.log?.debug(`[${account.accountId}] Disconnected from relay: ${relay}`); }, onEose: (relays) => { ctx.log?.debug(`[${account.accountId}] EOSE received from relays: ${relays}`); }, onMetric: (event: MetricEvent) => { // Log significant metrics at appropriate levels if (event.name.startsWith("event.rejected.")) { ctx.log?.debug(`[${account.accountId}] Metric: ${event.name}`, event.labels); } else if (event.name === "relay.circuit_breaker.open") { ctx.log?.warn(`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`); } else if (event.name === "relay.circuit_breaker.close") { ctx.log?.info(`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`); } else if (event.name === "relay.error") { ctx.log?.debug(`[${account.accountId}] Relay error: ${event.labels?.relay}`); } // Update cached metrics snapshot if (busHandle) { metricsSnapshots.set(account.accountId, busHandle.getMetrics()); } }, }); busHandle = bus; // Store the bus handle activeBuses.set(account.accountId, bus); ctx.log?.info(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`); // Return cleanup function return { stop: () => { bus.close(); activeBuses.delete(account.accountId); metricsSnapshots.delete(account.accountId); ctx.log?.info(`[${account.accountId}] Nostr provider stopped`); }, }; }, }, }; /** * Get metrics snapshot for a Nostr account. * Returns undefined if account is not running. */ export function getNostrMetrics(accountId: string = DEFAULT_ACCOUNT_ID): MetricsSnapshot | undefined { const bus = activeBuses.get(accountId); if (bus) { return bus.getMetrics(); } return metricsSnapshots.get(accountId); } /** * Get all active Nostr bus handles. * Useful for debugging and status reporting. */ export function getActiveNostrBuses(): Map { return new Map(activeBuses); } /** * Publish a profile (kind:0) for a Nostr account. * @param accountId - Account ID (defaults to "default") * @param profile - Profile data to publish * @returns Publish results with successes and failures * @throws Error if account is not running */ export async function publishNostrProfile( accountId: string = DEFAULT_ACCOUNT_ID, profile: NostrProfile ): Promise { const bus = activeBuses.get(accountId); if (!bus) { throw new Error(`Nostr bus not running for account ${accountId}`); } return bus.publishProfile(profile); } /** * Get profile publish state for a Nostr account. * @param accountId - Account ID (defaults to "default") * @returns Profile publish state or null if account not running */ export async function getNostrProfileState( accountId: string = DEFAULT_ACCOUNT_ID ): Promise<{ lastPublishedAt: number | null; lastPublishedEventId: string | null; lastPublishResults: Record | null; } | null> { const bus = activeBuses.get(accountId); if (!bus) { return null; } return bus.getProfileState(); }