mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
0adc2cf606
* refactor(media): consolidate parallel media-kind unions onto canonical MediaKind One canonical MediaKind union (media-core constants) replaces ~40 duplicate/parallel kind declarations across core and channel plugins; channel-specific narrower contracts derive via Extract/Exclude. Also fixes a review-caught fallback bug where a stored "unknown" reply-chain kind preempted MIME inference and relabeled images as documents. * refactor(ui): derive attachment kinds from MediaKind * fix(telegram): drop type-dead unknown guard in reply-context kind fallback * style(telegram): format media kind fallback
171 lines
5.0 KiB
TypeScript
171 lines
5.0 KiB
TypeScript
// Line plugin module implements outbound media behavior.
|
|
import type { messagingApi } from "@line/bot-sdk";
|
|
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import type { LineChannelData, LineOutboundMediaKind } from "./types.js";
|
|
|
|
type LineOutboundMediaResolved = {
|
|
mediaUrl: string;
|
|
mediaKind: LineOutboundMediaKind;
|
|
previewImageUrl?: string;
|
|
durationMs?: number;
|
|
trackingId?: string;
|
|
};
|
|
|
|
type ResolveLineOutboundMediaOpts = {
|
|
mediaKind?: LineOutboundMediaKind;
|
|
previewImageUrl?: string;
|
|
durationMs?: number;
|
|
trackingId?: string;
|
|
};
|
|
|
|
const LINE_OUTBOUND_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
|
allowPrivateNetwork: false,
|
|
};
|
|
|
|
export async function validateLineMediaUrl(url: string): Promise<void> {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error("LINE outbound media URL must be a valid URL");
|
|
}
|
|
if (parsed.protocol !== "https:") {
|
|
throw new Error("LINE outbound media URL must use HTTPS");
|
|
}
|
|
if (url.length > 2000) {
|
|
throw new Error(`LINE outbound media URL must be 2000 chars or less (got ${url.length})`);
|
|
}
|
|
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
|
policy: LINE_OUTBOUND_MEDIA_SSRF_POLICY,
|
|
});
|
|
}
|
|
|
|
function isHttpsUrl(url: string): boolean {
|
|
try {
|
|
return new URL(url).protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
|
|
try {
|
|
const pathname = normalizeLowercaseStringOrEmpty(new URL(url).pathname);
|
|
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)) {
|
|
await validateLineMediaUrl(trimmedUrl);
|
|
const previewImageUrl = opts.previewImageUrl?.trim();
|
|
if (previewImageUrl) {
|
|
await 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 } : {}),
|
|
};
|
|
}
|
|
|
|
let parsed: URL | undefined;
|
|
try {
|
|
parsed = new URL(trimmedUrl);
|
|
} catch {
|
|
// Local paths reach the generic public-HTTPS error below.
|
|
}
|
|
if (parsed) {
|
|
throw new Error("LINE outbound media URL must use HTTPS");
|
|
}
|
|
throw new Error("LINE outbound media currently requires a public HTTPS URL");
|
|
}
|
|
|
|
function isLineUserTarget(target: string): boolean {
|
|
const normalized = target
|
|
.trim()
|
|
.replace(/^line:(group|room|user):/i, "")
|
|
.replace(/^line:/i, "");
|
|
return /^U/i.test(normalized);
|
|
}
|
|
|
|
export function hasLineSpecificMediaOptions(lineData: LineChannelData): boolean {
|
|
return (
|
|
lineData.mediaKind !== undefined ||
|
|
Boolean(lineData.previewImageUrl?.trim()) ||
|
|
typeof lineData.durationMs === "number" ||
|
|
Boolean(lineData.trackingId?.trim())
|
|
);
|
|
}
|
|
|
|
function buildLineMediaMessageObject(
|
|
resolved: LineOutboundMediaResolved,
|
|
opts?: { allowTrackingId?: boolean },
|
|
): messagingApi.Message {
|
|
switch (resolved.mediaKind) {
|
|
case "video": {
|
|
const previewImageUrl = resolved.previewImageUrl?.trim();
|
|
if (!previewImageUrl) {
|
|
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
|
|
}
|
|
return {
|
|
type: "video",
|
|
originalContentUrl: resolved.mediaUrl,
|
|
previewImageUrl,
|
|
...(opts?.allowTrackingId && resolved.trackingId
|
|
? { trackingId: resolved.trackingId }
|
|
: {}),
|
|
};
|
|
}
|
|
case "audio":
|
|
return {
|
|
type: "audio",
|
|
originalContentUrl: resolved.mediaUrl,
|
|
duration: resolved.durationMs ?? 60000,
|
|
};
|
|
default:
|
|
return {
|
|
type: "image",
|
|
originalContentUrl: resolved.mediaUrl,
|
|
previewImageUrl: resolved.previewImageUrl ?? resolved.mediaUrl,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Resolve and build through one leaf so reply-token and inline push delivery
|
|
// cannot drift on media kind, preview, duration, or tracking-id policy.
|
|
export async function buildLineMediaMessage(
|
|
mediaUrl: string,
|
|
opts: ResolveLineOutboundMediaOpts,
|
|
target: string,
|
|
): Promise<messagingApi.Message> {
|
|
const resolved = await resolveLineOutboundMedia(mediaUrl, opts);
|
|
return buildLineMediaMessageObject(resolved, {
|
|
allowTrackingId: isLineUserTarget(target),
|
|
});
|
|
}
|