@openclaw/nostr 2026.3.13 → 2026.5.2-beta.1

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