@gakr-gakr/nostr 0.1.0

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 ADDED
@@ -0,0 +1,142 @@
1
+ # @gakr-gakr/nostr
2
+
3
+ Nostr DM channel plugin for AutoBot using NIP-04 encrypted direct messages.
4
+
5
+ ## Overview
6
+
7
+ This extension adds Nostr as a messaging channel to AutoBot. 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
+ autobot plugins install @gakr-gakr/nostr
17
+ ```
18
+
19
+ ## Quick Setup
20
+
21
+ 1. Generate a Nostr keypair (if you don't have one):
22
+
23
+ ```bash
24
+ # Using nak CLI
25
+ nak key generate
26
+
27
+ # Or use any Nostr key generator
28
+ ```
29
+
30
+ 2. Add to your config:
31
+
32
+ ```json
33
+ {
34
+ "channels": {
35
+ "nostr": {
36
+ "privateKey": "${NOSTR_PRIVATE_KEY}",
37
+ "relays": ["wss://relay.damus.io", "wss://nos.lol"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ 3. Set the environment variable:
44
+
45
+ ```bash
46
+ export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
47
+ ```
48
+
49
+ 4. Restart the gateway
50
+
51
+ ## Configuration
52
+
53
+ | Key | Type | Default | Description |
54
+ | ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- |
55
+ | `privateKey` | string | required | Bot's private key (nsec or hex format) |
56
+ | `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
57
+ | `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
58
+ | `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
59
+ | `enabled` | boolean | `true` | Enable/disable the channel |
60
+ | `name` | string | - | Display name for the account |
61
+
62
+ ## Access Control
63
+
64
+ ### DM Policies
65
+
66
+ - **pairing** (default): Unknown senders receive a pairing code to request access
67
+ - **allowlist**: Only pubkeys in `allowFrom` can message the bot
68
+ - **open**: Anyone can message the bot (use with caution)
69
+ - **disabled**: DMs are disabled
70
+
71
+ Inbound event signatures are verified before policy enforcement and NIP-04 decryption.
72
+ Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not
73
+ processed unless approved.
74
+
75
+ ### Example: Allowlist Mode
76
+
77
+ ```json
78
+ {
79
+ "channels": {
80
+ "nostr": {
81
+ "privateKey": "${NOSTR_PRIVATE_KEY}",
82
+ "dmPolicy": "allowlist",
83
+ "allowFrom": ["npub1abc...", "0123456789abcdef..."]
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## Testing
90
+
91
+ ### Local Relay (Recommended)
92
+
93
+ ```bash
94
+ # Using strfry
95
+ docker run -p 7777:7777 ghcr.io/hoytech/strfry
96
+
97
+ # Configure autobot to use local relay
98
+ "relays": ["ws://localhost:7777"]
99
+ ```
100
+
101
+ ### Manual Test
102
+
103
+ 1. Start the gateway with Nostr configured
104
+ 2. Open Damus, Amethyst, or another Nostr client
105
+ 3. Send a DM to your bot's npub
106
+ 4. Verify the bot responds
107
+
108
+ ## Protocol Support
109
+
110
+ | NIP | Status | Notes |
111
+ | ------ | --------- | ---------------------- |
112
+ | NIP-01 | Supported | Basic event structure |
113
+ | NIP-04 | Supported | Encrypted DMs (kind:4) |
114
+ | NIP-17 | Planned | Gift-wrapped DMs (v2) |
115
+
116
+ ## Security Notes
117
+
118
+ - Private keys are never logged
119
+ - Event signatures are verified before processing
120
+ - Sender policy is checked before expensive crypto work
121
+ - Inbound DMs are rate-limited and oversized payloads are dropped before decrypt
122
+ - Use environment variables for keys, never commit to config files
123
+ - Consider using `allowlist` mode in production
124
+
125
+ ## Troubleshooting
126
+
127
+ ### Bot not receiving messages
128
+
129
+ 1. Verify private key is correctly configured
130
+ 2. Check relay connectivity
131
+ 3. Ensure `enabled` is not set to `false`
132
+ 4. Check the bot's public key matches what you're sending to
133
+
134
+ ### Messages not being delivered
135
+
136
+ 1. Check relay URLs are correct (must use `wss://`)
137
+ 2. Verify relays are online and accepting connections
138
+ 3. Check for rate limiting (reduce message frequency)
139
+
140
+ ## License
141
+
142
+ MIT
package/api.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ getPluginRuntimeGatewayRequestScope,
3
+ type AutoBotConfig,
4
+ type PluginRuntime,
5
+ } from "./runtime-api.js";
6
+ export { nostrPlugin } from "./src/channel.js";
7
+ export { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
8
+ export { getNostrRuntime, setNostrRuntime } from "./src/runtime.js";
9
+ export { resolveNostrAccount } from "./src/types.js";
10
+ export type { ResolvedNostrAccount } from "./src/types.js";
@@ -0,0 +1,15 @@
1
+ {
2
+ "id": "nostr",
3
+ "activation": {
4
+ "onStartup": false
5
+ },
6
+ "channels": ["nostr"],
7
+ "channelEnvVars": {
8
+ "nostr": ["NOSTR_PRIVATE_KEY"]
9
+ },
10
+ "configSchema": {
11
+ "type": "object",
12
+ "additionalProperties": false,
13
+ "properties": {}
14
+ }
15
+ }
@@ -0,0 +1 @@
1
+ export { nostrPlugin } from "./src/channel.js";
package/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ defineBundledChannelEntry,
3
+ loadBundledEntryExportSync,
4
+ } from "autobot/plugin-sdk/channel-entry-contract";
5
+ import type { AutoBotConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js";
6
+
7
+ function createNostrProfileHttpHandler() {
8
+ return loadBundledEntryExportSync<
9
+ (params: Record<string, unknown>) => (ctx: unknown) => Promise<void> | void
10
+ >(import.meta.url, {
11
+ specifier: "./api.js",
12
+ exportName: "createNostrProfileHttpHandler",
13
+ });
14
+ }
15
+
16
+ function getNostrRuntime() {
17
+ return loadBundledEntryExportSync<() => PluginRuntime>(import.meta.url, {
18
+ specifier: "./api.js",
19
+ exportName: "getNostrRuntime",
20
+ })();
21
+ }
22
+
23
+ function resolveNostrAccount(params: { cfg: unknown; accountId: string }) {
24
+ return loadBundledEntryExportSync<
25
+ (params: { cfg: unknown; accountId: string }) => ResolvedNostrAccount
26
+ >(import.meta.url, {
27
+ specifier: "./api.js",
28
+ exportName: "resolveNostrAccount",
29
+ })(params);
30
+ }
31
+
32
+ export default defineBundledChannelEntry({
33
+ id: "nostr",
34
+ name: "Nostr",
35
+ description: "Nostr DM channel plugin via NIP-04",
36
+ importMetaUrl: import.meta.url,
37
+ plugin: {
38
+ specifier: "./channel-plugin-api.js",
39
+ exportName: "nostrPlugin",
40
+ },
41
+ runtime: {
42
+ specifier: "./api.js",
43
+ exportName: "setNostrRuntime",
44
+ },
45
+ registerFull(api) {
46
+ const httpHandler = createNostrProfileHttpHandler()({
47
+ getConfigProfile: (accountId: string) => {
48
+ const runtime = getNostrRuntime();
49
+ const cfg = runtime.config.current() as AutoBotConfig;
50
+ const account = resolveNostrAccount({ cfg, accountId });
51
+ return account.profile;
52
+ },
53
+ updateConfigProfile: async (_accountId: string, profile: unknown) => {
54
+ const runtime = getNostrRuntime();
55
+
56
+ await runtime.config.mutateConfigFile({
57
+ afterWrite: { mode: "auto" },
58
+ mutate: (draft) => {
59
+ const channels = (draft.channels ?? {}) as Record<string, unknown>;
60
+ const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
61
+
62
+ draft.channels = {
63
+ ...channels,
64
+ nostr: {
65
+ ...nostrConfig,
66
+ profile,
67
+ },
68
+ };
69
+ },
70
+ });
71
+ },
72
+ getAccountInfo: (accountId: string) => {
73
+ const runtime = getNostrRuntime();
74
+ const cfg = runtime.config.current() as AutoBotConfig;
75
+ const account = resolveNostrAccount({ cfg, accountId });
76
+ if (!account.configured || !account.publicKey) {
77
+ return null;
78
+ }
79
+ return {
80
+ pubkey: account.publicKey,
81
+ relays: account.relays,
82
+ };
83
+ },
84
+ log: api.logger,
85
+ });
86
+
87
+ api.registerHttpRoute({
88
+ path: "/api/channels/nostr",
89
+ auth: "gateway",
90
+ match: "prefix",
91
+ gatewayRuntimeScopeSurface: "trusted-operator",
92
+ handler: httpHandler,
93
+ });
94
+ },
95
+ });
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@gakr-gakr/nostr",
3
+ "version": "0.1.0",
4
+ "description": "AutoBot Nostr channel plugin for NIP-04 encrypted DMs",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/autobot/autobot"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "nostr-tools": "2.23.3",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@gakr-gakr/plugin-sdk": "workspace:*",
16
+ "@gakr-gakr/autobot": "workspace:*",
17
+ "autobot": "workspace:@gakr-gakr/autobot@*"
18
+ },
19
+ "peerDependencies": {
20
+ "@gakr-gakr/autobot": ">=0.1.0"
21
+ },
22
+ "peerDependenciesMeta": {
23
+ "@gakr-gakr/autobot": {
24
+ "optional": true
25
+ }
26
+ },
27
+ "autobot": {
28
+ "extensions": [
29
+ "./index.ts"
30
+ ],
31
+ "setupEntry": "./setup-entry.ts",
32
+ "channel": {
33
+ "id": "nostr",
34
+ "label": "Nostr",
35
+ "selectionLabel": "Nostr (NIP-04 DMs)",
36
+ "docsPath": "/channels/nostr",
37
+ "docsLabel": "nostr",
38
+ "blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
39
+ "order": 55,
40
+ "quickstartAllowFrom": true,
41
+ "cliAddOptions": [
42
+ {
43
+ "flags": "--private-key <key>",
44
+ "description": "Nostr private key (nsec... or hex)"
45
+ },
46
+ {
47
+ "flags": "--relay-urls <list>",
48
+ "description": "Nostr relay URLs (comma-separated)"
49
+ }
50
+ ]
51
+ },
52
+ "install": {
53
+ "npmSpec": "@gakr-gakr/nostr",
54
+ "defaultChoice": "npm",
55
+ "minHostVersion": ">=2026.4.10"
56
+ },
57
+ "compat": {
58
+ "pluginApi": ">=2026.5.19"
59
+ },
60
+ "build": {
61
+ "autobotVersion": "2026.5.19"
62
+ },
63
+ "release": {
64
+ "publishToClawHub": true,
65
+ "publishToNpm": true
66
+ }
67
+ }
68
+ }
package/runtime-api.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Private runtime barrel for the bundled Nostr extension.
2
+ // Keep this barrel thin and aligned with the local extension surface.
3
+
4
+ export type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
5
+ export { getPluginRuntimeGatewayRequestScope } from "autobot/plugin-sdk/plugin-runtime";
6
+ export type { PluginRuntime } from "autobot/plugin-sdk/runtime-store";
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,9 @@
1
+ import { defineBundledChannelSetupEntry } from "autobot/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./setup-plugin-api.js",
7
+ exportName: "nostrSetupPlugin",
8
+ },
9
+ });
@@ -0,0 +1,3 @@
1
+ // Keep bundled setup entry imports narrow so setup loads do not pull the
2
+ // broader Nostr runtime plugin surface.
3
+ export { nostrSetupPlugin } from "./src/channel.setup.js";
@@ -0,0 +1,11 @@
1
+ export {
2
+ buildChannelConfigSchema,
3
+ DEFAULT_ACCOUNT_ID,
4
+ formatPairingApproveHint,
5
+ type ChannelPlugin,
6
+ } from "autobot/plugin-sdk/channel-plugin-common";
7
+ export type { ChannelOutboundAdapter } from "autobot/plugin-sdk/channel-contract";
8
+ export {
9
+ collectStatusIssuesFromLastError,
10
+ createDefaultChannelRuntimeState,
11
+ } from "autobot/plugin-sdk/status-helpers";
@@ -0,0 +1,234 @@
1
+ import { describeAccountSnapshot } from "autobot/plugin-sdk/account-helpers";
2
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
3
+ import { patchTopLevelChannelConfigSection } from "autobot/plugin-sdk/setup";
4
+ import {
5
+ createDelegatedSetupWizardProxy,
6
+ createStandardChannelSetupStatus,
7
+ DEFAULT_ACCOUNT_ID,
8
+ createSetupTranslator,
9
+ type ChannelSetupAdapter,
10
+ } from "autobot/plugin-sdk/setup-runtime";
11
+ import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js";
12
+ import { NostrConfigSchema } from "./config-schema.js";
13
+ import { DEFAULT_RELAYS } from "./default-relays.js";
14
+
15
+ const t = createSetupTranslator();
16
+
17
+ const channel = "nostr" as const;
18
+
19
+ type NostrAccountConfig = {
20
+ enabled?: boolean;
21
+ name?: string;
22
+ defaultAccount?: string;
23
+ privateKey?: unknown;
24
+ relays?: string[];
25
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
26
+ allowFrom?: Array<string | number>;
27
+ profile?: unknown;
28
+ };
29
+
30
+ type ResolvedNostrSetupAccount = {
31
+ accountId: string;
32
+ name?: string;
33
+ enabled: boolean;
34
+ configured: boolean;
35
+ privateKey: string;
36
+ publicKey: string;
37
+ relays: string[];
38
+ profile?: unknown;
39
+ config: NostrAccountConfig;
40
+ };
41
+
42
+ function getNostrConfig(cfg: AutoBotConfig): NostrAccountConfig | undefined {
43
+ return (cfg.channels as Record<string, unknown> | undefined)?.nostr as
44
+ | NostrAccountConfig
45
+ | undefined;
46
+ }
47
+
48
+ function listSetupNostrAccountIds(cfg: AutoBotConfig): string[] {
49
+ const nostrCfg = getNostrConfig(cfg);
50
+ const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
51
+ if (!privateKey) {
52
+ return [];
53
+ }
54
+ return [resolveDefaultSetupNostrAccountId(cfg)];
55
+ }
56
+
57
+ function resolveDefaultSetupNostrAccountId(cfg: AutoBotConfig): string {
58
+ const configured = getNostrConfig(cfg)?.defaultAccount;
59
+ return typeof configured === "string" && configured.trim()
60
+ ? configured.trim()
61
+ : DEFAULT_ACCOUNT_ID;
62
+ }
63
+
64
+ function resolveSetupNostrAccount(params: {
65
+ cfg: AutoBotConfig;
66
+ accountId?: string | null;
67
+ }): ResolvedNostrSetupAccount {
68
+ const nostrCfg = getNostrConfig(params.cfg);
69
+ const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg);
70
+ const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
71
+ const configured = Boolean(privateKey);
72
+ return {
73
+ accountId,
74
+ name: typeof nostrCfg?.name === "string" ? nostrCfg.name : undefined,
75
+ enabled: nostrCfg?.enabled !== false,
76
+ configured,
77
+ privateKey,
78
+ publicKey: "",
79
+ relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
80
+ profile: nostrCfg?.profile,
81
+ config: {
82
+ enabled: nostrCfg?.enabled,
83
+ name: nostrCfg?.name,
84
+ privateKey: nostrCfg?.privateKey,
85
+ relays: nostrCfg?.relays,
86
+ dmPolicy: nostrCfg?.dmPolicy,
87
+ allowFrom: nostrCfg?.allowFrom,
88
+ profile: nostrCfg?.profile,
89
+ },
90
+ };
91
+ }
92
+
93
+ function buildNostrSetupPatch(accountId: string, patch: Record<string, unknown>) {
94
+ return {
95
+ ...(accountId !== DEFAULT_ACCOUNT_ID ? { defaultAccount: accountId } : {}),
96
+ ...patch,
97
+ };
98
+ }
99
+
100
+ function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
101
+ const entries = raw
102
+ .split(/[,\n]/)
103
+ .map((entry) => entry.trim())
104
+ .filter(Boolean);
105
+ const relays: string[] = [];
106
+ for (const entry of entries) {
107
+ try {
108
+ const parsed = new URL(entry);
109
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
110
+ return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` };
111
+ }
112
+ } catch {
113
+ return { relays: [], error: `Invalid relay URL: ${entry}` };
114
+ }
115
+ relays.push(entry);
116
+ }
117
+ return { relays: [...new Set(relays)] };
118
+ }
119
+
120
+ function looksLikeNostrPrivateKey(privateKey: string): boolean {
121
+ return privateKey.startsWith("nsec1") || /^[0-9a-fA-F]{64}$/.test(privateKey);
122
+ }
123
+
124
+ const nostrSetupAdapter: ChannelSetupAdapter = {
125
+ resolveAccountId: ({ cfg, accountId }) =>
126
+ accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg),
127
+ applyAccountName: ({ cfg, accountId, name }) =>
128
+ patchTopLevelChannelConfigSection({
129
+ cfg,
130
+ channel,
131
+ patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}),
132
+ }),
133
+ validateInput: ({ input }) => {
134
+ const typedInput = input as {
135
+ useEnv?: boolean;
136
+ privateKey?: string;
137
+ relayUrls?: string;
138
+ };
139
+ if (!typedInput.useEnv) {
140
+ const privateKey = typedInput.privateKey?.trim();
141
+ if (!privateKey) {
142
+ return "Nostr requires --private-key or --use-env.";
143
+ }
144
+ if (!looksLikeNostrPrivateKey(privateKey)) {
145
+ return "Nostr private key must be valid nsec or 64-character hex.";
146
+ }
147
+ }
148
+ if (typedInput.relayUrls?.trim()) {
149
+ return parseRelayUrls(typedInput.relayUrls).error ?? null;
150
+ }
151
+ return null;
152
+ },
153
+ applyAccountConfig: ({ cfg, accountId, input }) => {
154
+ const typedInput = input as {
155
+ useEnv?: boolean;
156
+ privateKey?: string;
157
+ relayUrls?: string;
158
+ };
159
+ const relayResult = typedInput.relayUrls?.trim()
160
+ ? parseRelayUrls(typedInput.relayUrls)
161
+ : { relays: [] };
162
+ return patchTopLevelChannelConfigSection({
163
+ cfg,
164
+ channel,
165
+ enabled: true,
166
+ clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
167
+ patch: buildNostrSetupPatch(accountId, {
168
+ ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
169
+ ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
170
+ }),
171
+ });
172
+ },
173
+ };
174
+
175
+ const nostrSetupWizard = createDelegatedSetupWizardProxy({
176
+ channel,
177
+ loadWizard: async () => (await import("./setup-surface.js")).nostrSetupWizard,
178
+ status: {
179
+ ...createStandardChannelSetupStatus({
180
+ channelLabel: "Nostr",
181
+ configuredLabel: t("wizard.channels.statusConfigured"),
182
+ unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
183
+ configuredHint: t("wizard.channels.statusConfigured"),
184
+ unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
185
+ configuredScore: 1,
186
+ unconfiguredScore: 0,
187
+ includeStatusLine: true,
188
+ resolveConfigured: ({ cfg, accountId }) =>
189
+ resolveSetupNostrAccount({ cfg, accountId }).configured,
190
+ resolveExtraStatusLines: ({ cfg }) => {
191
+ const account = resolveSetupNostrAccount({ cfg });
192
+ return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
193
+ },
194
+ }),
195
+ },
196
+ resolveShouldPromptAccountIds: () => false,
197
+ delegatePrepare: true,
198
+ delegateFinalize: true,
199
+ });
200
+
201
+ export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrSetupAccount> = {
202
+ id: channel,
203
+ meta: {
204
+ id: channel,
205
+ label: "Nostr",
206
+ selectionLabel: "Nostr",
207
+ docsPath: "/channels/nostr",
208
+ docsLabel: "nostr",
209
+ blurb: "Decentralized DMs via Nostr relays (NIP-04)",
210
+ order: 100,
211
+ },
212
+ capabilities: {
213
+ chatTypes: ["direct"],
214
+ media: false,
215
+ },
216
+ reload: { configPrefixes: ["channels.nostr"] },
217
+ configSchema: buildChannelConfigSchema(NostrConfigSchema),
218
+ setup: nostrSetupAdapter,
219
+ setupWizard: nostrSetupWizard,
220
+ config: {
221
+ listAccountIds: listSetupNostrAccountIds,
222
+ resolveAccount: (cfg, accountId) => resolveSetupNostrAccount({ cfg, accountId }),
223
+ defaultAccountId: resolveDefaultSetupNostrAccountId,
224
+ isConfigured: (account) => account.configured,
225
+ describeAccount: (account) =>
226
+ describeAccountSnapshot({
227
+ account,
228
+ configured: account.configured,
229
+ extra: {
230
+ publicKey: account.publicKey,
231
+ },
232
+ }),
233
+ },
234
+ };