diff --git a/CHANGELOG.md b/CHANGELOG.md index c798c658527..7fdb585a846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +- LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino. + ### Fixes - macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape. diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index b525e4468fd..c6b7d972235 100644 --- a/extensions/line/src/channel.sendPayload.test.ts +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -258,12 +258,16 @@ describe("linePlugin outbound.sendPayload", () => { cfg, }); - expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", { - verbose: false, - mediaUrl: "https://example.com/img.jpg", - accountId: "default", - cfg, - }); + expect(mocks.sendMessageLine).toHaveBeenCalledWith( + "line:user:3", + "", + expect.objectContaining({ + verbose: false, + mediaUrl: "https://example.com/img.jpg", + accountId: "default", + cfg, + }), + ); expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith( "line:user:3", "Hello", @@ -275,6 +279,63 @@ describe("linePlugin outbound.sendPayload", () => { expect(mediaOrder).toBeLessThan(quickReplyOrder); }); + it("keeps generic media payloads on the image-only send path", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + await linePlugin.outbound!.sendPayload!({ + to: "line:user:4", + text: "", + payload: { + mediaUrl: "https://example.com/video.mp4", + }, + accountId: "default", + cfg, + }); + + expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:4", "", { + verbose: false, + mediaUrl: "https://example.com/video.mp4", + accountId: "default", + cfg, + }); + }); + + it("uses LINE-specific media options for rich media payloads", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + await linePlugin.outbound!.sendPayload!({ + to: "line:user:5", + text: "", + payload: { + mediaUrl: "https://example.com/video.mp4", + channelData: { + line: { + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-123", + }, + }, + }, + accountId: "default", + cfg, + }); + + expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:5", "", { + verbose: false, + mediaUrl: "https://example.com/video.mp4", + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + durationMs: undefined, + trackingId: "track-123", + accountId: "default", + cfg, + }); + }); + it("uses configured text chunk limit for payloads", async () => { const { runtime, mocks } = createRuntime(); setLineRuntime(runtime); @@ -305,6 +366,114 @@ describe("linePlugin outbound.sendPayload", () => { }); expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123); }); + + it("omits trackingId for non-user quick-reply inline video media", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "", + mediaUrl: "https://example.com/video.mp4", + channelData: { + line: { + quickReplies: ["One"], + mediaKind: "video" as const, + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-group", + }, + }, + }; + + await linePlugin.outbound!.sendPayload!({ + to: "line:group:C123", + text: payload.text, + payload, + accountId: "default", + cfg, + }); + + expect(mocks.pushMessagesLine).toHaveBeenCalledWith( + "line:group:C123", + [ + { + type: "video", + originalContentUrl: "https://example.com/video.mp4", + previewImageUrl: "https://example.com/preview.jpg", + quickReply: { items: ["One"] }, + }, + ], + { verbose: false, accountId: "default", cfg }, + ); + }); + + it("keeps trackingId for user quick-reply inline video media", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "", + mediaUrl: "https://example.com/video.mp4", + channelData: { + line: { + quickReplies: ["One"], + mediaKind: "video" as const, + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-user", + }, + }, + }; + + await linePlugin.outbound!.sendPayload!({ + to: "line:user:U123", + text: payload.text, + payload, + accountId: "default", + cfg, + }); + + expect(mocks.pushMessagesLine).toHaveBeenCalledWith( + "line:user:U123", + [ + { + type: "video", + originalContentUrl: "https://example.com/video.mp4", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-user", + quickReply: { items: ["One"] }, + }, + ], + { verbose: false, accountId: "default", cfg }, + ); + }); + + it("rejects quick-reply inline video media without previewImageUrl", async () => { + const { runtime } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "", + mediaUrl: "https://example.com/video.mp4", + channelData: { + line: { + quickReplies: ["One"], + mediaKind: "video" as const, + }, + }, + }; + + await expect( + linePlugin.outbound!.sendPayload!({ + to: "line:user:U123", + text: payload.text, + payload, + accountId: "default", + cfg, + }), + ).rejects.toThrow(/require previewimageurl/i); + }); }); describe("linePlugin config.formatAllowFrom", () => { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 8a1817f258c..2241669c997 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -49,9 +49,6 @@ export const linePlugin: ChannelPlugin = createChatChannelP if (!trimmed) { return false; } - // LINE user IDs are typically U followed by 32 hex characters - // Group IDs are C followed by 32 hex characters - // Room IDs are R followed by 32 hex characters return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed); }, hint: "", @@ -115,7 +112,6 @@ export const linePlugin: ChannelPlugin = createChatChannelP text: { idLabel: "lineUserId", message: "OpenClaw: your access has been approved.", - // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i), notify: async ({ cfg, id, message }) => { const line = getLineRuntime().channel.line; diff --git a/extensions/line/src/outbound-media.test.ts b/extensions/line/src/outbound-media.test.ts new file mode 100644 index 00000000000..9aad9f0776f --- /dev/null +++ b/extensions/line/src/outbound-media.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { detectLineMediaKind, resolveLineOutboundMedia, validateLineMediaUrl } from "./outbound-media.js"; + +describe("validateLineMediaUrl", () => { + it("accepts HTTPS URL", () => { + expect(() => validateLineMediaUrl("https://example.com/image.jpg")).not.toThrow(); + }); + + it("accepts uppercase HTTPS scheme", () => { + expect(() => validateLineMediaUrl("HTTPS://EXAMPLE.COM/img.jpg")).not.toThrow(); + }); + + it("rejects HTTP URL", () => { + expect(() => validateLineMediaUrl("http://example.com/image.jpg")).toThrow(/must use HTTPS/i); + }); + + it("rejects URL longer than 2000 chars", () => { + const longUrl = `https://example.com/${"a".repeat(1981)}`; + expect(longUrl.length).toBeGreaterThan(2000); + expect(() => validateLineMediaUrl(longUrl)).toThrow(/2000 chars or less/i); + }); +}); + +describe("detectLineMediaKind", () => { + it("maps image MIME to image", () => { + expect(detectLineMediaKind("image/jpeg")).toBe("image"); + }); + + it("maps uppercase image MIME to image", () => { + expect(detectLineMediaKind("IMAGE/JPEG")).toBe("image"); + }); + + it("maps video MIME to video", () => { + expect(detectLineMediaKind("video/mp4")).toBe("video"); + }); + + it("maps audio MIME to audio", () => { + expect(detectLineMediaKind("audio/mpeg")).toBe("audio"); + }); + + it("falls back unknown MIME to image", () => { + expect(detectLineMediaKind("application/octet-stream")).toBe("image"); + }); +}); + +describe("resolveLineOutboundMedia", () => { + it("respects explicit media kind without remote MIME probing", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/download?id=123", { mediaKind: "video" }), + ).resolves.toEqual({ + mediaUrl: "https://example.com/download?id=123", + mediaKind: "video", + }); + }); + + it("preserves explicit video kind when a preview URL is provided", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/download?id=123", { + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + }), + ).resolves.toEqual({ + mediaUrl: "https://example.com/download?id=123", + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + }); + }); + + it("infers audio kind from explicit duration metadata when mediaKind is omitted", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/download?id=audio", { + durationMs: 60000, + }), + ).resolves.toEqual({ + mediaUrl: "https://example.com/download?id=audio", + mediaKind: "audio", + durationMs: 60000, + }); + }); + + it("does not infer video from previewImageUrl alone", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/image.jpg", { + previewImageUrl: "https://example.com/preview.jpg", + }), + ).resolves.toEqual({ + mediaUrl: "https://example.com/image.jpg", + mediaKind: "image", + previewImageUrl: "https://example.com/preview.jpg", + }); + }); + + it("infers media kinds from known HTTPS file extensions", async () => { + await expect(resolveLineOutboundMedia("https://example.com/audio.mp3")).resolves.toEqual({ + mediaUrl: "https://example.com/audio.mp3", + mediaKind: "audio", + }); + await expect(resolveLineOutboundMedia("https://example.com/video.mp4")).resolves.toEqual({ + mediaUrl: "https://example.com/video.mp4", + mediaKind: "video", + }); + await expect(resolveLineOutboundMedia("https://example.com/image.jpg")).resolves.toEqual({ + mediaUrl: "https://example.com/image.jpg", + mediaKind: "image", + }); + }); + + it("validates previewImageUrl when provided", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/video.mp4", { + mediaKind: "video", + previewImageUrl: "http://example.com/preview.jpg", + }), + ).rejects.toThrow(/must use HTTPS/i); + }); + + it("falls back to image when no explicit LINE media options or known extension are present", async () => { + await expect( + resolveLineOutboundMedia("https://example.com/download?id=audio"), + ).resolves.toEqual({ + mediaUrl: "https://example.com/download?id=audio", + mediaKind: "image", + }); + }); + + it("rejects local paths because LINE outbound media requires public HTTPS URLs", async () => { + await expect(resolveLineOutboundMedia("./assets/image.jpg")).rejects.toThrow( + /requires a public https url/i, + ); + }); + + it("rejects non-HTTPS URL explicitly", async () => { + await expect(resolveLineOutboundMedia("http://example.com/image.jpg")).rejects.toThrow( + /must use HTTPS/i, + ); + }); +}); diff --git a/extensions/line/src/outbound-media.ts b/extensions/line/src/outbound-media.ts new file mode 100644 index 00000000000..88eab9317da --- /dev/null +++ b/extensions/line/src/outbound-media.ts @@ -0,0 +1,110 @@ +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 { + 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"); +} diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index 137f6316ecb..6054bf2eca5 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -9,15 +9,74 @@ import { type LineChannelData, type ResolvedLineAccount, } from "../api.js"; +import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js"; import { getLineRuntime } from "./runtime.js"; +type LineChannelDataWithMedia = LineChannelData & { + mediaKind?: "image" | "video" | "audio"; + previewImageUrl?: string; + durationMs?: number; + trackingId?: string; +}; + +function isLineUserTarget(target: string): boolean { + const normalized = target + .trim() + .replace(/^line:(group|room|user):/i, "") + .replace(/^line:/i, ""); + return /^U/i.test(normalized); +} + +function hasLineSpecificMediaOptions(lineData: LineChannelDataWithMedia): boolean { + return Boolean( + lineData.mediaKind ?? + lineData.previewImageUrl?.trim() ?? + (typeof lineData.durationMs === "number" ? lineData.durationMs : undefined) ?? + lineData.trackingId?.trim(), + ); +} + +function buildLineMediaMessageObject( + resolved: LineOutboundMediaResolved, + opts?: { allowTrackingId?: boolean }, +): Record { + 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, + }; + } +} + export const lineOutboundAdapter: NonNullable["outbound"]> = { deliveryMode: "direct", chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 5000, sendPayload: async ({ to, payload, accountId, cfg }) => { const runtime = getLineRuntime(); - const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; + const lineData = (payload.channelData?.line as LineChannelDataWithMedia | undefined) ?? {}; const sendText = runtime.channel.line.pushMessageLine; const sendBatch = runtime.channel.line.pushMessagesLine; const sendFlex = runtime.channel.line.pushFlexMessage; @@ -61,12 +120,36 @@ export const lineOutboundAdapter: NonNullable ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) : []; const mediaUrls = resolveOutboundMediaUrls(payload); + const useLineSpecificMedia = hasLineSpecificMediaOptions(lineData); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; const sendMediaMessages = async () => { for (const url of mediaUrls) { + const trimmed = url?.trim(); + if (!trimmed) { + continue; + } + if (!useLineSpecificMedia) { + lastResult = await runtime.channel.line.sendMessageLine(to, "", { + verbose: false, + mediaUrl: trimmed, + cfg, + accountId: accountId ?? undefined, + }); + continue; + } + const resolved = await resolveLineOutboundMedia(trimmed, { + mediaKind: lineData.mediaKind, + previewImageUrl: lineData.previewImageUrl, + durationMs: lineData.durationMs, + trackingId: lineData.trackingId, + }); lastResult = await runtime.channel.line.sendMessageLine(to, "", { verbose: false, - mediaUrl: url, + mediaUrl: resolved.mediaUrl, + mediaKind: resolved.mediaKind, + previewImageUrl: resolved.previewImageUrl, + durationMs: resolved.durationMs, + trackingId: resolved.trackingId, cfg, accountId: accountId ?? undefined, }); @@ -170,11 +253,23 @@ export const lineOutboundAdapter: NonNullable if (!trimmed) { continue; } - quickReplyMessages.push({ - type: "image", - originalContentUrl: trimmed, - previewImageUrl: trimmed, + if (!useLineSpecificMedia) { + quickReplyMessages.push({ + type: "image", + originalContentUrl: trimmed, + previewImageUrl: trimmed, + }); + continue; + } + const resolved = await resolveLineOutboundMedia(trimmed, { + mediaKind: lineData.mediaKind, + previewImageUrl: lineData.previewImageUrl, + durationMs: lineData.durationMs, + trackingId: lineData.trackingId, }); + quickReplyMessages.push( + buildLineMediaMessageObject(resolved, { allowTrackingId: isLineUserTarget(to) }), + ); } if (quickReplyMessages.length > 0 && quickReply) { const lastIndex = quickReplyMessages.length - 1; diff --git a/extensions/line/src/send.test.ts b/extensions/line/src/send.test.ts index 4f0eb9e2e29..0a629777a27 100644 --- a/extensions/line/src/send.test.ts +++ b/extensions/line/src/send.test.ts @@ -169,6 +169,64 @@ describe("LINE send helpers", () => { expect(result).toEqual({ messageId: "reply", chatId: "C1" }); }); + it("sends video with explicit image preview URL", async () => { + await sendModule.sendMessageLine("line:user:U100", "Video", { + mediaUrl: "https://example.com/video.mp4", + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-1", + }); + + expect(pushMessageMock).toHaveBeenCalledWith({ + to: "U100", + messages: [ + { + type: "video", + originalContentUrl: "https://example.com/video.mp4", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-1", + }, + { + type: "text", + text: "Video", + }, + ], + }); + }); + + it("throws when video preview URL is missing", async () => { + await expect( + sendModule.sendMessageLine("line:user:U200", "Video", { + mediaUrl: "https://example.com/video.mp4", + mediaKind: "video", + }), + ).rejects.toThrow(/require previewimageurl/i); + }); + + it("omits trackingId for non-user destinations", async () => { + await sendModule.sendMessageLine("line:group:C100", "Video", { + mediaUrl: "https://example.com/video.mp4", + mediaKind: "video", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-group", + }); + + expect(pushMessageMock).toHaveBeenCalledWith({ + to: "C100", + messages: [ + { + type: "video", + originalContentUrl: "https://example.com/video.mp4", + previewImageUrl: "https://example.com/preview.jpg", + }, + { + type: "text", + text: "Video", + }, + ], + }); + }); + it("throws when push messages are empty", async () => { await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow( "Message must be non-empty for LINE sends", diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index 3fc4962d4c2..1ab76e32f51 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -9,6 +9,8 @@ import type { LineSendResult } from "./types.js"; type Message = messagingApi.Message; type TextMessage = messagingApi.TextMessage; type ImageMessage = messagingApi.ImageMessage; +type VideoMessage = messagingApi.VideoMessage & { trackingId?: string }; +type AudioMessage = messagingApi.AudioMessage; type LocationMessage = messagingApi.LocationMessage; type FlexMessage = messagingApi.FlexMessage; type FlexContainer = messagingApi.FlexContainer; @@ -28,6 +30,10 @@ interface LineSendOpts { accountId?: string; verbose?: boolean; mediaUrl?: string; + mediaKind?: "image" | "video" | "audio"; + previewImageUrl?: string; + durationMs?: number; + trackingId?: string; replyToken?: string; } @@ -62,6 +68,10 @@ function normalizeTarget(to: string): string { return normalized; } +function isLineUserChatId(chatId: string): boolean { + return /^U/i.test(chatId); +} + function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; @@ -106,6 +116,27 @@ export function createImageMessage( }; } +export function createVideoMessage( + originalContentUrl: string, + previewImageUrl: string, + trackingId?: string, +): VideoMessage { + return { + type: "video", + originalContentUrl, + previewImageUrl, + ...(trackingId ? { trackingId } : {}), + }; +} + +export function createAudioMessage(originalContentUrl: string, durationMs: number): AudioMessage { + return { + type: "audio", + originalContentUrl, + duration: durationMs, + }; +} + export function createLocationMessage(location: { title: string; address: string; @@ -215,8 +246,27 @@ export async function sendMessageLine( const chatId = normalizeTarget(to); const messages: Message[] = []; - if (opts.mediaUrl?.trim()) { - messages.push(createImageMessage(opts.mediaUrl.trim())); + const mediaUrl = opts.mediaUrl?.trim(); + if (mediaUrl) { + switch (opts.mediaKind) { + case "video": { + const previewImageUrl = opts.previewImageUrl?.trim(); + if (!previewImageUrl) { + throw new Error("LINE video messages require previewImageUrl to reference an image URL"); + } + const trackingId = isLineUserChatId(chatId) ? opts.trackingId : undefined; + messages.push(createVideoMessage(mediaUrl, previewImageUrl, trackingId)); + break; + } + case "audio": + messages.push(createAudioMessage(mediaUrl, opts.durationMs ?? 60000)); + break; + case "image": + default: + // Backward compatibility: keep image as default when media kind is unspecified. + messages.push(createImageMessage(mediaUrl, opts.previewImageUrl?.trim() || mediaUrl)); + break; + } } if (text?.trim()) { diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index efbae4cfccf..046e0f002d1 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "./registry.js"; +import type { PluginHttpRouteRegistration } from "./registry.js"; import { getActivePluginHttpRouteRegistryVersion, getActivePluginRegistryVersion, @@ -132,3 +133,91 @@ describe("plugin runtime route registry", () => { }); }); }); + +const makeRoute = (path: string): PluginHttpRouteRegistration => ({ + path, + handler: () => {}, + auth: "gateway", + match: "exact", +}); + +describe("setActivePluginRegistry", () => { + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("does not carry forward httpRoutes when new registry has none", () => { + const oldRegistry = createEmptyPluginRegistry(); + const fakeRoute = makeRoute("/test"); + oldRegistry.httpRoutes.push(fakeRoute); + setActivePluginRegistry(oldRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + + const newRegistry = createEmptyPluginRegistry(); + expect(newRegistry.httpRoutes).toHaveLength(0); + setActivePluginRegistry(newRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(0); + }); + + it("does not carry forward when new registry already has routes", () => { + const oldRegistry = createEmptyPluginRegistry(); + oldRegistry.httpRoutes.push(makeRoute("/old")); + setActivePluginRegistry(oldRegistry); + + const newRegistry = createEmptyPluginRegistry(); + const newRoute = makeRoute("/new"); + newRegistry.httpRoutes.push(newRoute); + setActivePluginRegistry(newRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + expect(getActivePluginRegistry()?.httpRoutes[0]).toEqual(newRoute); + }); + + it("does not carry forward when same registry is set again", () => { + const registry = createEmptyPluginRegistry(); + registry.httpRoutes.push(makeRoute("/test")); + setActivePluginRegistry(registry); + setActivePluginRegistry(registry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + }); +}); + +describe("setActivePluginRegistry", () => { + beforeEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("does not carry forward httpRoutes when new registry has none", () => { + const oldRegistry = createEmptyPluginRegistry(); + const fakeRoute = makeRoute("/test"); + oldRegistry.httpRoutes.push(fakeRoute); + setActivePluginRegistry(oldRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + + const newRegistry = createEmptyPluginRegistry(); + expect(newRegistry.httpRoutes).toHaveLength(0); + setActivePluginRegistry(newRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(0); + }); + + it("does not carry forward when new registry already has routes", () => { + const oldRegistry = createEmptyPluginRegistry(); + oldRegistry.httpRoutes.push(makeRoute("/old")); + setActivePluginRegistry(oldRegistry); + + const newRegistry = createEmptyPluginRegistry(); + const newRoute = makeRoute("/new"); + newRegistry.httpRoutes.push(newRoute); + setActivePluginRegistry(newRegistry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + expect(getActivePluginRegistry()?.httpRoutes[0]).toEqual(newRoute); + }); + + it("does not carry forward when same registry is set again", () => { + const registry = createEmptyPluginRegistry(); + registry.httpRoutes.push(makeRoute("/test")); + setActivePluginRegistry(registry); + setActivePluginRegistry(registry); + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); + }); +});