openclaw/extensions/nostr/index.ts
Patrick Ulrich b46d0395a6 feat(nostr): NIP-17 private DMs and NIP-46 bunker authentication
Two major enhancements to the Nostr extension:

## NIP-17 Private Direct Messages

Adds NIP-17 with NIP-59 gift wrapping for privacy-preserving DMs.

Protocol comparison:
- NIP-04 (legacy): Kind 4, sender/timing visible, AES-CBC
- NIP-17 (new): Kind 1059→13→14, sender hidden, timestamps randomized ±48h, XChaCha20-Poly1305

New dmProtocol config option:
- "dual" (default): Accept both, send NIP-17
- "nip17": NIP-17 only (max privacy)
- "nip04": Legacy only

Changes:
- Subscribe to kind:4 (NIP-04) and kind:1059 (GiftWrap) events
- 48-hour lookback for NIP-17 randomized timestamps
- Unwrap gift wraps via nostr-tools/nip59

## NIP-46 Bunker Authentication

Users can connect their Nostr identity via remote signers (Amber, nsec.app).

Agent tools:
- nostr_connect: Link via bunker URL
- nostr_post: Kind 1 notes with NIP-10 threading
- nostr_react: Reactions (+/-/emoji) per NIP-25
- nostr_repost: Kind 6/16 reposts per NIP-18
- nostr_fetch: Query by author/hashtag/mentions/NIP-50 search
- nostr_article: Kind 30023 long-form content per NIP-23
- nostr_disconnect/nostr_status: Session management

Bunker features:
- Session persistence (client key saved to disk)
- NIP-65 relay discovery
- Auth URL flow for browser approval
- Multi-account via bunkerIndex

HTTP endpoints for web UI:
- GET/POST/DELETE /api/channels/nostr/:id/bunker/:idx

## Safety

- NIP-09 deletion excluded (no agent deletion capability)
- Private keys remain in signer app
- Explicit user connection required

NIPs: 01, 10, 17, 18, 23, 25, 44, 46, 50, 59, 65

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:27:15 -05:00

153 lines
5.1 KiB
TypeScript

import type { MoltbotPluginApi, MoltbotConfig } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { nostrPlugin } from "./src/channel.js";
import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
import { createNostrBunkerHttpHandler } from "./src/nostr-bunker-http.js";
import { createNostrAgentTools } from "./src/agent-tools.js";
import { resolveNostrAccount } from "./src/types.js";
import type { NostrProfile, BunkerAccountConfig } from "./src/config-schema.js";
const plugin = {
id: "nostr",
name: "Nostr",
description: "Nostr DM channel plugin via NIP-04",
configSchema: emptyPluginConfigSchema(),
register(api: MoltbotPluginApi) {
setNostrRuntime(api.runtime);
api.registerChannel({ plugin: nostrPlugin });
// Register HTTP handler for profile management
const httpHandler = createNostrProfileHttpHandler({
getConfigProfile: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as MoltbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
return account.profile;
},
updateConfigProfile: async (accountId: string, profile: NostrProfile) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as MoltbotConfig;
// Build the config patch for channels.nostr.profile
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
const updatedNostrConfig = {
...nostrConfig,
profile,
};
const updatedChannels = {
...channels,
nostr: updatedNostrConfig,
};
await runtime.config.writeConfigFile({
...cfg,
channels: updatedChannels,
});
},
getAccountInfo: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as MoltbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
if (!account.configured || !account.publicKey) {
return null;
}
return {
pubkey: account.publicKey,
relays: account.relays,
};
},
log: api.logger,
});
api.registerHttpHandler(httpHandler);
// Register HTTP handler for bunker management
const bunkerHttpHandler = createNostrBunkerHttpHandler({
getBunkerAccounts: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
return account.bunkerAccounts;
},
updateBunkerAccount: async (
accountId: string,
bunkerIndex: number,
update: Partial<BunkerAccountConfig>
) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
// Update the specific bunker account
const bunkerAccounts = [...account.bunkerAccounts];
while (bunkerAccounts.length <= bunkerIndex) {
bunkerAccounts.push({ bunkerUrl: "" });
}
bunkerAccounts[bunkerIndex] = {
...bunkerAccounts[bunkerIndex],
...update,
};
// Write back to config
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
await runtime.config.writeConfigFile({
...cfg,
channels: {
...channels,
nostr: {
...nostrConfig,
bunkerAccounts,
},
},
});
},
clearConfigBunkerUrl: async (accountId: string, bunkerIndex: number) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
// Clear the specific bunker account
const bunkerAccounts = [...account.bunkerAccounts];
if (bunkerIndex < bunkerAccounts.length) {
bunkerAccounts[bunkerIndex] = {
...bunkerAccounts[bunkerIndex],
bunkerUrl: "",
userPubkey: undefined,
connectedAt: undefined,
};
}
// Write back to config
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
await runtime.config.writeConfigFile({
...cfg,
channels: {
...channels,
nostr: {
...nostrConfig,
bunkerAccounts,
},
},
});
},
log: api.logger,
});
api.registerHttpHandler(bunkerHttpHandler);
// Register agent tools for bunker operations
for (const tool of createNostrAgentTools()) {
api.registerTool(tool);
}
},
};
export default plugin;