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:
parent
109ac1c549
commit
3130c65e74
@ -32,7 +32,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
selectionLabel: "Nostr",
|
selectionLabel: "Nostr",
|
||||||
docsPath: "/channels/nostr",
|
docsPath: "/channels/nostr",
|
||||||
docsLabel: "nostr",
|
docsLabel: "nostr",
|
||||||
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
blurb: "Decentralized DMs via Nostr relays (NIP-04/NIP-17)",
|
||||||
order: 100,
|
order: 100,
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@ -218,21 +218,88 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
privateKey: account.privateKey,
|
privateKey: account.privateKey,
|
||||||
relays: account.relays,
|
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)}...`);
|
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
|
||||||
|
|
||||||
// Forward to moltbot's message pipeline
|
try {
|
||||||
await runtime.channel.reply.handleInboundMessage({
|
// Load config for routing and session handling
|
||||||
channel: "nostr",
|
const config = runtime.config.loadConfig();
|
||||||
accountId: account.accountId,
|
|
||||||
senderId: senderPubkey,
|
// Resolve agent route for this DM
|
||||||
chatType: "direct",
|
const route = runtime.channel.routing.resolveAgentRoute({
|
||||||
chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
|
cfg: config,
|
||||||
text,
|
channel: "nostr",
|
||||||
reply: async (responseText: string) => {
|
accountId: account.accountId,
|
||||||
await reply(responseText);
|
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) => {
|
onError: (error, context) => {
|
||||||
ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
|
ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
|
||||||
|
|||||||
@ -75,6 +75,14 @@ export const NostrConfigSchema = z.object({
|
|||||||
/** DM access policy: pairing, allowlist, open, or disabled */
|
/** DM access policy: pairing, allowlist, open, or disabled */
|
||||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
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) */
|
/** Allowed sender pubkeys (npub or hex format) */
|
||||||
allowFrom: z.array(allowFromEntry).optional(),
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,17 @@ import {
|
|||||||
verifyEvent,
|
verifyEvent,
|
||||||
nip19,
|
nip19,
|
||||||
type Event,
|
type Event,
|
||||||
|
type Filter,
|
||||||
} from "nostr-tools";
|
} from "nostr-tools";
|
||||||
import { decrypt, encrypt } from "nostr-tools/nip04";
|
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 {
|
import {
|
||||||
readNostrBusState,
|
readNostrBusState,
|
||||||
@ -55,6 +64,9 @@ const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
|
|||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** DM protocol preference */
|
||||||
|
export type DmProtocol = "dual" | "nip17" | "nip04";
|
||||||
|
|
||||||
export interface NostrBusOptions {
|
export interface NostrBusOptions {
|
||||||
/** Private key in hex or nsec format */
|
/** Private key in hex or nsec format */
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
@ -62,6 +74,8 @@ export interface NostrBusOptions {
|
|||||||
relays?: string[];
|
relays?: string[];
|
||||||
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
|
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
|
||||||
accountId?: string;
|
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 */
|
/** Called when a DM is received */
|
||||||
onMessage: (
|
onMessage: (
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
@ -344,6 +358,7 @@ export async function startNostrBus(
|
|||||||
const {
|
const {
|
||||||
privateKey,
|
privateKey,
|
||||||
relays = DEFAULT_RELAYS,
|
relays = DEFAULT_RELAYS,
|
||||||
|
dmProtocol = "dual",
|
||||||
onMessage,
|
onMessage,
|
||||||
onError,
|
onError,
|
||||||
onEose,
|
onEose,
|
||||||
@ -420,7 +435,7 @@ export async function startNostrBus(
|
|||||||
|
|
||||||
const inflight = new Set<string>();
|
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> {
|
async function handleEvent(event: Event): Promise<void> {
|
||||||
try {
|
try {
|
||||||
metrics.emit("event.received");
|
metrics.emit("event.received");
|
||||||
@ -432,14 +447,10 @@ export async function startNostrBus(
|
|||||||
}
|
}
|
||||||
inflight.add(event.id);
|
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)
|
// 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");
|
metrics.emit("event.rejected.stale");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -468,35 +479,87 @@ export async function startNostrBus(
|
|||||||
seen.add(event.id);
|
seen.add(event.id);
|
||||||
metrics.emit("memory.seen_tracker_size", seen.size());
|
metrics.emit("memory.seen_tracker_size", seen.size());
|
||||||
|
|
||||||
// Decrypt the message
|
// Variables to be set by protocol-specific handling
|
||||||
let plaintext: string;
|
let plaintext: string;
|
||||||
try {
|
let senderPubkey: string;
|
||||||
plaintext = await decrypt(sk, event.pubkey, event.content);
|
|
||||||
|
// 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");
|
metrics.emit("decrypt.success");
|
||||||
} catch (err) {
|
} else if (event.kind === EncryptedDirectMessage) {
|
||||||
metrics.emit("decrypt.failure");
|
// NIP-04: Legacy encrypted DM
|
||||||
metrics.emit("event.rejected.decrypt_failed");
|
// Self-message loop prevention for NIP-04
|
||||||
onError?.(err as Error, `decrypt from ${event.pubkey}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reply function (try relays by health score)
|
// 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> => {
|
const replyTo = async (text: string): Promise<void> => {
|
||||||
await sendEncryptedDm(
|
await sendEncryptedDm(
|
||||||
pool,
|
pool,
|
||||||
sk,
|
sk,
|
||||||
event.pubkey,
|
senderPubkey,
|
||||||
text,
|
text,
|
||||||
relays,
|
relays,
|
||||||
metrics,
|
metrics,
|
||||||
circuitBreakers,
|
circuitBreakers,
|
||||||
healthTracker,
|
healthTracker,
|
||||||
onError
|
onError,
|
||||||
|
replyProtocol
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call the message handler
|
// Call the message handler
|
||||||
await onMessage(event.pubkey, plaintext, replyTo);
|
await onMessage(senderPubkey, plaintext, replyTo);
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
metrics.emit("event.processed");
|
metrics.emit("event.processed");
|
||||||
@ -510,33 +573,76 @@ export async function startNostrBus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = pool.subscribeMany(
|
// Build subscription filters based on dmProtocol setting
|
||||||
relays,
|
// NIP-17 gift wraps have randomized timestamps up to 2 days in the past,
|
||||||
[{ kinds: [4], "#p": [pk], since }],
|
// so we need a much older `since` for kind 1059 to avoid relay filtering
|
||||||
{
|
const NIP17_LOOKBACK_SEC = 48 * 60 * 60; // 48 hours
|
||||||
onevent: handleEvent,
|
const nip17Since = Math.floor(Date.now() / 1000) - NIP17_LOOKBACK_SEC;
|
||||||
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"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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> => {
|
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
|
||||||
await sendEncryptedDm(
|
await sendEncryptedDm(
|
||||||
pool,
|
pool,
|
||||||
@ -547,7 +653,8 @@ export async function startNostrBus(
|
|||||||
metrics,
|
metrics,
|
||||||
circuitBreakers,
|
circuitBreakers,
|
||||||
healthTracker,
|
healthTracker,
|
||||||
onError
|
onError,
|
||||||
|
defaultSendProtocol
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -592,7 +699,10 @@ export async function startNostrBus(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
close: () => {
|
close: () => {
|
||||||
sub.close();
|
// Close all active subscriptions
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
sub.close();
|
||||||
|
}
|
||||||
seen.stop();
|
seen.stop();
|
||||||
// Flush pending state write synchronously on close
|
// Flush pending state write synchronously on close
|
||||||
if (pendingWrite) {
|
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(
|
async function sendEncryptedDm(
|
||||||
pool: SimplePool,
|
pool: SimplePool,
|
||||||
@ -629,56 +739,83 @@ async function sendEncryptedDm(
|
|||||||
metrics: NostrMetrics,
|
metrics: NostrMetrics,
|
||||||
circuitBreakers: Map<string, CircuitBreaker>,
|
circuitBreakers: Map<string, CircuitBreaker>,
|
||||||
healthTracker: RelayHealthTracker,
|
healthTracker: RelayHealthTracker,
|
||||||
onError?: (error: Error, context: string) => void
|
onError?: (error: Error, context: string) => void,
|
||||||
|
protocol: "nip04" | "nip17" = "nip17"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ciphertext = await encrypt(sk, toPubkey, text);
|
// Build the event(s) to publish based on protocol
|
||||||
const reply = finalizeEvent(
|
let eventsToPublish: Event[];
|
||||||
{
|
|
||||||
kind: 4,
|
if (protocol === "nip17") {
|
||||||
content: ciphertext,
|
// NIP-17: Create gift-wrapped private DM
|
||||||
tags: [["p", toPubkey]],
|
// wrapManyEvents returns [senderCopy, recipientCopy]
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
const wrappedEvents = nip17.wrapManyEvents(
|
||||||
},
|
sk,
|
||||||
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)
|
// Sort relays by health score (best first)
|
||||||
const sortedRelays = healthTracker.getSortedRelays(relays);
|
const sortedRelays = healthTracker.getSortedRelays(relays);
|
||||||
|
|
||||||
// Try relays in order of health, respecting circuit breakers
|
// Publish all events - for NIP-17 this includes both sender and recipient copies
|
||||||
let lastError: Error | undefined;
|
for (const eventToPublish of eventsToPublish) {
|
||||||
for (const relay of sortedRelays) {
|
// Try relays in order of health, respecting circuit breakers
|
||||||
const cb = circuitBreakers.get(relay);
|
let lastError: Error | undefined;
|
||||||
|
let published = false;
|
||||||
|
|
||||||
// Skip if circuit breaker is open
|
for (const relay of sortedRelays) {
|
||||||
if (cb && !cb.canAttempt()) {
|
const cb = circuitBreakers.get(relay);
|
||||||
continue;
|
|
||||||
|
// 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();
|
if (!published) {
|
||||||
try {
|
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -150,7 +150,8 @@ export async function publishProfileEvent(
|
|||||||
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
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);
|
successes.push(relay);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
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 { DEFAULT_RELAYS } from "./nostr-bus.js";
|
||||||
import type { NostrProfile } from "./config-schema.js";
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export interface NostrAccountConfig {
|
|||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
relays?: string[];
|
relays?: string[];
|
||||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
dmProtocol?: DmProtocol;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
profile?: NostrProfile;
|
profile?: NostrProfile;
|
||||||
}
|
}
|
||||||
@ -21,6 +22,7 @@ export interface ResolvedNostrAccount {
|
|||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
relays: string[];
|
relays: string[];
|
||||||
|
dmProtocol: DmProtocol;
|
||||||
profile?: NostrProfile;
|
profile?: NostrProfile;
|
||||||
config: NostrAccountConfig;
|
config: NostrAccountConfig;
|
||||||
}
|
}
|
||||||
@ -85,6 +87,7 @@ export function resolveNostrAccount(opts: {
|
|||||||
privateKey,
|
privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
|
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
|
||||||
|
dmProtocol: nostrCfg?.dmProtocol ?? "dual",
|
||||||
profile: nostrCfg?.profile,
|
profile: nostrCfg?.profile,
|
||||||
config: {
|
config: {
|
||||||
enabled: nostrCfg?.enabled,
|
enabled: nostrCfg?.enabled,
|
||||||
@ -92,6 +95,7 @@ export function resolveNostrAccount(opts: {
|
|||||||
privateKey: nostrCfg?.privateKey,
|
privateKey: nostrCfg?.privateKey,
|
||||||
relays: nostrCfg?.relays,
|
relays: nostrCfg?.relays,
|
||||||
dmPolicy: nostrCfg?.dmPolicy,
|
dmPolicy: nostrCfg?.dmPolicy,
|
||||||
|
dmProtocol: nostrCfg?.dmProtocol,
|
||||||
allowFrom: nostrCfg?.allowFrom,
|
allowFrom: nostrCfg?.allowFrom,
|
||||||
profile: nostrCfg?.profile,
|
profile: nostrCfg?.profile,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user