diff --git a/extensions/ndr/README.md b/extensions/ndr/README.md new file mode 100644 index 000000000..5ec6620b8 --- /dev/null +++ b/extensions/ndr/README.md @@ -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 `. + +### 3. Join someone else's invite + +```bash +ndr chat join +``` + +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 --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` diff --git a/extensions/ndr/clawdbot.plugin.json b/extensions/ndr/clawdbot.plugin.json new file mode 100644 index 000000000..ea2b141ee --- /dev/null +++ b/extensions/ndr/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "ndr", + "channels": [ + "ndr" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/ndr/index.ts b/extensions/ndr/index.ts new file mode 100644 index 000000000..1d883611a --- /dev/null +++ b/extensions/ndr/index.ts @@ -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; diff --git a/extensions/ndr/package.json b/extensions/ndr/package.json new file mode 100644 index 000000000..344b26f0b --- /dev/null +++ b/extensions/ndr/package.json @@ -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": "*" + } +} diff --git a/extensions/ndr/src/channel.ts b/extensions/ndr/src/channel.ts new file mode 100644 index 000000000..d791c0ab4 --- /dev/null +++ b/extensions/ndr/src/channel.ts @@ -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(); + +export const ndrPlugin: ChannelPlugin = { + 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: "", + }, + }, + + 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 { + return new Map(activeBuses); +} diff --git a/extensions/ndr/src/config-schema.ts b/extensions/ndr/src/config-schema.ts new file mode 100644 index 000000000..822c36c90 --- /dev/null +++ b/extensions/ndr/src/config-schema.ts @@ -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; + +/** 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", +]; diff --git a/extensions/ndr/src/ndr-bus.ts b/extensions/ndr/src/ndr-bus.ts new file mode 100644 index 000000000..5d22ae183 --- /dev/null +++ b/extensions/ndr/src/ndr-bus.ts @@ -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) => Promise; + onNewSession?: (chatId: string, theirPubkey: string) => Promise; + onError?: (error: Error, context: string) => void; + onConnect?: () => void; + onDisconnect?: () => void; +} + +export interface NdrBusHandle { + sendMessage: (chatId: string, text: string) => Promise; + react: (chatId: string, messageId: string, emoji: string) => Promise; + createInvite: () => Promise<{ inviteUrl: string; inviteId: string }>; + joinInvite: (inviteUrl: string) => Promise<{ chatId: string; theirPubkey: string }>; + listChats: () => Promise>; + 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 { + 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 = {}; + 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> { + 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 }; +} diff --git a/extensions/ndr/src/onboarding.ts b/extensions/ndr/src/onboarding.ts new file mode 100644 index 000000000..cb29b0cb0 --- /dev/null +++ b/extensions/ndr/src/onboarding.ts @@ -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; + text: (opts: { + message: string; + placeholder?: string; + initialValue?: string; + validate?: (value: string | undefined) => string | undefined; + }) => Promise; + confirm: (opts: { message: string; initialValue?: boolean }) => Promise; + }; + }) => 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":"","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 }, + }, + }), +}; diff --git a/extensions/ndr/src/runtime.ts b/extensions/ndr/src/runtime.ts new file mode 100644 index 000000000..4c1cdafce --- /dev/null +++ b/extensions/ndr/src/runtime.ts @@ -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; +} diff --git a/extensions/ndr/src/types.ts b/extensions/ndr/src/types.ts new file mode 100644 index 000000000..fb4ef9f94 --- /dev/null +++ b/extensions/ndr/src/types.ts @@ -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; + 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; + 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}`); +} diff --git a/package.json b/package.json index 6a88df982..1ccdac0b5 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1c55dd8d..d440055e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh index a1cf7ff2b..3b33c8618 100755 --- a/scripts/bundle-a2ui.sh +++ b/scripts/bundle-a2ui.sh @@ -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")" diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 19a232f5c..597fec345 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a +53ab7a34ec406ea6b1a3170a79b3dde01b33a676bb8870215a9f230bf007bf94 diff --git a/src/channels/dock.ts b/src/channels/dock.ts index a48059a6c..b91c1aa08 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -369,6 +369,13 @@ const DOCKS: Record = { }, }, }, + ndr: { + id: "ndr", + capabilities: { + chatTypes: ["direct"], + }, + outbound: { textChunkLimit: 4000 }, + }, }; function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 10ddeed4a..1681ba742 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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 = { 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 = {