@openclaw/nostr 2026.1.29
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/CHANGELOG.md +51 -0
- package/README.md +136 -0
- package/index.ts +69 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +31 -0
- package/src/channel.test.ts +141 -0
- package/src/channel.ts +342 -0
- package/src/config-schema.ts +90 -0
- package/src/metrics.ts +464 -0
- package/src/nostr-bus.fuzz.test.ts +544 -0
- package/src/nostr-bus.integration.test.ts +452 -0
- package/src/nostr-bus.test.ts +199 -0
- package/src/nostr-bus.ts +741 -0
- package/src/nostr-profile-http.test.ts +378 -0
- package/src/nostr-profile-http.ts +500 -0
- package/src/nostr-profile-import.test.ts +120 -0
- package/src/nostr-profile-import.ts +259 -0
- package/src/nostr-profile.fuzz.test.ts +479 -0
- package/src/nostr-profile.test.ts +410 -0
- package/src/nostr-profile.ts +242 -0
- package/src/nostr-state-store.test.ts +129 -0
- package/src/nostr-state-store.ts +226 -0
- package/src/runtime.ts +14 -0
- package/src/seen-tracker.ts +271 -0
- package/src/types.test.ts +161 -0
- package/src/types.ts +99 -0
- package/test/setup.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2026.1.29
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
- Version alignment with core OpenClaw release numbers.
|
|
7
|
+
|
|
8
|
+
## 2026.1.23
|
|
9
|
+
|
|
10
|
+
### Changes
|
|
11
|
+
- Version alignment with core OpenClaw release numbers.
|
|
12
|
+
|
|
13
|
+
## 2026.1.22
|
|
14
|
+
|
|
15
|
+
### Changes
|
|
16
|
+
- Version alignment with core OpenClaw release numbers.
|
|
17
|
+
|
|
18
|
+
## 2026.1.21
|
|
19
|
+
|
|
20
|
+
### Changes
|
|
21
|
+
- Version alignment with core OpenClaw release numbers.
|
|
22
|
+
|
|
23
|
+
## 2026.1.20
|
|
24
|
+
|
|
25
|
+
### Changes
|
|
26
|
+
- Version alignment with core OpenClaw release numbers.
|
|
27
|
+
|
|
28
|
+
## 2026.1.19-1
|
|
29
|
+
|
|
30
|
+
Initial release.
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
- NIP-04 encrypted DM support (kind:4 events)
|
|
35
|
+
- Key validation (hex and nsec formats)
|
|
36
|
+
- Multi-relay support with sequential fallback
|
|
37
|
+
- Event signature verification
|
|
38
|
+
- TTL-based deduplication (24h)
|
|
39
|
+
- Access control via dmPolicy (pairing, allowlist, open, disabled)
|
|
40
|
+
- Pubkey normalization (hex/npub)
|
|
41
|
+
|
|
42
|
+
### Protocol Support
|
|
43
|
+
|
|
44
|
+
- NIP-01: Basic event structure
|
|
45
|
+
- NIP-04: Encrypted direct messages
|
|
46
|
+
|
|
47
|
+
### Planned for v2
|
|
48
|
+
|
|
49
|
+
- NIP-17: Gift-wrapped DMs
|
|
50
|
+
- NIP-44: Versioned encryption
|
|
51
|
+
- Media attachments
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# @openclaw/nostr
|
|
2
|
+
|
|
3
|
+
Nostr DM channel plugin for OpenClaw using NIP-04 encrypted direct messages.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This extension adds Nostr as a messaging channel to OpenClaw. It enables your bot to:
|
|
8
|
+
|
|
9
|
+
- Receive encrypted DMs from Nostr users
|
|
10
|
+
- Send encrypted responses back
|
|
11
|
+
- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install @openclaw/nostr
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Setup
|
|
20
|
+
|
|
21
|
+
1. Generate a Nostr keypair (if you don't have one):
|
|
22
|
+
```bash
|
|
23
|
+
# Using nak CLI
|
|
24
|
+
nak key generate
|
|
25
|
+
|
|
26
|
+
# Or use any Nostr key generator
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. Add to your config:
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"channels": {
|
|
33
|
+
"nostr": {
|
|
34
|
+
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
|
35
|
+
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
3. Set the environment variable:
|
|
42
|
+
```bash
|
|
43
|
+
export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
4. Restart the gateway
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
| Key | Type | Default | Description |
|
|
51
|
+
|-----|------|---------|-------------|
|
|
52
|
+
| `privateKey` | string | required | Bot's private key (nsec or hex format) |
|
|
53
|
+
| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
|
|
54
|
+
| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
|
|
55
|
+
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
|
|
56
|
+
| `enabled` | boolean | `true` | Enable/disable the channel |
|
|
57
|
+
| `name` | string | - | Display name for the account |
|
|
58
|
+
|
|
59
|
+
## Access Control
|
|
60
|
+
|
|
61
|
+
### DM Policies
|
|
62
|
+
|
|
63
|
+
- **pairing** (default): Unknown senders receive a pairing code to request access
|
|
64
|
+
- **allowlist**: Only pubkeys in `allowFrom` can message the bot
|
|
65
|
+
- **open**: Anyone can message the bot (use with caution)
|
|
66
|
+
- **disabled**: DMs are disabled
|
|
67
|
+
|
|
68
|
+
### Example: Allowlist Mode
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"channels": {
|
|
73
|
+
"nostr": {
|
|
74
|
+
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
|
75
|
+
"dmPolicy": "allowlist",
|
|
76
|
+
"allowFrom": [
|
|
77
|
+
"npub1abc...",
|
|
78
|
+
"0123456789abcdef..."
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Testing
|
|
86
|
+
|
|
87
|
+
### Local Relay (Recommended)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Using strfry
|
|
91
|
+
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
|
92
|
+
|
|
93
|
+
# Configure openclaw to use local relay
|
|
94
|
+
"relays": ["ws://localhost:7777"]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Manual Test
|
|
98
|
+
|
|
99
|
+
1. Start the gateway with Nostr configured
|
|
100
|
+
2. Open Damus, Amethyst, or another Nostr client
|
|
101
|
+
3. Send a DM to your bot's npub
|
|
102
|
+
4. Verify the bot responds
|
|
103
|
+
|
|
104
|
+
## Protocol Support
|
|
105
|
+
|
|
106
|
+
| NIP | Status | Notes |
|
|
107
|
+
|-----|--------|-------|
|
|
108
|
+
| NIP-01 | Supported | Basic event structure |
|
|
109
|
+
| NIP-04 | Supported | Encrypted DMs (kind:4) |
|
|
110
|
+
| NIP-17 | Planned | Gift-wrapped DMs (v2) |
|
|
111
|
+
|
|
112
|
+
## Security Notes
|
|
113
|
+
|
|
114
|
+
- Private keys are never logged
|
|
115
|
+
- Event signatures are verified before processing
|
|
116
|
+
- Use environment variables for keys, never commit to config files
|
|
117
|
+
- Consider using `allowlist` mode in production
|
|
118
|
+
|
|
119
|
+
## Troubleshooting
|
|
120
|
+
|
|
121
|
+
### Bot not receiving messages
|
|
122
|
+
|
|
123
|
+
1. Verify private key is correctly configured
|
|
124
|
+
2. Check relay connectivity
|
|
125
|
+
3. Ensure `enabled` is not set to `false`
|
|
126
|
+
4. Check the bot's public key matches what you're sending to
|
|
127
|
+
|
|
128
|
+
### Messages not being delivered
|
|
129
|
+
|
|
130
|
+
1. Check relay URLs are correct (must use `wss://`)
|
|
131
|
+
2. Verify relays are online and accepting connections
|
|
132
|
+
3. Check for rate limiting (reduce message frequency)
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
import { nostrPlugin } from "./src/channel.js";
|
|
5
|
+
import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
|
|
6
|
+
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
|
|
7
|
+
import { resolveNostrAccount } from "./src/types.js";
|
|
8
|
+
import type { NostrProfile } from "./src/config-schema.js";
|
|
9
|
+
|
|
10
|
+
const plugin = {
|
|
11
|
+
id: "nostr",
|
|
12
|
+
name: "Nostr",
|
|
13
|
+
description: "Nostr DM channel plugin via NIP-04",
|
|
14
|
+
configSchema: emptyPluginConfigSchema(),
|
|
15
|
+
register(api: OpenClawPluginApi) {
|
|
16
|
+
setNostrRuntime(api.runtime);
|
|
17
|
+
api.registerChannel({ plugin: nostrPlugin });
|
|
18
|
+
|
|
19
|
+
// Register HTTP handler for profile management
|
|
20
|
+
const httpHandler = createNostrProfileHttpHandler({
|
|
21
|
+
getConfigProfile: (accountId: string) => {
|
|
22
|
+
const runtime = getNostrRuntime();
|
|
23
|
+
const cfg = runtime.config.loadConfig() as OpenClawConfig;
|
|
24
|
+
const account = resolveNostrAccount({ cfg, accountId });
|
|
25
|
+
return account.profile;
|
|
26
|
+
},
|
|
27
|
+
updateConfigProfile: async (accountId: string, profile: NostrProfile) => {
|
|
28
|
+
const runtime = getNostrRuntime();
|
|
29
|
+
const cfg = runtime.config.loadConfig() as OpenClawConfig;
|
|
30
|
+
|
|
31
|
+
// Build the config patch for channels.nostr.profile
|
|
32
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
33
|
+
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
const updatedNostrConfig = {
|
|
36
|
+
...nostrConfig,
|
|
37
|
+
profile,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const updatedChannels = {
|
|
41
|
+
...channels,
|
|
42
|
+
nostr: updatedNostrConfig,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
await runtime.config.writeConfigFile({
|
|
46
|
+
...cfg,
|
|
47
|
+
channels: updatedChannels,
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
getAccountInfo: (accountId: string) => {
|
|
51
|
+
const runtime = getNostrRuntime();
|
|
52
|
+
const cfg = runtime.config.loadConfig() as OpenClawConfig;
|
|
53
|
+
const account = resolveNostrAccount({ cfg, accountId });
|
|
54
|
+
if (!account.configured || !account.publicKey) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
pubkey: account.publicKey,
|
|
59
|
+
relays: account.relays,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
log: api.logger,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
api.registerHttpHandler(httpHandler);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/nostr",
|
|
3
|
+
"version": "2026.1.29",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts"
|
|
9
|
+
],
|
|
10
|
+
"channel": {
|
|
11
|
+
"id": "nostr",
|
|
12
|
+
"label": "Nostr",
|
|
13
|
+
"selectionLabel": "Nostr (NIP-04 DMs)",
|
|
14
|
+
"docsPath": "/channels/nostr",
|
|
15
|
+
"docsLabel": "nostr",
|
|
16
|
+
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
|
|
17
|
+
"order": 55,
|
|
18
|
+
"quickstartAllowFrom": true
|
|
19
|
+
},
|
|
20
|
+
"install": {
|
|
21
|
+
"npmSpec": "@openclaw/nostr",
|
|
22
|
+
"localPath": "extensions/nostr",
|
|
23
|
+
"defaultChoice": "npm"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"openclaw": "workspace:*",
|
|
28
|
+
"nostr-tools": "^2.20.0",
|
|
29
|
+
"zod": "^4.3.6"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { nostrPlugin } from "./channel.js";
|
|
3
|
+
|
|
4
|
+
describe("nostrPlugin", () => {
|
|
5
|
+
describe("meta", () => {
|
|
6
|
+
it("has correct id", () => {
|
|
7
|
+
expect(nostrPlugin.id).toBe("nostr");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("has required meta fields", () => {
|
|
11
|
+
expect(nostrPlugin.meta.label).toBe("Nostr");
|
|
12
|
+
expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr");
|
|
13
|
+
expect(nostrPlugin.meta.blurb).toContain("NIP-04");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("capabilities", () => {
|
|
18
|
+
it("supports direct messages", () => {
|
|
19
|
+
expect(nostrPlugin.capabilities.chatTypes).toContain("direct");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not support groups (MVP)", () => {
|
|
23
|
+
expect(nostrPlugin.capabilities.chatTypes).not.toContain("group");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not support media (MVP)", () => {
|
|
27
|
+
expect(nostrPlugin.capabilities.media).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("config adapter", () => {
|
|
32
|
+
it("has required config functions", () => {
|
|
33
|
+
expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function");
|
|
34
|
+
expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function");
|
|
35
|
+
expect(nostrPlugin.config.isConfigured).toBeTypeOf("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("listAccountIds returns empty array for unconfigured", () => {
|
|
39
|
+
const cfg = { channels: {} };
|
|
40
|
+
const ids = nostrPlugin.config.listAccountIds(cfg);
|
|
41
|
+
expect(ids).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("listAccountIds returns default for configured", () => {
|
|
45
|
+
const cfg = {
|
|
46
|
+
channels: {
|
|
47
|
+
nostr: {
|
|
48
|
+
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const ids = nostrPlugin.config.listAccountIds(cfg);
|
|
53
|
+
expect(ids).toContain("default");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("messaging", () => {
|
|
58
|
+
it("has target resolver", () => {
|
|
59
|
+
expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("recognizes npub as valid target", () => {
|
|
63
|
+
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
|
64
|
+
if (!looksLikeId) return;
|
|
65
|
+
|
|
66
|
+
expect(looksLikeId("npub1xyz123")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("recognizes hex pubkey as valid target", () => {
|
|
70
|
+
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
|
71
|
+
if (!looksLikeId) return;
|
|
72
|
+
|
|
73
|
+
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
74
|
+
expect(looksLikeId(hexPubkey)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects invalid input", () => {
|
|
78
|
+
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
|
79
|
+
if (!looksLikeId) return;
|
|
80
|
+
|
|
81
|
+
expect(looksLikeId("not-a-pubkey")).toBe(false);
|
|
82
|
+
expect(looksLikeId("")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("normalizeTarget strips nostr: prefix", () => {
|
|
86
|
+
const normalize = nostrPlugin.messaging?.normalizeTarget;
|
|
87
|
+
if (!normalize) return;
|
|
88
|
+
|
|
89
|
+
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
90
|
+
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("outbound", () => {
|
|
95
|
+
it("has correct delivery mode", () => {
|
|
96
|
+
expect(nostrPlugin.outbound?.deliveryMode).toBe("direct");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("has reasonable text chunk limit", () => {
|
|
100
|
+
expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("pairing", () => {
|
|
105
|
+
it("has id label for pairing", () => {
|
|
106
|
+
expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("normalizes nostr: prefix in allow entries", () => {
|
|
110
|
+
const normalize = nostrPlugin.pairing?.normalizeAllowEntry;
|
|
111
|
+
if (!normalize) return;
|
|
112
|
+
|
|
113
|
+
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
114
|
+
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("security", () => {
|
|
119
|
+
it("has resolveDmPolicy function", () => {
|
|
120
|
+
expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("gateway", () => {
|
|
125
|
+
it("has startAccount function", () => {
|
|
126
|
+
expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("status", () => {
|
|
131
|
+
it("has default runtime", () => {
|
|
132
|
+
expect(nostrPlugin.status?.defaultRuntime).toBeDefined();
|
|
133
|
+
expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default");
|
|
134
|
+
expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("has buildAccountSnapshot function", () => {
|
|
138
|
+
expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|