refactor: consolidate reply/media helpers
This commit is contained in:
31
src/media/audio-tags.ts
Normal file
31
src/media/audio-tags.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Extract audio mode tag from text.
|
||||
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
|
||||
* Default is file (preserves backward compatibility).
|
||||
*/
|
||||
export function parseAudioTag(text?: string): {
|
||||
text: string;
|
||||
audioAsVoice: boolean;
|
||||
hadTag: boolean;
|
||||
} {
|
||||
if (!text) return { text: "", audioAsVoice: false, hadTag: false };
|
||||
let cleaned = text;
|
||||
let audioAsVoice = false; // default: audio file (backward compatible)
|
||||
let hadTag = false;
|
||||
|
||||
// [[audio_as_voice]] -> send as voice bubble (opt-in)
|
||||
const voiceMatch = cleaned.match(/\[\[audio_as_voice\]\]/i);
|
||||
if (voiceMatch) {
|
||||
cleaned = cleaned.replace(/\[\[audio_as_voice\]\]/gi, " ");
|
||||
audioAsVoice = true;
|
||||
hadTag = true;
|
||||
}
|
||||
|
||||
// Clean up whitespace
|
||||
cleaned = cleaned
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
|
||||
return { text: cleaned, audioAsVoice, hadTag };
|
||||
}
|
||||
125
src/media/fetch.ts
Normal file
125
src/media/fetch.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
|
||||
type FetchMediaResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
type FetchMediaOptions = {
|
||||
url: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
filePathHint?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
return value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(
|
||||
header?: string | null,
|
||||
): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
|
||||
if (starMatch?.[1]) {
|
||||
const cleaned = stripQuotes(starMatch[1].trim());
|
||||
const encoded = cleaned.split("''").slice(1).join("''") || cleaned;
|
||||
try {
|
||||
return path.basename(decodeURIComponent(encoded));
|
||||
} catch {
|
||||
return path.basename(encoded);
|
||||
}
|
||||
}
|
||||
const match = /filename\s*=\s*([^;]+)/i.exec(header);
|
||||
if (match?.[1]) return path.basename(stripQuotes(match[1].trim()));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readErrorBodySnippet(
|
||||
res: Response,
|
||||
maxChars = 200,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (!text) return undefined;
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) return undefined;
|
||||
if (collapsed.length <= maxChars) return collapsed;
|
||||
return `${collapsed.slice(0, maxChars)}…`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRemoteMedia(
|
||||
options: FetchMediaOptions,
|
||||
): Promise<FetchMediaResult> {
|
||||
const { url, fetchImpl, filePathHint } = options;
|
||||
const fetcher = fetchImpl ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetcher(url);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch media from ${url}: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
||||
const redirected =
|
||||
res.url && res.url !== url ? ` (redirected to ${res.url})` : "";
|
||||
let detail = `HTTP ${res.status}${statusText}`;
|
||||
if (!res.body) {
|
||||
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
||||
} else {
|
||||
const snippet = await readErrorBodySnippet(res);
|
||||
if (snippet) detail += `; body: ${snippet}`;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to fetch media from ${url}${redirected}: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
let fileNameFromUrl: string | undefined;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const base = path.basename(parsed.pathname);
|
||||
fileNameFromUrl = base || undefined;
|
||||
} catch {
|
||||
// ignore parse errors; leave undefined
|
||||
}
|
||||
|
||||
const headerFileName = parseContentDispositionFileName(
|
||||
res.headers.get("content-disposition"),
|
||||
);
|
||||
let fileName =
|
||||
headerFileName ||
|
||||
fileNameFromUrl ||
|
||||
(filePathHint ? path.basename(filePathHint) : undefined);
|
||||
|
||||
const filePathForMime =
|
||||
headerFileName && path.extname(headerFileName)
|
||||
? headerFileName
|
||||
: (filePathHint ?? url);
|
||||
const contentType = await detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: filePathForMime,
|
||||
});
|
||||
if (fileName && !path.extname(fileName) && contentType) {
|
||||
const ext = extensionForMime(contentType);
|
||||
if (ext) fileName = `${fileName}${ext}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType: contentType ?? undefined,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,17 @@ const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
|
||||
);
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
".aac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
]);
|
||||
|
||||
function normalizeHeaderMime(mime?: string | null): string | undefined {
|
||||
if (!mime) return undefined;
|
||||
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
|
||||
@@ -52,7 +63,7 @@ async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
function extFromPath(filePath?: string): string | undefined {
|
||||
export function getFileExtension(filePath?: string | null): string | undefined {
|
||||
if (!filePath) return undefined;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
@@ -66,6 +77,12 @@ function extFromPath(filePath?: string): string | undefined {
|
||||
return ext || undefined;
|
||||
}
|
||||
|
||||
export function isAudioFileName(fileName?: string | null): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
if (!ext) return false;
|
||||
return AUDIO_FILE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function detectMime(opts: {
|
||||
buffer?: Buffer;
|
||||
headerMime?: string | null;
|
||||
@@ -85,7 +102,7 @@ async function detectMimeImpl(opts: {
|
||||
headerMime?: string | null;
|
||||
filePath?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const ext = extFromPath(opts.filePath);
|
||||
const ext = getFileExtension(opts.filePath);
|
||||
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
||||
|
||||
const headerMime = normalizeHeaderMime(opts.headerMime);
|
||||
@@ -112,9 +129,7 @@ export function isGifMedia(opts: {
|
||||
fileName?: string | null;
|
||||
}): boolean {
|
||||
if (opts.contentType?.toLowerCase() === "image/gif") return true;
|
||||
const ext = opts.fileName
|
||||
? path.extname(opts.fileName).toLowerCase()
|
||||
: undefined;
|
||||
const ext = getFileExtension(opts.fileName);
|
||||
return ext === ".gif";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||
|
||||
import { parseFenceSpans } from "../markdown/fences.js";
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
|
||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||
@@ -32,10 +33,6 @@ function isInsideFence(
|
||||
return fenceSpans.some((span) => offset >= span.start && offset < span.end);
|
||||
}
|
||||
|
||||
// Regex to detect [[audio_as_voice]] tag
|
||||
const AUDIO_AS_VOICE_RE = /\[\[audio_as_voice\]\]/gi;
|
||||
const AUDIO_AS_VOICE_TEST_RE = /\[\[audio_as_voice\]\]/i;
|
||||
|
||||
export function splitMediaFromOutput(raw: string): {
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
@@ -124,13 +121,10 @@ export function splitMediaFromOutput(raw: string): {
|
||||
.trim();
|
||||
|
||||
// Detect and strip [[audio_as_voice]] tag
|
||||
const hasAudioAsVoice = AUDIO_AS_VOICE_TEST_RE.test(cleanedText);
|
||||
if (hasAudioAsVoice) {
|
||||
cleanedText = cleanedText
|
||||
.replace(AUDIO_AS_VOICE_RE, "")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
const audioTagResult = parseAudioTag(cleanedText);
|
||||
const hasAudioAsVoice = audioTagResult.audioAsVoice;
|
||||
if (audioTagResult.hadTag) {
|
||||
cleanedText = audioTagResult.text.replace(/\n{2,}/g, "\n").trim();
|
||||
}
|
||||
|
||||
if (media.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user