@openclaw/nostr 2026.3.12 → 2026.5.1-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 (48) hide show
  1. package/README.md +6 -0
  2. package/api.ts +10 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +60 -36
  5. package/openclaw.plugin.json +190 -1
  6. package/package.json +41 -9
  7. package/runtime-api.ts +6 -0
  8. package/setup-api.ts +1 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/channel-api.ts +15 -0
  12. package/src/channel.inbound.test.ts +176 -0
  13. package/src/channel.outbound.test.ts +89 -49
  14. package/src/channel.setup.ts +231 -0
  15. package/src/channel.test.ts +439 -71
  16. package/src/channel.ts +146 -284
  17. package/src/config-schema.ts +18 -12
  18. package/src/default-relays.ts +1 -0
  19. package/src/gateway.ts +302 -0
  20. package/src/inbound-direct-dm-runtime.ts +1 -0
  21. package/src/metrics.ts +6 -6
  22. package/src/nostr-bus.fuzz.test.ts +74 -247
  23. package/src/nostr-bus.inbound.test.ts +526 -0
  24. package/src/nostr-bus.integration.test.ts +88 -64
  25. package/src/nostr-bus.test.ts +22 -31
  26. package/src/nostr-bus.ts +206 -136
  27. package/src/nostr-key-utils.ts +94 -0
  28. package/src/nostr-profile-core.ts +134 -0
  29. package/src/nostr-profile-http-runtime.ts +6 -0
  30. package/src/nostr-profile-http.test.ts +310 -192
  31. package/src/nostr-profile-http.ts +51 -36
  32. package/src/nostr-profile-import.ts +3 -3
  33. package/src/nostr-profile-url-safety.ts +21 -0
  34. package/src/nostr-profile.fuzz.test.ts +7 -57
  35. package/src/nostr-profile.test.ts +16 -14
  36. package/src/nostr-profile.ts +13 -146
  37. package/src/nostr-state-store.test.ts +106 -2
  38. package/src/nostr-state-store.ts +46 -49
  39. package/src/runtime.ts +6 -3
  40. package/src/seen-tracker.ts +1 -1
  41. package/src/session-route.ts +25 -0
  42. package/src/setup-surface.ts +265 -0
  43. package/src/test-fixtures.ts +45 -0
  44. package/src/types.ts +26 -25
  45. package/test-api.ts +1 -0
  46. package/tsconfig.json +16 -0
  47. package/CHANGELOG.md +0 -110
  48. package/src/types.test.ts +0 -175
package/src/channel.ts CHANGED
@@ -1,18 +1,34 @@
1
+ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
2
+ import {
3
+ createScopedDmSecurityResolver,
4
+ createTopLevelChannelConfigAdapter,
5
+ } from "openclaw/plugin-sdk/channel-config-helpers";
6
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
7
+ import {
8
+ buildPassiveChannelStatusSummary,
9
+ buildTrafficStatusSummary,
10
+ } from "openclaw/plugin-sdk/extension-shared";
11
+ import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
1
12
  import {
2
13
  buildChannelConfigSchema,
3
14
  collectStatusIssuesFromLastError,
4
15
  createDefaultChannelRuntimeState,
5
16
  DEFAULT_ACCOUNT_ID,
6
17
  formatPairingApproveHint,
7
- mapAllowFromEntries,
8
18
  type ChannelPlugin,
9
- } from "openclaw/plugin-sdk/nostr";
19
+ } from "./channel-api.js";
10
20
  import type { NostrProfile } from "./config-schema.js";
11
21
  import { NostrConfigSchema } from "./config-schema.js";
12
- import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
13
- import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
22
+ import {
23
+ getActiveNostrBuses,
24
+ nostrOutboundAdapter,
25
+ nostrPairingTextAdapter,
26
+ startNostrGatewayAccount,
27
+ } from "./gateway.js";
28
+ import { normalizePubkey } from "./nostr-key-utils.js";
14
29
  import type { ProfilePublishResult } from "./nostr-profile.js";
