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:
parent
e954a55f1f
commit
37a724e041
@ -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
134
extensions/ndr/src/media.ts
Normal 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 };
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user