ndr: add inbound media support via htree

- 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 <noreply@anthropic.com>
This commit is contained in:
Iris Deploy 2026-01-27 12:51:44 +01:00
parent e954a55f1f
commit 37a724e041
3 changed files with 167 additions and 7 deletions

View File

@ -34,7 +34,7 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
},
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<ResolvedNdrAccount> = {
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<ResolvedNdrAccount> = {
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

134
extensions/ndr/src/media.ts Normal file
View File

@ -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<base32>/filename.ext or just nhash1<base32>
*/
const NHASH_REGEX = /\b(nhash1[a-z0-9]+(?:\/[^\s]+)?)\b/i;
/**
* Common image/video/audio extensions for MIME type detection
*/
const MIME_MAP: Record<string, string> = {
".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<DownloadedMedia | null> {
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 };
}

View File

@ -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<void>) => Promise<void>;
onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise<void>, media?: NdrMessageMedia) => Promise<void>;
onNewSession?: (chatId: string, theirPubkey: string) => Promise<void>;
onError?: (error: Error, context: string) => void;
onConnect?: () => void;
@ -94,15 +101,30 @@ export async function startNdrBus(options: NdrBusOptions): Promise<NdrBusHandle>
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");
});
});
}