feat(ndr): add NDR channel plugin for nostr-double-ratchet
Adds NDR (Nostr Double Ratchet) as a channel extension providing forward-secure E2E encrypted messaging over Nostr relays. Features: - Full channel implementation with send/receive/react - Onboarding flow with invite URL acceptance and hello message - Auto-generated identity if not configured - Configurable relays (defaults to popular Nostr relays) - Owner pubkey authentication for message filtering Requires external `ndr` CLI (cargo install ndr). Control surface: chat.iris.to 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f4004054ab
commit
e954a55f1f
181
extensions/ndr/README.md
Normal file
181
extensions/ndr/README.md
Normal file
@ -0,0 +1,181 @@
|
||||
# @clawdbot/ndr
|
||||
|
||||
Clawdbot channel plugin for [nostr-double-ratchet](https://files.iris.to/#/npub1xndmdgymsf4a34rzr7346vp8qcptxf75pjqweh8naa8rklgxpfqqmfjtce/nostr-double-ratchet) - forward-secure end-to-end encrypted messaging over Nostr.
|
||||
|
||||
Compatible with [chat.iris.to](https://chat.iris.to).
|
||||
|
||||
## Features
|
||||
|
||||
- **Forward secrecy** - Past messages remain secure even if keys are compromised
|
||||
- **Double ratchet encryption** - Based on Signal's proven protocol
|
||||
- **Nostr transport** - Messages sent via Nostr relays
|
||||
- **CLI integration** - Uses the `ndr` CLI for encryption/decryption
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Rust and the required CLIs:
|
||||
|
||||
```bash
|
||||
curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli
|
||||
```
|
||||
|
||||
- **ndr** - Required for double ratchet encryption
|
||||
- **hashtree-cli** - Optional, for encrypted media uploads via [hashtree](https://github.com/mmalmi/hashtree)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/ndr
|
||||
```
|
||||
|
||||
Or link for development:
|
||||
|
||||
```bash
|
||||
clawdbot plugins install -l ./extensions/ndr
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
ndr: {
|
||||
// Owner's pubkey - only messages from this npub are handled as commands
|
||||
ownerPubkey: "npub1...",
|
||||
|
||||
// Optional: Nostr relays (defaults shown below)
|
||||
relays: [
|
||||
"wss://temp.iris.to",
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.damus.io",
|
||||
"wss://offchain.pub"
|
||||
],
|
||||
|
||||
// Optional: Path to ndr CLI (default: "ndr" in PATH)
|
||||
ndrPath: "/path/to/ndr",
|
||||
|
||||
// Optional: Custom data directory for ndr
|
||||
dataDir: "~/.ndr-clawdbot",
|
||||
|
||||
// Optional: Private key (hex or nsec). If not provided, ndr auto-generates one.
|
||||
// privateKey: "nsec1...",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Authorization:**
|
||||
- Only messages from `ownerPubkey` are handled as agent commands
|
||||
- Messages from other pubkeys are logged but ignored (for now)
|
||||
- If `ownerPubkey` is not set, all messages are handled (legacy behavior)
|
||||
|
||||
## Setup
|
||||
|
||||
The ndr CLI auto-generates an identity on first use.
|
||||
|
||||
### 1. Check your identity
|
||||
|
||||
```bash
|
||||
ndr whoami
|
||||
```
|
||||
|
||||
This shows your npub. Share this with people who want to message you.
|
||||
|
||||
### 2. Create an invite (to let others connect to you)
|
||||
|
||||
```bash
|
||||
ndr invite create --label "clawdbot"
|
||||
```
|
||||
|
||||
Share the invite URL with the person you want to chat with. They accept it with `ndr chat join <url>`.
|
||||
|
||||
### 3. Join someone else's invite
|
||||
|
||||
```bash
|
||||
ndr chat join <invite_url>
|
||||
```
|
||||
|
||||
This creates a chat session. You can now send/receive messages.
|
||||
|
||||
### 4. Configure your owner pubkey
|
||||
|
||||
Add your npub to the config so only you can control the agent:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
ndr: {
|
||||
ownerPubkey: "npub1..." // your npub from step 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Start the gateway
|
||||
|
||||
```bash
|
||||
clawdbot gateway run
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Check channel status
|
||||
|
||||
```bash
|
||||
clawdbot channels status --channel ndr
|
||||
```
|
||||
|
||||
### List active chats
|
||||
|
||||
```bash
|
||||
ndr chat list
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```bash
|
||||
clawdbot message send --channel ndr --to <chat_id> --message "Hello!"
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Initialization** - ndr auto-generates an identity if not logged in
|
||||
2. **Listening** - Runs `ndr listen` to receive incoming messages
|
||||
3. **Receiving** - Decrypts messages using the double ratchet session
|
||||
4. **Sending** - Uses `ndr send` to encrypt and publish messages
|
||||
5. **Session management** - ndr handles key rotation automatically
|
||||
|
||||
## Security
|
||||
|
||||
- **No key exposure** - Private keys are only passed to the ndr CLI
|
||||
- **Forward secrecy** - Each message uses a unique encryption key
|
||||
- **Session isolation** - Each chat has its own ratchet state
|
||||
|
||||
## Comparison with Nostr NIP-04
|
||||
|
||||
| Feature | NDR (Double Ratchet) | Nostr NIP-04 |
|
||||
|---------|---------------------|--------------|
|
||||
| Forward secrecy | Yes | No |
|
||||
| Key rotation | Automatic | None |
|
||||
| Session state | Required | Stateless |
|
||||
| Complexity | Higher | Lower |
|
||||
|
||||
Use NDR for high-security conversations where forward secrecy matters.
|
||||
Use NIP-04 for simpler use cases where stateless encryption is acceptable.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ndr: command not found"
|
||||
|
||||
Install ndr CLI: `cargo install ndr`
|
||||
|
||||
### "Failed to send message"
|
||||
|
||||
Check that:
|
||||
1. You have an active chat session with the recipient
|
||||
2. The relay is reachable
|
||||
3. ndr CLI is working: `ndr chat list`
|
||||
11
extensions/ndr/clawdbot.plugin.json
Normal file
11
extensions/ndr/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "ndr",
|
||||
"channels": [
|
||||
"ndr"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
18
extensions/ndr/index.ts
Normal file
18
extensions/ndr/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { ndrPlugin } from "./src/channel.js";
|
||||
import { setNdrRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "ndr",
|
||||
name: "NDR",
|
||||
description: "Forward-secure E2E encryption via nostr-double-ratchet",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setNdrRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: ndrPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
28
extensions/ndr/package.json
Normal file
28
extensions/ndr/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ndr",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot channel plugin for nostr-double-ratchet (forward-secure E2E encryption)",
|
||||
"keywords": ["clawdbot", "nostr", "double-ratchet", "ndr", "e2e", "encryption", "chat"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mmalmi/nostr-double-ratchet"
|
||||
},
|
||||
"license": "MIT",
|
||||
"clawdbot": {
|
||||
"id": "ndr",
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": "*"
|
||||
}
|
||||
}
|
||||
360
extensions/ndr/src/channel.ts
Normal file
360
extensions/ndr/src/channel.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelPlugin,
|
||||
createReplyPrefixContext,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { NdrConfigSchema } from "./config-schema.js";
|
||||
import { getNdrRuntime } from "./runtime.js";
|
||||
import {
|
||||
listNdrAccountIds,
|
||||
resolveDefaultNdrAccountId,
|
||||
resolveNdrAccount,
|
||||
type ResolvedNdrAccount,
|
||||
} from "./types.js";
|
||||
import { startNdrBus, type NdrBusHandle } from "./ndr-bus.js";
|
||||
import { ndrOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
// Store active bus handles per account
|
||||
const activeBuses = new Map<string, NdrBusHandle>();
|
||||
|
||||
export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
||||
id: "ndr",
|
||||
meta: {
|
||||
id: "ndr",
|
||||
label: "NDR",
|
||||
selectionLabel: "NDR (Nostr Double Ratchet)",
|
||||
docsPath: "/channels/ndr",
|
||||
docsLabel: "ndr",
|
||||
blurb: "Forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
|
||||
order: 56,
|
||||
selectionExtras: ["https://chat.iris.to"],
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"], // DMs only
|
||||
media: false, // No media support yet
|
||||
},
|
||||
reload: { configPrefixes: ["channels.ndr"] },
|
||||
configSchema: buildChannelConfigSchema(NdrConfigSchema),
|
||||
onboarding: ndrOnboardingAdapter,
|
||||
|
||||
config: {
|
||||
listAccountIds: (cfg) => listNdrAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveNdrAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultNdrAccountId(cfg),
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
publicKey: account.publicKey,
|
||||
}),
|
||||
},
|
||||
|
||||
// Authorization is handled by NDR's invite/accept flow.
|
||||
// Only users with an established double ratchet session can message.
|
||||
// No pairing/allowFrom config needed - the invite exchange IS the authorization.
|
||||
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
// NDR uses chat IDs, not pubkeys directly
|
||||
return target.trim();
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (input) => {
|
||||
const trimmed = input.trim();
|
||||
// Chat IDs are short hex strings
|
||||
return /^[0-9a-fA-F]{8}$/.test(trimmed) || trimmed.startsWith("npub1");
|
||||
},
|
||||
hint: "<chat_id|npub>",
|
||||
},
|
||||
},
|
||||
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
const core = getNdrRuntime();
|
||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const bus = activeBuses.get(aid);
|
||||
if (!bus) {
|
||||
throw new Error(`NDR bus not running for account ${aid}`);
|
||||
}
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: core.config.loadConfig(),
|
||||
channel: "ndr",
|
||||
accountId: aid,
|
||||
});
|
||||
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
await bus.sendMessage(to, message);
|
||||
return { channel: "ndr", to };
|
||||
},
|
||||
sendMedia: async ({ to, media, accountId }) => {
|
||||
const core = getNdrRuntime();
|
||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const bus = activeBuses.get(aid);
|
||||
if (!bus) {
|
||||
throw new Error(`NDR bus not running for account ${aid}`);
|
||||
}
|
||||
const caption = media.caption ? `${media.caption}\n` : "";
|
||||
|
||||
// Try to upload via htree if we have a local file path
|
||||
let mediaLink = media.url ?? "[media attachment]";
|
||||
if (media.path) {
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
// Properly escape the file path for shell
|
||||
const escapedPath = media.path.replace(/'/g, "'\\''");
|
||||
const output = execSync(`htree add '${escapedPath}'`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 60000,
|
||||
});
|
||||
// Parse "url: nhash1.../filename" from output
|
||||
const urlMatch = output.match(/url:\s+(nhash1[^\s]+)/);
|
||||
if (urlMatch) {
|
||||
mediaLink = urlMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// htree not available or failed - fall back to URL or placeholder
|
||||
mediaLink = media.url ?? "[media: upload failed]";
|
||||
}
|
||||
}
|
||||
|
||||
const message = `${caption}${mediaLink}`;
|
||||
await bus.sendMessage(to, message);
|
||||
return { channel: "ndr", to };
|
||||
},
|
||||
},
|
||||
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) return [];
|
||||
return [
|
||||
{
|
||||
channel: "ndr",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime" as const,
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
publicKey: snapshot.publicKey ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
publicKey: account.publicKey,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
publicKey: account.publicKey,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting NDR provider (pubkey: ${account.publicKey})`);
|
||||
|
||||
// Note: account.configured is always true since ndr auto-generates identity
|
||||
|
||||
const runtime = getNdrRuntime();
|
||||
|
||||
const bus = await startNdrBus({
|
||||
accountId: account.accountId,
|
||||
privateKey: account.privateKey,
|
||||
relays: account.relays,
|
||||
ndrPath: account.ndrPath,
|
||||
dataDir: account.dataDir,
|
||||
onMessage: async (chatId, messageId, senderPubkey, text, replyFn) => {
|
||||
ctx.log?.debug(`[${account.accountId}] Message from ${senderPubkey} in chat ${chatId}: ${text.slice(0, 50)}...`);
|
||||
|
||||
// React with "eyes" emoji to indicate we're processing (like WhatsApp "typing" indicator)
|
||||
if (messageId) {
|
||||
try {
|
||||
await bus.react(chatId, messageId, "👀");
|
||||
} catch {
|
||||
// Reaction failed, continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Check if sender is the owner
|
||||
// Note: senderPubkey is the ephemeral key used in the message, not the identity key.
|
||||
// We need to look up the chat's their_pubkey (identity) to compare with ownerPubkey.
|
||||
let identityPubkey = senderPubkey; // fallback
|
||||
try {
|
||||
const chats = await bus.listChats();
|
||||
const chat = chats.find((c) => c.id === chatId);
|
||||
if (chat) {
|
||||
identityPubkey = chat.their_pubkey;
|
||||
}
|
||||
} catch {
|
||||
// If lookup fails, fall back to senderPubkey
|
||||
}
|
||||
|
||||
const isOwner = account.ownerPubkey && identityPubkey === account.ownerPubkey;
|
||||
|
||||
if (!isOwner && account.ownerPubkey) {
|
||||
// Non-owner message - log and ignore
|
||||
ctx.log?.info(`[${account.accountId}] Ignoring message from non-owner ${identityPubkey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the message through clawdbot's reply pipeline
|
||||
const cfg = runtime.config.loadConfig();
|
||||
const ndrTo = `ndr:${chatId}`;
|
||||
|
||||
// Resolve agent route for this chat
|
||||
const route = runtime.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "ndr",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "dm", id: chatId },
|
||||
});
|
||||
|
||||
// Build the envelope for the message
|
||||
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const body = runtime.channel.reply.formatInboundEnvelope({
|
||||
channel: "NDR",
|
||||
from: identityPubkey.slice(0, 16) + "...",
|
||||
body: text,
|
||||
chatType: "direct",
|
||||
sender: { name: identityPubkey.slice(0, 8), id: identityPubkey },
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
// Finalize the inbound context
|
||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: text,
|
||||
CommandBody: text,
|
||||
From: `ndr:${identityPubkey}`,
|
||||
To: ndrTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: "direct" as const,
|
||||
ConversationLabel: `NDR chat ${chatId}`,
|
||||
SenderName: identityPubkey.slice(0, 8),
|
||||
SenderId: identityPubkey,
|
||||
Provider: "ndr" as const,
|
||||
Surface: "ndr" as const,
|
||||
MessageSid: `${chatId}-${Date.now()}`,
|
||||
CommandAuthorized: true, // Owner is always authorized
|
||||
OriginatingChannel: "ndr" as const,
|
||||
OriginatingTo: ndrTo,
|
||||
});
|
||||
|
||||
// Record the session
|
||||
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await runtime.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "ndr",
|
||||
to: chatId,
|
||||
accountId: route.accountId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create reply prefix context
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
|
||||
// Create dispatcher with typing (simplified - no typing indicator for NDR)
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
ctx.log?.info(`[${account.accountId}] NDR deliver called with payload: ${JSON.stringify(payload).slice(0, 200)}`);
|
||||
const responseText = payload.text ?? "";
|
||||
if (responseText) {
|
||||
ctx.log?.info(`[${account.accountId}] NDR sending reply: ${responseText.slice(0, 100)}...`);
|
||||
await replyFn(responseText);
|
||||
ctx.log?.info(`[${account.accountId}] NDR reply sent successfully`);
|
||||
} else {
|
||||
ctx.log?.warn(`[${account.accountId}] NDR deliver called but no text in payload`);
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
ctx.log?.error(`[${account.accountId}] NDR reply failed (${info.kind}): ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Dispatch the message
|
||||
await runtime.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
onModelSelected: (modelCtx) => {
|
||||
prefixContext.onModelSelected(modelCtx);
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
},
|
||||
onError: (error, context) => {
|
||||
ctx.log?.error(`[${account.accountId}] NDR error (${context}): ${error.message}`);
|
||||
},
|
||||
onConnect: () => {
|
||||
ctx.log?.info(`[${account.accountId}] NDR listener started`);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
ctx.log?.warn(`[${account.accountId}] NDR listener disconnected`);
|
||||
},
|
||||
});
|
||||
|
||||
// Store the bus handle
|
||||
activeBuses.set(account.accountId, bus);
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] NDR provider started`);
|
||||
|
||||
// Return cleanup function
|
||||
return {
|
||||
stop: () => {
|
||||
bus.close();
|
||||
activeBuses.delete(account.accountId);
|
||||
ctx.log?.info(`[${account.accountId}] NDR provider stopped`);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all active NDR bus handles
|
||||
*/
|
||||
export function getActiveNdrBuses(): Map<string, NdrBusHandle> {
|
||||
return new Map(activeBuses);
|
||||
}
|
||||
38
extensions/ndr/src/config-schema.ts
Normal file
38
extensions/ndr/src/config-schema.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* NDR channel configuration schema
|
||||
*/
|
||||
export const NdrConfigSchema = z.object({
|
||||
/** Private key for signing (hex or nsec format). If not provided, ndr auto-generates one. */
|
||||
privateKey: z.string().optional(),
|
||||
|
||||
/** Owner's pubkey (npub or hex). Only messages from this pubkey are handled as commands. */
|
||||
ownerPubkey: z.string().optional(),
|
||||
|
||||
/** Nostr relays to connect to */
|
||||
relays: z.array(z.string()).optional(),
|
||||
|
||||
/** Whether the channel is enabled */
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
/** Display name for the account */
|
||||
name: z.string().optional(),
|
||||
|
||||
/** Path to ndr CLI binary (defaults to 'ndr' in PATH) */
|
||||
ndrPath: z.string().optional(),
|
||||
|
||||
/** Custom data directory for ndr */
|
||||
dataDir: z.string().optional(),
|
||||
});
|
||||
|
||||
export type NdrConfig = z.infer<typeof NdrConfigSchema>;
|
||||
|
||||
/** Default relays if none configured */
|
||||
export const DEFAULT_RELAYS = [
|
||||
"wss://temp.iris.to",
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.damus.io",
|
||||
"wss://offchain.pub",
|
||||
];
|
||||
301
extensions/ndr/src/ndr-bus.ts
Normal file
301
extensions/ndr/src/ndr-bus.ts
Normal file
@ -0,0 +1,301 @@
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
|
||||
export interface NdrBusOptions {
|
||||
accountId: string;
|
||||
privateKey: string;
|
||||
relays: string[];
|
||||
ndrPath: string;
|
||||
dataDir: string | null;
|
||||
onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise<void>) => Promise<void>;
|
||||
onNewSession?: (chatId: string, theirPubkey: string) => Promise<void>;
|
||||
onError?: (error: Error, context: string) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export interface NdrBusHandle {
|
||||
sendMessage: (chatId: string, text: string) => Promise<void>;
|
||||
react: (chatId: string, messageId: string, emoji: string) => Promise<void>;
|
||||
createInvite: () => Promise<{ inviteUrl: string; inviteId: string }>;
|
||||
joinInvite: (inviteUrl: string) => Promise<{ chatId: string; theirPubkey: string }>;
|
||||
listChats: () => Promise<Array<{ id: string; their_pubkey: string }>>;
|
||||
close: () => void;
|
||||
isRunning: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the NDR bus - manages ndr CLI process for listening and sending
|
||||
*
|
||||
* The `ndr listen` command handles both incoming messages AND invite responses,
|
||||
* so we only need a single listener process.
|
||||
*/
|
||||
export async function startNdrBus(options: NdrBusOptions): Promise<NdrBusHandle> {
|
||||
const {
|
||||
privateKey,
|
||||
relays,
|
||||
ndrPath,
|
||||
dataDir,
|
||||
onMessage,
|
||||
onNewSession,
|
||||
onError,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
} = options;
|
||||
|
||||
let listenProcess: ChildProcess | null = null;
|
||||
let running = false;
|
||||
|
||||
// Build common args
|
||||
const baseArgs: string[] = ["--json"];
|
||||
if (dataDir) {
|
||||
baseArgs.push("--data-dir", dataDir);
|
||||
}
|
||||
|
||||
// Initialize: login with provided key, or let ndr auto-generate on first use
|
||||
if (privateKey) {
|
||||
await runNdrCommand(ndrPath, [...baseArgs, "login", privateKey]);
|
||||
}
|
||||
// If no privateKey, ndr will auto-generate identity when needed (invite/listen/send)
|
||||
|
||||
// Update relay config if needed (preserve existing config like private_key)
|
||||
if (dataDir && relays.length > 0) {
|
||||
const configPath = `${dataDir}/config.json`;
|
||||
const fs = await import("fs/promises");
|
||||
try {
|
||||
let existingConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = await fs.readFile(configPath, "utf-8");
|
||||
existingConfig = JSON.parse(content);
|
||||
} catch {
|
||||
// Config doesn't exist yet, start fresh
|
||||
}
|
||||
// Merge relays into existing config, preserving other fields like private_key
|
||||
const mergedConfig = { ...existingConfig, relays };
|
||||
await fs.writeFile(configPath, JSON.stringify(mergedConfig, null, 2));
|
||||
} catch {
|
||||
// Config dir may not exist yet, ndr will create it
|
||||
}
|
||||
}
|
||||
|
||||
// Start listening for messages and invite responses (both handled by `ndr listen`)
|
||||
const startListening = () => {
|
||||
listenProcess = spawn(ndrPath, [...baseArgs, "listen"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
listenProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
|
||||
// Handle incoming messages
|
||||
if (json.event === "message") {
|
||||
const chatId = json.chat_id;
|
||||
const messageId = json.message_id ?? "";
|
||||
const senderPubkey = json.from_pubkey;
|
||||
const content = json.content;
|
||||
|
||||
// Create reply function
|
||||
const reply = async (text: string) => {
|
||||
await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
||||
};
|
||||
|
||||
onMessage(chatId, messageId, senderPubkey, content, reply).catch((err) => {
|
||||
onError?.(err, "message_handler");
|
||||
});
|
||||
}
|
||||
|
||||
// Handle new sessions from invite responses
|
||||
if (json.event === "session_created") {
|
||||
const chatId = json.chat_id;
|
||||
const theirPubkey = json.their_pubkey;
|
||||
|
||||
onNewSession?.(chatId, theirPubkey).catch((err) => {
|
||||
onError?.(err, "new_session_handler");
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
listenProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString().trim();
|
||||
if (text && !text.includes("Listening")) {
|
||||
onError?.(new Error(text), "listen_stderr");
|
||||
}
|
||||
});
|
||||
|
||||
listenProcess.on("exit", (code) => {
|
||||
if (running && code !== 0) {
|
||||
onError?.(new Error(`ndr listen exited with code ${code}`), "listen_exit");
|
||||
setTimeout(() => running && startListening(), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
listenProcess.on("error", (err) => {
|
||||
onError?.(err, "listen_spawn");
|
||||
});
|
||||
};
|
||||
|
||||
running = true;
|
||||
onConnect?.();
|
||||
startListening();
|
||||
|
||||
return {
|
||||
sendMessage: async (chatId: string, text: string) => {
|
||||
const result = await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to send message");
|
||||
}
|
||||
},
|
||||
|
||||
react: async (chatId: string, messageId: string, emoji: string) => {
|
||||
const result = await runNdrCommand(ndrPath, [...baseArgs, "react", chatId, messageId, emoji]);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to send reaction");
|
||||
}
|
||||
},
|
||||
|
||||
createInvite: async () => {
|
||||
const result = await runNdrCommand(ndrPath, [...baseArgs, "invite", "create"]);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to create invite");
|
||||
}
|
||||
const data = result.data as { url: string; id: string };
|
||||
return { inviteUrl: data.url, inviteId: data.id };
|
||||
},
|
||||
|
||||
joinInvite: async (inviteUrl: string) => {
|
||||
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "join", inviteUrl]);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to join invite");
|
||||
}
|
||||
const data = result.data as { id: string; their_pubkey: string };
|
||||
return { chatId: data.id, theirPubkey: data.their_pubkey };
|
||||
},
|
||||
|
||||
listChats: async () => {
|
||||
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "list"]);
|
||||
if (result.status === "ok" && result.data) {
|
||||
const data = result.data as { chats: Array<{ id: string; their_pubkey: string }> };
|
||||
return data.chats || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
close: () => {
|
||||
running = false;
|
||||
onDisconnect?.();
|
||||
if (listenProcess) {
|
||||
listenProcess.kill();
|
||||
listenProcess = null;
|
||||
}
|
||||
},
|
||||
|
||||
isRunning: () => running,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an ndr CLI command and return parsed JSON output
|
||||
*/
|
||||
async function runNdrCommand(ndrPath: string, args: string[]): Promise<{ status: string; error?: string; data?: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(ndrPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const json = JSON.parse(stdout.trim());
|
||||
resolve(json);
|
||||
} catch {
|
||||
resolve({ status: "ok", data: stdout.trim() });
|
||||
}
|
||||
} else {
|
||||
resolve({ status: "error", error: stderr.trim() || `Exit code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat list from ndr
|
||||
*/
|
||||
export async function listChats(ndrPath: string, dataDir: string | null): Promise<Array<{ id: string; their_pubkey: string }>> {
|
||||
const args = ["--json"];
|
||||
if (dataDir) {
|
||||
args.push("--data-dir", dataDir);
|
||||
}
|
||||
args.push("chat", "list");
|
||||
|
||||
const result = await runNdrCommand(ndrPath, args);
|
||||
if (result.status === "ok" && Array.isArray(result.data)) {
|
||||
return result.data as Array<{ id: string; their_pubkey: string }>;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a chat via invite URL
|
||||
*/
|
||||
export async function joinChat(
|
||||
ndrPath: string,
|
||||
dataDir: string | null,
|
||||
inviteUrl: string
|
||||
): Promise<{ chatId: string; theirPubkey: string }> {
|
||||
const args = ["--json"];
|
||||
if (dataDir) {
|
||||
args.push("--data-dir", dataDir);
|
||||
}
|
||||
args.push("chat", "join", inviteUrl);
|
||||
|
||||
const result = await runNdrCommand(ndrPath, args);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to join chat");
|
||||
}
|
||||
|
||||
const data = result.data as { id: string; their_pubkey: string };
|
||||
return { chatId: data.id, theirPubkey: data.their_pubkey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invite
|
||||
*/
|
||||
export async function createInvite(
|
||||
ndrPath: string,
|
||||
dataDir: string | null
|
||||
): Promise<{ inviteUrl: string; inviteId: string }> {
|
||||
const args = ["--json"];
|
||||
if (dataDir) {
|
||||
args.push("--data-dir", dataDir);
|
||||
}
|
||||
args.push("invite", "create");
|
||||
|
||||
const result = await runNdrCommand(ndrPath, args);
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(result.error || "Failed to create invite");
|
||||
}
|
||||
|
||||
const data = result.data as { url: string; id: string };
|
||||
return { inviteUrl: data.url, inviteId: data.id };
|
||||
}
|
||||
245
extensions/ndr/src/onboarding.ts
Normal file
245
extensions/ndr/src/onboarding.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { listNdrAccountIds, resolveNdrAccount, resolveDefaultNdrAccountId } from "./types.js";
|
||||
|
||||
const channel = "ndr" as const;
|
||||
|
||||
type ChannelOnboardingAdapter = {
|
||||
channel: typeof channel;
|
||||
getStatus: (ctx: {
|
||||
cfg: ClawdbotConfig;
|
||||
}) => Promise<{
|
||||
channel: typeof channel;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
}>;
|
||||
configure: (ctx: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: {
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
text: (opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string | undefined) => string | undefined;
|
||||
}) => Promise<string>;
|
||||
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
|
||||
};
|
||||
}) => Promise<{ cfg: ClawdbotConfig; accountId?: string }>;
|
||||
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an NDR invite URL to extract the inviter's hex pubkey.
|
||||
* Invite URL format: https://iris.to#{"inviter":"<hex>","ephemeralKey":"...","sharedSecret":"..."}
|
||||
*/
|
||||
function parseInviteUrl(url: string): { inviter: string } | null {
|
||||
try {
|
||||
const trimmed = url.trim();
|
||||
// Handle both full URL and just the fragment
|
||||
let fragment = trimmed;
|
||||
if (trimmed.includes("#")) {
|
||||
fragment = trimmed.split("#")[1] ?? "";
|
||||
}
|
||||
const decoded = decodeURIComponent(fragment);
|
||||
const data = JSON.parse(decoded) as { inviter?: string };
|
||||
if (data.inviter && /^[0-9a-fA-F]{64}$/.test(data.inviter)) {
|
||||
return { inviter: data.inviter };
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL format
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ndrOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
|
||||
getStatus: async ({ cfg }) => {
|
||||
const accountIds = listNdrAccountIds(cfg);
|
||||
const hasAccount = accountIds.length > 0;
|
||||
const defaultAccountId = resolveDefaultNdrAccountId(cfg);
|
||||
const account = resolveNdrAccount({ cfg, accountId: defaultAccountId });
|
||||
const enabled = account.enabled;
|
||||
|
||||
return {
|
||||
channel,
|
||||
configured: hasAccount && enabled,
|
||||
statusLines: [
|
||||
`NDR: ${hasAccount ? (enabled ? "configured" : "disabled") : "not configured"}`,
|
||||
],
|
||||
selectionHint: hasAccount
|
||||
? enabled
|
||||
? "configured"
|
||||
: "disabled"
|
||||
: "E2E encrypted · Nostr",
|
||||
quickstartScore: hasAccount && enabled ? 1 : 5,
|
||||
};
|
||||
},
|
||||
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
await prompter.note(
|
||||
[
|
||||
"NDR (Nostr Double Ratchet) provides forward-secure E2E encrypted messaging.",
|
||||
"",
|
||||
"Install dependencies (requires Rust):",
|
||||
" curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli",
|
||||
"",
|
||||
"Control surface: chat.iris.to",
|
||||
].join("\n"),
|
||||
"NDR Setup",
|
||||
);
|
||||
|
||||
// Check if ndr CLI is available
|
||||
let ndrAvailable = false;
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("ndr --version", { stdio: "ignore" });
|
||||
ndrAvailable = true;
|
||||
} catch {
|
||||
// ndr not found
|
||||
}
|
||||
|
||||
if (!ndrAvailable) {
|
||||
await prompter.note(
|
||||
[
|
||||
"ndr CLI not found in PATH.",
|
||||
"",
|
||||
"Install: cargo install ndr",
|
||||
"",
|
||||
"You can configure now and install ndr later.",
|
||||
].join("\n"),
|
||||
"Warning",
|
||||
);
|
||||
}
|
||||
|
||||
// Ask for invite URL from chat.iris.to
|
||||
await prompter.note(
|
||||
[
|
||||
"To connect your bot to your account:",
|
||||
"",
|
||||
"1. Go to chat.iris.to",
|
||||
"2. Click 'New Chat' (+ button)",
|
||||
"3. Click 'Copy your chat link'",
|
||||
"4. Paste the invite URL below",
|
||||
"",
|
||||
"The bot will accept your invite and send a hello message.",
|
||||
].join("\n"),
|
||||
"Chat Invite",
|
||||
);
|
||||
|
||||
const inviteUrl = await prompter.text({
|
||||
message: "Paste your chat invite URL from chat.iris.to",
|
||||
placeholder: "https://chat.iris.to/#...",
|
||||
validate: (value) => {
|
||||
if (!value?.trim()) return "Required";
|
||||
const parsed = parseInviteUrl(value);
|
||||
if (!parsed) return "Invalid invite URL. Should be from chat.iris.to";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseInviteUrl(inviteUrl);
|
||||
if (!parsed) {
|
||||
await prompter.note("Failed to parse invite URL.", "Error");
|
||||
return { cfg };
|
||||
}
|
||||
|
||||
const ownerPubkey = parsed.inviter;
|
||||
|
||||
// Try to accept the invite and send hello
|
||||
let chatId: string | null = null;
|
||||
let joinError: string | null = null;
|
||||
let sendError: string | null = null;
|
||||
|
||||
if (ndrAvailable) {
|
||||
const { execSync } = await import("child_process");
|
||||
const os = await import("os");
|
||||
const path = await import("path");
|
||||
|
||||
// Use ~/.clawdbot/ndr-data to match channel plugin's default dataDir
|
||||
const ndrDataDir = path.join(os.homedir(), ".clawdbot", "ndr-data");
|
||||
const ndrCmd = `ndr --data-dir "${ndrDataDir}" --json`;
|
||||
|
||||
// Accept the invite
|
||||
try {
|
||||
const joinOutput = execSync(`${ndrCmd} chat join "${inviteUrl.trim()}"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 60000,
|
||||
});
|
||||
// Parse JSON output for chat ID
|
||||
// Format: { "status": "ok", "command": "chat.join", "data": { "id": "..." } }
|
||||
try {
|
||||
const joinResult = JSON.parse(joinOutput) as {
|
||||
data?: { id?: string; chat_id?: string };
|
||||
id?: string;
|
||||
chat_id?: string;
|
||||
};
|
||||
chatId = joinResult.data?.id ?? joinResult.data?.chat_id ?? joinResult.id ?? joinResult.chat_id ?? null;
|
||||
} catch {
|
||||
// Fallback: parse from text output
|
||||
const chatMatch = joinOutput.match(/chat[:\s_]+([0-9a-fA-F]{8})/i);
|
||||
if (chatMatch) chatId = chatMatch[1];
|
||||
}
|
||||
} catch (err) {
|
||||
joinError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Send hello message if we got a chat ID
|
||||
if (chatId) {
|
||||
try {
|
||||
execSync(`${ndrCmd} send "${chatId}" "Hello! I'm your clawdbot agent. 🤖"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
});
|
||||
} catch (err) {
|
||||
sendError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next: ClawdbotConfig = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
ndr: {
|
||||
...cfg.channels?.ndr,
|
||||
enabled: true,
|
||||
ownerPubkey,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const successMsg: string[] = ["NDR channel configured!", "", `Owner: ${ownerPubkey.slice(0, 16)}...`];
|
||||
|
||||
if (chatId) {
|
||||
successMsg.push("", `Chat established: ${chatId}`);
|
||||
if (sendError) {
|
||||
successMsg.push("", `Warning: Failed to send hello message: ${sendError.slice(0, 100)}`);
|
||||
} else {
|
||||
successMsg.push("", "Hello message sent! Check chat.iris.to");
|
||||
}
|
||||
} else if (joinError) {
|
||||
successMsg.push("", `Warning: Failed to join chat: ${joinError.slice(0, 100)}`);
|
||||
successMsg.push("", "Join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
|
||||
} else if (!ndrAvailable) {
|
||||
successMsg.push("", "Install ndr CLI and join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
|
||||
}
|
||||
|
||||
successMsg.push("", "Start the gateway: clawdbot gateway run");
|
||||
|
||||
await prompter.note(successMsg.join("\n"), "Setup Complete");
|
||||
|
||||
return { cfg: next };
|
||||
},
|
||||
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
ndr: { ...cfg.channels?.ndr, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
14
extensions/ndr/src/runtime.ts
Normal file
14
extensions/ndr/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNdrRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getNdrRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("NDR runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
136
extensions/ndr/src/types.ts
Normal file
136
extensions/ndr/src/types.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { getPublicKey } from "nostr-tools";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { DEFAULT_RELAYS, type NdrConfig } from "./config-schema.js";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandTilde(p: string): string {
|
||||
if (p.startsWith("~/")) {
|
||||
return join(homedir(), p.slice(2));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface ResolvedNdrAccount {
|
||||
accountId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
privateKey: string;
|
||||
publicKey: string | null;
|
||||
ownerPubkey: string | null;
|
||||
relays: string[];
|
||||
ndrPath: string;
|
||||
dataDir: string | null;
|
||||
config: NdrConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured NDR account IDs
|
||||
*/
|
||||
export function listNdrAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
||||
const ndrConfig = channels.ndr;
|
||||
if (!ndrConfig || typeof ndrConfig !== "object") {
|
||||
return [];
|
||||
}
|
||||
// For now, only support single "default" account
|
||||
return ["default"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default NDR account ID
|
||||
*/
|
||||
export function resolveDefaultNdrAccountId(cfg: ClawdbotConfig): string | undefined {
|
||||
const ids = listNdrAccountIds(cfg);
|
||||
return ids.length > 0 ? ids[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve NDR account configuration
|
||||
*/
|
||||
export function resolveNdrAccount(opts: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedNdrAccount {
|
||||
const { cfg, accountId = "default" } = opts;
|
||||
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
||||
const ndrConfig = (channels.ndr ?? {}) as NdrConfig;
|
||||
|
||||
const privateKey = ndrConfig.privateKey ?? "";
|
||||
let publicKey: string | null = null;
|
||||
|
||||
if (privateKey) {
|
||||
try {
|
||||
// Handle hex format
|
||||
const keyBytes = hexToBytes(privateKey);
|
||||
publicKey = getPublicKey(keyBytes);
|
||||
} catch {
|
||||
// Invalid key format
|
||||
}
|
||||
}
|
||||
|
||||
// ndr auto-generates identity if not provided, so we're always "configured"
|
||||
// The actual identity will be created/loaded by ndr on first use
|
||||
const configured = true;
|
||||
const relays = ndrConfig.relays ?? DEFAULT_RELAYS;
|
||||
|
||||
// Normalize owner pubkey to hex if provided
|
||||
let ownerPubkey: string | null = null;
|
||||
if (ndrConfig.ownerPubkey) {
|
||||
ownerPubkey = normalizePubkey(ndrConfig.ownerPubkey);
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: ndrConfig.name ?? "NDR",
|
||||
enabled: ndrConfig.enabled !== false,
|
||||
configured,
|
||||
privateKey,
|
||||
publicKey,
|
||||
ownerPubkey,
|
||||
relays,
|
||||
ndrPath: ndrConfig.ndrPath ?? "ndr",
|
||||
// Default to ~/.clawdbot/ndr-data for persistence (container mounts ~/.clawdbot)
|
||||
dataDir: expandTilde(ndrConfig.dataDir ?? "~/.clawdbot/ndr-data"),
|
||||
config: ndrConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const cleaned = hex.replace(/^0x/, "");
|
||||
const bytes = new Uint8Array(cleaned.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pubkey to hex format
|
||||
*/
|
||||
function normalizePubkey(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Already hex
|
||||
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
// npub format - decode bech32
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const { nip19 } = require("nostr-tools");
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === "npub") {
|
||||
return decoded.data as string;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Invalid pubkey format: ${input}`);
|
||||
}
|
||||
@ -248,7 +248,8 @@
|
||||
"overrides": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"hono": "4.11.4",
|
||||
"tar": "7.5.4"
|
||||
"tar": "7.5.4",
|
||||
"clawdbot": "workspace:moltbot@*"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
117
pnpm-lock.yaml
generated
117
pnpm-lock.yaml
generated
@ -8,6 +8,7 @@ overrides:
|
||||
'@sinclair/typebox': 0.34.47
|
||||
hono: 4.11.4
|
||||
tar: 7.5.4
|
||||
clawdbot: workspace:moltbot@*
|
||||
|
||||
importers:
|
||||
|
||||
@ -315,7 +316,7 @@ importers:
|
||||
version: 10.5.0
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/imessage: {}
|
||||
@ -323,7 +324,7 @@ importers:
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/llm-task: {}
|
||||
@ -349,7 +350,7 @@ importers:
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/mattermost: {}
|
||||
@ -357,8 +358,8 @@ importers:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: '>=2026.1.24-3'
|
||||
version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@ -384,7 +385,7 @@ importers:
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
@ -393,12 +394,25 @@ importers:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
|
||||
extensions/ndr:
|
||||
dependencies:
|
||||
nostr-tools:
|
||||
specifier: ^2.20.0
|
||||
version: 2.20.0(typescript@5.9.3)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/nextcloud-talk: {}
|
||||
|
||||
extensions/nostr:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
nostr-tools:
|
||||
specifier: ^2.20.0
|
||||
@ -440,7 +454,7 @@ importers:
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
extensions/voice-call:
|
||||
@ -460,7 +474,7 @@ importers:
|
||||
extensions/zalo:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
undici:
|
||||
specifier: 7.19.0
|
||||
@ -472,7 +486,7 @@ importers:
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
specifier: workspace:moltbot@*
|
||||
version: link:../..
|
||||
|
||||
ui:
|
||||
@ -3208,11 +3222,6 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clawdbot@2026.1.24-3:
|
||||
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -9092,84 +9101,6 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.975.0
|
||||
'@buape/carbon': 0.14.0(hono@4.11.4)
|
||||
'@clack/prompts': 0.11.0
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
||||
'@homebridge/ciao': 1.3.4
|
||||
'@line/bot-sdk': 10.6.0
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@sinclair/typebox': 0.34.47
|
||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api': 7.13.0
|
||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
ajv: 8.17.1
|
||||
body-parser: 2.2.2
|
||||
chalk: 5.6.2
|
||||
chokidar: 5.0.0
|
||||
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
|
||||
cli-highlight: 2.1.11
|
||||
commander: 14.0.2
|
||||
croner: 9.1.0
|
||||
detect-libc: 2.1.2
|
||||
discord-api-types: 0.38.37
|
||||
dotenv: 17.2.3
|
||||
express: 5.2.1
|
||||
file-type: 21.3.0
|
||||
grammy: 1.39.3
|
||||
hono: 4.11.4
|
||||
jiti: 2.6.1
|
||||
json5: 2.2.3
|
||||
jszip: 3.10.1
|
||||
linkedom: 0.18.12
|
||||
long: 5.3.2
|
||||
markdown-it: 14.1.0
|
||||
node-edge-tts: 1.2.9
|
||||
osc-progress: 0.3.0
|
||||
pdfjs-dist: 5.4.530
|
||||
playwright-core: 1.58.0
|
||||
proper-lockfile: 4.1.2
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
tar: 7.5.4
|
||||
tslog: 4.10.2
|
||||
undici: 7.19.0
|
||||
ws: 8.19.0
|
||||
yaml: 2.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.88
|
||||
node-llama-cpp: 3.15.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- '@types/express'
|
||||
- audio-decode
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- canvas
|
||||
- debug
|
||||
- devtools-protocol
|
||||
- encoding
|
||||
- ffmpeg-static
|
||||
- jimp
|
||||
- link-preview-js
|
||||
- node-opus
|
||||
- opusscript
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
@ -37,6 +37,18 @@ compute_hash() {
|
||||
| awk '{print $1}'
|
||||
}
|
||||
|
||||
# In Docker builds (vendor/ and apps/ excluded), skip bundling if output exists
|
||||
for path in "${INPUT_PATHS[@]}"; do
|
||||
if [[ ! -e "$path" ]]; then
|
||||
if [[ -f "$OUTPUT_FILE" ]]; then
|
||||
echo "A2UI bundle exists; source files unavailable (docker build?); skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "Error: Required path $path not found and no pre-built bundle exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
current_hash="$(compute_hash)"
|
||||
if [[ -f "$HASH_FILE" ]]; then
|
||||
previous_hash="$(cat "$HASH_FILE")"
|
||||
|
||||
@ -1 +1 @@
|
||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||
53ab7a34ec406ea6b1a3170a79b3dde01b33a676bb8870215a9f230bf007bf94
|
||||
|
||||
@ -369,6 +369,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
ndr: {
|
||||
id: "ndr",
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
},
|
||||
};
|
||||
|
||||
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
||||
|
||||
@ -12,6 +12,7 @@ export const CHAT_CHANNEL_ORDER = [
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
"ndr",
|
||||
] as const;
|
||||
|
||||
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
|
||||
@ -98,6 +99,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
||||
blurb: "this is still a work in progress.",
|
||||
systemImage: "message.fill",
|
||||
},
|
||||
ndr: {
|
||||
id: "ndr",
|
||||
label: "NDR",
|
||||
selectionLabel: "NDR (Nostr Double Ratchet)",
|
||||
detailLabel: "NDR",
|
||||
docsPath: "/channels/ndr",
|
||||
docsLabel: "ndr",
|
||||
blurb: "forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
|
||||
selectionExtras: ["https://chat.iris.to"],
|
||||
},
|
||||
};
|
||||
|
||||
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user