15
- import { getNostrRuntime } from "./runtime.js";
30
+ import { resolveNostrOutboundSessionRoute } from "./session-route.js";
31
+ import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js";
16
32
  import {
17
33
  listNostrAccountIds,
18
34
  resolveDefaultNostrAccountId,
@@ -20,293 +36,139 @@ import {
20
36
  type ResolvedNostrAccount,
21
37
  } from "./types.js";
22
38
 
23
- // Store active bus handles per account
24
- const activeBuses = new Map<string, NostrBusHandle>();
25
-
26
- // Store metrics snapshots per account (for status reporting)
27
- const metricsSnapshots = new Map<string, MetricsSnapshot>();
28
-
29
- export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
30
- id: "nostr",
31
- meta: {
32
- id: "nostr",
33
- label: "Nostr",
34
- selectionLabel: "Nostr",
35
- docsPath: "/channels/nostr",
36
- docsLabel: "nostr",
37
- blurb: "Decentralized DMs via Nostr relays (NIP-04)",
38
- order: 100,
39
- },
40
- capabilities: {
41
- chatTypes: ["direct"], // DMs only for MVP
42
- media: false, // No media for MVP
43
- },
44
- reload: { configPrefixes: ["channels.nostr"] },
45
- configSchema: buildChannelConfigSchema(NostrConfigSchema),
46
-
47
- config: {
48
- listAccountIds: (cfg) => listNostrAccountIds(cfg),
49
- resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }),
50
- defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg),
51
- isConfigured: (account) => account.configured,
52
- describeAccount: (account) => ({
53
- accountId: account.accountId,
54
- name: account.name,
55
- enabled: account.enabled,
56
- configured: account.configured,
57
- publicKey: account.publicKey,
58
- }),
59
- resolveAllowFrom: ({ cfg, accountId }) =>
60
- mapAllowFromEntries(resolveNostrAccount({ cfg, accountId }).config.allowFrom),
61
- formatAllowFrom: ({ allowFrom }) =>
62
- allowFrom
63
- .map((entry) => String(entry).trim())
64
- .filter(Boolean)
65
- .map((entry) => {
66
- if (entry === "*") {
67
- return "*";
68
- }
69
- try {
70
- return normalizePubkey(entry);
71
- } catch {
72
- return entry; // Keep as-is if normalization fails
73
- }
74
- })
75
- .filter(Boolean),
39
+ const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
40
+ channelKey: "nostr",
41
+ resolvePolicy: (account) => account.config.dmPolicy,
42
+ resolveAllowFrom: (account) => account.config.allowFrom,
43
+ policyPathSuffix: "dmPolicy",
44
+ defaultPolicy: "pairing",
45
+ approveHint: formatPairingApproveHint("nostr"),
46
+ normalizeEntry: (raw) => {
47
+ try {
48
+ return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
49
+ } catch {
50
+ return raw.trim();
51
+ }
76
52
  },
