openclaw-nostr 2026.7.30

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 (72) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +845 -0
  3. package/api.ts +6 -0
  4. package/index.ts +69 -0
  5. package/node_modules/cascadia-ts/cascadia.ts +1187 -0
  6. package/node_modules/cascadia-ts/dist/cascadia.d.ts +607 -0
  7. package/node_modules/cascadia-ts/dist/cascadia.js +705 -0
  8. package/node_modules/cascadia-ts/package.json +21 -0
  9. package/openclaw.plugin.json +153 -0
  10. package/package.json +81 -0
  11. package/runtime-api.ts +12 -0
  12. package/setup-api.ts +1 -0
  13. package/setup-entry.ts +4 -0
  14. package/src/channel-actions.test.ts +304 -0
  15. package/src/channel-actions.ts +199 -0
  16. package/src/channel.dm-access.test.ts +387 -0
  17. package/src/channel.inbound.test.ts +476 -0
  18. package/src/channel.lifecycle.test.ts +768 -0
  19. package/src/channel.outbound.test.ts +1143 -0
  20. package/src/channel.test.ts +175 -0
  21. package/src/channel.ts +2827 -0
  22. package/src/config-schema.test.ts +486 -0
  23. package/src/config-schema.ts +356 -0
  24. package/src/default-relays.ts +1 -0
  25. package/src/fleet-agent.test.ts +423 -0
  26. package/src/fleet-agent.ts +549 -0
  27. package/src/metrics.ts +466 -0
  28. package/src/nip46-cutover.test.ts +324 -0
  29. package/src/nip46-cutover.ts +461 -0
  30. package/src/nip46-doctor.test.ts +191 -0
  31. package/src/nip46-doctor.ts +329 -0
  32. package/src/nip46-enroll.test.ts +346 -0
  33. package/src/nip46-enroll.ts +567 -0
  34. package/src/nip46-signer.test.ts +809 -0
  35. package/src/nip46-signer.ts +1263 -0
  36. package/src/nip46-status.test.ts +186 -0
  37. package/src/nip46-status.ts +250 -0
  38. package/src/nip51-lists.test.ts +404 -0
  39. package/src/nip51-lists.ts +777 -0
  40. package/src/nostr-access.test.ts +175 -0
  41. package/src/nostr-access.ts +134 -0
  42. package/src/nostr-bus-nip29.ts +998 -0
  43. package/src/nostr-bus-relay.ts +477 -0
  44. package/src/nostr-bus-types.ts +392 -0
  45. package/src/nostr-bus.fuzz.test.ts +533 -0
  46. package/src/nostr-bus.integration.test.ts +505 -0
  47. package/src/nostr-bus.protocol.test.ts +2057 -0
  48. package/src/nostr-bus.test.ts +235 -0
  49. package/src/nostr-bus.ts +1868 -0
  50. package/src/nostr-capabilities-extended.test.ts +612 -0
  51. package/src/nostr-capabilities.ts +719 -0
  52. package/src/nostr-discovery.test.ts +568 -0
  53. package/src/nostr-discovery.ts +336 -0
  54. package/src/nostr-extras.test.ts +88 -0
  55. package/src/nostr-extras.ts +136 -0
  56. package/src/nostr-profile-http.test.ts +662 -0
  57. package/src/nostr-profile-http.ts +836 -0
  58. package/src/nostr-profile-import.test.ts +274 -0
  59. package/src/nostr-profile-import.ts +295 -0
  60. package/src/nostr-profile.fuzz.test.ts +480 -0
  61. package/src/nostr-profile.test.ts +410 -0
  62. package/src/nostr-profile.ts +320 -0
  63. package/src/nostr-pubkey.ts +56 -0
  64. package/src/nostr-state-store.test.ts +790 -0
  65. package/src/nostr-state-store.ts +1284 -0
  66. package/src/runtime.ts +9 -0
  67. package/src/seen-tracker.test.ts +25 -0
  68. package/src/seen-tracker.ts +292 -0
  69. package/src/session-route.ts +25 -0
  70. package/src/setup-surface.ts +320 -0
  71. package/src/types.test.ts +783 -0
  72. package/src/types.ts +508 -0
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "cascadia-ts",
3
+ "version": "0.1.0",
4
+ "description": "Generated Cascadia event kind constants and ContextVM contracts",
5
+ "type": "module",
6
+ "main": "./dist/cascadia.js",
7
+ "types": "./dist/cascadia.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/cascadia.d.ts",
11
+ "import": "./dist/cascadia.js"
12
+ }
13
+ },
14
+ "files": ["dist", "cascadia.ts"],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json"
17
+ },
18
+ "devDependencies": {
19
+ "typescript": "^5.5.0"
20
+ }
21
+ }
@@ -0,0 +1,153 @@
1
+ {
2
+ "id": "nostr",
3
+ "channels": ["nostr"],
4
+ "channelConfigs": {
5
+ "nostr": {
6
+ "schema": {
7
+ "type": "object",
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "name": { "type": "string", "description": "Account display name" },
11
+ "defaultAccount": { "type": "string", "description": "Default account id for routing" },
12
+ "enabled": { "type": "boolean", "description": "Whether the Nostr channel is enabled" },
13
+ "privateKey": { "type": "string", "description": "Private key in hex or nsec bech32 format" },
14
+ "relays": {
15
+ "type": "array",
16
+ "items": { "type": "string" },
17
+ "description": "WebSocket relay URLs to connect to"
18
+ },
19
+ "dmRelays": {
20
+ "type": "array",
21
+ "items": { "type": "string" },
22
+ "description": "DM relay URLs for NIP-17 (defaults to relays if unset)"
23
+ },
24
+ "nip17Enabled": { "type": "boolean", "description": "Enable NIP-17 gift-wrapped DMs (default: true)" },
25
+ "nip04Enabled": { "type": "boolean", "description": "Enable NIP-04 legacy DMs (default: true)" },
26
+ "inboundDispatchTimeoutSeconds": {
27
+ "type": "number",
28
+ "description": "Maximum seconds an inbound DM or NIP-29 message may occupy the delivery queue while waiting for agent dispatch (default: 300; <=0 disables)"
29
+ },
30
+ "dmPolicy": {
31
+ "type": "string",
32
+ "enum": ["pairing", "allowlist", "open", "disabled"],
33
+ "description": "DM access policy"
34
+ },
35
+ "allowFrom": {
36
+ "type": "array",
37
+ "items": { "type": "string" },
38
+ "description": "Allowed sender pubkeys (npub or hex format)"
39
+ },
40
+ "nostr_channels": {
41
+ "type": "object",
42
+ "description": "NIP-29 group and NIP-C7 chat channel subscriptions"
43
+ },
44
+ "nostrChannels": {
45
+ "type": "object",
46
+ "description": "Alias for nostr_channels"
47
+ },
48
+ "nip51": {
49
+ "type": "object",
50
+ "description": "Account-scoped NIP-51 fleet, mute-list, and relay-set synchronization",
51
+ "properties": {
52
+ "enabled": { "type": "boolean", "description": "Enable NIP-51 fleet synchronization (default: false)" },
53
+ "relays": { "type": "array", "items": { "type": "string" } },
54
+ "initialSyncTimeoutMs": { "type": "number", "minimum": 0 },
55
+ "capabilitySync": { "type": "boolean" },
56
+ "tiers": {
57
+ "type": "object",
58
+ "additionalProperties": {
59
+ "type": "object",
60
+ "required": ["dTag"],
61
+ "properties": {
62
+ "dTag": { "type": "string", "minLength": 1 },
63
+ "members": {
64
+ "type": "array",
65
+ "items": {
66
+ "oneOf": [
67
+ { "type": "string" },
68
+ {
69
+ "type": "object",
70
+ "required": ["pubkey"],
71
+ "properties": {
72
+ "pubkey": { "type": "string" },
73
+ "relay": { "type": "string" },
74
+ "petname": { "type": "string" }
75
+ }
76
+ }
77
+ ]
78
+ }
79
+ }
80
+ }
81
+ }
82
+ },
83
+ "relaySets": {
84
+ "type": "object",
85
+ "additionalProperties": {
86
+ "type": "object",
87
+ "properties": {
88
+ "dTag": { "type": "string", "minLength": 1 },
89
+ "relays": { "type": "array", "items": { "type": "string" } }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ },
95
+ "profile": {
96
+ "type": "object",
97
+ "description": "Profile metadata (NIP-01 kind:0 content)",
98
+ "properties": {
99
+ "name": { "type": "string", "maxLength": 256 },
100
+ "displayName": { "type": "string", "maxLength": 256 },
101
+ "about": { "type": "string", "maxLength": 2000 },
102
+ "picture": { "type": "string", "format": "uri" },
103
+ "banner": { "type": "string", "format": "uri" },
104
+ "website": { "type": "string", "format": "uri" },
105
+ "nip05": { "type": "string" },
106
+ "lud16": { "type": "string" }
107
+ },
108
+ "additionalProperties": false
109
+ }
110
+ }
111
+ },
112
+ "uiHints": {
113
+ "privateKey": {
114
+ "label": "Private Key",
115
+ "placeholder": "nsec1... or 64-char hex",
116
+ "sensitive": true
117
+ },
118
+ "relays": {
119
+ "label": "Relay URLs",
120
+ "placeholder": "wss://relay.example.com"
121
+ },
122
+ "dmRelays": {
123
+ "label": "DM Relay URLs",
124
+ "placeholder": "wss://relay.example.com (defaults to relays)"
125
+ },
126
+ "dmPolicy": {
127
+ "label": "DM Policy"
128
+ },
129
+ "allowFrom": {
130
+ "label": "Allowed Senders",
131
+ "placeholder": "npub1... or hex pubkey"
132
+ },
133
+ "nip51": {
134
+ "label": "NIP-51 Fleet Lists"
135
+ }
136
+ },
137
+ "label": "Nostr",
138
+ "description": "Decentralized DMs via Nostr relays (NIP-04 + NIP-17)"
139
+ }
140
+ },
141
+ "configSchema": {
142
+ "type": "object",
143
+ "additionalProperties": true,
144
+ "properties": {
145
+ "nostr_channels": {
146
+ "type": "object"
147
+ },
148
+ "nostrChannels": {
149
+ "type": "object"
150
+ }
151
+ }
152
+ }
153
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "openclaw-nostr",
3
+ "version": "2026.7.30",
4
+ "description": "Nostr channel plugin for OpenClaw",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "api.ts",
9
+ "runtime-api.ts",
10
+ "setup-api.ts",
11
+ "setup-entry.ts",
12
+ "src",
13
+ "openclaw.plugin.json",
14
+ "README.md",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc --noEmit --noCheck --module NodeNext --moduleResolution NodeNext --target ES2023 index.ts api.ts runtime-api.ts setup-api.ts setup-entry.ts src/*.ts test/setup.ts vitest.config.ts",
19
+ "test:bootstrap": "node --test test/nostr-claw-bootstrap.test.mjs",
20
+ "test:scanner": "vitest run test/runtime-security-scanner.test.ts"
21
+ },
22
+ "dependencies": {
23
+ "cascadia-ts": "file:../cascadia-nips/generated/typescript",
24
+ "nostr-tools": "2.23.3",
25
+ "ws": "^8.18.0"
26
+ },
27
+ "bundledDependencies": [
28
+ "cascadia-ts"
29
+ ],
30
+ "devDependencies": {
31
+ "openclaw": ">=2026.4.15",
32
+ "typescript": "^5.5.0",
33
+ "vitest": "^4.1.5"
34
+ },
35
+ "peerDependencies": {
36
+ "openclaw": ">=2026.4.15"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "openclaw": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "openclaw": {
44
+ "extensions": [
45
+ "./index.ts"
46
+ ],
47
+ "runtimeExtensions": [
48
+ "./index.ts"
49
+ ],
50
+ "setupEntry": "./setup-entry.ts",
51
+ "runtimeSetupEntry": "./setup-entry.ts",
52
+ "channel": {
53
+ "id": "nostr",
54
+ "label": "Nostr",
55
+ "selectionLabel": "Nostr (NIP-04 + NIP-17 DMs)",
56
+ "docsPath": "/channels/nostr",
57
+ "docsLabel": "nostr",
58
+ "blurb": "Decentralized protocol; encrypted DMs via NIP-04 and NIP-17.",
59
+ "order": 55,
60
+ "quickstartAllowFrom": true
61
+ },
62
+ "install": {
63
+ "npmSpec": "openclaw-nostr",
64
+ "localPath": "extensions/nostr",
65
+ "defaultChoice": "npm",
66
+ "minHostVersion": ">=2026.4.15"
67
+ },
68
+ "compat": {
69
+ "pluginApi": ">=2026.4.15"
70
+ },
71
+ "build": {
72
+ "openclawVersion": "2026.4.15"
73
+ },
74
+ "bundle": {
75
+ "stageRuntimeDependencies": true
76
+ },
77
+ "release": {
78
+ "publishToNpm": true
79
+ }
80
+ }
81
+ }
package/runtime-api.ts ADDED
@@ -0,0 +1,12 @@
1
+ // Private runtime barrel for the bundled Nostr extension.
2
+ // Re-exports SDK symbols used by the extension's internal surface and tests.
3
+
4
+ export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
5
+ export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
6
+ export type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-core";
7
+ export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
8
+ export type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
9
+ export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
10
+ export { buildChannelConfigSchema, formatPairingApproveHint } from "openclaw/plugin-sdk/channel-plugin-common";
11
+ export { collectStatusIssuesFromLastError, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
12
+ export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
package/setup-api.ts ADDED
@@ -0,0 +1 @@
1
+ export { nostrSetupAdapter, nostrSetupWizard } from "./src/setup-surface.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ import { nostrPlugin } from "./src/channel.js";
3
+
4
+ export default defineSetupPluginEntry(nostrPlugin);
@@ -0,0 +1,304 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createNostrMessageActions, type NostrSendActionDeps } from "./channel-actions.js";
3
+
4
+ type ActionResult = { content: Array<{ type: string; text: string }>; details: Record<string, unknown> };
5
+
6
+ function extractPayload(result: unknown): Record<string, unknown> {
7
+ return (result as ActionResult).details;
8
+ }
9
+
10
+ function createMockBus() {
11
+ return {
12
+ sendDm: vi.fn(async () => ({
13
+ protocol: "nip17" as const,
14
+ eventId: "rumor-id",
15
+ publishedEventIds: ["wrap-id-self", "wrap-id-recipient"],
16
+ relayResults: { successes: ["wss://relay.example.com"], failures: [] },
17
+ })),
18
+ sendGroupMessage: vi.fn(async () => ({
19
+ eventId: "group-event-id",
20
+ relay: "wss://groups.example.com",
21
+ groupId: "test-room",
22
+ })),
23
+ sendGroupReaction: vi.fn(),
24
+ deleteGroupEvent: vi.fn(),
25
+ close: vi.fn(),
26
+ };
27
+ }
28
+
29
+ function createSendDeps(
30
+ bus: ReturnType<typeof createMockBus>,
31
+ roomChannels?: Record<string, { kind?: string; enabled?: boolean }>,
32
+ ): NostrSendActionDeps {
33
+ return {
34
+ resolveBus: () => bus as never,
35
+ loadConfig: () => ({
36
+ channels: {
37
+ nostr: {
38
+ nostr_channels: roomChannels ?? {},
39
+ },
40
+ },
41
+ }),
42
+ resolveMarkdownTableMode: () => "off",
43
+ convertMarkdownTables: (text: string) => text,
44
+ normalizePubkey: (value: string) => {
45
+ const cleaned = value.replace(/^nostr:/i, "").trim();
46
+ return cleaned.toLowerCase();
47
+ },
48
+ extractMentionPubkeys: () => [],
49
+ };
50
+ }
51
+
52
+ describe("nostr send message action", () => {
53
+ it("sends a DM when target is a pubkey", async () => {
54
+ const bus = createMockBus();
55
+ const deps = createSendDeps(bus);
56
+ const actions = createNostrMessageActions(() => bus as never, deps);
57
+
58
+ const result = await actions.handleAction!({
59
+ action: "send",
60
+ channel: "nostr",
61
+ params: {
62
+ target: "aaaa".repeat(16),
63
+ text: "hello from action",
64
+ },
65
+ } as never);
66
+
67
+ expect(bus.sendDm).toHaveBeenCalledWith("aaaa".repeat(16), "hello from action");
68
+ expect(bus.sendGroupMessage).not.toHaveBeenCalled();
69
+ const payload = extractPayload(result);
70
+ expect(payload.ok).toBe(true);
71
+ expect(payload.action).toBe("send");
72
+ expect(payload.to).toBe("aaaa".repeat(16));
73
+ expect(payload.messageId).toBe("wrap-id-self");
74
+ });
75
+
76
+ it("uses relay-visible wrap ID for NIP-17 DM sends", async () => {
77
+ const bus = createMockBus();
78
+ const deps = createSendDeps(bus);
79
+ const actions = createNostrMessageActions(() => bus as never, deps);
80
+
81
+ const result = await actions.handleAction!({
82
+ action: "send",
83
+ channel: "nostr",
84
+ params: { target: "bbbb".repeat(16), text: "nip17 dm" },
85
+ } as never);
86
+
87
+ const payload = extractPayload(result);
88
+ // Must use relay-visible wrap ID, not the inner rumor ID
89
+ expect(payload.messageId).toBe("wrap-id-self");
90
+ expect(payload.messageId).not.toBe("rumor-id");
91
+ });
92
+
93
+ it("sends a group message when target is a configured NIP-29 room", async () => {
94
+ const bus = createMockBus();
95
+ const deps = createSendDeps(bus, {
96
+ "fleet-ops": { kind: "nip29", enabled: true },
97
+ });
98
+ const actions = createNostrMessageActions(() => bus as never, deps);
99
+
100
+ const result = await actions.handleAction!({
101
+ action: "send",
102
+ channel: "nostr",
103
+ params: {
104
+ target: "fleet-ops",
105
+ text: "hello room",
106
+ },
107
+ } as never);
108
+
109
+ expect(bus.sendGroupMessage).toHaveBeenCalledWith("fleet-ops", "hello room", {
110
+ mentionPubkeys: [],
111
+ });
112
+ expect(bus.sendDm).not.toHaveBeenCalled();
113
+ const payload = extractPayload(result);
114
+ expect(payload.ok).toBe(true);
115
+ expect(payload.to).toBe("fleet-ops");
116
+ expect(payload.messageId).toBe("group-event-id");
117
+ expect(payload.groupId).toBe("test-room");
118
+ });
119
+
120
+ it("forwards replyToId to NIP-29 group sends", async () => {
121
+ const bus = createMockBus();
122
+ const deps = createSendDeps(bus, {
123
+ "fleet-ops": { kind: "nip29", enabled: true },
124
+ });
125
+ const actions = createNostrMessageActions(() => bus as never, deps);
126
+
127
+ await actions.handleAction!({
128
+ action: "send",
129
+ channel: "nostr",
130
+ params: {
131
+ target: "fleet-ops",
132
+ text: "reply in room",
133
+ replyToId: "parent-event-id",
134
+ },
135
+ } as never);
136
+
137
+ expect(bus.sendGroupMessage).toHaveBeenCalledWith("fleet-ops", "reply in room", {
138
+ mentionPubkeys: [],
139
+ replyToEventId: "parent-event-id",
140
+ });
141
+ });
142
+
143
+ it("forwards threadId to NIP-29 group sends", async () => {
144
+ const bus = createMockBus();
145
+ const deps = createSendDeps(bus, {
146
+ "fleet-ops": { kind: "nip29", enabled: true },
147
+ });
148
+ const actions = createNostrMessageActions(() => bus as never, deps);
149
+
150
+ await actions.handleAction!({
151
+ action: "send",
152
+ channel: "nostr",
153
+ params: {
154
+ target: "fleet-ops",
155
+ text: "threaded reply",
156
+ threadId: "thread-root-id",
157
+ },
158
+ } as never);
159
+
160
+ expect(bus.sendGroupMessage).toHaveBeenCalledWith("fleet-ops", "threaded reply", {
161
+ mentionPubkeys: [],
162
+ threadEventId: "thread-root-id",
163
+ });
164
+ });
165
+
166
+ it("strips nostr: prefix from target", async () => {
167
+ const bus = createMockBus();
168
+ const deps = createSendDeps(bus);
169
+ const actions = createNostrMessageActions(() => bus as never, deps);
170
+
171
+ await actions.handleAction!({
172
+ action: "send",
173
+ channel: "nostr",
174
+ params: { target: "nostr:" + "cccc".repeat(16), text: "prefixed" },
175
+ } as never);
176
+
177
+ expect(bus.sendDm).toHaveBeenCalledWith("cccc".repeat(16), "prefixed");
178
+ });
179
+
180
+ it("reads 'to' param as fallback for target", async () => {
181
+ const bus = createMockBus();
182
+ const deps = createSendDeps(bus);
183
+ const actions = createNostrMessageActions(() => bus as never, deps);
184
+
185
+ await actions.handleAction!({
186
+ action: "send",
187
+ channel: "nostr",
188
+ params: { to: "dddd".repeat(16), text: "via to param" },
189
+ } as never);
190
+
191
+ expect(bus.sendDm).toHaveBeenCalledWith("dddd".repeat(16), "via to param");
192
+ });
193
+
194
+ it("reads 'message' param as fallback for text", async () => {
195
+ const bus = createMockBus();
196
+ const deps = createSendDeps(bus);
197
+ const actions = createNostrMessageActions(() => bus as never, deps);
198
+
199
+ await actions.handleAction!({
200
+ action: "send",
201
+ channel: "nostr",
202
+ params: { target: "eeee".repeat(16), message: "via message param" },
203
+ } as never);
204
+
205
+ expect(bus.sendDm).toHaveBeenCalledWith("eeee".repeat(16), "via message param");
206
+ });
207
+
208
+ it("throws when no target is provided", async () => {
209
+ const bus = createMockBus();
210
+ const deps = createSendDeps(bus);
211
+ const actions = createNostrMessageActions(() => bus as never, deps);
212
+
213
+ await expect(
214
+ actions.handleAction!({
215
+ action: "send",
216
+ channel: "nostr",
217
+ params: { text: "no target" },
218
+ } as never),
219
+ ).rejects.toThrow("target or to parameter required");
220
+ });
221
+
222
+ it("throws when sendDeps are not provided", async () => {
223
+ const bus = createMockBus();
224
+ const actions = createNostrMessageActions(() => bus as never);
225
+
226
+ await expect(
227
+ actions.handleAction!({
228
+ action: "send",
229
+ channel: "nostr",
230
+ params: { target: "aaaa".repeat(16), text: "no deps" },
231
+ } as never),
232
+ ).rejects.toThrow("Nostr send action requires runtime dependencies");
233
+ });
234
+
235
+ it("applies markdown table conversion", async () => {
236
+ const bus = createMockBus();
237
+ const deps = createSendDeps(bus);
238
+ deps.convertMarkdownTables = (text: string) => `converted:${text}`;
239
+ const actions = createNostrMessageActions(() => bus as never, deps);
240
+
241
+ await actions.handleAction!({
242
+ action: "send",
243
+ channel: "nostr",
244
+ params: { target: "aaaa".repeat(16), text: "|a|b|" },
245
+ } as never);
246
+
247
+ expect(bus.sendDm).toHaveBeenCalledWith("aaaa".repeat(16), "converted:|a|b|");
248
+ });
249
+
250
+ it("extracts mention pubkeys for NIP-29 group sends", async () => {
251
+ const bus = createMockBus();
252
+ const deps = createSendDeps(bus, {
253
+ "fleet-ops": { kind: "nip29", enabled: true },
254
+ });
255
+ deps.extractMentionPubkeys = () => ["mentioned-pubkey"];
256
+ const actions = createNostrMessageActions(() => bus as never, deps);
257
+
258
+ await actions.handleAction!({
259
+ action: "send",
260
+ channel: "nostr",
261
+ params: { target: "fleet-ops", text: "hello @someone" },
262
+ } as never);
263
+
264
+ expect(bus.sendGroupMessage).toHaveBeenCalledWith("fleet-ops", "hello @someone", {
265
+ mentionPubkeys: ["mentioned-pubkey"],
266
+ });
267
+ });
268
+
269
+ it("sends empty text when no text param provided", async () => {
270
+ const bus = createMockBus();
271
+ const deps = createSendDeps(bus);
272
+ const actions = createNostrMessageActions(() => bus as never, deps);
273
+
274
+ await actions.handleAction!({
275
+ action: "send",
276
+ channel: "nostr",
277
+ params: { target: "aaaa".repeat(16) },
278
+ } as never);
279
+
280
+ expect(bus.sendDm).toHaveBeenCalledWith("aaaa".repeat(16), "");
281
+ });
282
+ });
283
+
284
+ describe("nostr message action discovery", () => {
285
+ it("advertises send, react, and delete actions", () => {
286
+ const bus = createMockBus();
287
+ const deps = createSendDeps(bus);
288
+ const actions = createNostrMessageActions(() => bus as never, deps);
289
+
290
+ const discovery = actions.describeMessageTool!({} as never);
291
+ expect(discovery?.actions).toEqual(["send", "react", "delete"]);
292
+ });
293
+
294
+ it("supportsAction returns true for send", () => {
295
+ const bus = createMockBus();
296
+ const deps = createSendDeps(bus);
297
+ const actions = createNostrMessageActions(() => bus as never, deps);
298
+
299
+ expect(actions.supportsAction!({ action: "send" })).toBe(true);
300
+ expect(actions.supportsAction!({ action: "react" })).toBe(true);
301
+ expect(actions.supportsAction!({ action: "delete" })).toBe(true);
302
+ expect(actions.supportsAction!({ action: "edit" as never })).toBe(false);
303
+ });
304
+ });