@openclaw/nostr 2026.5.2 → 2026.5.3-beta.2

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.
Files changed (59) hide show
  1. package/dist/api.js +532 -0
  2. package/dist/channel-DfEqBtUh.js +1466 -0
  3. package/dist/channel-plugin-api.js +2 -0
  4. package/dist/config-schema-DIk4jlBg.js +64 -0
  5. package/dist/default-relays-DLwdWOTu.js +4 -0
  6. package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
  7. package/dist/index.js +84 -0
  8. package/dist/runtime-api.js +2 -0
  9. package/dist/setup-api.js +2 -0
  10. package/dist/setup-entry.js +11 -0
  11. package/dist/setup-plugin-api.js +165 -0
  12. package/dist/setup-surface-DxAaUTyC.js +336 -0
  13. package/dist/test-api.js +2 -0
  14. package/package.json +15 -6
  15. package/api.ts +0 -10
  16. package/channel-plugin-api.ts +0 -1
  17. package/index.ts +0 -97
  18. package/runtime-api.ts +0 -6
  19. package/setup-api.ts +0 -1
  20. package/setup-entry.ts +0 -9
  21. package/setup-plugin-api.ts +0 -3
  22. package/src/channel-api.ts +0 -15
  23. package/src/channel.inbound.test.ts +0 -176
  24. package/src/channel.outbound.test.ts +0 -128
  25. package/src/channel.setup.ts +0 -231
  26. package/src/channel.test.ts +0 -519
  27. package/src/channel.ts +0 -207
  28. package/src/config-schema.ts +0 -98
  29. package/src/default-relays.ts +0 -1
  30. package/src/gateway.ts +0 -302
  31. package/src/inbound-direct-dm-runtime.ts +0 -1
  32. package/src/metrics.ts +0 -458
  33. package/src/nostr-bus.fuzz.test.ts +0 -360
  34. package/src/nostr-bus.inbound.test.ts +0 -526
  35. package/src/nostr-bus.integration.test.ts +0 -472
  36. package/src/nostr-bus.test.ts +0 -190
  37. package/src/nostr-bus.ts +0 -789
  38. package/src/nostr-key-utils.ts +0 -94
  39. package/src/nostr-profile-core.ts +0 -134
  40. package/src/nostr-profile-http-runtime.ts +0 -6
  41. package/src/nostr-profile-http.test.ts +0 -632
  42. package/src/nostr-profile-http.ts +0 -594
  43. package/src/nostr-profile-import.test.ts +0 -119
  44. package/src/nostr-profile-import.ts +0 -262
  45. package/src/nostr-profile-url-safety.ts +0 -21
  46. package/src/nostr-profile.fuzz.test.ts +0 -430
  47. package/src/nostr-profile.test.ts +0 -412
  48. package/src/nostr-profile.ts +0 -144
  49. package/src/nostr-state-store.test.ts +0 -237
  50. package/src/nostr-state-store.ts +0 -223
  51. package/src/runtime.ts +0 -9
  52. package/src/seen-tracker.ts +0 -289
  53. package/src/session-route.ts +0 -25
  54. package/src/setup-surface.ts +0 -265
  55. package/src/test-fixtures.ts +0 -45
  56. package/src/types.ts +0 -117
  57. package/test/setup.ts +0 -5
  58. package/test-api.ts +0 -1
  59. package/tsconfig.json +0 -16
