feat(nostr): add NIP-17 private direct messages support

- Add NIP-17 (Private Direct Messages) with NIP-59 (Gift Wrap) support
- Subscribe to both kind:4 (NIP-04) and kind:1059 (GiftWrap) events
- Use 48-hour lookback for NIP-17 due to randomized timestamps
- Add dmProtocol config option: "dual" (default), "nip17", or "nip04"
- Send replies using NIP-17 when dmProtocol is "dual" or "nip17"
- Pass raw message text without envelope formatting
- Fix pool.publish() calls (returns array of promises)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Ulrich 2026-01-26 09:31:19 -05:00
parent 109ac1c549
commit 3130c65e74
5 changed files with 320 additions and 103 deletions

View File

@ -32,7 +32,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
selectionLabel: "Nostr",
docsPath: "/channels/nostr",
docsLabel: "nostr",
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
blurb: "Decentralized DMs via Nostr relays (NIP-04/NIP-17)",
order: 100,
},
capabilities: {
@ -218,21 +218,88 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
accountId: account.accountId,
privateKey: account.privateKey,
relays: account.relays,
onMessage: async (senderPubkey, text, reply) => {
dmProtocol: account.dmProtocol,
onMessage: async (senderPubkey, text, replyFn) => {
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
// Forward to moltbot's message pipeline
await runtime.channel.reply.handleInboundMessage({
channel: "nostr",
accountId: account.accountId,
senderId: senderPubkey,
chatType: "direct",
chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
text,
reply: async (responseText: string) => {
await reply(responseText);
},
});
try {
// Load config for routing and session handling
const config = runtime.config.loadConfig();
// Resolve agent route for this DM
const route = runtime.channel.routing.resolveAgentRoute({
cfg: config,
channel: "nostr",
accountId: account.accountId,
peer: { kind: "dm", id: senderPubkey },
});
// Resolve store path for session recording
const storePath = runtime.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
// Build the inbound context payload (no envelope - pass raw text)
const fromLabel = `nostr:${senderPubkey.slice(0, 8)}`;
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: text,
RawBody: text,
CommandBody: text,
From: `nostr:${senderPubkey}`,
To: `nostr:${account.publicKey}`,
SessionKey: route.sessionKey,
AccountId: account.accountId,
ChatType: "direct",
ConversationLabel: fromLabel,
SenderName: undefined,
SenderId: senderPubkey,
Provider: "nostr" as const,
Surface: "nostr" as const,
MessageSid: `nostr-${Date.now()}`,
OriginatingChannel: "nostr" as const,
OriginatingTo: `nostr:${account.publicKey}`,
});
// Record the inbound session
await runtime.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
ctx.log?.error(`[${account.accountId}] Failed to record session: ${String(err)}`);
},
});
// Resolve table mode for reply formatting
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "nostr",
accountId: account.accountId,
});
// Dispatch the reply through the agent pipeline
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
const replyText = runtime.channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode
);
if (replyText.trim()) {
await replyFn(replyText);
ctx.setStatus({ lastOutboundAt: Date.now() });
}
},
onError: (err, info) => {
ctx.log?.error(`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`);
},
},
});
} catch (err) {
ctx.log?.error(`[${account.accountId}] onMessage error: ${String(err)}`);
}
},
onError: (error, context) => {
ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`);

View File

@ -75,6 +75,14 @@ export const NostrConfigSchema = z.object({
/** DM access policy: pairing, allowlist, open, or disabled */
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
/**
* DM protocol preference:
* - "dual" (default): Accept both NIP-04 and NIP-17, send using NIP-17
* - "nip17": NIP-17 only (private DMs with gift wrap)
* - "nip04": NIP-04 only (legacy encrypted DMs)
*/
dmProtocol: z.enum(["dual", "nip17", "nip04"]).optional(),
/** Allowed sender pubkeys (npub or hex format) */
allowFrom: z.array(allowFromEntry).optional(),

View File

@ -5,8 +5,17 @@ import {
verifyEvent,
nip19,
type Event,
type Filter,
} from "nostr-tools";
import { decrypt, encrypt } from "nostr-tools/nip04";
import {
EncryptedDirectMessage,
GiftWrap,
PrivateDirectMessage,
DirectMessageRelaysList,
} from "nostr-tools/kinds";
import { unwrapEvent, type Rumor } from "nostr-tools/nip59";
import * as nip17 from "nostr-tools/nip17";
import {
readNostrBusState,
@ -55,6 +64,9 @@ const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
// Types
// ============================================================================
/** DM protocol preference */
export type DmProtocol = "dual" | "nip17" | "nip04";
export interface NostrBusOptions {
/** Private key in hex or nsec format */
privateKey: string;
@ -62,6 +74,8 @@ export interface NostrBusOptions {
relays?: string[];
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
accountId?: string;
/** DM protocol: "dual" (default - both NIP-04 and NIP-17), "nip17" (NIP-17 only), or "nip04" (NIP-04 only) */
dmProtocol?: DmProtocol;
/** Called when a DM is received */
onMessage: (
pubkey: string,
@ -344,6 +358,7 @@ export async function startNostrBus(
const {
privateKey,
relays = DEFAULT_RELAYS,
dmProtocol = "dual",
onMessage,
onError,
onEose,
@ -420,7 +435,7 @@ export async function startNostrBus(
const inflight = new Set<string>();
// Event handler
// Event handler - supports both NIP-04 (kind 4) and NIP-17 (kind 1059)
async function handleEvent(event: Event): Promise<void> {
try {
metrics.emit("event.received");
@ -432,14 +447,10 @@ export async function startNostrBus(
}
inflight.add(event.id);
// Self-message loop prevention: skip our own messages
if (event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
// Skip events older than our `since` (relay may ignore filter)
if (event.created_at < since) {
// IMPORTANT: For NIP-17 GiftWrap events, timestamps are randomized up to 2 days in the past
// to protect metadata, so we skip the stale check for kind 1059
if (event.kind !== GiftWrap && event.created_at < since) {
metrics.emit("event.rejected.stale");
return;
}
@ -468,35 +479,87 @@ export async function startNostrBus(
seen.add(event.id);
metrics.emit("memory.seen_tracker_size", seen.size());
// Decrypt the message
// Variables to be set by protocol-specific handling
let plaintext: string;
try {
plaintext = await decrypt(sk, event.pubkey, event.content);
let senderPubkey: string;
// Handle based on event kind
if (event.kind === GiftWrap) {
// NIP-17: Gift wrap containing sealed private DM
let rumor: Rumor;
try {
rumor = unwrapEvent(event, sk);
} catch (err) {
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `unwrap gift wrap from ${event.pubkey}`);
return;
}
// Validate the unwrapped rumor is a private DM
if (rumor.kind !== PrivateDirectMessage) {
metrics.emit("event.rejected.wrong_kind");
onError?.(
new Error(`Unexpected rumor kind ${rumor.kind}, expected ${PrivateDirectMessage}`),
`event ${event.id}`
);
return;
}
// For NIP-17, the actual sender is in the rumor, not the gift wrap
senderPubkey = rumor.pubkey;
plaintext = rumor.content;
// Self-message check on rumor.pubkey (the real sender)
if (senderPubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `decrypt from ${event.pubkey}`);
} else if (event.kind === EncryptedDirectMessage) {
// NIP-04: Legacy encrypted DM
// Self-message loop prevention for NIP-04
if (event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
senderPubkey = event.pubkey;
try {
plaintext = await decrypt(sk, event.pubkey, event.content);
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `decrypt from ${event.pubkey}`);
return;
}
} else {
// Unknown kind - shouldn't happen with our filter, but be defensive
metrics.emit("event.rejected.wrong_kind");
return;
}
// Create reply function (try relays by health score)
// Reply uses the same protocol as the incoming message
const replyProtocol = event.kind === GiftWrap ? "nip17" : "nip04";
const replyTo = async (text: string): Promise<void> => {
await sendEncryptedDm(
pool,
sk,
event.pubkey,
senderPubkey,
text,
relays,
metrics,
circuitBreakers,
healthTracker,
onError
onError,
replyProtocol
);
};
// Call the message handler
await onMessage(event.pubkey, plaintext, replyTo);
await onMessage(senderPubkey, plaintext, replyTo);
// Mark as processed
metrics.emit("event.processed");
@ -510,33 +573,76 @@ export async function startNostrBus(
}
}
const sub = pool.subscribeMany(
relays,
[{ kinds: [4], "#p": [pk], since }],
{
onevent: handleEvent,
oneose: () => {
// EOSE handler - called when all stored events have been received
for (const relay of relays) {
metrics.emit("relay.message.eose", 1, { relay });
}
onEose?.(relays.join(", "));
},
onclose: (reason) => {
// Handle subscription close
for (const relay of relays) {
metrics.emit("relay.message.closed", 1, { relay });
options.onDisconnect?.(relay);
}
onError?.(
new Error(`Subscription closed: ${reason}`),
"subscription"
);
},
}
);
// Build subscription filters based on dmProtocol setting
// NIP-17 gift wraps have randomized timestamps up to 2 days in the past,
// so we need a much older `since` for kind 1059 to avoid relay filtering
const NIP17_LOOKBACK_SEC = 48 * 60 * 60; // 48 hours
const nip17Since = Math.floor(Date.now() / 1000) - NIP17_LOOKBACK_SEC;
// Public sendDm function
const subscriptions: Array<{ close: (reason?: string) => void }> = [];
// Subscribe to NIP-04 (kind 4) with normal since
if (dmProtocol !== "nip17") {
const nip04Filter: Filter = {
kinds: [EncryptedDirectMessage],
"#p": [pk],
since,
};
const nip04Sub = pool.subscribeMany(
relays,
nip04Filter,
{
onevent: handleEvent,
oneose: () => {
for (const relay of relays) {
metrics.emit("relay.message.eose", 1, { relay });
}
},
onclose: (reason) => {
for (const relay of relays) {
metrics.emit("relay.message.closed", 1, { relay });
options.onDisconnect?.(relay);
}
onError?.(new Error(`NIP-04 subscription closed: ${reason}`), "subscription");
},
}
);
subscriptions.push(nip04Sub);
}
// Subscribe to NIP-17 gift wrap (kind 1059) with extended lookback
if (dmProtocol !== "nip04") {
const nip17Filter: Filter = {
kinds: [GiftWrap],
"#p": [pk],
since: nip17Since,
};
const nip17Sub = pool.subscribeMany(
relays,
nip17Filter,
{
onevent: handleEvent,
oneose: () => {
for (const relay of relays) {
metrics.emit("relay.message.eose", 1, { relay });
}
// Only call onEose once all subscriptions have received EOSE
onEose?.(relays.join(", "));
},
onclose: (reason) => {
for (const relay of relays) {
metrics.emit("relay.message.closed", 1, { relay });
options.onDisconnect?.(relay);
}
onError?.(new Error(`NIP-17 subscription closed: ${reason}`), "subscription");
},
}
);
subscriptions.push(nip17Sub);
}
// Public sendDm function - uses NIP-17 by default, or NIP-04 if dmProtocol is "nip04"
const defaultSendProtocol = dmProtocol === "nip04" ? "nip04" : "nip17";
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
await sendEncryptedDm(
pool,
@ -547,7 +653,8 @@ export async function startNostrBus(
metrics,
circuitBreakers,
healthTracker,
onError
onError,
defaultSendProtocol
);
};
@ -592,7 +699,10 @@ export async function startNostrBus(
return {
close: () => {
sub.close();
// Close all active subscriptions
for (const sub of subscriptions) {
sub.close();
}
seen.stop();
// Flush pending state write synchronously on close
if (pendingWrite) {
@ -618,7 +728,7 @@ export async function startNostrBus(
// ============================================================================
/**
* Send an encrypted DM to a pubkey
* Send an encrypted DM to a pubkey using the specified protocol
*/
async function sendEncryptedDm(
pool: SimplePool,
@ -629,56 +739,83 @@ async function sendEncryptedDm(
metrics: NostrMetrics,
circuitBreakers: Map<string, CircuitBreaker>,
healthTracker: RelayHealthTracker,
onError?: (error: Error, context: string) => void
onError?: (error: Error, context: string) => void,
protocol: "nip04" | "nip17" = "nip17"
): Promise<void> {
const ciphertext = await encrypt(sk, toPubkey, text);
const reply = finalizeEvent(
{
kind: 4,
content: ciphertext,
tags: [["p", toPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
sk
);
// Build the event(s) to publish based on protocol
let eventsToPublish: Event[];
if (protocol === "nip17") {
// NIP-17: Create gift-wrapped private DM
// wrapManyEvents returns [senderCopy, recipientCopy]
const wrappedEvents = nip17.wrapManyEvents(
sk,
[{ publicKey: toPubkey }],
text
);
// Publish both sender copy (for our inbox) and recipient copy
eventsToPublish = wrappedEvents;
} else {
// NIP-04: Legacy encrypted DM
const ciphertext = await encrypt(sk, toPubkey, text);
const reply = finalizeEvent(
{
kind: EncryptedDirectMessage,
content: ciphertext,
tags: [["p", toPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
sk
);
eventsToPublish = [reply];
}
// Sort relays by health score (best first)
const sortedRelays = healthTracker.getSortedRelays(relays);
// Try relays in order of health, respecting circuit breakers
let lastError: Error | undefined;
for (const relay of sortedRelays) {
const cb = circuitBreakers.get(relay);
// Publish all events - for NIP-17 this includes both sender and recipient copies
for (const eventToPublish of eventsToPublish) {
// Try relays in order of health, respecting circuit breakers
let lastError: Error | undefined;
let published = false;
// Skip if circuit breaker is open
if (cb && !cb.canAttempt()) {
continue;
for (const relay of sortedRelays) {
const cb = circuitBreakers.get(relay);
// Skip if circuit breaker is open
if (cb && !cb.canAttempt()) {
continue;
}
const startTime = Date.now();
try {
// pool.publish returns Promise<string>[], await the first promise
await pool.publish([relay], eventToPublish)[0];
const latency = Date.now() - startTime;
// Record success
cb?.recordSuccess();
healthTracker.recordSuccess(relay, latency);
published = true;
break; // Success - move to next event
} catch (err) {
lastError = err as Error;
const latency = Date.now() - startTime;
// Record failure
cb?.recordFailure();
healthTracker.recordFailure(relay);
metrics.emit("relay.error", 1, { relay, latency });
onError?.(lastError, `publish to ${relay}`);
}
}
const startTime = Date.now();
try {
await pool.publish([relay], reply);
const latency = Date.now() - startTime;
// Record success
cb?.recordSuccess();
healthTracker.recordSuccess(relay, latency);
return; // Success - exit early
} catch (err) {
lastError = err as Error;
const latency = Date.now() - startTime;
// Record failure
cb?.recordFailure();
healthTracker.recordFailure(relay);
metrics.emit("relay.error", 1, { relay, latency });
onError?.(lastError, `publish to ${relay}`);
if (!published) {
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
}
}
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
}
// ============================================================================

View File

@ -150,7 +150,8 @@ export async function publishProfileEvent(
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
});
await Promise.race([pool.publish([relay], event), timeoutPromise]);
// pool.publish returns Promise<string>[], get the first promise
await Promise.race([pool.publish([relay], event)[0], timeoutPromise]);
successes.push(relay);
} catch (err) {

View File

@ -1,5 +1,5 @@
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
import { getPublicKeyFromPrivate, type DmProtocol } from "./nostr-bus.js";
import { DEFAULT_RELAYS } from "./nostr-bus.js";
import type { NostrProfile } from "./config-schema.js";
@ -9,6 +9,7 @@ export interface NostrAccountConfig {
privateKey?: string;
relays?: string[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
dmProtocol?: DmProtocol;
allowFrom?: Array<string | number>;
profile?: NostrProfile;
}
@ -21,6 +22,7 @@ export interface ResolvedNostrAccount {
privateKey: string;
publicKey: string;
relays: string[];
dmProtocol: DmProtocol;
profile?: NostrProfile;
config: NostrAccountConfig;
}
@ -85,6 +87,7 @@ export function resolveNostrAccount(opts: {
privateKey,
publicKey,
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
dmProtocol: nostrCfg?.dmProtocol ?? "dual",
profile: nostrCfg?.profile,
config: {
enabled: nostrCfg?.enabled,
@ -92,6 +95,7 @@ export function resolveNostrAccount(opts: {
privateKey: nostrCfg?.privateKey,
relays: nostrCfg?.relays,
dmPolicy: nostrCfg?.dmPolicy,
dmProtocol: nostrCfg?.dmProtocol,
allowFrom: nostrCfg?.allowFrom,
profile: nostrCfg?.profile,
},