From 37a724e04150030cea408d370553b85b58427934 Mon Sep 17 00:00:00 2001 From: Iris Deploy Date: Tue, 27 Jan 2026 12:51:44 +0100 Subject: [PATCH] ndr: add inbound media support via htree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add media.ts helper module to detect and download nhash URLs - Auto-download nhash1.../filename.ext URLs in incoming messages - Pass MediaPath/MediaType/MediaUrl to inbound context - Enable media capability flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- extensions/ndr/src/channel.ts | 10 ++- extensions/ndr/src/media.ts | 134 ++++++++++++++++++++++++++++++++++ extensions/ndr/src/ndr-bus.ts | 30 +++++++- 3 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 extensions/ndr/src/media.ts diff --git a/extensions/ndr/src/channel.ts b/extensions/ndr/src/channel.ts index d791c0ab4..a2925ee33 100644 --- a/extensions/ndr/src/channel.ts +++ b/extensions/ndr/src/channel.ts @@ -34,7 +34,7 @@ export const ndrPlugin: ChannelPlugin = { }, capabilities: { chatTypes: ["direct"], // DMs only - media: false, // No media support yet + media: true, // Supports nhash media via htree }, reload: { configPrefixes: ["channels.ndr"] }, configSchema: buildChannelConfigSchema(NdrConfigSchema), @@ -192,8 +192,8 @@ export const ndrPlugin: ChannelPlugin = { 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)}...`); + 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) { @@ -268,6 +268,10 @@ export const ndrPlugin: ChannelPlugin = { 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 diff --git a/extensions/ndr/src/media.ts b/extensions/ndr/src/media.ts new file mode 100644 index 000000000..6410029b9 --- /dev/null +++ b/extensions/ndr/src/media.ts @@ -0,0 +1,134 @@ +import { execSync } from "child_process"; +import { mkdirSync, existsSync } from "fs"; +import { join, extname } from "path"; +import { tmpdir } from "os"; + +/** + * Regex to detect nhash URLs in message content. + * Matches: nhash1/filename.ext or just nhash1 + */ +const NHASH_REGEX = /\b(nhash1[a-z0-9]+(?:\/[^\s]+)?)\b/i; + +/** + * Common image/video/audio extensions for MIME type detection + */ +const MIME_MAP: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".pdf": "application/pdf", +}; + +export interface ParsedNhashUrl { + full: string; + cid: string; + filename: string | null; +} + +export interface DownloadedMedia { + path: string; + mimeType: string | null; + url: string; +} + +/** + * Parse an nhash URL from message content. + * Returns the full match, CID, and optional filename. + */ +export function parseNhashUrl(content: string): ParsedNhashUrl | null { + const match = content.match(NHASH_REGEX); + if (!match) return null; + + const full = match[1]; + const parts = full.split("/"); + const cid = parts[0]; + const filename = parts.length > 1 ? parts.slice(1).join("/") : null; + + return { full, cid, filename }; +} + +/** + * Detect MIME type from filename extension + */ +function mimeFromFilename(filename: string | null): string | null { + if (!filename) return null; + const ext = extname(filename).toLowerCase(); + return MIME_MAP[ext] ?? null; +} + +/** + * Download media from an nhash URL using htree CLI. + * Returns the local file path and detected MIME type. + */ +export async function downloadNhashMedia( + nhash: ParsedNhashUrl, + opts?: { tempDir?: string; timeout?: number } +): Promise { + const tempDir = opts?.tempDir ?? join(tmpdir(), "ndr-media"); + const timeout = opts?.timeout ?? 30000; + + // Ensure temp directory exists + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + + // Determine output filename + const outputFilename = nhash.filename ?? nhash.cid; + const outputPath = join(tempDir, outputFilename); + + try { + // Run htree get to download the file + execSync(`htree get "${nhash.cid}" -o "${outputPath}"`, { + encoding: "utf-8", + timeout, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Verify file exists + if (!existsSync(outputPath)) { + return null; + } + + return { + path: outputPath, + mimeType: mimeFromFilename(nhash.filename), + url: nhash.full, + }; + } catch { + // htree not available or download failed + return null; + } +} + +/** + * Extract and download media from message content. + * Returns the downloaded media info and the text with the nhash URL removed. + */ +export async function extractAndDownloadMedia( + content: string, + opts?: { tempDir?: string; timeout?: number } +): Promise<{ + media: DownloadedMedia | null; + textContent: string; +}> { + const parsed = parseNhashUrl(content); + if (!parsed) { + return { media: null, textContent: content }; + } + + const media = await downloadNhashMedia(parsed, opts); + + // Remove the nhash URL from the text content + const textContent = content.replace(NHASH_REGEX, "").trim(); + + return { media, textContent }; +} diff --git a/extensions/ndr/src/ndr-bus.ts b/extensions/ndr/src/ndr-bus.ts index 5d22ae183..3902af82f 100644 --- a/extensions/ndr/src/ndr-bus.ts +++ b/extensions/ndr/src/ndr-bus.ts @@ -1,4 +1,11 @@ import { spawn, type ChildProcess } from "child_process"; +import { extractAndDownloadMedia, type DownloadedMedia } from "./media.js"; + +export interface NdrMessageMedia { + path: string; + mimeType: string | null; + url: string; +} export interface NdrBusOptions { accountId: string; @@ -6,7 +13,7 @@ export interface NdrBusOptions { relays: string[]; ndrPath: string; dataDir: string | null; - onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise) => Promise; + onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise, media?: NdrMessageMedia) => Promise; onNewSession?: (chatId: string, theirPubkey: string) => Promise; onError?: (error: Error, context: string) => void; onConnect?: () => void; @@ -94,15 +101,30 @@ export async function startNdrBus(options: NdrBusOptions): Promise const chatId = json.chat_id; const messageId = json.message_id ?? ""; const senderPubkey = json.from_pubkey; - const content = json.content; + const rawContent = 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"); + // Extract and download any nhash media URLs + extractAndDownloadMedia(rawContent).then(({ media, textContent }) => { + const messageMedia = media ? { + path: media.path, + mimeType: media.mimeType, + url: media.url, + } : undefined; + // Use textContent (with nhash removed) if media was found, otherwise use raw content + const content = media ? textContent : rawContent; + onMessage(chatId, messageId, senderPubkey, content, reply, messageMedia).catch((err) => { + onError?.(err, "message_handler"); + }); + }).catch((err) => { + // If media extraction fails, still process the message with raw content + onMessage(chatId, messageId, senderPubkey, rawContent, reply).catch((handlerErr) => { + onError?.(handlerErr, "message_handler"); + }); }); }