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: {
|
capabilities: {
|
||||||
chatTypes: ["direct"], // DMs only
|
chatTypes: ["direct"], // DMs only
|
||||||
media: false, // No media support yet
|
media: true, // Supports nhash media via htree
|
||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.ndr"] },
|
reload: { configPrefixes: ["channels.ndr"] },
|
||||||
configSchema: buildChannelConfigSchema(NdrConfigSchema),
|
configSchema: buildChannelConfigSchema(NdrConfigSchema),
|
||||||
@ -192,8 +192,8 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
|||||||
relays: account.relays,
|
relays: account.relays,
|
||||||
ndrPath: account.ndrPath,
|
ndrPath: account.ndrPath,
|
||||||
dataDir: account.dataDir,
|
dataDir: account.dataDir,
|
||||||
onMessage: async (chatId, messageId, senderPubkey, text, replyFn) => {
|
onMessage: async (chatId, messageId, senderPubkey, text, replyFn, media) => {
|
||||||
ctx.log?.debug(`[${account.accountId}] Message from ${senderPubkey} in chat ${chatId}: ${text.slice(0, 50)}...`);
|
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)
|
// React with "eyes" emoji to indicate we're processing (like WhatsApp "typing" indicator)
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
@ -268,6 +268,10 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
|||||||
CommandAuthorized: true, // Owner is always authorized
|
CommandAuthorized: true, // Owner is always authorized
|
||||||
OriginatingChannel: "ndr" as const,
|
OriginatingChannel: "ndr" as const,
|
||||||
OriginatingTo: ndrTo,
|
OriginatingTo: ndrTo,
|
||||||
|
// Media fields (if nhash URL was downloaded)
|
||||||
|
MediaPath: media?.path,
|
||||||
|
MediaType: media?.mimeType ?? undefined,
|
||||||
|
MediaUrl: media?.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Record the session
|
// 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 { 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 {
|
export interface NdrBusOptions {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -6,7 +13,7 @@ export interface NdrBusOptions {
|
|||||||
relays: string[];
|
relays: string[];
|
||||||
ndrPath: string;
|
ndrPath: string;
|
||||||
dataDir: string | null;
|
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>;
|
onNewSession?: (chatId: string, theirPubkey: string) => Promise<void>;
|
||||||
onError?: (error: Error, context: string) => void;
|
onError?: (error: Error, context: string) => void;
|
||||||
onConnect?: () => void;
|
onConnect?: () => void;
|
||||||
@ -94,15 +101,30 @@ export async function startNdrBus(options: NdrBusOptions): Promise<NdrBusHandle>
|
|||||||
const chatId = json.chat_id;
|
const chatId = json.chat_id;
|
||||||
const messageId = json.message_id ?? "";
|
const messageId = json.message_id ?? "";
|
||||||
const senderPubkey = json.from_pubkey;
|
const senderPubkey = json.from_pubkey;
|
||||||
const content = json.content;
|
const rawContent = json.content;
|
||||||
|
|
||||||
// Create reply function
|
// Create reply function
|
||||||
const reply = async (text: string) => {
|
const reply = async (text: string) => {
|
||||||
await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMessage(chatId, messageId, senderPubkey, content, reply).catch((err) => {
|
// Extract and download any nhash media URLs
|
||||||
onError?.(err, "message_handler");
|
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