Files
openclaw/extensions/line/src/outbound-media.ts
Masato Hoshino 9449e54f4f feat(line): add outbound media support for image, video, and audio
pnpm install --frozen-lockfile
pnpm build
pnpm check
pnpm vitest run extensions/line/src/channel.sendPayload.test.ts extensions/line/src/send.test.ts extensions/line/src/outbound-media.test.ts

Co-authored-by: masatohoshino <246810661+masatohoshino@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-28 20:51:16 -05:00

111 lines
3.0 KiB
TypeScript

export type LineOutboundMediaKind = "image" | "video" | "audio";
export type LineOutboundMediaResolved = {
mediaUrl: string;
mediaKind: LineOutboundMediaKind;
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
type ResolveLineOutboundMediaOpts = {
mediaKind?: LineOutboundMediaKind;
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
export function validateLineMediaUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`LINE outbound media URL must be a valid URL: ${url}`);
}
if (parsed.protocol !== "https:") {
throw new Error(`LINE outbound media URL must use HTTPS: ${url}`);
}
if (url.length > 2000) {
throw new Error(`LINE outbound media URL must be 2000 chars or less (got ${url.length})`);
}
}
export function detectLineMediaKind(mimeType: string): LineOutboundMediaKind {
const normalized = mimeType.toLowerCase();
if (normalized.startsWith("image/")) {
return "image";
}
if (normalized.startsWith("video/")) {
return "video";
}
if (normalized.startsWith("audio/")) {
return "audio";
}
return "image";
}
function isHttpsUrl(url: string): boolean {
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
}
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (/\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(pathname)) {
return "image";
}
if (/\.(mp4|mov|m4v|webm)$/i.test(pathname)) {
return "video";
}
if (/\.(mp3|m4a|aac|wav|ogg|oga)$/i.test(pathname)) {
return "audio";
}
} catch {
return undefined;
}
return undefined;
}
export async function resolveLineOutboundMedia(
mediaUrl: string,
opts: ResolveLineOutboundMediaOpts = {},
): Promise<LineOutboundMediaResolved> {
const trimmedUrl = mediaUrl.trim();
if (isHttpsUrl(trimmedUrl)) {
validateLineMediaUrl(trimmedUrl);
const previewImageUrl = opts.previewImageUrl?.trim();
if (previewImageUrl) {
validateLineMediaUrl(previewImageUrl);
}
const mediaKind =
opts.mediaKind ??
(typeof opts.durationMs === "number" ? "audio" : undefined) ??
(opts.trackingId?.trim() ? "video" : undefined) ??
detectLineMediaKindFromUrl(trimmedUrl) ??
"image";
return {
mediaUrl: trimmedUrl,
mediaKind,
...(previewImageUrl ? { previewImageUrl } : {}),
...(typeof opts.durationMs === "number" ? { durationMs: opts.durationMs } : {}),
...(opts.trackingId ? { trackingId: opts.trackingId } : {}),
};
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== "https:") {
throw new Error(`LINE outbound media URL must use HTTPS: ${trimmedUrl}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("LINE outbound")) {
throw e;
}
}
throw new Error("LINE outbound media currently requires a public HTTPS URL");
}