ndr manages its own identity in its config.json - no need for clawdbot to know or store the private key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
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: true, // Supports nhash media via htree
|
|
},
|
|
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}`);
|
|
}
|
|
// Resolve npub to chat_id if needed
|
|
const chatId = await resolveNpubToChatId(bus, to);
|
|
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(chatId, message);
|
|
return { channel: "ndr", to: chatId };
|
|
},
|
|
sendMedia: async ({ to, text, mediaUrl, 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}`);
|
|
}
|
|
// Resolve npub to chat_id if needed
|
|
const chatId = await resolveNpubToChatId(bus, to);
|
|
const caption = text ? `${text}\n` : "";
|
|
|
|
// mediaUrl could be a local file path or a remote URL
|
|
let mediaLink = mediaUrl ?? "[media attachment]";
|
|
if (mediaUrl && !mediaUrl.startsWith("http")) {
|
|
// Local file path - upload via htree
|
|
try {
|
|
const { execSync } = await import("child_process");
|
|
// Properly escape the file path for shell
|
|
const escapedPath = mediaUrl.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 original URL
|
|
mediaLink = mediaUrl ?? "[media: upload failed]";
|
|
}
|
|
}
|
|
|
|
const message = `${caption}${mediaLink}`;
|
|
await bus.sendMessage(chatId, message);
|
|
return { channel: "ndr", to: chatId };
|
|
},
|
|
},
|
|
|
|
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,
|
|
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,
|
|
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,
|
|
});
|
|
ctx.log?.info(`[${account.accountId}] starting NDR provider`);
|
|
|
|
const runtime = getNdrRuntime();
|
|
|
|
const bus = await startNdrBus({
|
|
accountId: account.accountId,
|
|
relays: account.relays,
|
|
ndrPath: account.ndrPath,
|
|
dataDir: account.dataDir,
|
|
onMessage: async (chatId, messageId, senderPubkey, text, replyFn, media) => {
|
|
ctx.log?.debug(`[${account.accountId}] Message from ${senderPubkey} in chat ${chatId}: ${text.slice(0, 50)}...${media ? ` [media: ${media.path}]` : ""}`);
|
|
|
|
// 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,
|
|
// Media fields (if nhash URL was downloaded)
|
|
MediaPath: media?.path,
|
|
MediaType: media?.mimeType ?? undefined,
|
|
MediaUrl: media?.url,
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Resolve npub to chat_id by looking up the chat list.
|
|
* If the target is already a chat_id (8-char hex), returns it unchanged.
|
|
* If it's an npub, finds the chat with matching their_pubkey.
|
|
*/
|
|
async function resolveNpubToChatId(bus: NdrBusHandle, target: string): Promise<string> {
|
|
const trimmed = target.trim();
|
|
|
|
// If it's already a chat_id (8-char hex), return as-is
|
|
if (/^[0-9a-fA-F]{8}$/.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
// If it's an npub, resolve to chat_id
|
|
if (trimmed.startsWith("npub1")) {
|
|
// Convert npub to hex pubkey
|
|
const hexPubkey = npubToHex(trimmed);
|
|
if (!hexPubkey) {
|
|
throw new Error(`Invalid npub: ${trimmed}`);
|
|
}
|
|
|
|
// Look up the chat with this pubkey
|
|
const chats = await bus.listChats();
|
|
const chat = chats.find((c) => c.their_pubkey === hexPubkey);
|
|
if (!chat) {
|
|
const availableChats = chats.length > 0
|
|
? ` Available chats: ${chats.map((c) => c.id).join(", ")}`
|
|
: " No active chats found.";
|
|
throw new Error(`No chat found with pubkey ${trimmed.slice(0, 20)}...${availableChats}`);
|
|
}
|
|
return chat.id;
|
|
}
|
|
|
|
// Unknown format, try as-is (let ndr CLI handle it)
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* Convert bech32 npub to hex pubkey
|
|
*/
|
|
function npubToHex(npub: string): string | null {
|
|
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
const CHARSET_REV: Record<string, number> = {};
|
|
for (let i = 0; i < CHARSET.length; i++) {
|
|
CHARSET_REV[CHARSET[i]] = i;
|
|
}
|
|
|
|
const bech = npub.toLowerCase();
|
|
const pos = bech.lastIndexOf("1");
|
|
if (pos < 1 || pos + 7 > bech.length) return null;
|
|
|
|
const hrp = bech.slice(0, pos);
|
|
if (hrp !== "npub") return null;
|
|
|
|
const data: number[] = [];
|
|
for (const c of bech.slice(pos + 1)) {
|
|
if (!(c in CHARSET_REV)) return null;
|
|
data.push(CHARSET_REV[c]);
|
|
}
|
|
|
|
// Remove checksum (last 6 chars)
|
|
const dataWithoutChecksum = data.slice(0, -6);
|
|
|
|
// Convert from 5-bit to 8-bit
|
|
let acc = 0;
|
|
let bits = 0;
|
|
const result: number[] = [];
|
|
for (const value of dataWithoutChecksum) {
|
|
acc = (acc << 5) | value;
|
|
bits += 5;
|
|
while (bits >= 8) {
|
|
bits -= 8;
|
|
result.push((acc >> bits) & 0xff);
|
|
}
|
|
}
|
|
|
|
return result.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
}
|