@elizaos/plugin-nostr 2.0.3-beta.5 → 2.0.3-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,980 @@
1
+ /**
2
+ * Nostr service implementation for ElizaOS.
3
+ */
4
+ import { ChannelType, createUniqueUuid, logger, Service, } from "@elizaos/core";
5
+ import { finalizeEvent, getPublicKey, SimplePool, verifyEvent, } from "nostr-tools";
6
+ import { decrypt, encrypt } from "nostr-tools/nip04";
7
+ import { listNostrAccountIds, normalizeNostrAccountId, readNostrAccountId, resolveDefaultNostrAccountId, resolveNostrAccountSettings, } from "./accounts.js";
8
+ import { NOSTR_SERVICE_NAME, NostrConfigurationError, NostrEventTypes, normalizePubkey, pubkeyToNpub, splitMessageForNostr, validatePrivateKey, } from "./types.js";
9
+ const NOSTR_CONNECTOR_CONTEXTS = ["social", "connectors"];
10
+ const NOSTR_CONNECTOR_CAPABILITIES = [
11
+ "send_message",
12
+ "fetch_messages",
13
+ "resolve_targets",
14
+ "user_context",
15
+ ];
16
+ function getNostrTargetMetadata(target) {
17
+ const metadata = target.metadata;
18
+ return metadata && typeof metadata === "object"
19
+ ? metadata
20
+ : undefined;
21
+ }
22
+ /**
23
+ * Append agent-generated attachment URLs to Nostr text (#8876). Nostr has no
24
+ * separate attachment field — kind:1 notes and DMs carry media as URLs in the
25
+ * content, which clients render inline. So a generated/sent attachment becomes a
26
+ * URL appended to the text rather than being dropped. http(s) URLs only.
27
+ */
28
+ function appendNostrAttachmentUrls(text, content) {
29
+ const urls = Array.isArray(content.attachments)
30
+ ? content.attachments
31
+ .map((media) => (typeof media?.url === "string" ? media.url.trim() : ""))
32
+ .filter((url) => /^https?:\/\//i.test(url))
33
+ : [];
34
+ if (urls.length === 0)
35
+ return text;
36
+ return [text, ...urls].filter(Boolean).join("\n");
37
+ }
38
+ function clampLimit(value, defaultValue, max) {
39
+ if (!Number.isFinite(value)) {
40
+ return defaultValue;
41
+ }
42
+ return Math.min(Math.max(1, Math.floor(value)), max);
43
+ }
44
+ function isSafeRelayUrl(relay) {
45
+ try {
46
+ const parsed = new URL(relay);
47
+ return ((parsed.protocol === "wss:" || parsed.protocol === "ws:") &&
48
+ !parsed.username &&
49
+ !parsed.password);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ function isEventShape(value) {
56
+ if (!value || typeof value !== "object")
57
+ return false;
58
+ const event = value;
59
+ return (typeof event.id === "string" &&
60
+ typeof event.pubkey === "string" &&
61
+ typeof event.content === "string" &&
62
+ typeof event.kind === "number" &&
63
+ Number.isFinite(event.kind) &&
64
+ typeof event.created_at === "number" &&
65
+ Number.isFinite(event.created_at) &&
66
+ Array.isArray(event.tags) &&
67
+ typeof event.sig === "string");
68
+ }
69
+ function normalizeEventTags(tags) {
70
+ if (!Array.isArray(tags))
71
+ return [];
72
+ return tags
73
+ .filter(Array.isArray)
74
+ .map((tag) => tag.filter((value) => typeof value === "string"))
75
+ .filter((tag) => tag.length > 0);
76
+ }
77
+ function createdAtMs(event) {
78
+ return Number.isFinite(event.created_at) && event.created_at > 0
79
+ ? event.created_at * 1000
80
+ : Date.now();
81
+ }
82
+ export class NostrService extends Service {
83
+ static serviceType = NOSTR_SERVICE_NAME;
84
+ capabilityDescription = "Provides Nostr protocol integration for encrypted direct messages";
85
+ settings = null;
86
+ pool = null;
87
+ privateKey = null;
88
+ connected = false;
89
+ seenEventIds = new Set();
90
+ accountServices = new Map();
91
+ /**
92
+ * Start the Nostr service.
93
+ */
94
+ static async start(runtime) {
95
+ logger.info("Starting Nostr service...");
96
+ const service = new NostrService(runtime);
97
+ await service.initialize();
98
+ return service;
99
+ }
100
+ static registerSendHandlers(runtime, serviceInstance) {
101
+ if (!serviceInstance) {
102
+ return;
103
+ }
104
+ for (const accountService of serviceInstance.getAccountServiceList()) {
105
+ const accountId = accountService.getAccountId(runtime);
106
+ accountService.registerPostConnector(runtime);
107
+ const sendHandler = accountService.handleSendMessage.bind(accountService);
108
+ if (typeof runtime.registerMessageConnector === "function") {
109
+ const registration = {
110
+ source: "nostr",
111
+ accountId,
112
+ label: "Nostr",
113
+ description: "Nostr encrypted DM connector using NIP-04.",
114
+ capabilities: [...NOSTR_CONNECTOR_CAPABILITIES],
115
+ supportedTargetKinds: ["user", "contact"],
116
+ contexts: [...NOSTR_CONNECTOR_CONTEXTS],
117
+ metadata: {
118
+ accountId,
119
+ service: NOSTR_SERVICE_NAME,
120
+ },
121
+ resolveTargets: accountService.resolveConnectorTargets.bind(accountService),
122
+ listRecentTargets: accountService.listRecentConnectorTargets.bind(accountService),
123
+ getUserContext: accountService.getConnectorUserContext.bind(accountService),
124
+ fetchMessages: accountService.fetchConnectorMessages.bind(accountService),
125
+ contentShaping: {
126
+ systemPromptFragment: "For Nostr encrypted DMs, keep messages concise. Long messages may be split by the connector for relay delivery.",
127
+ constraints: {
128
+ supportsMarkdown: false,
129
+ channelType: ChannelType.DM,
130
+ },
131
+ },
132
+ sendHandler,
133
+ };
134
+ runtime.registerMessageConnector(registration);
135
+ runtime.logger.info({ src: "plugin:nostr", agentId: runtime.agentId }, "Registered Nostr DM connector");
136
+ }
137
+ }
138
+ }
139
+ registerPostConnector(runtime) {
140
+ const withPostConnector = runtime;
141
+ if (typeof withPostConnector.registerPostConnector !== "function") {
142
+ return;
143
+ }
144
+ const accountId = this.getAccountId(runtime);
145
+ withPostConnector.registerPostConnector({
146
+ source: "nostr",
147
+ accountId,
148
+ label: "Nostr",
149
+ description: "Nostr public note connector for publishing kind:1 notes, reading relay feeds, and NIP-50 relay search where supported.",
150
+ capabilities: ["post", "fetch_feed", "search_posts"],
151
+ contexts: ["social", "social_posting", "connectors"],
152
+ metadata: {
153
+ accountId,
154
+ service: NOSTR_SERVICE_NAME,
155
+ },
156
+ postHandler: this.handleSendPost.bind(this),
157
+ fetchFeed: this.fetchConnectorFeed.bind(this),
158
+ searchPosts: this.searchConnectorPosts.bind(this),
159
+ contentShaping: {
160
+ systemPromptFragment: "For Nostr notes, write plain public text. Hashtags and nostr: references are acceptable when useful; avoid Markdown-specific formatting.",
161
+ constraints: {
162
+ supportsMarkdown: false,
163
+ channelType: ChannelType.FEED,
164
+ },
165
+ },
166
+ });
167
+ runtime.logger.info({ src: "plugin:nostr", agentId: runtime.agentId }, "Registered Nostr post connector");
168
+ }
169
+ /**
170
+ * Initialize the service.
171
+ */
172
+ async initialize() {
173
+ const startedAccounts = [];
174
+ for (const accountId of listNostrAccountIds(this.runtime)) {
175
+ const settings = resolveNostrAccountSettings(this.runtime, accountId);
176
+ if (settings.enabled === false) {
177
+ continue;
178
+ }
179
+ const accountService = new NostrService(this.runtime);
180
+ await accountService.initializeAccount(accountId);
181
+ this.accountServices.set(accountService.getAccountId(), accountService);
182
+ startedAccounts.push(accountService.getAccountId());
183
+ }
184
+ if (startedAccounts.length === 0) {
185
+ logger.warn("No enabled Nostr accounts configured");
186
+ return;
187
+ }
188
+ logger.info(`Nostr service started ${startedAccounts.length} account(s): ${startedAccounts.join(", ")}`);
189
+ }
190
+ async initializeAccount(accountId) {
191
+ this.settings = this.loadSettings(accountId);
192
+ this.validateSettings();
193
+ // Initialize private key
194
+ this.privateKey = validatePrivateKey(this.settings.privateKey);
195
+ // Initialize SimplePool
196
+ this.pool = new SimplePool();
197
+ // Start subscription
198
+ await this.startSubscription();
199
+ this.connected = true;
200
+ logger.info(`Nostr service started (pubkey: ${this.settings.publicKey.slice(0, 16)}...)`);
201
+ this.runtime.emitEvent(NostrEventTypes.CONNECTION_READY, {
202
+ runtime: this.runtime,
203
+ service: this,
204
+ accountId: this.getAccountId(),
205
+ });
206
+ }
207
+ /**
208
+ * Stop the Nostr service.
209
+ */
210
+ async stop() {
211
+ logger.info("Stopping Nostr service...");
212
+ if (this.accountServices.size > 0) {
213
+ await Promise.all(Array.from(this.accountServices.values()).map((service) => service.stop()));
214
+ this.accountServices.clear();
215
+ logger.info("Nostr service stopped");
216
+ return;
217
+ }
218
+ this.connected = false;
219
+ if (this.pool) {
220
+ this.pool.close(this.settings?.relays || []);
221
+ this.pool = null;
222
+ }
223
+ this.privateKey = null;
224
+ this.seenEventIds.clear();
225
+ logger.info("Nostr service stopped");
226
+ }
227
+ getAccountServiceList() {
228
+ return this.accountServices.size > 0 ? Array.from(this.accountServices.values()) : [this];
229
+ }
230
+ getDefaultAccountService() {
231
+ if (!this.accountServices || this.accountServices.size === 0) {
232
+ return this;
233
+ }
234
+ const defaultAccountId = normalizeNostrAccountId(resolveDefaultNostrAccountId(this.runtime));
235
+ return (this.accountServices.get(defaultAccountId) ?? Array.from(this.accountServices.values())[0]);
236
+ }
237
+ getAccountService(accountId) {
238
+ if (!this.accountServices || this.accountServices.size === 0) {
239
+ const ownAccountId = this.getAccountId();
240
+ if (normalizeNostrAccountId(accountId) !== ownAccountId) {
241
+ throw new Error(`Nostr account '${accountId}' is not available in this service instance`);
242
+ }
243
+ return this;
244
+ }
245
+ const normalized = normalizeNostrAccountId(accountId);
246
+ const service = this.accountServices.get(normalized);
247
+ if (!service) {
248
+ throw new Error(`Nostr account '${normalized}' is not available`);
249
+ }
250
+ return service;
251
+ }
252
+ /**
253
+ * Load settings from runtime configuration.
254
+ */
255
+ loadSettings(accountId) {
256
+ const runtime = this.runtime;
257
+ if (!runtime) {
258
+ throw new NostrConfigurationError("Runtime not initialized");
259
+ }
260
+ const resolved = resolveNostrAccountSettings(runtime, accountId);
261
+ const allowFrom = resolved.allowFrom.map((p) => {
262
+ try {
263
+ return normalizePubkey(p.trim());
264
+ }
265
+ catch {
266
+ return p.trim();
267
+ }
268
+ });
269
+ // Derive public key
270
+ let publicKey = "";
271
+ if (resolved.privateKey) {
272
+ try {
273
+ const sk = validatePrivateKey(resolved.privateKey);
274
+ publicKey = getPublicKey(sk);
275
+ }
276
+ catch {
277
+ // Will be caught in validation
278
+ }
279
+ }
280
+ return {
281
+ ...resolved,
282
+ publicKey,
283
+ allowFrom,
284
+ };
285
+ }
286
+ /**
287
+ * Validate the settings.
288
+ */
289
+ validateSettings() {
290
+ const settings = this.settings;
291
+ if (!settings) {
292
+ throw new NostrConfigurationError("Settings not loaded");
293
+ }
294
+ if (!settings.privateKey) {
295
+ throw new NostrConfigurationError("NOSTR_PRIVATE_KEY is required", "NOSTR_PRIVATE_KEY");
296
+ }
297
+ if (!settings.publicKey) {
298
+ throw new NostrConfigurationError("Invalid private key - could not derive public key", "NOSTR_PRIVATE_KEY");
299
+ }
300
+ if (settings.relays.length === 0) {
301
+ throw new NostrConfigurationError("At least one relay is required", "NOSTR_RELAYS");
302
+ }
303
+ // Validate relay URLs
304
+ for (const relay of settings.relays) {
305
+ if (!isSafeRelayUrl(relay)) {
306
+ throw new NostrConfigurationError(`Invalid relay URL: ${relay}`, "NOSTR_RELAYS");
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * Start the DM subscription.
312
+ */
313
+ async startSubscription() {
314
+ const settings = this.settings;
315
+ const pool = this.pool;
316
+ const privateKey = this.privateKey;
317
+ if (!settings || !pool || !privateKey) {
318
+ throw new NostrConfigurationError("Service not properly initialized");
319
+ }
320
+ const pk = settings.publicKey;
321
+ const since = Math.floor(Date.now() / 1000) - 120; // Last 2 minutes
322
+ // Subscribe to DMs (kind:4)
323
+ const filter = { kinds: [4], "#p": [pk], since };
324
+ pool.subscribeMany(settings.relays, filter, {
325
+ onevent: async (event) => {
326
+ await this.handleEvent(event);
327
+ },
328
+ oneose: () => {
329
+ logger.debug("Nostr EOSE received - initial sync complete");
330
+ },
331
+ });
332
+ logger.info(`Subscribed to ${settings.relays.length} relay(s)`);
333
+ }
334
+ /**
335
+ * Handle an incoming event.
336
+ */
337
+ async handleEvent(event) {
338
+ if (!isEventShape(event)) {
339
+ logger.warn("Ignoring malformed Nostr event payload");
340
+ return;
341
+ }
342
+ const settings = this.settings;
343
+ const privateKey = this.privateKey;
344
+ if (!settings || !privateKey) {
345
+ return;
346
+ }
347
+ // Dedupe
348
+ if (this.seenEventIds.has(event.id)) {
349
+ return;
350
+ }
351
+ this.seenEventIds.add(event.id);
352
+ // Limit seen set size
353
+ if (this.seenEventIds.size > 10000) {
354
+ const toDelete = Array.from(this.seenEventIds).slice(0, 5000);
355
+ for (const id of toDelete) {
356
+ this.seenEventIds.delete(id);
357
+ }
358
+ }
359
+ // Skip self-messages
360
+ if (event.pubkey === settings.publicKey) {
361
+ return;
362
+ }
363
+ // Verify signature
364
+ if (!verifyEvent(event)) {
365
+ logger.warn(`Invalid signature on event ${event.id}`);
366
+ return;
367
+ }
368
+ // Check if this is addressed to us
369
+ const isToUs = event.tags.some((t) => t[0] === "p" && t[1] === settings.publicKey);
370
+ if (!isToUs) {
371
+ return;
372
+ }
373
+ // Check DM policy
374
+ if (settings.dmPolicy === "disabled") {
375
+ logger.debug(`DM from ${event.pubkey} blocked - DMs disabled`);
376
+ return;
377
+ }
378
+ if (settings.dmPolicy === "allowlist") {
379
+ const allowed = settings.allowFrom.includes(event.pubkey);
380
+ if (!allowed) {
381
+ logger.debug(`DM from ${event.pubkey} blocked - not in allowlist`);
382
+ return;
383
+ }
384
+ }
385
+ // Decrypt the message (NIP-04)
386
+ let plaintext;
387
+ try {
388
+ logger.debug({ src: "plugin:nostr", op: "nip04:decrypt", from: event.pubkey }, "Decrypting Nostr DM");
389
+ plaintext = decrypt(privateKey, event.pubkey, event.content);
390
+ }
391
+ catch (err) {
392
+ logger.warn({ src: "plugin:nostr", op: "nip04:decrypt", from: event.pubkey, err: String(err) }, "Failed to decrypt Nostr DM");
393
+ return;
394
+ }
395
+ logger.debug(`Received DM from ${event.pubkey.slice(0, 8)}...: ${plaintext.slice(0, 50)}...`);
396
+ // Emit event
397
+ if (this.runtime) {
398
+ this.runtime.emitEvent(NostrEventTypes.MESSAGE_RECEIVED, {
399
+ runtime: this.runtime,
400
+ accountId: this.getAccountId(),
401
+ from: event.pubkey,
402
+ text: plaintext,
403
+ eventId: event.id,
404
+ createdAt: event.created_at,
405
+ });
406
+ }
407
+ }
408
+ /**
409
+ * Check if the service is connected.
410
+ */
411
+ isConnected() {
412
+ if (this.accountServices.size > 0) {
413
+ return Array.from(this.accountServices.values()).some((service) => service.isConnected());
414
+ }
415
+ return this.connected;
416
+ }
417
+ /**
418
+ * Get the bot's public key in hex format.
419
+ */
420
+ getPublicKey() {
421
+ if (this.accountServices.size > 0) {
422
+ return this.getDefaultAccountService().getPublicKey();
423
+ }
424
+ return this.settings?.publicKey || "";
425
+ }
426
+ getAccountId(runtime) {
427
+ if (this.accountServices?.size > 0) {
428
+ return this.getDefaultAccountService().getAccountId(runtime);
429
+ }
430
+ return normalizeNostrAccountId(this.settings?.accountId ?? (runtime ? resolveDefaultNostrAccountId(runtime) : undefined));
431
+ }
432
+ /**
433
+ * Get the bot's public key in npub format.
434
+ */
435
+ getNpub() {
436
+ const pk = this.getPublicKey();
437
+ return pk ? pubkeyToNpub(pk) : "";
438
+ }
439
+ /**
440
+ * Get connected relays.
441
+ */
442
+ getRelays() {
443
+ if (this.accountServices.size > 0) {
444
+ return this.getDefaultAccountService().getRelays();
445
+ }
446
+ return this.settings?.relays || [];
447
+ }
448
+ async handleSendMessage(_runtime, target, content) {
449
+ const requestedAccountId = normalizeNostrAccountId(target.accountId ?? readNostrAccountId(content, target) ?? this.getAccountId());
450
+ if (this.accountServices.size > 0) {
451
+ await this.getAccountService(requestedAccountId).handleSendMessage(_runtime, target, content);
452
+ return;
453
+ }
454
+ if (requestedAccountId !== this.getAccountId()) {
455
+ throw new Error(`Nostr account '${requestedAccountId}' is not available in this service instance`);
456
+ }
457
+ const text = appendNostrAttachmentUrls(typeof content.text === "string" ? content.text.trim() : "", content);
458
+ if (!text) {
459
+ throw new Error("Nostr DM connector requires non-empty text content or an attachment.");
460
+ }
461
+ const metadata = getNostrTargetMetadata(target);
462
+ const targetPubkey = (typeof metadata?.nostrPubkey === "string" ? metadata.nostrPubkey : undefined) ??
463
+ (typeof target.entityId === "string" ? target.entityId : undefined) ??
464
+ target.channelId ??
465
+ target.threadId;
466
+ if (!targetPubkey) {
467
+ throw new Error("Nostr DM connector requires a pubkey target.");
468
+ }
469
+ const chunks = splitMessageForNostr(text);
470
+ for (const chunk of chunks) {
471
+ const result = await this.sendDm({ toPubkey: targetPubkey, text: chunk });
472
+ if (!result.success) {
473
+ throw new Error(result.error ?? "Failed to send Nostr DM");
474
+ }
475
+ }
476
+ }
477
+ async handleSendPost(runtime, content) {
478
+ const requestedAccountId = normalizeNostrAccountId(readNostrAccountId(content) ?? this.getAccountId());
479
+ if (this.accountServices.size > 0) {
480
+ return this.getAccountService(requestedAccountId).handleSendPost(runtime, content);
481
+ }
482
+ if (requestedAccountId !== this.getAccountId()) {
483
+ throw new Error(`Nostr account '${requestedAccountId}' is not available in this service instance`);
484
+ }
485
+ const text = appendNostrAttachmentUrls(typeof content.text === "string" ? content.text.trim() : "", content);
486
+ if (!text) {
487
+ throw new Error("Nostr post connector requires non-empty text content or an attachment.");
488
+ }
489
+ const result = await this.publishNote(text);
490
+ if (!result.success || !result.eventId) {
491
+ throw new Error(result.error ?? "Failed to publish Nostr note");
492
+ }
493
+ const event = {
494
+ id: result.eventId,
495
+ pubkey: this.getPublicKey(),
496
+ kind: 1,
497
+ content: text,
498
+ created_at: Math.floor(Date.now() / 1000),
499
+ tags: [],
500
+ sig: "",
501
+ };
502
+ return this.nostrEventToPostMemory(runtime, event);
503
+ }
504
+ async fetchConnectorFeed(context, params = {}) {
505
+ const settings = this.settings;
506
+ const pool = this.pool;
507
+ if (!settings || !pool) {
508
+ return [];
509
+ }
510
+ const target = params.target ?? context.target;
511
+ const metadata = target ? getNostrTargetMetadata(target) : undefined;
512
+ const author = (typeof metadata?.nostrPubkey === "string" ? metadata.nostrPubkey : undefined) ??
513
+ (typeof target?.entityId === "string" ? target.entityId : undefined);
514
+ const normalizedAuthor = author ? normalizePubkey(author) : undefined;
515
+ const filter = {
516
+ kinds: [1],
517
+ limit: clampLimit(params.limit, 25, 100),
518
+ ...(normalizedAuthor ? { authors: [normalizedAuthor] } : {}),
519
+ ...(params.cursor && Number.isFinite(Number(params.cursor))
520
+ ? { until: Number(params.cursor) }
521
+ : {}),
522
+ };
523
+ const events = await pool.querySync(settings.relays, filter, { maxWait: 3000 });
524
+ return events
525
+ .sort((a, b) => b.created_at - a.created_at)
526
+ .map((event) => this.nostrEventToPostMemory(context.runtime, event));
527
+ }
528
+ async searchConnectorPosts(context, params) {
529
+ const query = params.query.trim();
530
+ if (!query) {
531
+ throw new Error("Nostr searchPosts connector requires a query.");
532
+ }
533
+ const settings = this.settings;
534
+ const pool = this.pool;
535
+ if (!settings || !pool) {
536
+ return [];
537
+ }
538
+ const filter = {
539
+ kinds: [1],
540
+ search: query,
541
+ limit: clampLimit(params.limit, 25, 100),
542
+ ...(params.cursor && Number.isFinite(Number(params.cursor))
543
+ ? { until: Number(params.cursor) }
544
+ : {}),
545
+ };
546
+ const events = await pool.querySync(settings.relays, filter, { maxWait: 3000 });
547
+ return events
548
+ .sort((a, b) => b.created_at - a.created_at)
549
+ .map((event) => this.nostrEventToPostMemory(context.runtime, event));
550
+ }
551
+ async fetchConnectorMessages(context, params = {}) {
552
+ const settings = this.settings;
553
+ const pool = this.pool;
554
+ const privateKey = this.privateKey;
555
+ if (!settings || !pool || !privateKey) {
556
+ return [];
557
+ }
558
+ const target = params.target ?? context.target;
559
+ const metadata = target ? getNostrTargetMetadata(target) : undefined;
560
+ const targetPubkeyRaw = (typeof metadata?.nostrPubkey === "string" ? metadata.nostrPubkey : undefined) ??
561
+ (typeof target?.entityId === "string" ? target.entityId : undefined);
562
+ const targetPubkey = targetPubkeyRaw ? normalizePubkey(targetPubkeyRaw) : undefined;
563
+ const limit = clampLimit(params.limit, 25, 100);
564
+ const filters = [
565
+ {
566
+ kinds: [4],
567
+ "#p": [settings.publicKey],
568
+ ...(targetPubkey ? { authors: [targetPubkey] } : {}),
569
+ limit,
570
+ },
571
+ ...(targetPubkey
572
+ ? [
573
+ {
574
+ kinds: [4],
575
+ authors: [settings.publicKey],
576
+ "#p": [targetPubkey],
577
+ limit,
578
+ },
579
+ ]
580
+ : []),
581
+ ];
582
+ const byId = new Map();
583
+ for (const filter of filters) {
584
+ const events = await pool.querySync(settings.relays, filter, { maxWait: 3000 });
585
+ for (const event of events) {
586
+ byId.set(event.id, event);
587
+ }
588
+ }
589
+ const memories = [];
590
+ for (const event of Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)) {
591
+ const isOwn = event.pubkey === settings.publicKey;
592
+ const peerPubkey = isOwn ? event.tags.find((tag) => tag[0] === "p")?.[1] : event.pubkey;
593
+ if (!peerPubkey) {
594
+ continue;
595
+ }
596
+ try {
597
+ const plaintext = decrypt(privateKey, peerPubkey, event.content);
598
+ memories.push(this.nostrEventToDmMemory(context.runtime, event, plaintext, peerPubkey));
599
+ }
600
+ catch (error) {
601
+ logger.debug({
602
+ src: "plugin:nostr",
603
+ op: "fetchConnectorMessages",
604
+ eventId: event.id,
605
+ error: error instanceof Error ? error.message : String(error),
606
+ }, "Skipping Nostr DM that could not be decrypted");
607
+ }
608
+ if (memories.length >= limit) {
609
+ break;
610
+ }
611
+ }
612
+ return memories;
613
+ }
614
+ async resolveConnectorTargets(query, _context) {
615
+ const trimmed = query.trim();
616
+ if (!trimmed) {
617
+ return this.listRecentConnectorTargets(_context);
618
+ }
619
+ let pubkey;
620
+ try {
621
+ pubkey = normalizePubkey(trimmed);
622
+ }
623
+ catch {
624
+ return [];
625
+ }
626
+ return [this.buildPubkeyTarget(pubkey, 1)];
627
+ }
628
+ async listRecentConnectorTargets(_context) {
629
+ const allowFrom = this.settings?.allowFrom ?? [];
630
+ return allowFrom.map((pubkey, index) => this.buildPubkeyTarget(pubkey, 0.8 - index * 0.01));
631
+ }
632
+ async getConnectorUserContext(entityId, _context) {
633
+ let pubkey;
634
+ try {
635
+ pubkey = normalizePubkey(entityId);
636
+ }
637
+ catch {
638
+ return null;
639
+ }
640
+ return {
641
+ entityId,
642
+ label: pubkeyToNpub(pubkey),
643
+ aliases: [pubkey, pubkeyToNpub(pubkey)],
644
+ handles: {
645
+ nostr: pubkeyToNpub(pubkey),
646
+ },
647
+ metadata: {
648
+ accountId: this.getAccountId(),
649
+ nostrPubkey: pubkey,
650
+ relays: this.getRelays(),
651
+ },
652
+ };
653
+ }
654
+ nostrEventToPostMemory(runtime, event) {
655
+ const createdAt = createdAtMs(event);
656
+ const entityId = event.pubkey === runtime.agentId
657
+ ? runtime.agentId
658
+ : createUniqueUuid(runtime, `nostr:user:${event.pubkey}`);
659
+ const roomId = createUniqueUuid(runtime, `nostr:feed:${event.pubkey}`);
660
+ return {
661
+ id: createUniqueUuid(runtime, `nostr:note:${event.id}`),
662
+ agentId: runtime.agentId,
663
+ entityId,
664
+ roomId,
665
+ createdAt,
666
+ content: {
667
+ text: event.content,
668
+ source: "nostr",
669
+ url: `nostr:${event.id}`,
670
+ channelType: ChannelType.FEED,
671
+ },
672
+ metadata: {
673
+ type: "message",
674
+ source: "nostr",
675
+ accountId: this.getAccountId(runtime),
676
+ provider: "nostr",
677
+ timestamp: createdAt,
678
+ fromBot: event.pubkey === this.getPublicKey(),
679
+ messageIdFull: event.id,
680
+ chatType: ChannelType.FEED,
681
+ sender: {
682
+ id: event.pubkey,
683
+ username: pubkeyToNpub(event.pubkey),
684
+ },
685
+ nostr: {
686
+ accountId: this.getAccountId(runtime),
687
+ eventId: event.id,
688
+ pubkey: event.pubkey,
689
+ npub: pubkeyToNpub(event.pubkey),
690
+ kind: event.kind,
691
+ tags: normalizeEventTags(event.tags),
692
+ },
693
+ },
694
+ };
695
+ }
696
+ nostrEventToDmMemory(runtime, event, plaintext, peerPubkey) {
697
+ const createdAt = createdAtMs(event);
698
+ const senderId = event.pubkey;
699
+ const entityId = senderId === runtime.agentId
700
+ ? runtime.agentId
701
+ : createUniqueUuid(runtime, `nostr:user:${senderId}`);
702
+ const roomId = createUniqueUuid(runtime, `nostr:dm:${peerPubkey}`);
703
+ return {
704
+ id: createUniqueUuid(runtime, `nostr:dm:${event.id}`),
705
+ agentId: runtime.agentId,
706
+ entityId,
707
+ roomId,
708
+ createdAt,
709
+ content: {
710
+ text: plaintext,
711
+ source: "nostr",
712
+ channelType: ChannelType.DM,
713
+ },
714
+ metadata: {
715
+ type: "message",
716
+ source: "nostr",
717
+ accountId: this.getAccountId(runtime),
718
+ provider: "nostr",
719
+ timestamp: createdAt,
720
+ fromBot: senderId === this.getPublicKey(),
721
+ messageIdFull: event.id,
722
+ chatType: ChannelType.DM,
723
+ sender: {
724
+ id: senderId,
725
+ username: pubkeyToNpub(senderId),
726
+ },
727
+ nostr: {
728
+ accountId: this.getAccountId(runtime),
729
+ eventId: event.id,
730
+ pubkey: senderId,
731
+ npub: pubkeyToNpub(senderId),
732
+ peerPubkey,
733
+ peerNpub: pubkeyToNpub(peerPubkey),
734
+ kind: event.kind,
735
+ tags: normalizeEventTags(event.tags),
736
+ },
737
+ },
738
+ };
739
+ }
740
+ buildPubkeyTarget(pubkey, score) {
741
+ return {
742
+ target: {
743
+ source: "nostr",
744
+ accountId: this.getAccountId(),
745
+ entityId: pubkey,
746
+ },
747
+ label: pubkeyToNpub(pubkey),
748
+ kind: "user",
749
+ description: "Nostr encrypted DM recipient",
750
+ score,
751
+ contexts: [...NOSTR_CONNECTOR_CONTEXTS],
752
+ metadata: {
753
+ accountId: this.getAccountId(),
754
+ nostrPubkey: pubkey,
755
+ },
756
+ };
757
+ }
758
+ /**
759
+ * Send a DM to a pubkey.
760
+ */
761
+ async sendDm(options) {
762
+ if (this.accountServices.size > 0) {
763
+ const accountId = normalizeNostrAccountId(options.accountId ?? this.getAccountId());
764
+ return this.getAccountService(accountId).sendDm(options);
765
+ }
766
+ const settings = this.settings;
767
+ const pool = this.pool;
768
+ const privateKey = this.privateKey;
769
+ if (!settings || !pool || !privateKey) {
770
+ return {
771
+ success: false,
772
+ error: "Service not initialized",
773
+ };
774
+ }
775
+ const text = typeof options.text === "string" ? options.text.trim() : "";
776
+ if (!text) {
777
+ return {
778
+ success: false,
779
+ error: "DM content cannot be empty",
780
+ };
781
+ }
782
+ // Normalize the target pubkey
783
+ let toPubkey;
784
+ try {
785
+ toPubkey = normalizePubkey(options.toPubkey);
786
+ }
787
+ catch (err) {
788
+ return {
789
+ success: false,
790
+ error: `Invalid target pubkey: ${err}`,
791
+ };
792
+ }
793
+ // Encrypt the message (NIP-04)
794
+ let ciphertext;
795
+ try {
796
+ logger.debug({ src: "plugin:nostr", op: "nip04:encrypt", to: toPubkey }, "Encrypting Nostr DM");
797
+ ciphertext = encrypt(privateKey, toPubkey, text);
798
+ }
799
+ catch (err) {
800
+ return {
801
+ success: false,
802
+ error: `Encryption failed: ${err}`,
803
+ };
804
+ }
805
+ // Create the event
806
+ const event = finalizeEvent({
807
+ kind: 4,
808
+ content: ciphertext,
809
+ tags: [["p", toPubkey]],
810
+ created_at: Math.floor(Date.now() / 1000),
811
+ }, privateKey);
812
+ // Publish to relays
813
+ const successRelays = [];
814
+ const errors = [];
815
+ for (const relay of settings.relays) {
816
+ try {
817
+ logger.debug({ src: "plugin:nostr", op: "pool.publish", kind: 4, relay, eventId: event.id }, "Publishing Nostr DM event to relay");
818
+ await pool.publish([relay], event);
819
+ successRelays.push(relay);
820
+ }
821
+ catch (err) {
822
+ errors.push(`${relay}: ${err}`);
823
+ }
824
+ }
825
+ if (successRelays.length === 0) {
826
+ return {
827
+ success: false,
828
+ error: `Failed to publish to any relay: ${errors.join("; ")}`,
829
+ };
830
+ }
831
+ logger.debug(`DM sent to ${toPubkey.slice(0, 8)}... via ${successRelays.length} relay(s)`);
832
+ if (this.runtime) {
833
+ this.runtime.emitEvent(NostrEventTypes.MESSAGE_SENT, {
834
+ runtime: this.runtime,
835
+ accountId: this.getAccountId(),
836
+ to: toPubkey,
837
+ eventId: event.id,
838
+ relays: successRelays,
839
+ });
840
+ }
841
+ return {
842
+ success: true,
843
+ eventId: event.id,
844
+ relays: successRelays,
845
+ };
846
+ }
847
+ /**
848
+ * Publish profile (kind:0).
849
+ */
850
+ async publishProfile(profile) {
851
+ if (this.accountServices.size > 0) {
852
+ const accountId = normalizeNostrAccountId(profile.accountId ?? this.getAccountId());
853
+ return this.getAccountService(accountId).publishProfile(profile);
854
+ }
855
+ const settings = this.settings;
856
+ const pool = this.pool;
857
+ const privateKey = this.privateKey;
858
+ if (!settings || !pool || !privateKey) {
859
+ return {
860
+ success: false,
861
+ error: "Service not initialized",
862
+ };
863
+ }
864
+ // Build profile content
865
+ const content = JSON.stringify({
866
+ name: profile.name,
867
+ display_name: profile.displayName,
868
+ about: profile.about,
869
+ picture: profile.picture,
870
+ banner: profile.banner,
871
+ nip05: profile.nip05,
872
+ lud16: profile.lud16,
873
+ website: profile.website,
874
+ });
875
+ // Create the event
876
+ const event = finalizeEvent({
877
+ kind: 0,
878
+ content,
879
+ tags: [],
880
+ created_at: Math.floor(Date.now() / 1000),
881
+ }, privateKey);
882
+ // Publish to relays
883
+ const successRelays = [];
884
+ const errors = [];
885
+ for (const relay of settings.relays) {
886
+ try {
887
+ logger.debug({ src: "plugin:nostr", op: "pool.publish", kind: 0, relay, eventId: event.id }, "Publishing Nostr profile event to relay");
888
+ await pool.publish([relay], event);
889
+ successRelays.push(relay);
890
+ }
891
+ catch (err) {
892
+ errors.push(`${relay}: ${err}`);
893
+ }
894
+ }
895
+ if (successRelays.length === 0) {
896
+ return {
897
+ success: false,
898
+ error: `Failed to publish profile to any relay: ${errors.join("; ")}`,
899
+ };
900
+ }
901
+ logger.info(`Profile published via ${successRelays.length} relay(s)`);
902
+ if (this.runtime) {
903
+ this.runtime.emitEvent(NostrEventTypes.PROFILE_PUBLISHED, {
904
+ runtime: this.runtime,
905
+ accountId: this.getAccountId(),
906
+ eventId: event.id,
907
+ relays: successRelays,
908
+ });
909
+ }
910
+ return {
911
+ success: true,
912
+ eventId: event.id,
913
+ relays: successRelays,
914
+ };
915
+ }
916
+ /**
917
+ * Publish a text note (kind:1).
918
+ */
919
+ async publishNote(text, tags = []) {
920
+ if (this.accountServices.size > 0) {
921
+ return this.getDefaultAccountService().publishNote(text, tags);
922
+ }
923
+ const settings = this.settings;
924
+ const pool = this.pool;
925
+ const privateKey = this.privateKey;
926
+ if (!settings || !pool || !privateKey) {
927
+ return {
928
+ success: false,
929
+ error: "Service not initialized",
930
+ };
931
+ }
932
+ const trimmed = text.trim();
933
+ if (!trimmed) {
934
+ return {
935
+ success: false,
936
+ error: "Note content cannot be empty",
937
+ };
938
+ }
939
+ const event = finalizeEvent({
940
+ kind: 1,
941
+ content: trimmed,
942
+ tags: normalizeEventTags(tags),
943
+ created_at: Math.floor(Date.now() / 1000),
944
+ }, privateKey);
945
+ const successRelays = [];
946
+ const errors = [];
947
+ for (const relay of settings.relays) {
948
+ try {
949
+ logger.debug({ src: "plugin:nostr", op: "pool.publish", kind: 1, relay, eventId: event.id }, "Publishing Nostr note to relay");
950
+ await pool.publish([relay], event);
951
+ successRelays.push(relay);
952
+ }
953
+ catch (err) {
954
+ errors.push(`${relay}: ${err}`);
955
+ }
956
+ }
957
+ if (successRelays.length === 0) {
958
+ return {
959
+ success: false,
960
+ error: `Failed to publish note to any relay: ${errors.join("; ")}`,
961
+ };
962
+ }
963
+ logger.info({ src: "plugin:nostr", op: "publishNote", eventId: event.id, relays: successRelays.length }, "Nostr note published");
964
+ return {
965
+ success: true,
966
+ eventId: event.id,
967
+ relays: successRelays,
968
+ };
969
+ }
970
+ /**
971
+ * Get the settings.
972
+ */
973
+ getSettings() {
974
+ if (this.accountServices.size > 0) {
975
+ return this.getDefaultAccountService().getSettings();
976
+ }
977
+ return this.settings;
978
+ }
979
+ }
980
+ //# sourceMappingURL=service.js.map