@openclaw/nostr 2026.3.13 → 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.
- package/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +146 -283
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +276 -167
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -116
- package/src/types.test.ts +0 -175
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
2
|
+
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
4
|
+
import {
|
|
5
|
+
createPreCryptoDirectDmAuthorizer,
|
|
6
|
+
type ChannelOutboundAdapter,
|
|
7
|
+
resolveInboundDirectDmAccessWithRuntime,
|
|
8
|
+
type ChannelPlugin,
|
|
9
|
+
} from "./channel-api.js";
|
|
10
|
+
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
|
|
11
|
+
import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
|
12
|
+
import { normalizePubkey } from "./nostr-key-utils.js";
|
|
13
|
+
import { getNostrRuntime } from "./runtime.js";
|
|
14
|
+
import { resolveDefaultNostrAccountId, type ResolvedNostrAccount } from "./types.js";
|
|
15
|
+
|
|
16
|
+
type NostrGatewayStart = NonNullable<
|
|
17
|
+
NonNullable<ChannelPlugin<ResolvedNostrAccount>["gateway"]>["startAccount"]
|
|
18
|
+
>;
|
|
19
|
+
type NostrOutboundAdapter = Pick<
|
|
20
|
+
ChannelOutboundAdapter,
|
|
21
|
+
"deliveryMode" | "textChunkLimit" | "sendText"
|
|
22
|
+
> & {
|
|
23
|
+
sendText: NonNullable<ChannelOutboundAdapter["sendText"]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const activeBuses = new Map<string, NostrBusHandle>();
|
|
27
|
+
const metricsSnapshots = new Map<string, MetricsSnapshot>();
|
|
28
|
+
|
|
29
|
+
function normalizeNostrAllowEntry(entry: string): string | null {
|
|
30
|
+
const trimmed = entry.trim();
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (trimmed === "*") {
|
|
35
|
+
return "*";
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return normalizePubkey(trimmed.replace(/^nostr:/i, ""));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isNostrSenderAllowed(senderPubkey: string, allowFrom: string[]): boolean {
|
|
45
|
+
const normalizedSender = normalizePubkey(senderPubkey);
|
|
46
|
+
for (const entry of allowFrom) {
|
|
47
|
+
const normalized = normalizeNostrAllowEntry(entry);
|
|
48
|
+
if (normalized === "*" || normalized === normalizedSender) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveNostrDirectAccess(params: {
|
|
56
|
+
cfg: OpenClawConfig;
|
|
57
|
+
accountId: string;
|
|
58
|
+
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
|
|
59
|
+
allowFrom: Array<string | number> | undefined;
|
|
60
|
+
senderPubkey: string;
|
|
61
|
+
rawBody: string;
|
|
62
|
+
runtime: Parameters<typeof resolveInboundDirectDmAccessWithRuntime>[0]["runtime"];
|
|
63
|
+
}) {
|
|
64
|
+
return resolveInboundDirectDmAccessWithRuntime({
|
|
65
|
+
cfg: params.cfg,
|
|
66
|
+
channel: "nostr",
|
|
67
|
+
accountId: params.accountId,
|
|
68
|
+
dmPolicy: params.dmPolicy,
|
|
69
|
+
allowFrom: params.allowFrom,
|
|
70
|
+
senderId: params.senderPubkey,
|
|
71
|
+
rawBody: params.rawBody,
|
|
72
|
+
isSenderAllowed: isNostrSenderAllowed,
|
|
73
|
+
runtime: params.runtime,
|
|
74
|
+
modeWhenAccessGroupsOff: "configured",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
|
|
79
|
+
const account = ctx.account;
|
|
80
|
+
ctx.setStatus({
|
|
81
|
+
accountId: account.accountId,
|
|
82
|
+
publicKey: account.publicKey,
|
|
83
|
+
});
|
|
84
|
+
ctx.log?.info?.(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`);
|
|
85
|
+
|
|
86
|
+
if (!account.configured) {
|
|
87
|
+
throw new Error("Nostr private key not configured");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const runtime = getNostrRuntime();
|
|
91
|
+
const pairing = createChannelPairingController({
|
|
92
|
+
core: runtime,
|
|
93
|
+
channel: "nostr",
|
|
94
|
+
accountId: account.accountId,
|
|
95
|
+
});
|
|
96
|
+
const resolveInboundAccess = async (senderPubkey: string, rawBody: string) =>
|
|
97
|
+
await resolveNostrDirectAccess({
|
|
98
|
+
cfg: ctx.cfg,
|
|
99
|
+
accountId: account.accountId,
|
|
100
|
+
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
101
|
+
allowFrom: account.config.allowFrom,
|
|
102
|
+
senderPubkey,
|
|
103
|
+
rawBody,
|
|
104
|
+
runtime: {
|
|
105
|
+
shouldComputeCommandAuthorized: runtime.channel.commands.shouldComputeCommandAuthorized,
|
|
106
|
+
resolveCommandAuthorizedFromAuthorizers:
|
|
107
|
+
runtime.channel.commands.resolveCommandAuthorizedFromAuthorizers,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let busHandle: NostrBusHandle | null = null;
|
|
112
|
+
|
|
113
|
+
const authorizeSender = createPreCryptoDirectDmAuthorizer({
|
|
114
|
+
resolveAccess: async (senderPubkey) => await resolveInboundAccess(senderPubkey, ""),
|
|
115
|
+
issuePairingChallenge: async ({ senderId, reply }) => {
|
|
116
|
+
await pairing.issueChallenge({
|
|
117
|
+
senderId,
|
|
118
|
+
senderIdLine: `Your Nostr pubkey: ${senderId}`,
|
|
119
|
+
sendPairingReply: reply,
|
|
120
|
+
onCreated: () => {
|
|
121
|
+
ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${senderId}`);
|
|
122
|
+
},
|
|
123
|
+
onReplyError: (err) => {
|
|
124
|
+
ctx.log?.warn?.(
|
|
125
|
+
`[${account.accountId}] nostr pairing reply failed for ${senderId}: ${String(err)}`,
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
onBlocked: ({ senderId, reason }) => {
|
|
131
|
+
ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${senderId} (${reason})`);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const bus = await startNostrBus({
|
|
136
|
+
accountId: account.accountId,
|
|
137
|
+
privateKey: account.privateKey,
|
|
138
|
+
relays: account.relays,
|
|
139
|
+
authorizeSender: async ({ senderPubkey, reply }) =>
|
|
140
|
+
await authorizeSender({ senderId: senderPubkey, reply }),
|
|
141
|
+
onMessage: async (senderPubkey, text, reply, meta) => {
|
|
142
|
+
const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
|
|
143
|
+
if (resolvedAccess.access.decision !== "allow") {
|
|
144
|
+
ctx.log?.warn?.(
|
|
145
|
+
`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.access.reason})`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { dispatchInboundDirectDmWithRuntime } = await import("./inbound-direct-dm-runtime.js");
|
|
151
|
+
await dispatchInboundDirectDmWithRuntime({
|
|
152
|
+
cfg: ctx.cfg,
|
|
153
|
+
runtime,
|
|
154
|
+
channel: "nostr",
|
|
155
|
+
channelLabel: "Nostr",
|
|
156
|
+
accountId: account.accountId,
|
|
157
|
+
peer: {
|
|
158
|
+
kind: "direct",
|
|
159
|
+
id: senderPubkey,
|
|
160
|
+
},
|
|
161
|
+
senderId: senderPubkey,
|
|
162
|
+
senderAddress: `nostr:${senderPubkey}`,
|
|
163
|
+
recipientAddress: `nostr:${account.publicKey}`,
|
|
164
|
+
conversationLabel: senderPubkey,
|
|
165
|
+
rawBody: text,
|
|
166
|
+
messageId: meta.eventId,
|
|
167
|
+
timestamp: meta.createdAt * 1000,
|
|
168
|
+
commandAuthorized: resolvedAccess.commandAuthorized,
|
|
169
|
+
deliver: async (payload) => {
|
|
170
|
+
const outboundText =
|
|
171
|
+
payload && typeof payload === "object" && "text" in payload
|
|
172
|
+
? ((payload as { text?: string }).text ?? "")
|
|
173
|
+
: "";
|
|
174
|
+
if (!outboundText.trim()) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
178
|
+
cfg: ctx.cfg,
|
|
179
|
+
channel: "nostr",
|
|
180
|
+
accountId: account.accountId,
|
|
181
|
+
});
|
|
182
|
+
await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
|
|
183
|
+
},
|
|
184
|
+
onRecordError: (err) => {
|
|
185
|
+
ctx.log?.error?.(
|
|
186
|
+
`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`,
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
onDispatchError: (err, info) => {
|
|
190
|
+
ctx.log?.error?.(
|
|
191
|
+
`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`,
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
onError: (error, context) => {
|
|
197
|
+
ctx.log?.error?.(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
|
|
198
|
+
},
|
|
199
|
+
onConnect: (relay) => {
|
|
200
|
+
ctx.log?.debug?.(`[${account.accountId}] Connected to relay: ${relay}`);
|
|
201
|
+
},
|
|
202
|
+
onDisconnect: (relay) => {
|
|
203
|
+
ctx.log?.debug?.(`[${account.accountId}] Disconnected from relay: ${relay}`);
|
|
204
|
+
},
|
|
205
|
+
onEose: (relays) => {
|
|
206
|
+
ctx.log?.debug?.(`[${account.accountId}] EOSE received from relays: ${relays}`);
|
|
207
|
+
},
|
|
208
|
+
onMetric: (event: MetricEvent) => {
|
|
209
|
+
if (event.name.startsWith("event.rejected.")) {
|
|
210
|
+
ctx.log?.debug?.(
|
|
211
|
+
`[${account.accountId}] Metric: ${event.name} ${JSON.stringify(event.labels)}`,
|
|
212
|
+
);
|
|
213
|
+
} else if (event.name === "relay.circuit_breaker.open") {
|
|
214
|
+
ctx.log?.warn?.(
|
|
215
|
+
`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`,
|
|
216
|
+
);
|
|
217
|
+
} else if (event.name === "relay.circuit_breaker.close") {
|
|
218
|
+
ctx.log?.info?.(
|
|
219
|
+
`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`,
|
|
220
|
+
);
|
|
221
|
+
} else if (event.name === "relay.error") {
|
|
222
|
+
ctx.log?.debug?.(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
|
|
223
|
+
}
|
|
224
|
+
if (busHandle) {
|
|
225
|
+
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
busHandle = bus;
|
|
231
|
+
activeBuses.set(account.accountId, bus);
|
|
232
|
+
|
|
233
|
+
ctx.log?.info?.(
|
|
234
|
+
`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
stop: () => {
|
|
239
|
+
bus.close();
|
|
240
|
+
activeBuses.delete(account.accountId);
|
|
241
|
+
metricsSnapshots.delete(account.accountId);
|
|
242
|
+
ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const nostrPairingTextAdapter = {
|
|
248
|
+
idLabel: "nostrPubkey",
|
|
249
|
+
message: "Your pairing request has been approved!",
|
|
250
|
+
normalizeAllowEntry: (entry: string) => {
|
|
251
|
+
try {
|
|
252
|
+
return normalizePubkey(entry.trim().replace(/^nostr:/i, ""));
|
|
253
|
+
} catch {
|
|
254
|
+
return entry.trim();
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
notify: async ({
|
|
258
|
+
cfg,
|
|
259
|
+
id,
|
|
260
|
+
message,
|
|
261
|
+
accountId,
|
|
262
|
+
}: {
|
|
263
|
+
cfg: OpenClawConfig;
|
|
264
|
+
id: string;
|
|
265
|
+
message: string;
|
|
266
|
+
accountId?: string;
|
|
267
|
+
}) => {
|
|
268
|
+
const bus = activeBuses.get(accountId ?? resolveDefaultNostrAccountId(cfg));
|
|
269
|
+
if (bus) {
|
|
270
|
+
await bus.sendDm(id, message);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const nostrOutboundAdapter: NostrOutboundAdapter = {
|
|
276
|
+
deliveryMode: "direct",
|
|
277
|
+
textChunkLimit: 4000,
|
|
278
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
279
|
+
const core = getNostrRuntime();
|
|
280
|
+
const aid = accountId ?? resolveDefaultNostrAccountId(cfg);
|
|
281
|
+
const bus = activeBuses.get(aid);
|
|
282
|
+
if (!bus) {
|
|
283
|
+
throw new Error(`Nostr bus not running for account ${aid}`);
|
|
284
|
+
}
|
|
285
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
286
|
+
cfg,
|
|
287
|
+
channel: "nostr",
|
|
288
|
+
accountId: aid,
|
|
289
|
+
});
|
|
290
|
+
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
291
|
+
const normalizedTo = normalizePubkey(to);
|
|
292
|
+
await bus.sendDm(normalizedTo, message);
|
|
293
|
+
return attachChannelToResult("nostr", {
|
|
294
|
+
to: normalizedTo,
|
|
295
|
+
messageId: `nostr-${Date.now()}`,
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
|
|
301
|
+
return new Map(activeBuses);
|
|
302
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm";
|
package/src/metrics.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Metric Types
|
|
8
8
|
// ============================================================================
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type EventMetricName =
|
|
11
11
|
| "event.received"
|
|
12
12
|
| "event.processed"
|
|
13
13
|
| "event.duplicate"
|
|
@@ -22,7 +22,7 @@ export type EventMetricName =
|
|
|
22
22
|
| "event.rejected.decrypt_failed"
|
|
23
23
|
| "event.rejected.self_message";
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
type RelayMetricName =
|
|
26
26
|
| "relay.connect"
|
|
27
27
|
| "relay.disconnect"
|
|
28
28
|
| "relay.reconnect"
|
|
@@ -37,11 +37,11 @@ export type RelayMetricName =
|
|
|
37
37
|
| "relay.circuit_breaker.close"
|
|
38
38
|
| "relay.circuit_breaker.half_open";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global";
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
type DecryptMetricName = "decrypt.success" | "decrypt.failure";
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
type MemoryMetricName = "memory.seen_tracker_size" | "memory.rate_limiter_entries";
|
|
45
45
|
|
|
46
46
|
export type MetricName =
|
|
47
47
|
| EventMetricName
|
|
@@ -83,7 +83,7 @@ export interface MetricEvent {
|
|
|
83
83
|
labels?: Record<string, string | number>;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
type OnMetricCallback = (event: MetricEvent) => void;
|
|
87
87
|
|
|
88
88
|
// ============================================================================
|
|
89
89
|
// Metrics Snapshot (for getMetrics())
|