@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.
- package/dist/api.js +532 -0
- package/dist/channel-DfEqBtUh.js +1466 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/config-schema-DIk4jlBg.js +64 -0
- package/dist/default-relays-DLwdWOTu.js +4 -0
- package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
- package/dist/index.js +84 -0
- package/dist/runtime-api.js +2 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +165 -0
- package/dist/setup-surface-DxAaUTyC.js +336 -0
- package/dist/test-api.js +2 -0
- package/package.json +15 -6
- package/api.ts +0 -10
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -97
- package/runtime-api.ts +0 -6
- package/setup-api.ts +0 -1
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/channel-api.ts +0 -15
- package/src/channel.inbound.test.ts +0 -176
- package/src/channel.outbound.test.ts +0 -128
- package/src/channel.setup.ts +0 -231
- package/src/channel.test.ts +0 -519
- package/src/channel.ts +0 -207
- package/src/config-schema.ts +0 -98
- package/src/default-relays.ts +0 -1
- package/src/gateway.ts +0 -302
- package/src/inbound-direct-dm-runtime.ts +0 -1
- package/src/metrics.ts +0 -458
- package/src/nostr-bus.fuzz.test.ts +0 -360
- package/src/nostr-bus.inbound.test.ts +0 -526
- package/src/nostr-bus.integration.test.ts +0 -472
- package/src/nostr-bus.test.ts +0 -190
- package/src/nostr-bus.ts +0 -789
- package/src/nostr-key-utils.ts +0 -94
- package/src/nostr-profile-core.ts +0 -134
- package/src/nostr-profile-http-runtime.ts +0 -6
- package/src/nostr-profile-http.test.ts +0 -632
- package/src/nostr-profile-http.ts +0 -594
- package/src/nostr-profile-import.test.ts +0 -119
- package/src/nostr-profile-import.ts +0 -262
- package/src/nostr-profile-url-safety.ts +0 -21
- package/src/nostr-profile.fuzz.test.ts +0 -430
- package/src/nostr-profile.test.ts +0 -412
- package/src/nostr-profile.ts +0 -144
- package/src/nostr-state-store.test.ts +0 -237
- package/src/nostr-state-store.ts +0 -223
- package/src/runtime.ts +0 -9
- package/src/seen-tracker.ts +0 -289
- package/src/session-route.ts +0 -25
- package/src/setup-surface.ts +0 -265
- package/src/test-fixtures.ts +0 -45
- package/src/types.ts +0 -117
- package/test/setup.ts +0 -5
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import { a as resolveDefaultNostrAccountId, c as validatePrivateKey, i as listNostrAccountIds, n as nostrSetupWizard, o as resolveNostrAccount, s as normalizePubkey, t as nostrSetupAdapter } from "./setup-surface-DxAaUTyC.js";
|
|
2
|
+
import { a as collectStatusIssuesFromLastError, c as formatPairingApproveHint, i as buildChannelConfigSchema, l as resolveInboundDirectDmAccessWithRuntime, n as NostrProfileSchema, o as createDefaultChannelRuntimeState, r as DEFAULT_ACCOUNT_ID, s as createPreCryptoDirectDmAuthorizer, t as NostrConfigSchema } from "./config-schema-DIk4jlBg.js";
|
|
3
|
+
import { t as DEFAULT_RELAYS } from "./default-relays-DLwdWOTu.js";
|
|
4
|
+
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
|
5
|
+
import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
6
|
+
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
7
|
+
import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
|
8
|
+
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
|
9
|
+
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
10
|
+
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
11
|
+
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools";
|
|
12
|
+
import { decrypt, encrypt } from "nostr-tools/nip04";
|
|
13
|
+
import { createDirectDmPreCryptoGuardPolicy } from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
|
14
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
21
|
+
import { buildChannelOutboundSessionRoute, stripChannelTargetPrefix } from "openclaw/plugin-sdk/core";
|
|
22
|
+
//#region extensions/nostr/src/metrics.ts
|
|
23
|
+
/**
|
|
24
|
+
* Create a metrics collector instance.
|
|
25
|
+
* Optionally pass an onMetric callback to receive real-time metric events.
|
|
26
|
+
*/
|
|
27
|
+
function createMetrics(onMetric) {
|
|
28
|
+
let eventsReceived = 0;
|
|
29
|
+
let eventsProcessed = 0;
|
|
30
|
+
let eventsDuplicate = 0;
|
|
31
|
+
const eventsRejected = {
|
|
32
|
+
invalidShape: 0,
|
|
33
|
+
wrongKind: 0,
|
|
34
|
+
stale: 0,
|
|
35
|
+
future: 0,
|
|
36
|
+
rateLimited: 0,
|
|
37
|
+
invalidSignature: 0,
|
|
38
|
+
oversizedCiphertext: 0,
|
|
39
|
+
oversizedPlaintext: 0,
|
|
40
|
+
decryptFailed: 0,
|
|
41
|
+
selfMessage: 0
|
|
42
|
+
};
|
|
43
|
+
const relays = /* @__PURE__ */ new Map();
|
|
44
|
+
const rateLimiting = {
|
|
45
|
+
perSenderHits: 0,
|
|
46
|
+
globalHits: 0
|
|
47
|
+
};
|
|
48
|
+
const decrypt = {
|
|
49
|
+
success: 0,
|
|
50
|
+
failure: 0
|
|
51
|
+
};
|
|
52
|
+
const memory = {
|
|
53
|
+
seenTrackerSize: 0,
|
|
54
|
+
rateLimiterEntries: 0
|
|
55
|
+
};
|
|
56
|
+
function getOrCreateRelay(url) {
|
|
57
|
+
let relay = relays.get(url);
|
|
58
|
+
if (!relay) {
|
|
59
|
+
relay = {
|
|
60
|
+
connects: 0,
|
|
61
|
+
disconnects: 0,
|
|
62
|
+
reconnects: 0,
|
|
63
|
+
errors: 0,
|
|
64
|
+
messagesReceived: {
|
|
65
|
+
event: 0,
|
|
66
|
+
eose: 0,
|
|
67
|
+
closed: 0,
|
|
68
|
+
notice: 0,
|
|
69
|
+
ok: 0,
|
|
70
|
+
auth: 0
|
|
71
|
+
},
|
|
72
|
+
circuitBreakerState: "closed",
|
|
73
|
+
circuitBreakerOpens: 0,
|
|
74
|
+
circuitBreakerCloses: 0
|
|
75
|
+
};
|
|
76
|
+
relays.set(url, relay);
|
|
77
|
+
}
|
|
78
|
+
return relay;
|
|
79
|
+
}
|
|
80
|
+
function emit(name, value = 1, labels) {
|
|
81
|
+
if (onMetric) onMetric({
|
|
82
|
+
name,
|
|
83
|
+
value,
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
labels
|
|
86
|
+
});
|
|
87
|
+
const relayUrl = labels?.relay;
|
|
88
|
+
switch (name) {
|
|
89
|
+
case "event.received":
|
|
90
|
+
eventsReceived += value;
|
|
91
|
+
break;
|
|
92
|
+
case "event.processed":
|
|
93
|
+
eventsProcessed += value;
|
|
94
|
+
break;
|
|
95
|
+
case "event.duplicate":
|
|
96
|
+
eventsDuplicate += value;
|
|
97
|
+
break;
|
|
98
|
+
case "event.rejected.invalid_shape":
|
|
99
|
+
eventsRejected.invalidShape += value;
|
|
100
|
+
break;
|
|
101
|
+
case "event.rejected.wrong_kind":
|
|
102
|
+
eventsRejected.wrongKind += value;
|
|
103
|
+
break;
|
|
104
|
+
case "event.rejected.stale":
|
|
105
|
+
eventsRejected.stale += value;
|
|
106
|
+
break;
|
|
107
|
+
case "event.rejected.future":
|
|
108
|
+
eventsRejected.future += value;
|
|
109
|
+
break;
|
|
110
|
+
case "event.rejected.rate_limited":
|
|
111
|
+
eventsRejected.rateLimited += value;
|
|
112
|
+
break;
|
|
113
|
+
case "event.rejected.invalid_signature":
|
|
114
|
+
eventsRejected.invalidSignature += value;
|
|
115
|
+
break;
|
|
116
|
+
case "event.rejected.oversized_ciphertext":
|
|
117
|
+
eventsRejected.oversizedCiphertext += value;
|
|
118
|
+
break;
|
|
119
|
+
case "event.rejected.oversized_plaintext":
|
|
120
|
+
eventsRejected.oversizedPlaintext += value;
|
|
121
|
+
break;
|
|
122
|
+
case "event.rejected.decrypt_failed":
|
|
123
|
+
eventsRejected.decryptFailed += value;
|
|
124
|
+
break;
|
|
125
|
+
case "event.rejected.self_message":
|
|
126
|
+
eventsRejected.selfMessage += value;
|
|
127
|
+
break;
|
|
128
|
+
case "relay.connect":
|
|
129
|
+
if (relayUrl) getOrCreateRelay(relayUrl).connects += value;
|
|
130
|
+
break;
|
|
131
|
+
case "relay.disconnect":
|
|
132
|
+
if (relayUrl) getOrCreateRelay(relayUrl).disconnects += value;
|
|
133
|
+
break;
|
|
134
|
+
case "relay.reconnect":
|
|
135
|
+
if (relayUrl) getOrCreateRelay(relayUrl).reconnects += value;
|
|
136
|
+
break;
|
|
137
|
+
case "relay.error":
|
|
138
|
+
if (relayUrl) getOrCreateRelay(relayUrl).errors += value;
|
|
139
|
+
break;
|
|
140
|
+
case "relay.message.event":
|
|
141
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.event += value;
|
|
142
|
+
break;
|
|
143
|
+
case "relay.message.eose":
|
|
144
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.eose += value;
|
|
145
|
+
break;
|
|
146
|
+
case "relay.message.closed":
|
|
147
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.closed += value;
|
|
148
|
+
break;
|
|
149
|
+
case "relay.message.notice":
|
|
150
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.notice += value;
|
|
151
|
+
break;
|
|
152
|
+
case "relay.message.ok":
|
|
153
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.ok += value;
|
|
154
|
+
break;
|
|
155
|
+
case "relay.message.auth":
|
|
156
|
+
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.auth += value;
|
|
157
|
+
break;
|
|
158
|
+
case "relay.circuit_breaker.open":
|
|
159
|
+
if (relayUrl) {
|
|
160
|
+
const r = getOrCreateRelay(relayUrl);
|
|
161
|
+
r.circuitBreakerState = "open";
|
|
162
|
+
r.circuitBreakerOpens += value;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case "relay.circuit_breaker.close":
|
|
166
|
+
if (relayUrl) {
|
|
167
|
+
const r = getOrCreateRelay(relayUrl);
|
|
168
|
+
r.circuitBreakerState = "closed";
|
|
169
|
+
r.circuitBreakerCloses += value;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
case "relay.circuit_breaker.half_open":
|
|
173
|
+
if (relayUrl) getOrCreateRelay(relayUrl).circuitBreakerState = "half_open";
|
|
174
|
+
break;
|
|
175
|
+
case "rate_limit.per_sender":
|
|
176
|
+
rateLimiting.perSenderHits += value;
|
|
177
|
+
break;
|
|
178
|
+
case "rate_limit.global":
|
|
179
|
+
rateLimiting.globalHits += value;
|
|
180
|
+
break;
|
|
181
|
+
case "decrypt.success":
|
|
182
|
+
decrypt.success += value;
|
|
183
|
+
break;
|
|
184
|
+
case "decrypt.failure":
|
|
185
|
+
decrypt.failure += value;
|
|
186
|
+
break;
|
|
187
|
+
case "memory.seen_tracker_size":
|
|
188
|
+
memory.seenTrackerSize = value;
|
|
189
|
+
break;
|
|
190
|
+
case "memory.rate_limiter_entries":
|
|
191
|
+
memory.rateLimiterEntries = value;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function getSnapshot() {
|
|
196
|
+
const relaysObj = {};
|
|
197
|
+
for (const [url, stats] of relays) relaysObj[url] = {
|
|
198
|
+
...stats,
|
|
199
|
+
messagesReceived: { ...stats.messagesReceived }
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
eventsReceived,
|
|
203
|
+
eventsProcessed,
|
|
204
|
+
eventsDuplicate,
|
|
205
|
+
eventsRejected: { ...eventsRejected },
|
|
206
|
+
relays: relaysObj,
|
|
207
|
+
rateLimiting: { ...rateLimiting },
|
|
208
|
+
decrypt: { ...decrypt },
|
|
209
|
+
memory: { ...memory },
|
|
210
|
+
snapshotAt: Date.now()
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function reset() {
|
|
214
|
+
eventsReceived = 0;
|
|
215
|
+
eventsProcessed = 0;
|
|
216
|
+
eventsDuplicate = 0;
|
|
217
|
+
Object.assign(eventsRejected, {
|
|
218
|
+
invalidShape: 0,
|
|
219
|
+
wrongKind: 0,
|
|
220
|
+
stale: 0,
|
|
221
|
+
future: 0,
|
|
222
|
+
rateLimited: 0,
|
|
223
|
+
invalidSignature: 0,
|
|
224
|
+
oversizedCiphertext: 0,
|
|
225
|
+
oversizedPlaintext: 0,
|
|
226
|
+
decryptFailed: 0,
|
|
227
|
+
selfMessage: 0
|
|
228
|
+
});
|
|
229
|
+
relays.clear();
|
|
230
|
+
rateLimiting.perSenderHits = 0;
|
|
231
|
+
rateLimiting.globalHits = 0;
|
|
232
|
+
decrypt.success = 0;
|
|
233
|
+
decrypt.failure = 0;
|
|
234
|
+
memory.seenTrackerSize = 0;
|
|
235
|
+
memory.rateLimiterEntries = 0;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
emit,
|
|
239
|
+
getSnapshot,
|
|
240
|
+
reset
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Create a no-op metrics instance (for when metrics are disabled).
|
|
245
|
+
*/
|
|
246
|
+
function createNoopMetrics() {
|
|
247
|
+
const emptySnapshot = {
|
|
248
|
+
eventsReceived: 0,
|
|
249
|
+
eventsProcessed: 0,
|
|
250
|
+
eventsDuplicate: 0,
|
|
251
|
+
eventsRejected: {
|
|
252
|
+
invalidShape: 0,
|
|
253
|
+
wrongKind: 0,
|
|
254
|
+
stale: 0,
|
|
255
|
+
future: 0,
|
|
256
|
+
rateLimited: 0,
|
|
257
|
+
invalidSignature: 0,
|
|
258
|
+
oversizedCiphertext: 0,
|
|
259
|
+
oversizedPlaintext: 0,
|
|
260
|
+
decryptFailed: 0,
|
|
261
|
+
selfMessage: 0
|
|
262
|
+
},
|
|
263
|
+
relays: {},
|
|
264
|
+
rateLimiting: {
|
|
265
|
+
perSenderHits: 0,
|
|
266
|
+
globalHits: 0
|
|
267
|
+
},
|
|
268
|
+
decrypt: {
|
|
269
|
+
success: 0,
|
|
270
|
+
failure: 0
|
|
271
|
+
},
|
|
272
|
+
memory: {
|
|
273
|
+
seenTrackerSize: 0,
|
|
274
|
+
rateLimiterEntries: 0
|
|
275
|
+
},
|
|
276
|
+
snapshotAt: 0
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
emit: () => {},
|
|
280
|
+
getSnapshot: () => ({
|
|
281
|
+
...emptySnapshot,
|
|
282
|
+
snapshotAt: Date.now()
|
|
283
|
+
}),
|
|
284
|
+
reset: () => {}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region extensions/nostr/src/nostr-profile-core.ts
|
|
289
|
+
/**
|
|
290
|
+
* Convert our config profile schema to NIP-01 content format.
|
|
291
|
+
* Strips undefined fields and validates URLs.
|
|
292
|
+
*/
|
|
293
|
+
function profileToContent(profile) {
|
|
294
|
+
const validated = NostrProfileSchema.parse(profile);
|
|
295
|
+
const content = {};
|
|
296
|
+
if (validated.name !== void 0) content.name = validated.name;
|
|
297
|
+
if (validated.displayName !== void 0) content.display_name = validated.displayName;
|
|
298
|
+
if (validated.about !== void 0) content.about = validated.about;
|
|
299
|
+
if (validated.picture !== void 0) content.picture = validated.picture;
|
|
300
|
+
if (validated.banner !== void 0) content.banner = validated.banner;
|
|
301
|
+
if (validated.website !== void 0) content.website = validated.website;
|
|
302
|
+
if (validated.nip05 !== void 0) content.nip05 = validated.nip05;
|
|
303
|
+
if (validated.lud16 !== void 0) content.lud16 = validated.lud16;
|
|
304
|
+
return content;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Convert NIP-01 content format back to our config profile schema.
|
|
308
|
+
* Useful for importing existing profiles from relays.
|
|
309
|
+
*/
|
|
310
|
+
function contentToProfile(content) {
|
|
311
|
+
const profile = {};
|
|
312
|
+
if (content.name !== void 0) profile.name = content.name;
|
|
313
|
+
if (content.display_name !== void 0) profile.displayName = content.display_name;
|
|
314
|
+
if (content.about !== void 0) profile.about = content.about;
|
|
315
|
+
if (content.picture !== void 0) profile.picture = content.picture;
|
|
316
|
+
if (content.banner !== void 0) profile.banner = content.banner;
|
|
317
|
+
if (content.website !== void 0) profile.website = content.website;
|
|
318
|
+
if (content.nip05 !== void 0) profile.nip05 = content.nip05;
|
|
319
|
+
if (content.lud16 !== void 0) profile.lud16 = content.lud16;
|
|
320
|
+
return profile;
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region extensions/nostr/src/nostr-profile.ts
|
|
324
|
+
/**
|
|
325
|
+
* Nostr Profile Management (NIP-01 kind:0)
|
|
326
|
+
*
|
|
327
|
+
* Profile events are "replaceable" - the latest created_at wins.
|
|
328
|
+
* This module handles profile event creation and publishing.
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* Create a signed kind:0 profile event.
|
|
332
|
+
*
|
|
333
|
+
* @param sk - Private key as Uint8Array (32 bytes)
|
|
334
|
+
* @param profile - Profile data to include
|
|
335
|
+
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
|
|
336
|
+
* @returns Signed Nostr event
|
|
337
|
+
*/
|
|
338
|
+
function createProfileEvent(sk, profile, lastPublishedAt) {
|
|
339
|
+
const content = profileToContent(profile);
|
|
340
|
+
const contentJson = JSON.stringify(content);
|
|
341
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
342
|
+
return finalizeEvent({
|
|
343
|
+
kind: 0,
|
|
344
|
+
content: contentJson,
|
|
345
|
+
tags: [],
|
|
346
|
+
created_at: lastPublishedAt !== void 0 ? Math.max(now, lastPublishedAt + 1) : now
|
|
347
|
+
}, sk);
|
|
348
|
+
}
|
|
349
|
+
/** Per-relay publish timeout (ms) */
|
|
350
|
+
const RELAY_PUBLISH_TIMEOUT_MS = 5e3;
|
|
351
|
+
/**
|
|
352
|
+
* Publish a profile event to multiple relays.
|
|
353
|
+
*
|
|
354
|
+
* Best-effort: publishes to all relays in parallel, reports per-relay results.
|
|
355
|
+
* Does NOT retry automatically - caller should handle retries if needed.
|
|
356
|
+
*
|
|
357
|
+
* @param pool - SimplePool instance for relay connections
|
|
358
|
+
* @param relays - Array of relay WebSocket URLs
|
|
359
|
+
* @param event - Signed profile event (kind:0)
|
|
360
|
+
* @returns Publish results with successes and failures
|
|
361
|
+
*/
|
|
362
|
+
async function publishProfileEvent(pool, relays, event) {
|
|
363
|
+
const successes = [];
|
|
364
|
+
const failures = [];
|
|
365
|
+
const publishPromises = relays.map(async (relay) => {
|
|
366
|
+
try {
|
|
367
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
368
|
+
setTimeout(() => reject(/* @__PURE__ */ new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
|
369
|
+
});
|
|
370
|
+
await Promise.race([...pool.publish([relay], event), timeoutPromise]);
|
|
371
|
+
successes.push(relay);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const errorMessage = formatErrorMessage(err);
|
|
374
|
+
failures.push({
|
|
375
|
+
relay,
|
|
376
|
+
error: errorMessage
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
await Promise.all(publishPromises);
|
|
381
|
+
return {
|
|
382
|
+
eventId: event.id,
|
|
383
|
+
successes,
|
|
384
|
+
failures,
|
|
385
|
+
createdAt: event.created_at
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Create and publish a profile event in one call.
|
|
390
|
+
*
|
|
391
|
+
* @param pool - SimplePool instance
|
|
392
|
+
* @param sk - Private key as Uint8Array
|
|
393
|
+
* @param relays - Array of relay URLs
|
|
394
|
+
* @param profile - Profile data
|
|
395
|
+
* @param lastPublishedAt - Previous timestamp for monotonic ordering
|
|
396
|
+
* @returns Publish results
|
|
397
|
+
*/
|
|
398
|
+
async function publishProfile(pool, sk, relays, profile, lastPublishedAt) {
|
|
399
|
+
return publishProfileEvent(pool, relays, createProfileEvent(sk, profile, lastPublishedAt));
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region extensions/nostr/src/runtime.ts
|
|
403
|
+
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore({
|
|
404
|
+
pluginId: "nostr",
|
|
405
|
+
errorMessage: "Nostr runtime not initialized"
|
|
406
|
+
});
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region extensions/nostr/src/nostr-state-store.ts
|
|
409
|
+
const STORE_VERSION = 2;
|
|
410
|
+
const PROFILE_STATE_VERSION = 1;
|
|
411
|
+
const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null);
|
|
412
|
+
const NostrBusStateV1Schema = z.object({
|
|
413
|
+
version: z.literal(1),
|
|
414
|
+
lastProcessedAt: NullableFiniteNumberSchema,
|
|
415
|
+
gatewayStartedAt: NullableFiniteNumberSchema
|
|
416
|
+
});
|
|
417
|
+
const NostrBusStateSchema = z.object({
|
|
418
|
+
version: z.literal(2),
|
|
419
|
+
lastProcessedAt: NullableFiniteNumberSchema,
|
|
420
|
+
gatewayStartedAt: NullableFiniteNumberSchema,
|
|
421
|
+
recentEventIds: z.array(z.unknown()).catch([]).transform((ids) => ids.filter((id) => typeof id === "string"))
|
|
422
|
+
});
|
|
423
|
+
const NostrProfileStateSchema = z.object({
|
|
424
|
+
version: z.literal(1),
|
|
425
|
+
lastPublishedAt: NullableFiniteNumberSchema,
|
|
426
|
+
lastPublishedEventId: z.string().nullable().catch(null),
|
|
427
|
+
lastPublishResults: z.record(z.string(), z.enum([
|
|
428
|
+
"ok",
|
|
429
|
+
"failed",
|
|
430
|
+
"timeout"
|
|
431
|
+
])).nullable().catch(null)
|
|
432
|
+
});
|
|
433
|
+
function normalizeAccountId(accountId) {
|
|
434
|
+
const trimmed = accountId?.trim();
|
|
435
|
+
if (!trimmed) return "default";
|
|
436
|
+
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
|
|
437
|
+
}
|
|
438
|
+
function resolveNostrStatePath(accountId, env = process.env) {
|
|
439
|
+
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
|
440
|
+
const normalized = normalizeAccountId(accountId);
|
|
441
|
+
return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
|
|
442
|
+
}
|
|
443
|
+
function resolveNostrProfileStatePath(accountId, env = process.env) {
|
|
444
|
+
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
|
445
|
+
const normalized = normalizeAccountId(accountId);
|
|
446
|
+
return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
|
|
447
|
+
}
|
|
448
|
+
function safeParseState(raw) {
|
|
449
|
+
const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw);
|
|
450
|
+
if (parsedV2) return parsedV2;
|
|
451
|
+
const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw);
|
|
452
|
+
if (!parsedV1) return null;
|
|
453
|
+
return {
|
|
454
|
+
version: 2,
|
|
455
|
+
lastProcessedAt: parsedV1.lastProcessedAt,
|
|
456
|
+
gatewayStartedAt: parsedV1.gatewayStartedAt,
|
|
457
|
+
recentEventIds: []
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
async function readNostrBusState(params) {
|
|
461
|
+
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
|
462
|
+
try {
|
|
463
|
+
return safeParseState(await fs.readFile(filePath, "utf-8"));
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err.code === "ENOENT") return null;
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async function writeNostrBusState(params) {
|
|
470
|
+
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
|
471
|
+
const dir = path.dirname(filePath);
|
|
472
|
+
await fs.mkdir(dir, {
|
|
473
|
+
recursive: true,
|
|
474
|
+
mode: 448
|
|
475
|
+
});
|
|
476
|
+
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
|
|
477
|
+
const payload = {
|
|
478
|
+
version: STORE_VERSION,
|
|
479
|
+
lastProcessedAt: params.lastProcessedAt,
|
|
480
|
+
gatewayStartedAt: params.gatewayStartedAt,
|
|
481
|
+
recentEventIds: (params.recentEventIds ?? []).filter((x) => typeof x === "string")
|
|
482
|
+
};
|
|
483
|
+
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf-8" });
|
|
484
|
+
await fs.chmod(tmp, 384);
|
|
485
|
+
await fs.rename(tmp, filePath);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Determine the `since` timestamp for subscription.
|
|
489
|
+
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
|
|
490
|
+
* falling back to `now` for fresh starts.
|
|
491
|
+
*/
|
|
492
|
+
function computeSinceTimestamp(state, nowSec = Math.floor(Date.now() / 1e3)) {
|
|
493
|
+
if (!state) return nowSec;
|
|
494
|
+
const candidates = [state.lastProcessedAt, state.gatewayStartedAt].filter((t) => t !== null && t > 0);
|
|
495
|
+
if (candidates.length === 0) return nowSec;
|
|
496
|
+
return Math.max(...candidates);
|
|
497
|
+
}
|
|
498
|
+
function safeParseProfileState(raw) {
|
|
499
|
+
return safeParseJsonWithSchema(NostrProfileStateSchema, raw);
|
|
500
|
+
}
|
|
501
|
+
async function readNostrProfileState(params) {
|
|
502
|
+
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
|
503
|
+
try {
|
|
504
|
+
return safeParseProfileState(await fs.readFile(filePath, "utf-8"));
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (err.code === "ENOENT") return null;
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function writeNostrProfileState(params) {
|
|
511
|
+
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
|
512
|
+
const dir = path.dirname(filePath);
|
|
513
|
+
await fs.mkdir(dir, {
|
|
514
|
+
recursive: true,
|
|
515
|
+
mode: 448
|
|
516
|
+
});
|
|
517
|
+
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
|
|
518
|
+
const payload = {
|
|
519
|
+
version: PROFILE_STATE_VERSION,
|
|
520
|
+
lastPublishedAt: params.lastPublishedAt,
|
|
521
|
+
lastPublishedEventId: params.lastPublishedEventId,
|
|
522
|
+
lastPublishResults: params.lastPublishResults
|
|
523
|
+
};
|
|
524
|
+
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf-8" });
|
|
525
|
+
await fs.chmod(tmp, 384);
|
|
526
|
+
await fs.rename(tmp, filePath);
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region extensions/nostr/src/seen-tracker.ts
|
|
530
|
+
/**
|
|
531
|
+
* Create a new seen tracker with LRU eviction and TTL expiration.
|
|
532
|
+
*/
|
|
533
|
+
function createSeenTracker(options) {
|
|
534
|
+
const maxEntries = options?.maxEntries ?? 1e5;
|
|
535
|
+
const ttlMs = options?.ttlMs ?? 3600 * 1e3;
|
|
536
|
+
const pruneIntervalMs = options?.pruneIntervalMs ?? 600 * 1e3;
|
|
537
|
+
const entries = /* @__PURE__ */ new Map();
|
|
538
|
+
let head = null;
|
|
539
|
+
let tail = null;
|
|
540
|
+
function moveToFront(id) {
|
|
541
|
+
const entry = entries.get(id);
|
|
542
|
+
if (!entry) return;
|
|
543
|
+
if (head === id) return;
|
|
544
|
+
if (entry.prev) {
|
|
545
|
+
const prevEntry = entries.get(entry.prev);
|
|
546
|
+
if (prevEntry) prevEntry.next = entry.next;
|
|
547
|
+
}
|
|
548
|
+
if (entry.next) {
|
|
549
|
+
const nextEntry = entries.get(entry.next);
|
|
550
|
+
if (nextEntry) nextEntry.prev = entry.prev;
|
|
551
|
+
}
|
|
552
|
+
if (tail === id) tail = entry.prev;
|
|
553
|
+
entry.prev = null;
|
|
554
|
+
entry.next = head;
|
|
555
|
+
if (head) {
|
|
556
|
+
const headEntry = entries.get(head);
|
|
557
|
+
if (headEntry) headEntry.prev = id;
|
|
558
|
+
}
|
|
559
|
+
head = id;
|
|
560
|
+
if (!tail) tail = id;
|
|
561
|
+
}
|
|
562
|
+
function removeFromList(id) {
|
|
563
|
+
const entry = entries.get(id);
|
|
564
|
+
if (!entry) return;
|
|
565
|
+
if (entry.prev) {
|
|
566
|
+
const prevEntry = entries.get(entry.prev);
|
|
567
|
+
if (prevEntry) prevEntry.next = entry.next;
|
|
568
|
+
} else head = entry.next;
|
|
569
|
+
if (entry.next) {
|
|
570
|
+
const nextEntry = entries.get(entry.next);
|
|
571
|
+
if (nextEntry) nextEntry.prev = entry.prev;
|
|
572
|
+
} else tail = entry.prev;
|
|
573
|
+
}
|
|
574
|
+
function evictLRU() {
|
|
575
|
+
if (!tail) return;
|
|
576
|
+
const idToEvict = tail;
|
|
577
|
+
removeFromList(idToEvict);
|
|
578
|
+
entries.delete(idToEvict);
|
|
579
|
+
}
|
|
580
|
+
function insertAtFront(id, seenAt) {
|
|
581
|
+
const newEntry = {
|
|
582
|
+
seenAt,
|
|
583
|
+
prev: null,
|
|
584
|
+
next: head
|
|
585
|
+
};
|
|
586
|
+
if (head) {
|
|
587
|
+
const headEntry = entries.get(head);
|
|
588
|
+
if (headEntry) headEntry.prev = id;
|
|
589
|
+
}
|
|
590
|
+
entries.set(id, newEntry);
|
|
591
|
+
head = id;
|
|
592
|
+
if (!tail) tail = id;
|
|
593
|
+
}
|
|
594
|
+
function pruneExpired() {
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const toDelete = [];
|
|
597
|
+
for (const [id, entry] of entries) if (now - entry.seenAt > ttlMs) toDelete.push(id);
|
|
598
|
+
for (const id of toDelete) {
|
|
599
|
+
removeFromList(id);
|
|
600
|
+
entries.delete(id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
let pruneTimer;
|
|
604
|
+
if (pruneIntervalMs > 0) {
|
|
605
|
+
pruneTimer = setInterval(pruneExpired, pruneIntervalMs);
|
|
606
|
+
if (pruneTimer.unref) pruneTimer.unref();
|
|
607
|
+
}
|
|
608
|
+
function add(id) {
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const existing = entries.get(id);
|
|
611
|
+
if (existing) {
|
|
612
|
+
existing.seenAt = now;
|
|
613
|
+
moveToFront(id);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
while (entries.size >= maxEntries) evictLRU();
|
|
617
|
+
insertAtFront(id, now);
|
|
618
|
+
}
|
|
619
|
+
function has(id) {
|
|
620
|
+
const entry = entries.get(id);
|
|
621
|
+
if (!entry) {
|
|
622
|
+
add(id);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
if (Date.now() - entry.seenAt > ttlMs) {
|
|
626
|
+
removeFromList(id);
|
|
627
|
+
entries.delete(id);
|
|
628
|
+
add(id);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
entry.seenAt = Date.now();
|
|
632
|
+
moveToFront(id);
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
function peek(id) {
|
|
636
|
+
const entry = entries.get(id);
|
|
637
|
+
if (!entry) return false;
|
|
638
|
+
if (Date.now() - entry.seenAt > ttlMs) {
|
|
639
|
+
removeFromList(id);
|
|
640
|
+
entries.delete(id);
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
function deleteEntry(id) {
|
|
646
|
+
if (entries.has(id)) {
|
|
647
|
+
removeFromList(id);
|
|
648
|
+
entries.delete(id);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function clear() {
|
|
652
|
+
entries.clear();
|
|
653
|
+
head = null;
|
|
654
|
+
tail = null;
|
|
655
|
+
}
|
|
656
|
+
function size() {
|
|
657
|
+
return entries.size;
|
|
658
|
+
}
|
|
659
|
+
function stop() {
|
|
660
|
+
if (pruneTimer) {
|
|
661
|
+
clearInterval(pruneTimer);
|
|
662
|
+
pruneTimer = void 0;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function seed(ids) {
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
668
|
+
const id = ids[i];
|
|
669
|
+
if (!entries.has(id) && entries.size < maxEntries) insertAtFront(id, now);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
has,
|
|
674
|
+
add,
|
|
675
|
+
peek,
|
|
676
|
+
delete: deleteEntry,
|
|
677
|
+
clear,
|
|
678
|
+
size,
|
|
679
|
+
stop,
|
|
680
|
+
seed
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region extensions/nostr/src/nostr-bus.ts
|
|
685
|
+
const STARTUP_LOOKBACK_SEC = 120;
|
|
686
|
+
const MAX_PERSISTED_EVENT_IDS = 5e3;
|
|
687
|
+
const STATE_PERSIST_DEBOUNCE_MS = 5e3;
|
|
688
|
+
const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
|
|
689
|
+
const CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
690
|
+
const CIRCUIT_BREAKER_RESET_MS = 3e4;
|
|
691
|
+
const HEALTH_WINDOW_MS = 6e4;
|
|
692
|
+
function createFixedWindowRateLimiter(params) {
|
|
693
|
+
const windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
694
|
+
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
|
|
695
|
+
const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
|
|
696
|
+
const state = /* @__PURE__ */ new Map();
|
|
697
|
+
const touch = (key, value) => {
|
|
698
|
+
state.delete(key);
|
|
699
|
+
state.set(key, value);
|
|
700
|
+
};
|
|
701
|
+
const prune = (nowMs) => {
|
|
702
|
+
for (const [key, entry] of state) if (nowMs - entry.windowStartMs >= windowMs) state.delete(key);
|
|
703
|
+
while (state.size > maxTrackedKeys) {
|
|
704
|
+
const oldest = state.keys().next().value;
|
|
705
|
+
if (!oldest) break;
|
|
706
|
+
state.delete(oldest);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
return {
|
|
710
|
+
isRateLimited: (key, nowMs = Date.now()) => {
|
|
711
|
+
if (!key) return false;
|
|
712
|
+
prune(nowMs);
|
|
713
|
+
const existing = state.get(key);
|
|
714
|
+
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
|
715
|
+
touch(key, {
|
|
716
|
+
count: 1,
|
|
717
|
+
windowStartMs: nowMs
|
|
718
|
+
});
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
const nextCount = existing.count + 1;
|
|
722
|
+
touch(key, {
|
|
723
|
+
count: nextCount,
|
|
724
|
+
windowStartMs: existing.windowStartMs
|
|
725
|
+
});
|
|
726
|
+
return nextCount > maxRequests;
|
|
727
|
+
},
|
|
728
|
+
size: () => state.size,
|
|
729
|
+
clear: () => state.clear()
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function createCircuitBreaker(relay, metrics, threshold = CIRCUIT_BREAKER_THRESHOLD, resetMs = CIRCUIT_BREAKER_RESET_MS) {
|
|
733
|
+
const state = {
|
|
734
|
+
state: "closed",
|
|
735
|
+
failures: 0,
|
|
736
|
+
lastFailure: 0,
|
|
737
|
+
lastSuccess: Date.now()
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
canAttempt() {
|
|
741
|
+
if (state.state === "closed") return true;
|
|
742
|
+
if (state.state === "open") {
|
|
743
|
+
if (Date.now() - state.lastFailure >= resetMs) {
|
|
744
|
+
state.state = "half_open";
|
|
745
|
+
metrics.emit("relay.circuit_breaker.half_open", 1, { relay });
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
return true;
|
|
751
|
+
},
|
|
752
|
+
recordSuccess() {
|
|
753
|
+
if (state.state === "half_open") {
|
|
754
|
+
state.state = "closed";
|
|
755
|
+
state.failures = 0;
|
|
756
|
+
metrics.emit("relay.circuit_breaker.close", 1, { relay });
|
|
757
|
+
} else if (state.state === "closed") state.failures = 0;
|
|
758
|
+
state.lastSuccess = Date.now();
|
|
759
|
+
},
|
|
760
|
+
recordFailure() {
|
|
761
|
+
state.failures++;
|
|
762
|
+
state.lastFailure = Date.now();
|
|
763
|
+
if (state.state === "half_open") {
|
|
764
|
+
state.state = "open";
|
|
765
|
+
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
|
766
|
+
} else if (state.state === "closed" && state.failures >= threshold) {
|
|
767
|
+
state.state = "open";
|
|
768
|
+
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
getState() {
|
|
772
|
+
return state.state;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function createRelayHealthTracker() {
|
|
777
|
+
const stats = /* @__PURE__ */ new Map();
|
|
778
|
+
function getOrCreate(relay) {
|
|
779
|
+
let s = stats.get(relay);
|
|
780
|
+
if (!s) {
|
|
781
|
+
s = {
|
|
782
|
+
successCount: 0,
|
|
783
|
+
failureCount: 0,
|
|
784
|
+
latencySum: 0,
|
|
785
|
+
latencyCount: 0,
|
|
786
|
+
lastSuccess: 0,
|
|
787
|
+
lastFailure: 0
|
|
788
|
+
};
|
|
789
|
+
stats.set(relay, s);
|
|
790
|
+
}
|
|
791
|
+
return s;
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
recordSuccess(relay, latencyMs) {
|
|
795
|
+
const s = getOrCreate(relay);
|
|
796
|
+
s.successCount++;
|
|
797
|
+
s.latencySum += latencyMs;
|
|
798
|
+
s.latencyCount++;
|
|
799
|
+
s.lastSuccess = Date.now();
|
|
800
|
+
},
|
|
801
|
+
recordFailure(relay) {
|
|
802
|
+
const s = getOrCreate(relay);
|
|
803
|
+
s.failureCount++;
|
|
804
|
+
s.lastFailure = Date.now();
|
|
805
|
+
},
|
|
806
|
+
getScore(relay) {
|
|
807
|
+
const s = stats.get(relay);
|
|
808
|
+
if (!s) return .5;
|
|
809
|
+
const total = s.successCount + s.failureCount;
|
|
810
|
+
if (total === 0) return .5;
|
|
811
|
+
const successRate = s.successCount / total;
|
|
812
|
+
const now = Date.now();
|
|
813
|
+
const recencyBonus = s.lastSuccess > s.lastFailure ? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * .2 : 0;
|
|
814
|
+
const avgLatency = s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1e3;
|
|
815
|
+
const latencyPenalty = Math.min(.2, avgLatency / 1e4);
|
|
816
|
+
return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
|
|
817
|
+
},
|
|
818
|
+
getSortedRelays(relays) {
|
|
819
|
+
return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a));
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
|
|
825
|
+
*/
|
|
826
|
+
async function startNostrBus(options) {
|
|
827
|
+
const { privateKey, relays = DEFAULT_RELAYS, onMessage, authorizeSender, onError, onEose, onMetric, maxSeenEntries = 1e5, seenTtlMs = 3600 * 1e3 } = options;
|
|
828
|
+
const sk = validatePrivateKey(privateKey);
|
|
829
|
+
const pk = getPublicKey(sk);
|
|
830
|
+
const pool = new SimplePool();
|
|
831
|
+
const accountId = options.accountId ?? pk.slice(0, 16);
|
|
832
|
+
const gatewayStartedAt = Math.floor(Date.now() / 1e3);
|
|
833
|
+
const guardPolicy = createDirectDmPreCryptoGuardPolicy({
|
|
834
|
+
...DEFAULT_INBOUND_GUARD_POLICY,
|
|
835
|
+
...options.guardPolicy,
|
|
836
|
+
rateLimit: {
|
|
837
|
+
...DEFAULT_INBOUND_GUARD_POLICY.rateLimit,
|
|
838
|
+
...options.guardPolicy?.rateLimit
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
|
|
842
|
+
const seen = createSeenTracker({
|
|
843
|
+
maxEntries: maxSeenEntries,
|
|
844
|
+
ttlMs: seenTtlMs
|
|
845
|
+
});
|
|
846
|
+
const circuitBreakers = /* @__PURE__ */ new Map();
|
|
847
|
+
const healthTracker = createRelayHealthTracker();
|
|
848
|
+
for (const relay of relays) circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
|
|
849
|
+
const state = await readNostrBusState({ accountId });
|
|
850
|
+
const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
|
|
851
|
+
const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
|
|
852
|
+
if (state?.recentEventIds?.length) seen.seed(state.recentEventIds);
|
|
853
|
+
await writeNostrBusState({
|
|
854
|
+
accountId,
|
|
855
|
+
lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
|
|
856
|
+
gatewayStartedAt,
|
|
857
|
+
recentEventIds: state?.recentEventIds ?? []
|
|
858
|
+
});
|
|
859
|
+
let pendingWrite;
|
|
860
|
+
let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
|
|
861
|
+
let recentEventIds = (state?.recentEventIds ?? []).slice(-MAX_PERSISTED_EVENT_IDS);
|
|
862
|
+
function scheduleStatePersist(eventCreatedAt, eventId) {
|
|
863
|
+
lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
|
|
864
|
+
recentEventIds.push(eventId);
|
|
865
|
+
if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
|
|
866
|
+
if (pendingWrite) clearTimeout(pendingWrite);
|
|
867
|
+
pendingWrite = setTimeout(() => {
|
|
868
|
+
writeNostrBusState({
|
|
869
|
+
accountId,
|
|
870
|
+
lastProcessedAt,
|
|
871
|
+
gatewayStartedAt,
|
|
872
|
+
recentEventIds
|
|
873
|
+
}).catch((err) => onError?.(err, "persist state"));
|
|
874
|
+
}, STATE_PERSIST_DEBOUNCE_MS);
|
|
875
|
+
}
|
|
876
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
877
|
+
const perSenderRateLimiter = createFixedWindowRateLimiter({
|
|
878
|
+
windowMs: guardPolicy.rateLimit.windowMs,
|
|
879
|
+
maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
|
|
880
|
+
maxTrackedKeys: guardPolicy.rateLimit.maxTrackedSenderKeys
|
|
881
|
+
});
|
|
882
|
+
const globalRateLimiter = createFixedWindowRateLimiter({
|
|
883
|
+
windowMs: guardPolicy.rateLimit.windowMs,
|
|
884
|
+
maxRequests: guardPolicy.rateLimit.maxGlobalPerWindow,
|
|
885
|
+
maxTrackedKeys: 1
|
|
886
|
+
});
|
|
887
|
+
const updateRateLimiterSizeMetric = () => {
|
|
888
|
+
metrics.emit("memory.rate_limiter_entries", perSenderRateLimiter.size() + globalRateLimiter.size());
|
|
889
|
+
};
|
|
890
|
+
async function handleEvent(event) {
|
|
891
|
+
try {
|
|
892
|
+
metrics.emit("event.received");
|
|
893
|
+
if (seen.peek(event.id) || inflight.has(event.id)) {
|
|
894
|
+
metrics.emit("event.duplicate");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
inflight.add(event.id);
|
|
898
|
+
const markSeen = () => {
|
|
899
|
+
seen.add(event.id);
|
|
900
|
+
metrics.emit("memory.seen_tracker_size", seen.size());
|
|
901
|
+
};
|
|
902
|
+
const rejectAndMarkSeen = (metric) => {
|
|
903
|
+
markSeen();
|
|
904
|
+
metrics.emit(metric);
|
|
905
|
+
};
|
|
906
|
+
if (event.pubkey === pk) {
|
|
907
|
+
rejectAndMarkSeen("event.rejected.self_message");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (event.created_at < since) {
|
|
911
|
+
rejectAndMarkSeen("event.rejected.stale");
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (event.created_at > Math.floor(Date.now() / 1e3) + guardPolicy.maxFutureSkewSec) {
|
|
915
|
+
metrics.emit("event.rejected.future");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (!guardPolicy.allowedKinds.includes(event.kind)) {
|
|
919
|
+
rejectAndMarkSeen("event.rejected.wrong_kind");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
let targetsUs = false;
|
|
923
|
+
for (const t of event.tags) if (t[0] === "p" && t[1] === pk) {
|
|
924
|
+
targetsUs = true;
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
if (!targetsUs) {
|
|
928
|
+
rejectAndMarkSeen("event.rejected.wrong_kind");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const replyTo = async (text) => {
|
|
932
|
+
await sendEncryptedDm(pool, sk, event.pubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
|
|
933
|
+
};
|
|
934
|
+
const rejectIfGlobalRateLimited = () => {
|
|
935
|
+
updateRateLimiterSizeMetric();
|
|
936
|
+
if (globalRateLimiter.isRateLimited("global")) {
|
|
937
|
+
metrics.emit("rate_limit.global");
|
|
938
|
+
metrics.emit("event.rejected.rate_limited");
|
|
939
|
+
updateRateLimiterSizeMetric();
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
updateRateLimiterSizeMetric();
|
|
943
|
+
return false;
|
|
944
|
+
};
|
|
945
|
+
const rejectIfVerifiedSenderRateLimited = () => {
|
|
946
|
+
updateRateLimiterSizeMetric();
|
|
947
|
+
if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
|
|
948
|
+
metrics.emit("rate_limit.per_sender");
|
|
949
|
+
metrics.emit("event.rejected.rate_limited");
|
|
950
|
+
updateRateLimiterSizeMetric();
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
updateRateLimiterSizeMetric();
|
|
954
|
+
return false;
|
|
955
|
+
};
|
|
956
|
+
if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
|
|
957
|
+
if (rejectIfGlobalRateLimited()) return;
|
|
958
|
+
rejectAndMarkSeen("event.rejected.oversized_ciphertext");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (rejectIfGlobalRateLimited()) return;
|
|
962
|
+
if (!verifyEvent(event)) {
|
|
963
|
+
rejectAndMarkSeen("event.rejected.invalid_signature");
|
|
964
|
+
onError?.(/* @__PURE__ */ new Error("Invalid signature"), `event ${event.id}`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (rejectIfVerifiedSenderRateLimited()) return;
|
|
968
|
+
if (authorizeSender) {
|
|
969
|
+
if (await authorizeSender({
|
|
970
|
+
senderPubkey: event.pubkey,
|
|
971
|
+
reply: replyTo
|
|
972
|
+
}) !== "allow") {
|
|
973
|
+
markSeen();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
let plaintext;
|
|
978
|
+
try {
|
|
979
|
+
plaintext = decrypt(sk, event.pubkey, event.content);
|
|
980
|
+
metrics.emit("decrypt.success");
|
|
981
|
+
} catch (err) {
|
|
982
|
+
markSeen();
|
|
983
|
+
metrics.emit("decrypt.failure");
|
|
984
|
+
metrics.emit("event.rejected.decrypt_failed");
|
|
985
|
+
onError?.(err, `decrypt from ${event.pubkey}`);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
|
|
989
|
+
markSeen();
|
|
990
|
+
metrics.emit("event.rejected.oversized_plaintext");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
await onMessage(event.pubkey, plaintext, replyTo, {
|
|
994
|
+
eventId: event.id,
|
|
995
|
+
createdAt: event.created_at
|
|
996
|
+
});
|
|
997
|
+
markSeen();
|
|
998
|
+
metrics.emit("event.processed");
|
|
999
|
+
scheduleStatePersist(event.created_at, event.id);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
onError?.(err, `event ${event.id}`);
|
|
1002
|
+
} finally {
|
|
1003
|
+
inflight.delete(event.id);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const sub = pool.subscribeMany(relays, [{
|
|
1007
|
+
kinds: [4],
|
|
1008
|
+
"#p": [pk],
|
|
1009
|
+
since
|
|
1010
|
+
}], {
|
|
1011
|
+
onevent: handleEvent,
|
|
1012
|
+
oneose: () => {
|
|
1013
|
+
for (const relay of relays) metrics.emit("relay.message.eose", 1, { relay });
|
|
1014
|
+
onEose?.(relays.join(", "));
|
|
1015
|
+
},
|
|
1016
|
+
onclose: (reason) => {
|
|
1017
|
+
for (const relay of relays) {
|
|
1018
|
+
metrics.emit("relay.message.closed", 1, { relay });
|
|
1019
|
+
options.onDisconnect?.(relay);
|
|
1020
|
+
}
|
|
1021
|
+
onError?.(/* @__PURE__ */ new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
const sendDm = async (toPubkey, text) => {
|
|
1025
|
+
await sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
|
|
1026
|
+
};
|
|
1027
|
+
const publishProfile$1 = async (profile) => {
|
|
1028
|
+
const result = await publishProfile(pool, sk, relays, profile, (await readNostrProfileState({ accountId }))?.lastPublishedAt ?? void 0);
|
|
1029
|
+
const publishResults = {};
|
|
1030
|
+
for (const relay of result.successes) publishResults[relay] = "ok";
|
|
1031
|
+
for (const { relay, error } of result.failures) publishResults[relay] = error === "timeout" ? "timeout" : "failed";
|
|
1032
|
+
await writeNostrProfileState({
|
|
1033
|
+
accountId,
|
|
1034
|
+
lastPublishedAt: result.createdAt,
|
|
1035
|
+
lastPublishedEventId: result.eventId,
|
|
1036
|
+
lastPublishResults: publishResults
|
|
1037
|
+
});
|
|
1038
|
+
return result;
|
|
1039
|
+
};
|
|
1040
|
+
const getProfileState = async () => {
|
|
1041
|
+
const state = await readNostrProfileState({ accountId });
|
|
1042
|
+
return {
|
|
1043
|
+
lastPublishedAt: state?.lastPublishedAt ?? null,
|
|
1044
|
+
lastPublishedEventId: state?.lastPublishedEventId ?? null,
|
|
1045
|
+
lastPublishResults: state?.lastPublishResults ?? null
|
|
1046
|
+
};
|
|
1047
|
+
};
|
|
1048
|
+
return {
|
|
1049
|
+
close: () => {
|
|
1050
|
+
sub.close();
|
|
1051
|
+
seen.stop();
|
|
1052
|
+
perSenderRateLimiter.clear();
|
|
1053
|
+
globalRateLimiter.clear();
|
|
1054
|
+
if (pendingWrite) {
|
|
1055
|
+
clearTimeout(pendingWrite);
|
|
1056
|
+
writeNostrBusState({
|
|
1057
|
+
accountId,
|
|
1058
|
+
lastProcessedAt,
|
|
1059
|
+
gatewayStartedAt,
|
|
1060
|
+
recentEventIds
|
|
1061
|
+
}).catch((err) => onError?.(err, "persist state on close"));
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
publicKey: pk,
|
|
1065
|
+
sendDm,
|
|
1066
|
+
getMetrics: () => metrics.getSnapshot(),
|
|
1067
|
+
publishProfile: publishProfile$1,
|
|
1068
|
+
getProfileState
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Send an encrypted DM to a pubkey
|
|
1073
|
+
*/
|
|
1074
|
+
async function sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError) {
|
|
1075
|
+
const reply = finalizeEvent({
|
|
1076
|
+
kind: 4,
|
|
1077
|
+
content: encrypt(sk, toPubkey, text),
|
|
1078
|
+
tags: [["p", toPubkey]],
|
|
1079
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
1080
|
+
}, sk);
|
|
1081
|
+
const sortedRelays = healthTracker.getSortedRelays(relays);
|
|
1082
|
+
let lastError;
|
|
1083
|
+
for (const relay of sortedRelays) {
|
|
1084
|
+
const cb = circuitBreakers.get(relay);
|
|
1085
|
+
if (cb && !cb.canAttempt()) continue;
|
|
1086
|
+
const startTime = Date.now();
|
|
1087
|
+
try {
|
|
1088
|
+
const [publishPromise] = pool.publish([relay], reply);
|
|
1089
|
+
if (!publishPromise) throw new Error(`Failed to create publish promise for relay ${relay}`);
|
|
1090
|
+
await publishPromise;
|
|
1091
|
+
const latency = Date.now() - startTime;
|
|
1092
|
+
cb?.recordSuccess();
|
|
1093
|
+
healthTracker.recordSuccess(relay, latency);
|
|
1094
|
+
return;
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
lastError = err;
|
|
1097
|
+
const latency = Date.now() - startTime;
|
|
1098
|
+
cb?.recordFailure();
|
|
1099
|
+
healthTracker.recordFailure(relay);
|
|
1100
|
+
metrics.emit("relay.error", 1, {
|
|
1101
|
+
relay,
|
|
1102
|
+
latency
|
|
1103
|
+
});
|
|
1104
|
+
onError?.(lastError, `publish to ${relay}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
|
1108
|
+
}
|
|
1109
|
+
//#endregion
|
|
1110
|
+
//#region extensions/nostr/src/gateway.ts
|
|
1111
|
+
const activeBuses = /* @__PURE__ */ new Map();
|
|
1112
|
+
const metricsSnapshots = /* @__PURE__ */ new Map();
|
|
1113
|
+
function normalizeNostrAllowEntry(entry) {
|
|
1114
|
+
const trimmed = entry.trim();
|
|
1115
|
+
if (!trimmed) return null;
|
|
1116
|
+
if (trimmed === "*") return "*";
|
|
1117
|
+
try {
|
|
1118
|
+
return normalizePubkey(trimmed.replace(/^nostr:/i, ""));
|
|
1119
|
+
} catch {
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
function isNostrSenderAllowed(senderPubkey, allowFrom) {
|
|
1124
|
+
const normalizedSender = normalizePubkey(senderPubkey);
|
|
1125
|
+
for (const entry of allowFrom) {
|
|
1126
|
+
const normalized = normalizeNostrAllowEntry(entry);
|
|
1127
|
+
if (normalized === "*" || normalized === normalizedSender) return true;
|
|
1128
|
+
}
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
async function resolveNostrDirectAccess(params) {
|
|
1132
|
+
return resolveInboundDirectDmAccessWithRuntime({
|
|
1133
|
+
cfg: params.cfg,
|
|
1134
|
+
channel: "nostr",
|
|
1135
|
+
accountId: params.accountId,
|
|
1136
|
+
dmPolicy: params.dmPolicy,
|
|
1137
|
+
allowFrom: params.allowFrom,
|
|
1138
|
+
senderId: params.senderPubkey,
|
|
1139
|
+
rawBody: params.rawBody,
|
|
1140
|
+
isSenderAllowed: isNostrSenderAllowed,
|
|
1141
|
+
runtime: params.runtime,
|
|
1142
|
+
modeWhenAccessGroupsOff: "configured"
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
const startNostrGatewayAccount = async (ctx) => {
|
|
1146
|
+
const account = ctx.account;
|
|
1147
|
+
ctx.setStatus({
|
|
1148
|
+
accountId: account.accountId,
|
|
1149
|
+
publicKey: account.publicKey
|
|
1150
|
+
});
|
|
1151
|
+
ctx.log?.info?.(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`);
|
|
1152
|
+
if (!account.configured) throw new Error("Nostr private key not configured");
|
|
1153
|
+
const runtime = getNostrRuntime();
|
|
1154
|
+
const pairing = createChannelPairingController({
|
|
1155
|
+
core: runtime,
|
|
1156
|
+
channel: "nostr",
|
|
1157
|
+
accountId: account.accountId
|
|
1158
|
+
});
|
|
1159
|
+
const resolveInboundAccess = async (senderPubkey, rawBody) => await resolveNostrDirectAccess({
|
|
1160
|
+
cfg: ctx.cfg,
|
|
1161
|
+
accountId: account.accountId,
|
|
1162
|
+
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
1163
|
+
allowFrom: account.config.allowFrom,
|
|
1164
|
+
senderPubkey,
|
|
1165
|
+
rawBody,
|
|
1166
|
+
runtime: {
|
|
1167
|
+
shouldComputeCommandAuthorized: runtime.channel.commands.shouldComputeCommandAuthorized,
|
|
1168
|
+
resolveCommandAuthorizedFromAuthorizers: runtime.channel.commands.resolveCommandAuthorizedFromAuthorizers
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
let busHandle = null;
|
|
1172
|
+
const authorizeSender = createPreCryptoDirectDmAuthorizer({
|
|
1173
|
+
resolveAccess: async (senderPubkey) => await resolveInboundAccess(senderPubkey, ""),
|
|
1174
|
+
issuePairingChallenge: async ({ senderId, reply }) => {
|
|
1175
|
+
await pairing.issueChallenge({
|
|
1176
|
+
senderId,
|
|
1177
|
+
senderIdLine: `Your Nostr pubkey: ${senderId}`,
|
|
1178
|
+
sendPairingReply: reply,
|
|
1179
|
+
onCreated: () => {
|
|
1180
|
+
ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${senderId}`);
|
|
1181
|
+
},
|
|
1182
|
+
onReplyError: (err) => {
|
|
1183
|
+
ctx.log?.warn?.(`[${account.accountId}] nostr pairing reply failed for ${senderId}: ${String(err)}`);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
},
|
|
1187
|
+
onBlocked: ({ senderId, reason }) => {
|
|
1188
|
+
ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${senderId} (${reason})`);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
const bus = await startNostrBus({
|
|
1192
|
+
accountId: account.accountId,
|
|
1193
|
+
privateKey: account.privateKey,
|
|
1194
|
+
relays: account.relays,
|
|
1195
|
+
authorizeSender: async ({ senderPubkey, reply }) => await authorizeSender({
|
|
1196
|
+
senderId: senderPubkey,
|
|
1197
|
+
reply
|
|
1198
|
+
}),
|
|
1199
|
+
onMessage: async (senderPubkey, text, reply, meta) => {
|
|
1200
|
+
const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
|
|
1201
|
+
if (resolvedAccess.access.decision !== "allow") {
|
|
1202
|
+
ctx.log?.warn?.(`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.access.reason})`);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const { dispatchInboundDirectDmWithRuntime } = await import("./inbound-direct-dm-runtime-22bZWcIW.js");
|
|
1206
|
+
await dispatchInboundDirectDmWithRuntime({
|
|
1207
|
+
cfg: ctx.cfg,
|
|
1208
|
+
runtime,
|
|
1209
|
+
channel: "nostr",
|
|
1210
|
+
channelLabel: "Nostr",
|
|
1211
|
+
accountId: account.accountId,
|
|
1212
|
+
peer: {
|
|
1213
|
+
kind: "direct",
|
|
1214
|
+
id: senderPubkey
|
|
1215
|
+
},
|
|
1216
|
+
senderId: senderPubkey,
|
|
1217
|
+
senderAddress: `nostr:${senderPubkey}`,
|
|
1218
|
+
recipientAddress: `nostr:${account.publicKey}`,
|
|
1219
|
+
conversationLabel: senderPubkey,
|
|
1220
|
+
rawBody: text,
|
|
1221
|
+
messageId: meta.eventId,
|
|
1222
|
+
timestamp: meta.createdAt * 1e3,
|
|
1223
|
+
commandAuthorized: resolvedAccess.commandAuthorized,
|
|
1224
|
+
deliver: async (payload) => {
|
|
1225
|
+
const outboundText = payload && typeof payload === "object" && "text" in payload ? payload.text ?? "" : "";
|
|
1226
|
+
if (!outboundText.trim()) return;
|
|
1227
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
1228
|
+
cfg: ctx.cfg,
|
|
1229
|
+
channel: "nostr",
|
|
1230
|
+
accountId: account.accountId
|
|
1231
|
+
});
|
|
1232
|
+
await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
|
|
1233
|
+
},
|
|
1234
|
+
onRecordError: (err) => {
|
|
1235
|
+
ctx.log?.error?.(`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`);
|
|
1236
|
+
},
|
|
1237
|
+
onDispatchError: (err, info) => {
|
|
1238
|
+
ctx.log?.error?.(`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
},
|
|
1242
|
+
onError: (error, context) => {
|
|
1243
|
+
ctx.log?.error?.(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
|
|
1244
|
+
},
|
|
1245
|
+
onConnect: (relay) => {
|
|
1246
|
+
ctx.log?.debug?.(`[${account.accountId}] Connected to relay: ${relay}`);
|
|
1247
|
+
},
|
|
1248
|
+
onDisconnect: (relay) => {
|
|
1249
|
+
ctx.log?.debug?.(`[${account.accountId}] Disconnected from relay: ${relay}`);
|
|
1250
|
+
},
|
|
1251
|
+
onEose: (relays) => {
|
|
1252
|
+
ctx.log?.debug?.(`[${account.accountId}] EOSE received from relays: ${relays}`);
|
|
1253
|
+
},
|
|
1254
|
+
onMetric: (event) => {
|
|
1255
|
+
if (event.name.startsWith("event.rejected.")) ctx.log?.debug?.(`[${account.accountId}] Metric: ${event.name} ${JSON.stringify(event.labels)}`);
|
|
1256
|
+
else if (event.name === "relay.circuit_breaker.open") ctx.log?.warn?.(`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`);
|
|
1257
|
+
else if (event.name === "relay.circuit_breaker.close") ctx.log?.info?.(`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`);
|
|
1258
|
+
else if (event.name === "relay.error") ctx.log?.debug?.(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
|
|
1259
|
+
if (busHandle) metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
busHandle = bus;
|
|
1263
|
+
activeBuses.set(account.accountId, bus);
|
|
1264
|
+
ctx.log?.info?.(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`);
|
|
1265
|
+
return { stop: () => {
|
|
1266
|
+
bus.close();
|
|
1267
|
+
activeBuses.delete(account.accountId);
|
|
1268
|
+
metricsSnapshots.delete(account.accountId);
|
|
1269
|
+
ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
|
|
1270
|
+
} };
|
|
1271
|
+
};
|
|
1272
|
+
const nostrPairingTextAdapter = {
|
|
1273
|
+
idLabel: "nostrPubkey",
|
|
1274
|
+
message: "Your pairing request has been approved!",
|
|
1275
|
+
normalizeAllowEntry: (entry) => {
|
|
1276
|
+
try {
|
|
1277
|
+
return normalizePubkey(entry.trim().replace(/^nostr:/i, ""));
|
|
1278
|
+
} catch {
|
|
1279
|
+
return entry.trim();
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
1282
|
+
notify: async ({ cfg, id, message, accountId }) => {
|
|
1283
|
+
const bus = activeBuses.get(accountId ?? resolveDefaultNostrAccountId(cfg));
|
|
1284
|
+
if (bus) await bus.sendDm(id, message);
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
const nostrOutboundAdapter = {
|
|
1288
|
+
deliveryMode: "direct",
|
|
1289
|
+
textChunkLimit: 4e3,
|
|
1290
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
1291
|
+
const core = getNostrRuntime();
|
|
1292
|
+
const aid = accountId ?? resolveDefaultNostrAccountId(cfg);
|
|
1293
|
+
const bus = activeBuses.get(aid);
|
|
1294
|
+
if (!bus) throw new Error(`Nostr bus not running for account ${aid}`);
|
|
1295
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
1296
|
+
cfg,
|
|
1297
|
+
channel: "nostr",
|
|
1298
|
+
accountId: aid
|
|
1299
|
+
});
|
|
1300
|
+
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
1301
|
+
const normalizedTo = normalizePubkey(to);
|
|
1302
|
+
await bus.sendDm(normalizedTo, message);
|
|
1303
|
+
return attachChannelToResult("nostr", {
|
|
1304
|
+
to: normalizedTo,
|
|
1305
|
+
messageId: `nostr-${Date.now()}`
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
function getActiveNostrBuses() {
|
|
1310
|
+
return new Map(activeBuses);
|
|
1311
|
+
}
|
|
1312
|
+
//#endregion
|
|
1313
|
+
//#region extensions/nostr/src/session-route.ts
|
|
1314
|
+
function resolveNostrOutboundSessionRoute(params) {
|
|
1315
|
+
const target = stripChannelTargetPrefix(params.target, "nostr");
|
|
1316
|
+
if (!target) return null;
|
|
1317
|
+
return buildChannelOutboundSessionRoute({
|
|
1318
|
+
cfg: params.cfg,
|
|
1319
|
+
agentId: params.agentId,
|
|
1320
|
+
channel: "nostr",
|
|
1321
|
+
accountId: params.accountId,
|
|
1322
|
+
peer: {
|
|
1323
|
+
kind: "direct",
|
|
1324
|
+
id: target
|
|
1325
|
+
},
|
|
1326
|
+
chatType: "direct",
|
|
1327
|
+
from: `nostr:${target}`,
|
|
1328
|
+
to: `nostr:${target}`
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region extensions/nostr/src/channel.ts
|
|
1333
|
+
const resolveNostrDmPolicy = createScopedDmSecurityResolver({
|
|
1334
|
+
channelKey: "nostr",
|
|
1335
|
+
resolvePolicy: (account) => account.config.dmPolicy,
|
|
1336
|
+
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
1337
|
+
policyPathSuffix: "dmPolicy",
|
|
1338
|
+
defaultPolicy: "pairing",
|
|
1339
|
+
approveHint: formatPairingApproveHint("nostr"),
|
|
1340
|
+
normalizeEntry: (raw) => {
|
|
1341
|
+
try {
|
|
1342
|
+
return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
|
|
1343
|
+
} catch {
|
|
1344
|
+
return raw.trim();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
const nostrConfigAdapter = createTopLevelChannelConfigAdapter({
|
|
1349
|
+
sectionKey: "nostr",
|
|
1350
|
+
resolveAccount: (cfg) => resolveNostrAccount({ cfg }),
|
|
1351
|
+
listAccountIds: listNostrAccountIds,
|
|
1352
|
+
defaultAccountId: resolveDefaultNostrAccountId,
|
|
1353
|
+
deleteMode: "clear-fields",
|
|
1354
|
+
clearBaseFields: [
|
|
1355
|
+
"name",
|
|
1356
|
+
"defaultAccount",
|
|
1357
|
+
"privateKey",
|
|
1358
|
+
"relays",
|
|
1359
|
+
"dmPolicy",
|
|
1360
|
+
"allowFrom",
|
|
1361
|
+
"profile"
|
|
1362
|
+
],
|
|
1363
|
+
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
1364
|
+
formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean).map((entry) => {
|
|
1365
|
+
if (entry === "*") return "*";
|
|
1366
|
+
try {
|
|
1367
|
+
return normalizePubkey(entry);
|
|
1368
|
+
} catch {
|
|
1369
|
+
return entry;
|
|
1370
|
+
}
|
|
1371
|
+
}).filter(Boolean)
|
|
1372
|
+
});
|
|
1373
|
+
const nostrPlugin = createChatChannelPlugin({
|
|
1374
|
+
base: {
|
|
1375
|
+
id: "nostr",
|
|
1376
|
+
meta: {
|
|
1377
|
+
id: "nostr",
|
|
1378
|
+
label: "Nostr",
|
|
1379
|
+
selectionLabel: "Nostr",
|
|
1380
|
+
docsPath: "/channels/nostr",
|
|
1381
|
+
docsLabel: "nostr",
|
|
1382
|
+
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
|
1383
|
+
order: 100
|
|
1384
|
+
},
|
|
1385
|
+
capabilities: {
|
|
1386
|
+
chatTypes: ["direct"],
|
|
1387
|
+
media: false
|
|
1388
|
+
},
|
|
1389
|
+
reload: { configPrefixes: ["channels.nostr"] },
|
|
1390
|
+
configSchema: buildChannelConfigSchema(NostrConfigSchema),
|
|
1391
|
+
setup: nostrSetupAdapter,
|
|
1392
|
+
setupWizard: nostrSetupWizard,
|
|
1393
|
+
config: {
|
|
1394
|
+
...nostrConfigAdapter,
|
|
1395
|
+
isConfigured: (account) => account.configured,
|
|
1396
|
+
describeAccount: (account) => describeAccountSnapshot({
|
|
1397
|
+
account,
|
|
1398
|
+
configured: account.configured,
|
|
1399
|
+
extra: { publicKey: account.publicKey }
|
|
1400
|
+
})
|
|
1401
|
+
},
|
|
1402
|
+
messaging: {
|
|
1403
|
+
targetPrefixes: ["nostr"],
|
|
1404
|
+
normalizeTarget: (target) => {
|
|
1405
|
+
const cleaned = target.trim().replace(/^nostr:/i, "");
|
|
1406
|
+
try {
|
|
1407
|
+
return normalizePubkey(cleaned);
|
|
1408
|
+
} catch {
|
|
1409
|
+
return cleaned;
|
|
1410
|
+
}
|
|
1411
|
+
},
|
|
1412
|
+
targetResolver: {
|
|
1413
|
+
looksLikeId: (input) => {
|
|
1414
|
+
const trimmed = input.trim();
|
|
1415
|
+
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
|
1416
|
+
},
|
|
1417
|
+
hint: "<npub|hex pubkey|nostr:npub...>"
|
|
1418
|
+
},
|
|
1419
|
+
resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params)
|
|
1420
|
+
},
|
|
1421
|
+
status: { ...createComputedAccountStatusAdapter({
|
|
1422
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
1423
|
+
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
|
|
1424
|
+
buildChannelSummary: ({ snapshot }) => buildPassiveChannelStatusSummary(snapshot, { publicKey: snapshot.publicKey ?? null }),
|
|
1425
|
+
resolveAccountSnapshot: ({ account, runtime }) => ({
|
|
1426
|
+
accountId: account.accountId,
|
|
1427
|
+
name: account.name,
|
|
1428
|
+
enabled: account.enabled,
|
|
1429
|
+
configured: account.configured,
|
|
1430
|
+
extra: {
|
|
1431
|
+
publicKey: account.publicKey,
|
|
1432
|
+
profile: account.profile,
|
|
1433
|
+
...buildTrafficStatusSummary(runtime)
|
|
1434
|
+
}
|
|
1435
|
+
})
|
|
1436
|
+
}) },
|
|
1437
|
+
gateway: { startAccount: startNostrGatewayAccount }
|
|
1438
|
+
},
|
|
1439
|
+
pairing: { text: nostrPairingTextAdapter },
|
|
1440
|
+
security: { resolveDmPolicy: resolveNostrDmPolicy },
|
|
1441
|
+
outbound: nostrOutboundAdapter
|
|
1442
|
+
});
|
|
1443
|
+
/**
|
|
1444
|
+
* Publish a profile (kind:0) for a Nostr account.
|
|
1445
|
+
* @param accountId - Account ID (defaults to "default")
|
|
1446
|
+
* @param profile - Profile data to publish
|
|
1447
|
+
* @returns Publish results with successes and failures
|
|
1448
|
+
* @throws Error if account is not running
|
|
1449
|
+
*/
|
|
1450
|
+
async function publishNostrProfile(accountId = DEFAULT_ACCOUNT_ID, profile) {
|
|
1451
|
+
const bus = getActiveNostrBuses().get(accountId);
|
|
1452
|
+
if (!bus) throw new Error(`Nostr bus not running for account ${accountId}`);
|
|
1453
|
+
return bus.publishProfile(profile);
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Get profile publish state for a Nostr account.
|
|
1457
|
+
* @param accountId - Account ID (defaults to "default")
|
|
1458
|
+
* @returns Profile publish state or null if account not running
|
|
1459
|
+
*/
|
|
1460
|
+
async function getNostrProfileState(accountId = DEFAULT_ACCOUNT_ID) {
|
|
1461
|
+
const bus = getActiveNostrBuses().get(accountId);
|
|
1462
|
+
if (!bus) return null;
|
|
1463
|
+
return bus.getProfileState();
|
|
1464
|
+
}
|
|
1465
|
+
//#endregion
|
|
1466
|
+
export { setNostrRuntime as a, getNostrRuntime as i, nostrPlugin as n, contentToProfile as o, publishNostrProfile as r, getNostrProfileState as t };
|