77
-
78
- pairing: {
79
- idLabel: "nostrPubkey",
80
- normalizeAllowEntry: (entry) => {
81
- try {
82
- return normalizePubkey(entry.replace(/^nostr:/i, ""));
83
- } catch {
84
- return entry;
85
- }
86
- },
87
- notifyApproval: async ({ id }) => {
88
- // Get the default account's bus and send approval message
89
- const bus = activeBuses.get(DEFAULT_ACCOUNT_ID);
90
- if (bus) {
91
- await bus.sendDm(id, "Your pairing request has been approved!");
92
- }
53
+ });
54
+
55
+ const nostrConfigAdapter = createTopLevelChannelConfigAdapter<ResolvedNostrAccount>({
56
+ sectionKey: "nostr",
57
+ resolveAccount: (cfg) => resolveNostrAccount({ cfg }),
58
+ listAccountIds: listNostrAccountIds,
59
+ defaultAccountId: resolveDefaultNostrAccountId,
60
+ deleteMode: "clear-fields",
61
+ clearBaseFields: [
62
+ "name",
63
+ "defaultAccount",
64
+ "privateKey",
65
+ "relays",
66
+ "dmPolicy",
67
+ "allowFrom",
68
+ "profile",
69
+ ],
70
+ resolveAllowFrom: (account) => account.config.allowFrom,
71
+ formatAllowFrom: (allowFrom) =>
72
+ allowFrom
73
+ .map((entry) => String(entry).trim())
74
+ .filter(Boolean)
75
+ .map((entry) => {
76
+ if (entry === "*") {
77
+ return "*";
78
+ }
79
+ try {
80
+ return normalizePubkey(entry);
81
+ } catch {
82
+ return entry;
83
+ }
84
+ })
85
+ .filter(Boolean),
86
+ });
87
+
88
+ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = createChatChannelPlugin({
89
+ base: {
90
+ id: "nostr",
91
+ meta: {
92
+ id: "nostr",
93
+ label: "Nostr",
94
+ selectionLabel: "Nostr",
95
+ docsPath: "/channels/nostr",
96
+ docsLabel: "nostr",
97
+ blurb: "Decentralized DMs via Nostr relays (NIP-04)",
98
+ order: 100,
93
99
  },
94
- },
95
-
96
- security: {
97
- resolveDmPolicy: ({ account }) => {
98
- return {
99
- policy: account.config.dmPolicy ?? "pairing",
100
- allowFrom: account.config.allowFrom ?? [],
101
- policyPath: "channels.nostr.dmPolicy",
102
- allowFromPath: "channels.nostr.allowFrom",
103
- approveHint: formatPairingApproveHint("nostr"),
104
- normalizeEntry: (raw) => {
105
- try {
106
- return normalizePubkey(raw.replace(/^nostr:/i, "").trim());
107
- } catch {
108
- return raw.trim();
109
- }
110
- },
111
- };
100
+ capabilities: {
101
+ chatTypes: ["direct"], // DMs only for MVP
102
+ media: false, // No media for MVP
112
103
  },
113
- },
114
-
115
- messaging: {
116
- normalizeTarget: (target) => {
117
- // Strip nostr: prefix if present
118
- const cleaned = target.replace(/^nostr:/i, "").trim();
119
- try {
120
- return normalizePubkey(cleaned);
121
- } catch {
122
- return cleaned;
123
- }
104
+ reload: { configPrefixes: ["channels.nostr"] },
105
+ configSchema: buildChannelConfigSchema(NostrConfigSchema),
106
+ setup: nostrSetupAdapter,
107
+ setupWizard: nostrSetupWizard,
108
+ config: {
109
+ ...nostrConfigAdapter,
110
+ isConfigured: (account) => account.configured,
111
+ describeAccount: (account) =>
112
+ describeAccountSnapshot({
113
+ account,
114
+ configured: account.configured,
115
+ extra: {
116
+ publicKey: account.publicKey,
117
+ },
118
+ }),
124
119
  },
125
- targetResolver: {
126
- looksLikeId: (input) => {
127
- const trimmed = input.trim();
128
- return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
120
+ messaging: {
121
+ normalizeTarget: (target) => {
122
+ // Strip nostr: prefix if present
123
+ const cleaned = target.trim().replace(/^nostr:/i, "");
124
+ try {
125
+ return normalizePubkey(cleaned);
126
+ } catch {
127
+ return cleaned;
128
+ }
129
+ },
130
+ targetResolver: {
131
+ looksLikeId: (input) => {
132
+ const trimmed = input.trim();
133
+ return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
134
+ },
135
+ hint: "<npub|hex pubkey|nostr:npub...>",
129
136
  },
130
- hint: "<npub|hex pubkey|nostr:npub...>",
137
+ resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params),
131
138
  },
132
- },
133
-
134
- outbound: {
135
- deliveryMode: "direct",
136
- textChunkLimit: 4000,
137
- sendText: async ({ cfg, to, text, accountId }) => {
138
- const core = getNostrRuntime();
139
- const aid = accountId ?? DEFAULT_ACCOUNT_ID;
140
- const bus = activeBuses.get(aid);
141
- if (!bus) {
142
- throw new Error(`Nostr bus not running for account ${aid}`);
143
- }
144
- const tableMode = core.channel.text.resolveMarkdownTableMode({
145
- cfg,
146
- channel: "nostr",
147
- accountId: aid,
148
- });
149
- const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
150
- const normalizedTo = normalizePubkey(to);
151
- await bus.sendDm(normalizedTo, message);
152
- return {
153
- channel: "nostr" as const,
154
- to: normalizedTo,
155
- messageId: `nostr-${Date.now()}`,
156
- };
139
+ status: {
140
+ ...createComputedAccountStatusAdapter<ResolvedNostrAccount>({
141
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
142
+ collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
143
+ buildChannelSummary: ({ snapshot }) =>
144
+ buildPassiveChannelStatusSummary(snapshot, {
145
+ publicKey: snapshot.publicKey ?? null,
146
+ }),
147
+ resolveAccountSnapshot: ({ account, runtime }) => ({
148
+ accountId: account.accountId,
149
+ name: account.name,
150
+ enabled: account.enabled,
151
+ configured: account.configured,
152
+ extra: {
153
+ publicKey: account.publicKey,
154
+ profile: account.profile,
155
+ ...buildTrafficStatusSummary(runtime),
156
+ },
157
+ }),
158
+ }),
159
+ },
160
+ gateway: {
161
+ startAccount: startNostrGatewayAccount,
157
162
  },
158
163
  },
159
-
160
- status: {
161
- defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
162
- collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
163
- buildChannelSummary: ({ snapshot }) => ({
164
- configured: snapshot.configured ?? false,
165
- publicKey: snapshot.publicKey ?? null,
166
- running: snapshot.running ?? false,
167
- lastStartAt: snapshot.lastStartAt ?? null,
168
- lastStopAt: snapshot.lastStopAt ?? null,
169
- lastError: snapshot.lastError ?? null,
170
- }),
171
- buildAccountSnapshot: ({ account, runtime }) => ({
172
- accountId: account.accountId,
173
- name: account.name,
174
- enabled: account.enabled,
175
- configured: account.configured,
176
- publicKey: account.publicKey,
177
- profile: account.profile,
178
- running: runtime?.running ?? false,
179
- lastStartAt: runtime?.lastStartAt ?? null,
180
- lastStopAt: runtime?.lastStopAt ?? null,
181
- lastError: runtime?.lastError ?? null,
182
- lastInboundAt: runtime?.lastInboundAt ?? null,
183
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
184
- }),
164
+ pairing: {
165
+ text: nostrPairingTextAdapter,
185
166
  },
186
-
187
- gateway: {
188
- startAccount: async (ctx) => {
189
- const account = ctx.account;
190
- ctx.setStatus({
191
- accountId: account.accountId,
192
- publicKey: account.publicKey,
193
- });
194
- ctx.log?.info(
195
- `[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`,
196
- );
197
-
198
- if (!account.configured) {
199
- throw new Error("Nostr private key not configured");
200
- }
201
-
202
- const runtime = getNostrRuntime();
203
-
204
- // Track bus handle for metrics callback
205
- let busHandle: NostrBusHandle | null = null;
206
-
207
- const bus = await startNostrBus({
208
- accountId: account.accountId,
209
- privateKey: account.privateKey,
210
- relays: account.relays,
211
- onMessage: async (senderPubkey, text, reply) => {
212
- ctx.log?.debug?.(
213
- `[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`,
214
- );
215
-
216
- // Forward to OpenClaw's message pipeline
217
- await (
218
- runtime.channel.reply as { handleInboundMessage?: (params: unknown) => Promise<void> }
219
- ).handleInboundMessage?.({
220
- channel: "nostr",
221
- accountId: account.accountId,
222
- senderId: senderPubkey,
223
- chatType: "direct",
224
- chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
225
- text,
226
- reply: async (responseText: string) => {
227
- await reply(responseText);
228
- },
229
- });
230
- },
231
- onError: (error, context) => {
232
- ctx.log?.error?.(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
233
- },
234
- onConnect: (relay) => {
235
- ctx.log?.debug?.(`[${account.accountId}] Connected to relay: ${relay}`);
236
- },
237
- onDisconnect: (relay) => {
238
- ctx.log?.debug?.(`[${account.accountId}] Disconnected from relay: ${relay}`);
239
- },
240
- onEose: (relays) => {
241
- ctx.log?.debug?.(`[${account.accountId}] EOSE received from relays: ${relays}`);
242
- },
243
- onMetric: (event: MetricEvent) => {
244
- // Log significant metrics at appropriate levels
245
- if (event.name.startsWith("event.rejected.")) {
246
- ctx.log?.debug?.(
247
- `[${account.accountId}] Metric: ${event.name} ${JSON.stringify(event.labels)}`,
248
- );
249
- } else if (event.name === "relay.circuit_breaker.open") {
250
- ctx.log?.warn?.(
251
- `[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`,
252
- );
253
- } else if (event.name === "relay.circuit_breaker.close") {
254
- ctx.log?.info?.(
255
- `[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`,
256
- );
257
- } else if (event.name === "relay.error") {
258
- ctx.log?.debug?.(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
259
- }
260
- // Update cached metrics snapshot
261
- if (busHandle) {
262
- metricsSnapshots.set(account.accountId, busHandle.getMetrics());
263
- }
264
- },
265
- });
266
-
267
- busHandle = bus;
268
-
269
- // Store the bus handle
270
- activeBuses.set(account.accountId, bus);
271
-
272
- ctx.log?.info(
273
- `[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`,
274
- );
275
-
276
- // Return cleanup function
277
- return {
278
- stop: () => {
279
- bus.close();
280
- activeBuses.delete(account.accountId);
281
- metricsSnapshots.delete(account.accountId);
282
- ctx.log?.info(`[${account.accountId}] Nostr provider stopped`);
283
- },
284
- };
285
- },
167
+ security: {
168
+ resolveDmPolicy: resolveNostrDmPolicy,
286
169
  },
287
- };
288
-
289
- /**
290
- * Get metrics snapshot for a Nostr account.
291
- * Returns undefined if account is not running.
292
- */
293
- export function getNostrMetrics(
294
- accountId: string = DEFAULT_ACCOUNT_ID,
295
- ): MetricsSnapshot | undefined {
296
- const bus = activeBuses.get(accountId);
297
- if (bus) {
298
- return bus.getMetrics();
299
- }
300
- return metricsSnapshots.get(accountId);
301
- }
302
-
303
- /**
304
- * Get all active Nostr bus handles.
305
- * Useful for debugging and status reporting.
306
- */
307
- export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
308
- return new Map(activeBuses);
309
- }
170
+ outbound: nostrOutboundAdapter,
171
+ });
310
172
 
311
173
  /**
312
174
  * Publish a profile (kind:0) for a Nostr account.
@@ -319,7 +181,7 @@ export async function publishNostrProfile(
319
181
  accountId: string = DEFAULT_ACCOUNT_ID,
320
182
  profile: NostrProfile,
321
183
  ): Promise<ProfilePublishResult> {
322
- const bus = activeBuses.get(accountId);
184
+ const bus = getActiveNostrBuses().get(accountId);
323
185
  if (!bus) {
324
186
  throw new Error(`Nostr bus not running for account ${accountId}`);
325
187
  }
@@ -336,7 +198,7 @@ export async function getNostrProfileState(accountId: string = DEFAULT_ACCOUNT_I
336
198
  lastPublishedEventId: string | null;
337
199
  lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
338
200
  } | null> {
339
- const bus = activeBuses.get(accountId);
201
+ const bus = getActiveNostrBuses().get(accountId);
340
202
  if (!bus) {
341
203
  return null;
342
204
  }
@@ -1,6 +1,10 @@
1
- import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/compat";
2
- import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr";
3
- import { z } from "zod";
1
+ import {
2
+ AllowFromListSchema,
3
+ DmPolicySchema,
4
+ MarkdownConfigSchema,
5
+ } from "openclaw/plugin-sdk/channel-config-primitives";
6
+ import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
7
+ import { z } from "openclaw/plugin-sdk/zod";
4
8
 
5
9
  /**
6
10
  * Validates https:// URLs only (no javascript:, data:, file:, etc.)
@@ -50,7 +54,16 @@ export const NostrProfileSchema = z.object({
50
54
  lud16: z.string().optional(),
51
55
  });
52
56
 
53
- export type NostrProfile = z.infer<typeof NostrProfileSchema>;
57
+ export interface NostrProfile {
58
+ name?: string;
59
+ displayName?: string;
60
+ about?: string;
61
+ picture?: string;
62
+ banner?: string;
63
+ website?: string;
64
+ nip05?: string;
65
+ lud16?: string;
66
+ }
54
67
 
55
68
  /**
56
69
  * Zod schema for channels.nostr.* configuration
@@ -69,7 +82,7 @@ export const NostrConfigSchema = z.object({
69
82
  markdown: MarkdownConfigSchema,
70
83
 
71
84
  /** Private key in hex or nsec bech32 format */
72
- privateKey: z.string().optional(),
85
+ privateKey: buildSecretInputSchema().optional(),
73
86
 
74
87
  /** WebSocket relay URLs to connect to */
75
88
  relays: z.array(z.string()).optional(),
@@ -83,10 +96,3 @@ export const NostrConfigSchema = z.object({
83
96
  /** Profile metadata (NIP-01 kind:0 content) */
84
97
  profile: NostrProfileSchema.optional(),
85
98
  });
86
-
87
- export type NostrConfig = z.infer<typeof NostrConfigSchema>;
88
-
89
- /**
90
- * JSON Schema for Control UI (converted from Zod)
91
- */
92
- export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema);
@@ -0,0 +1 @@
1
+ export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];