package/src/nostr-bus.ts DELETED
@@ -1,789 +0,0 @@
1
- import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools";
2
- import { decrypt, encrypt } from "nostr-tools/nip04";
3
- import {
4
- createDirectDmPreCryptoGuardPolicy,
5
- type DirectDmPreCryptoGuardPolicyOverrides,
6
- } from "openclaw/plugin-sdk/direct-dm-guard-policy";
7
- import type { NostrProfile } from "./config-schema.js";
8
- import { DEFAULT_RELAYS } from "./default-relays.js";
9
- import {
10
- createMetrics,
11
- createNoopMetrics,
12
- type NostrMetrics,
13
- type MetricsSnapshot,
14
- type MetricEvent,
15
- } from "./metrics.js";
16
- import { validatePrivateKey } from "./nostr-key-utils.js";
17
- import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
18
- import {
19
- readNostrBusState,
20
- writeNostrBusState,
21
- computeSinceTimestamp,
22
- readNostrProfileState,
23
- writeNostrProfileState,
24
- } from "./nostr-state-store.js";
25
- import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
26
-
27
- // ============================================================================
28
- // Constants
29
- // ============================================================================
30
-
31
- const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
32
- const MAX_PERSISTED_EVENT_IDS = 5000;
33
- const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
34
- const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
35
-
36
- // Circuit breaker configuration
37
- const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
38
- const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
39
-
40
- // Health tracker configuration
41
- const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
42
-
43
- // ============================================================================
44
- // Types
45
- // ============================================================================
46
-
47
- interface NostrBusOptions {
48
- /** Private key in hex or nsec format */
49
- privateKey: string;
50
- /** WebSocket relay URLs (defaults to damus + nos.lol) */
51
- relays?: string[];
52
- /** Account ID for state persistence (optional, defaults to pubkey prefix) */
53
- accountId?: string;
54
- /** Called when a DM is received */
55
- onMessage: (
56
- pubkey: string,
57
- text: string,
58
- reply: (text: string) => Promise<void>,
59
- meta: { eventId: string; createdAt: number },
60
- ) => Promise<void>;
61
- /** Called after signature verification and before decrypt to allow sender policy checks (optional) */
62
- authorizeSender?: (params: {
63
- senderPubkey: string;
64
- reply: (text: string) => Promise<void>;
65
- }) => Promise<"allow" | "block" | "pairing">;
66
- /** Override pre-crypto DM guardrails for tests or future channel tuning (optional) */
67
- guardPolicy?: DirectDmPreCryptoGuardPolicyOverrides;
68
- /** Called on errors (optional) */
69
- onError?: (error: Error, context: string) => void;
70
- /** Called on connection status changes (optional) */
71
- onConnect?: (relay: string) => void;
72
- /** Called on disconnection (optional) */
73
- onDisconnect?: (relay: string) => void;
74
- /** Called on EOSE (end of stored events) for initial sync (optional) */
75
- onEose?: (relay: string) => void;
76
- /** Called on each metric event (optional) */
77
- onMetric?: (event: MetricEvent) => void;
78
- /** Maximum entries in seen tracker (default: 100,000) */
79
- maxSeenEntries?: number;
80
- /** Seen tracker TTL in ms (default: 1 hour) */
81
- seenTtlMs?: number;
82
- }
83
-
84
- type FixedWindowRateLimiter = {
85
- isRateLimited: (key: string, nowMs?: number) => boolean;
86
- size: () => number;
87
- clear: () => void;
88
- };
89
-
90
- function createFixedWindowRateLimiter(params: {
91
- windowMs: number;
92
- maxRequests: number;
93
- maxTrackedKeys: number;
94
- }): FixedWindowRateLimiter {
95
- const windowMs = Math.max(1, Math.floor(params.windowMs));
96
- const maxRequests = Math.max(1, Math.floor(params.maxRequests));
97
- const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
98
- const state = new Map<string, { count: number; windowStartMs: number }>();
99
-
100
- const touch = (key: string, value: { count: number; windowStartMs: number }) => {
101
- state.delete(key);
102
- state.set(key, value);
103
- };
104
-
105
- const prune = (nowMs: number) => {
106
- for (const [key, entry] of state) {
107
- if (nowMs - entry.windowStartMs >= windowMs) {
108
- state.delete(key);
109
- }
110
- }
111
- while (state.size > maxTrackedKeys) {
112
- const oldest = state.keys().next().value;
113
- if (!oldest) {
114
- break;
115
- }
116
- state.delete(oldest);
117
- }
118
- };
119
-
120
- return {
121
- isRateLimited: (key: string, nowMs = Date.now()) => {
122
- if (!key) {
123
- return false;
124
- }
125
- prune(nowMs);
126
- const existing = state.get(key);
127
- if (!existing || nowMs - existing.windowStartMs >= windowMs) {
128
- touch(key, { count: 1, windowStartMs: nowMs });
129
- return false;
130
- }
131
- const nextCount = existing.count + 1;
132
- touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
133
- return nextCount > maxRequests;
134
- },
135
- size: () => state.size,
136
- clear: () => state.clear(),
137
- };
138
- }
139
-
140
- export interface NostrBusHandle {
141
- /** Stop the bus and close connections */
142
- close: () => void;
143
- /** Get the bot's public key */
144
- publicKey: string;
145
- /** Send a DM to a pubkey */
146
- sendDm: (toPubkey: string, text: string) => Promise<void>;
147
- /** Get current metrics snapshot */
148
- getMetrics: () => MetricsSnapshot;
149
- /** Publish a profile (kind:0) to all relays */
150
- publishProfile: (profile: NostrProfile) => Promise<ProfilePublishResult>;
151
- /** Get the last profile publish state */
152
- getProfileState: () => Promise<{
153
- lastPublishedAt: number | null;
154
- lastPublishedEventId: string | null;
155
- lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
156
- }>;
157
- }
158
-
159
- // ============================================================================
160
- // Circuit Breaker
161
- // ============================================================================
162
-
163
- interface CircuitBreakerState {
164
- state: "closed" | "open" | "half_open";
165
- failures: number;
166
- lastFailure: number;
167
- lastSuccess: number;
168
- }
169
-
170
- interface CircuitBreaker {
171
- /** Check if requests should be allowed */
172
- canAttempt: () => boolean;
173
- /** Record a success */
174
- recordSuccess: () => void;
175
- /** Record a failure */
176
- recordFailure: () => void;
177
- /** Get current state */
178
- getState: () => CircuitBreakerState["state"];
179
- }
180
-
181
- function createCircuitBreaker(
182
- relay: string,
183
- metrics: NostrMetrics,
184
- threshold: number = CIRCUIT_BREAKER_THRESHOLD,
185
- resetMs: number = CIRCUIT_BREAKER_RESET_MS,
186
- ): CircuitBreaker {
187
- const state: CircuitBreakerState = {
188
- state: "closed",
189
- failures: 0,
190
- lastFailure: 0,
191
- lastSuccess: Date.now(),
192
- };
193
-
194
- return {
195
- canAttempt(): boolean {
196
- if (state.state === "closed") {
197
- return true;
198
- }
199
-
200
- if (state.state === "open") {
201
- // Check if enough time has passed to try half-open
202
- if (Date.now() - state.lastFailure >= resetMs) {
203
- state.state = "half_open";
204
- metrics.emit("relay.circuit_breaker.half_open", 1, { relay });
205
- return true;
206
- }
207
- return false;
208
- }
209
-
210
- // half_open: allow one attempt
211
- return true;
212
- },
213
-
214
- recordSuccess(): void {
215
- if (state.state === "half_open") {
216
- state.state = "closed";
217
- state.failures = 0;
218
- metrics.emit("relay.circuit_breaker.close", 1, { relay });
219
- } else if (state.state === "closed") {
220
- state.failures = 0;
221
- }
222
- state.lastSuccess = Date.now();
223
- },
224
-
225
- recordFailure(): void {
226
- state.failures++;
227
- state.lastFailure = Date.now();
228
-
229
- if (state.state === "half_open") {
230
- state.state = "open";
231
- metrics.emit("relay.circuit_breaker.open", 1, { relay });
232
- } else if (state.state === "closed" && state.failures >= threshold) {
233
- state.state = "open";
234
- metrics.emit("relay.circuit_breaker.open", 1, { relay });
235
- }
236
- },
237
-
238
- getState(): CircuitBreakerState["state"] {
239
- return state.state;
240
- },
241
- };
242
- }
243
-
244
- // ============================================================================
245
- // Relay Health Tracker
246
- // ============================================================================
247
-
248
- interface RelayHealthStats {
249
- successCount: number;
250
- failureCount: number;
251
- latencySum: number;
252
- latencyCount: number;
253
- lastSuccess: number;
254
- lastFailure: number;
255
- }
256
-
257
- interface RelayHealthTracker {
258
- /** Record a successful operation */
259
- recordSuccess: (relay: string, latencyMs: number) => void;
260
- /** Record a failed operation */
261
- recordFailure: (relay: string) => void;
262
- /** Get health score (0-1, higher is better) */
263
- getScore: (relay: string) => number;
264
- /** Get relays sorted by health (best first) */
265
- getSortedRelays: (relays: string[]) => string[];
266
- }
267
-
268
- function createRelayHealthTracker(): RelayHealthTracker {
269
- const stats = new Map<string, RelayHealthStats>();
270
-
271
- function getOrCreate(relay: string): RelayHealthStats {
272
- let s = stats.get(relay);
273
- if (!s) {
274
- s = {
275
- successCount: 0,
276
- failureCount: 0,
277
- latencySum: 0,
278
- latencyCount: 0,
279
- lastSuccess: 0,
280
- lastFailure: 0,
281
- };
282
- stats.set(relay, s);
283
- }
284
- return s;
285
- }
286
-
287
- return {
288
- recordSuccess(relay: string, latencyMs: number): void {
289
- const s = getOrCreate(relay);
290
- s.successCount++;
291
- s.latencySum += latencyMs;
292
- s.latencyCount++;
293
- s.lastSuccess = Date.now();
294
- },
295
-
296
- recordFailure(relay: string): void {
297
- const s = getOrCreate(relay);
298
- s.failureCount++;
299
- s.lastFailure = Date.now();
300
- },
301
-
302
- getScore(relay: string): number {
303
- const s = stats.get(relay);
304
- if (!s) {
305
- return 0.5;
306
- } // Unknown relay gets neutral score
307
-
308
- const total = s.successCount + s.failureCount;
309
- if (total === 0) {
310
- return 0.5;
311
- }
312
-
313
- // Success rate (0-1)
314
- const successRate = s.successCount / total;
315
-
316
- // Recency bonus (prefer recently successful relays)
317
- const now = Date.now();
318
- const recencyBonus =
319
- s.lastSuccess > s.lastFailure
320
- ? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2
321
- : 0;
322
-
323
- // Latency penalty (lower is better)
324
- const avgLatency = s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
325
- const latencyPenalty = Math.min(0.2, avgLatency / 10000);
326
-
327
- return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
328
- },
329
-
330
- getSortedRelays(relays: string[]): string[] {
331
- return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a));
332
- },
333
- };
334
- }
335
-
336
- // ============================================================================
337
- // Main Bus
338
- // ============================================================================
339
-
340
- /**
341
- * Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
342
- */
343
- export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
344
- const {
345
- privateKey,
346
- relays = DEFAULT_RELAYS,
347
- onMessage,
348
- authorizeSender,
349
- onError,
350
- onEose,
351
- onMetric,
352
- maxSeenEntries = 100_000,
353
- seenTtlMs = 60 * 60 * 1000,
354
- } = options;
355
-
356
- const sk = validatePrivateKey(privateKey);
357
- const pk = getPublicKey(sk);
358
- const pool = new SimplePool();
359
- const accountId = options.accountId ?? pk.slice(0, 16);
360
- const gatewayStartedAt = Math.floor(Date.now() / 1000);
361
- const guardPolicy = createDirectDmPreCryptoGuardPolicy({
362
- ...DEFAULT_INBOUND_GUARD_POLICY,
363
- ...options.guardPolicy,
364
- rateLimit: {
365
- ...DEFAULT_INBOUND_GUARD_POLICY.rateLimit,
366
- ...options.guardPolicy?.rateLimit,
367
- },
368
- });
369
-
370
- // Initialize metrics
371
- const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
372
-
373
- // Initialize seen tracker with LRU
374
- const seen: SeenTracker = createSeenTracker({
375
- maxEntries: maxSeenEntries,
376
- ttlMs: seenTtlMs,
377
- });
378
-
379
- // Initialize circuit breakers and health tracker
380
- const circuitBreakers = new Map<string, CircuitBreaker>();
381
- const healthTracker = createRelayHealthTracker();
382
-
383
- for (const relay of relays) {
384
- circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
385
- }
386
-
387
- // Read persisted state and compute `since` timestamp (with small overlap)
388
- const state = await readNostrBusState({ accountId });
389
- const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
390
- const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
391
-
392
- // Seed in-memory dedupe with recent IDs from disk (prevents restart replay)
393
- if (state?.recentEventIds?.length) {
394
- seen.seed(state.recentEventIds);
395
- }
396
-
397
- // Persist startup timestamp
398
- await writeNostrBusState({
399
- accountId,
400
- lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
401
- gatewayStartedAt,
402
- recentEventIds: state?.recentEventIds ?? [],
403
- });
404
-
405
- // Debounced state persistence
406
- let pendingWrite: ReturnType<typeof setTimeout> | undefined;
407
- let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
408
- let recentEventIds = (state?.recentEventIds ?? []).slice(-MAX_PERSISTED_EVENT_IDS);
409
-
410
- function scheduleStatePersist(eventCreatedAt: number, eventId: string): void {
411
- lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
412
- recentEventIds.push(eventId);
413
- if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) {
414
- recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
415
- }
416
-
417
- if (pendingWrite) {
418
- clearTimeout(pendingWrite);
419
- }
420
- pendingWrite = setTimeout(() => {
421
- writeNostrBusState({
422
- accountId,
423
- lastProcessedAt,
424
- gatewayStartedAt,
425
- recentEventIds,
426
- }).catch((err) => onError?.(err as Error, "persist state"));
427
- }, STATE_PERSIST_DEBOUNCE_MS);
428
- }
429
-
430
- const inflight = new Set<string>();
431
- const perSenderRateLimiter = createFixedWindowRateLimiter({
432
- windowMs: guardPolicy.rateLimit.windowMs,
433
- maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
434
- maxTrackedKeys: guardPolicy.rateLimit.maxTrackedSenderKeys,
435
- });
436
- const globalRateLimiter = createFixedWindowRateLimiter({
437
- windowMs: guardPolicy.rateLimit.windowMs,
438
- maxRequests: guardPolicy.rateLimit.maxGlobalPerWindow,
439
- maxTrackedKeys: 1,
440
- });
441
-
442
- const updateRateLimiterSizeMetric = () => {
443
- metrics.emit(
444
- "memory.rate_limiter_entries",
445
- perSenderRateLimiter.size() + globalRateLimiter.size(),
446
- );
447
- };
448
-
449
- // Event handler
450
- async function handleEvent(event: Event): Promise<void> {
451
- try {
452
- metrics.emit("event.received");
453
-
454
- // Fast dedupe check (handles relay reconnections)
455
- if (seen.peek(event.id) || inflight.has(event.id)) {
456
- metrics.emit("event.duplicate");
457
- return;
458
- }
459
- inflight.add(event.id);
460
-
461
- const markSeen = () => {
462
- seen.add(event.id);
463
- metrics.emit("memory.seen_tracker_size", seen.size());
464
- };
465
- const rejectAndMarkSeen = (metric: Parameters<typeof metrics.emit>[0]) => {
466
- markSeen();
467
- metrics.emit(metric);
468
- };
469
-
470
- // Self-message loop prevention: skip our own messages
471
- if (event.pubkey === pk) {
472
- rejectAndMarkSeen("event.rejected.self_message");
473
- return;
474
- }
475
-
476
- // Skip events older than our `since` (relay may ignore filter)
477
- if (event.created_at < since) {
478
- rejectAndMarkSeen("event.rejected.stale");
479
- return;
480
- }
481
-
482
- if (event.created_at > Math.floor(Date.now() / 1000) + guardPolicy.maxFutureSkewSec) {
483
- metrics.emit("event.rejected.future");
484
- return;
485
- }
486
-
487
- if (!guardPolicy.allowedKinds.includes(event.kind)) {
488
- rejectAndMarkSeen("event.rejected.wrong_kind");
489
- return;
490
- }
491
-
492
- // Fast p-tag check BEFORE crypto (no allocation, cheaper)
493
- let targetsUs = false;
494
- for (const t of event.tags) {
495
- if (t[0] === "p" && t[1] === pk) {
496
- targetsUs = true;
497
- break;
498
- }
499
- }
500
- if (!targetsUs) {
501
- rejectAndMarkSeen("event.rejected.wrong_kind");
502
- return;
503
- }
504
-
505
- const replyTo = async (text: string): Promise<void> => {
506
- await sendEncryptedDm(
507
- pool,
508
- sk,
509
- event.pubkey,
510
- text,
511
- relays,
512
- metrics,
513
- circuitBreakers,
514
- healthTracker,
515
- onError,
516
- );
517
- };
518
-
519
- const rejectIfGlobalRateLimited = (): boolean => {
520
- updateRateLimiterSizeMetric();
521
- if (globalRateLimiter.isRateLimited("global")) {
522
- metrics.emit("rate_limit.global");
523
- metrics.emit("event.rejected.rate_limited");
524
- updateRateLimiterSizeMetric();
525
- return true;
526
- }
527
- updateRateLimiterSizeMetric();
528
- return false;
529
- };
530
-
531
- const rejectIfVerifiedSenderRateLimited = (): boolean => {
532
- updateRateLimiterSizeMetric();
533
- if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
534
- metrics.emit("rate_limit.per_sender");
535
- metrics.emit("event.rejected.rate_limited");
536
- updateRateLimiterSizeMetric();
537
- return true;
538
- }
539
- updateRateLimiterSizeMetric();
540
- return false;
541
- };
542
-
543
- if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
544
- if (rejectIfGlobalRateLimited()) {
545
- return;
546
- }
547
- rejectAndMarkSeen("event.rejected.oversized_ciphertext");
548
- return;
549
- }
550
-
551
- if (rejectIfGlobalRateLimited()) {
552
- return;
553
- }
554
-
555
- // Verify signature (must pass before we trust the event)
556
- if (!verifyEvent(event)) {
557
- rejectAndMarkSeen("event.rejected.invalid_signature");
558
- onError?.(new Error("Invalid signature"), `event ${event.id}`);
559
- return;
560
- }
561
-
562
- if (rejectIfVerifiedSenderRateLimited()) {
563
- return;
564
- }
565
-
566
- if (authorizeSender) {
567
- const decision = await authorizeSender({
568
- senderPubkey: event.pubkey,
569
- reply: replyTo,
570
- });
571
- if (decision !== "allow") {
572
- markSeen();
573
- return;
574
- }
575
- }
576
-
577
- // Decrypt the message
578
- let plaintext: string;
579
- try {
580
- plaintext = decrypt(sk, event.pubkey, event.content);
581
- metrics.emit("decrypt.success");
582
- } catch (err) {
583
- markSeen();
584
- metrics.emit("decrypt.failure");
585
- metrics.emit("event.rejected.decrypt_failed");
586
- onError?.(err as Error, `decrypt from ${event.pubkey}`);
587
- return;
588
- }
589
-
590
- if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
591
- markSeen();
592
- metrics.emit("event.rejected.oversized_plaintext");
593
- return;
594
- }
595
-
596
- // Call the message handler
597
- await onMessage(event.pubkey, plaintext, replyTo, {
598
- eventId: event.id,
599
- createdAt: event.created_at,
600
- });
601
-
602
- // Only cache successful deliveries so handler failures can retry.
603
- markSeen();
604
-
605
- // Mark as processed
606
- metrics.emit("event.processed");
607
-
608
- // Persist progress (debounced)
609
- scheduleStatePersist(event.created_at, event.id);
610
- } catch (err) {
611
- onError?.(err as Error, `event ${event.id}`);
612
- } finally {
613
- inflight.delete(event.id);
614
- }
615
- }
616
-
617
- const sub = pool.subscribeMany(
618
- relays,
619
- [{ kinds: [4], "#p": [pk], since }] as unknown as Parameters<typeof pool.subscribeMany>[1],
620
- {
621
- onevent: handleEvent,
622
- oneose: () => {
623
- // EOSE handler - called when all stored events have been received
624
- for (const relay of relays) {
625
- metrics.emit("relay.message.eose", 1, { relay });
626
- }
627
- onEose?.(relays.join(", "));
628
- },
629
- onclose: (reason) => {
630
- // Handle subscription close
631
- for (const relay of relays) {
632
- metrics.emit("relay.message.closed", 1, { relay });
633
- options.onDisconnect?.(relay);
634
- }
635
- onError?.(new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
636
- },
637
- },
638
- );
639
-
640
- // Public sendDm function
641
- const sendDm = async (toPubkey: string, text: string): Promise<void> => {
642
- await sendEncryptedDm(
643
- pool,
644
- sk,
645
- toPubkey,
646
- text,
647
- relays,
648
- metrics,
649
- circuitBreakers,
650
- healthTracker,
651
- onError,
652
- );
653
- };
654
-
655
- // Profile publishing function
656
- const publishProfile = async (profile: NostrProfile): Promise<ProfilePublishResult> => {
657
- // Read last published timestamp for monotonic ordering
658
- const profileState = await readNostrProfileState({ accountId });
659
- const lastPublishedAt = profileState?.lastPublishedAt ?? undefined;
660
-
661
- // Publish the profile
662
- const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt);
663
-
664
- // Convert results to state format
665
- const publishResults: Record<string, "ok" | "failed" | "timeout"> = {};
666
- for (const relay of result.successes) {
667
- publishResults[relay] = "ok";
668
- }
669
- for (const { relay, error } of result.failures) {
670
- publishResults[relay] = error === "timeout" ? "timeout" : "failed";
671
- }
672
-
673
- // Persist the publish state
674
- await writeNostrProfileState({
675
- accountId,
676
- lastPublishedAt: result.createdAt,
677
- lastPublishedEventId: result.eventId,
678
- lastPublishResults: publishResults,
679
- });
680
-
681
- return result;
682
- };
683
-
684
- // Get profile state function
685
- const getProfileState = async () => {
686
- const state = await readNostrProfileState({ accountId });
687
- return {
688
- lastPublishedAt: state?.lastPublishedAt ?? null,
689
- lastPublishedEventId: state?.lastPublishedEventId ?? null,
690
- lastPublishResults: state?.lastPublishResults ?? null,
691
- };
692
- };
693
-
694
- return {
695
- close: () => {
696
- sub.close();
697
- seen.stop();
698
- perSenderRateLimiter.clear();
699
- globalRateLimiter.clear();
700
- // Flush pending state write synchronously on close
701
- if (pendingWrite) {
702
- clearTimeout(pendingWrite);
703
- writeNostrBusState({
704
- accountId,
705
- lastProcessedAt,
706
- gatewayStartedAt,
707
- recentEventIds,
708
- }).catch((err) => onError?.(err as Error, "persist state on close"));
709
- }
710
- },
711
- publicKey: pk,
712
- sendDm,
713
- getMetrics: () => metrics.getSnapshot(),
714
- publishProfile,
715
- getProfileState,
716
- };
717
- }
718
-
719
- // ============================================================================
720
- // Send DM with Circuit Breaker + Health Scoring
721
- // ============================================================================
722
-
723
- /**
724
- * Send an encrypted DM to a pubkey
725
- */
726
- async function sendEncryptedDm(
727
- pool: SimplePool,
728
- sk: Uint8Array,
729
- toPubkey: string,
730
- text: string,
731
- relays: string[],
732
- metrics: NostrMetrics,
733
- circuitBreakers: Map<string, CircuitBreaker>,
734
- healthTracker: RelayHealthTracker,
735
- onError?: (error: Error, context: string) => void,
736
- ): Promise<void> {
737
- const ciphertext = encrypt(sk, toPubkey, text);
738
- const reply = finalizeEvent(
739
- {
740
- kind: 4,
741
- content: ciphertext,
742
- tags: [["p", toPubkey]],
743
- created_at: Math.floor(Date.now() / 1000),
744
- },
745
- sk,
746
- );
747
-
748
- // Sort relays by health score (best first)
749
- const sortedRelays = healthTracker.getSortedRelays(relays);
750
-
751
- // Try relays in order of health, respecting circuit breakers
752
- let lastError: Error | undefined;
753
- for (const relay of sortedRelays) {
754
- const cb = circuitBreakers.get(relay);
755
-
756
- // Skip if circuit breaker is open
757
- if (cb && !cb.canAttempt()) {
758
- continue;
759
- }
760
-
761
- const startTime = Date.now();
762
- try {
763
- const [publishPromise] = pool.publish([relay], reply);
764
- if (!publishPromise) {
765
- throw new Error(`Failed to create publish promise for relay ${relay}`);
766
- }
767
- await publishPromise;
768
- const latency = Date.now() - startTime;
769
-
770
- // Record success
771
- cb?.recordSuccess();
772
- healthTracker.recordSuccess(relay, latency);
773
-
774
- return; // Success - exit early
775
- } catch (err) {
776
- lastError = err as Error;
777
- const latency = Date.now() - startTime;
778
-
779
- // Record failure
780
- cb?.recordFailure();
781
- healthTracker.recordFailure(relay);
782
- metrics.emit("relay.error", 1, { relay, latency });
783
-
784
- onError?.(lastError, `publish to ${relay}`);
785
- }
786
- }
787
-
788
- throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
789
- }