diff --git a/CHANGELOG.md b/CHANGELOG.md index dffc77f0a685..577162a9e5ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **LINE reply-token media kinds:** honor video and audio metadata on inbound replies, share the canonical media builder with proactive sends, and fail visibly instead of recording empty media-only deliveries. (#106515) Thanks @edenfunf. - **Mattermost websocket connection deadlines:** bound opening handshakes so stalled TCP peers cannot hang channel startup indefinitely and reconnect control resumes after timeout. (#105553) Thanks @hugenshen. - **Feishu app registration deadlines:** bound OAuth device-registration requests to 10 seconds through the guarded fetch boundary so setup cannot hang indefinitely on stalled response headers. (#105549) Thanks @hugenshen. - **LINE control-command mentions:** detect authorized slash commands before mention stripping so inline group and direct-message controls preserve the original ingress metadata. (#107230) Thanks @edenfunf. diff --git a/extensions/line/src/auto-reply-delivery.test.ts b/extensions/line/src/auto-reply-delivery.test.ts index d7025cc0f84b..fdeddf8e3767 100644 --- a/extensions/line/src/auto-reply-delivery.test.ts +++ b/extensions/line/src/auto-reply-delivery.test.ts @@ -49,12 +49,36 @@ describe("deliverLineAutoReply", () => { text, })); const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels })); + const buildMediaMessage: LineAutoReplyDeps["buildMediaMessage"] = vi.fn( + async (mediaUrl, options) => { + switch (options.mediaKind) { + case "video": + if (!options.previewImageUrl) { + throw new Error( + "LINE video messages require previewImageUrl to reference an image URL", + ); + } + return { + type: "video" as const, + originalContentUrl: mediaUrl, + previewImageUrl: options.previewImageUrl, + }; + case "audio": + return { + type: "audio" as const, + originalContentUrl: mediaUrl, + duration: options.durationMs ?? 60_000, + }; + default: + return createImageMessage(mediaUrl); + } + }, + ); const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1", receipt: createLineSendReceipt({ messageId: "push", chatId: "u1", kind: "text" }), })); - const deps: LineAutoReplyDeps = { buildTemplateMessageFromPayload: () => null, processLineMessage: (text) => ({ text, flexMessages: [] }), @@ -68,6 +92,7 @@ describe("deliverLineAutoReply", () => { pushMessagesLine, createFlexMessage: createFlexMessage as LineAutoReplyDeps["createFlexMessage"], createImageMessage, + buildMediaMessage, createLocationMessage, ...overrides, }; @@ -79,6 +104,7 @@ describe("deliverLineAutoReply", () => { pushTextMessageWithQuickReplies, createTextMessageWithQuickReplies, createQuickReplyItems, + buildMediaMessage, pushMessagesLine, }; } @@ -491,4 +517,155 @@ describe("deliverLineAutoReply", () => { { cfg: LINE_TEST_CFG, accountId: "acc" }, ); }); + + it("honors channelData.line.mediaKind on the reply-token path instead of forcing image", async () => { + // The push path resolves mediaKind into a video/audio message; the reply path + // used to hardcode createImageMessage, silently downgrading video to a broken + // image. LINE-specific media must now resolve to the matching kind. + const lineData = { + mediaKind: "video" as const, + previewImageUrl: "https://example.com/preview.jpg", + }; + const { deps, replyMessageLine, buildMediaMessage } = createDeps({ + processLineMessage: () => ({ text: "", flexMessages: [] }), + chunkMarkdownText: () => [], + }); + + const result = await deliverLineAutoReply({ + ...baseDeliveryParams, + payload: { + mediaUrls: ["https://example.com/clip.mp4"], + channelData: { line: lineData }, + }, + lineData, + deps, + }); + + expect(result.status).toBe("delivered"); + expect(buildMediaMessage).toHaveBeenCalledWith( + "https://example.com/clip.mp4", + expect.objectContaining(lineData), + "line:user:1", + ); + expect(replyMessageLine).toHaveBeenCalledWith( + "token", + [ + { + type: "video", + originalContentUrl: "https://example.com/clip.mp4", + previewImageUrl: "https://example.com/preview.jpg", + }, + ], + { cfg: LINE_TEST_CFG, accountId: "acc" }, + ); + }); + + it("keeps the image route for generic media without LINE-specific options", async () => { + // Parity with the push path (docs/channels/line.md): a bare media URL with no + // LINE media options stays on the image route and does not attempt resolution. + // A .mp4 proves it: if the generic path wrongly resolved by kind it would infer + // "video" (missing preview → build failure), so an image bubble means image route. + const { deps, replyMessageLine, buildMediaMessage } = createDeps({ + processLineMessage: () => ({ text: "", flexMessages: [] }), + chunkMarkdownText: () => [], + }); + + const result = await deliverLineAutoReply({ + ...baseDeliveryParams, + payload: { + mediaUrls: ["https://example.com/clip.mp4"], + channelData: { line: {} }, + }, + lineData: {}, + deps, + }); + + expect(result.status).toBe("delivered"); + expect(buildMediaMessage).not.toHaveBeenCalled(); + expect(replyMessageLine).toHaveBeenCalledWith( + "token", + [createImageMessage("https://example.com/clip.mp4")], + { cfg: LINE_TEST_CFG, accountId: "acc" }, + ); + }); + + it("surfaces a visible partial delivery when a media message cannot be built", async () => { + // A video missing its preview image cannot be built. The text still reaches the + // user, but the lost media bubble must surface as a partial delivery. + const lineData = { mediaKind: "video" as const }; + const { deps, replyMessageLine } = createDeps(); + + const result = await deliverLineAutoReply({ + ...baseDeliveryParams, + payload: { + text: "here is your clip", + mediaUrls: ["https://example.com/clip.mp4"], + channelData: { line: lineData }, + }, + lineData, + deps, + }); + + expect(result).toMatchObject({ + status: "partial", + error: { sentBeforeError: true, visibleReplySent: true }, + }); + // Text still reached the user over the reply token despite the media failure. + expect(replyMessageLine).toHaveBeenCalledWith( + "token", + [{ type: "text", text: "here is your clip" }], + { cfg: LINE_TEST_CFG, accountId: "acc" }, + ); + }); + + it("rejects a media-only build failure instead of reporting an empty delivery", async () => { + const lineData = { mediaKind: "video" as const }; + const { deps, replyMessageLine, pushMessagesLine } = createDeps({ + processLineMessage: () => ({ text: "", flexMessages: [] }), + chunkMarkdownText: () => [], + }); + + await expect( + deliverLineAutoReply({ + ...baseDeliveryParams, + payload: { + mediaUrls: ["https://example.com/clip.mp4"], + channelData: { line: lineData }, + }, + lineData, + deps, + }), + ).rejects.toThrow(/require previewImageUrl/i); + + expect(replyMessageLine).not.toHaveBeenCalled(); + expect(pushMessagesLine).not.toHaveBeenCalled(); + }); + + it("wraps a non-Error media-only build failure", async () => { + const lineData = { mediaKind: "video" as const }; + const failure = { code: "invalid_media" }; + const { deps } = createDeps({ + processLineMessage: () => ({ text: "", flexMessages: [] }), + chunkMarkdownText: () => [], + buildMediaMessage: vi.fn(async () => { + // oxlint-disable-next-line typescript/only-throw-error -- dependency callbacks may reject unknown values; this proves the delivery boundary normalizes them. + throw failure; + }) as LineAutoReplyDeps["buildMediaMessage"], + }); + + await expect( + deliverLineAutoReply({ + ...baseDeliveryParams, + payload: { + mediaUrls: ["https://example.com/clip.mp4"], + channelData: { line: lineData }, + }, + lineData, + deps, + }), + ).rejects.toMatchObject({ + message: "LINE rich or media message send failed", + cause: failure, + }); + }); }); diff --git a/extensions/line/src/auto-reply-delivery.ts b/extensions/line/src/auto-reply-delivery.ts index 17c42f0ed247..55bf661ecc4e 100644 --- a/extensions/line/src/auto-reply-delivery.ts +++ b/extensions/line/src/auto-reply-delivery.ts @@ -7,6 +7,7 @@ import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking" import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; +import { hasLineSpecificMediaOptions } from "./outbound-media.js"; import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js"; import type { LineChannelData, LineTemplateMessagePayload } from "./types.js"; @@ -29,6 +30,11 @@ type LineAutoReplyDeps = { originalContentUrl: string, previewImageUrl?: string, ) => messagingApi.ImageMessage; + buildMediaMessage: ( + mediaUrl: string, + opts: Pick, + target: string, + ) => Promise; createLocationMessage: (location: { title: string; address: string; @@ -48,12 +54,21 @@ type LineAutoReplyDeliveryResult = | { status: "delivered"; replyTokenUsed: boolean; visibleReplySent: boolean } | { status: "partial"; replyTokenUsed: boolean; visibleReplySent: true; error: Error }; +function toLineDeliveryError(error: unknown): Error { + return error instanceof Error + ? error + : new Error("LINE rich or media message send failed", { cause: error }); +} + function markLineVisibleDeliveryError(error: unknown): Error { - if (error instanceof Error && Object.isExtensible(error)) { - Object.assign(error, { sentBeforeError: true, visibleReplySent: true }); - return error; + const deliveryError = toLineDeliveryError(error); + if (Object.isExtensible(deliveryError)) { + Object.assign(deliveryError, { sentBeforeError: true, visibleReplySent: true }); + return deliveryError; } - const visibleError = new Error("LINE rich or media message send failed", { cause: error }); + const visibleError = new Error("LINE rich or media message send failed", { + cause: deliveryError, + }); Object.assign(visibleError, { sentBeforeError: true, visibleReplySent: true }); return visibleError; } @@ -173,11 +188,36 @@ export async function deliverLineAutoReply(params: { const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; + // Match the push path (outbound.ts): honor channelData.line.mediaKind and the + // other LINE media options so a reply-token video/audio is not silently + // downgraded to an image. Generic media sends without LINE-specific options + // keep the image route. A media build failure is partial only after another + // visible part lands; media-only failures remain full failures. const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls; - const mediaMessages = mediaUrls - .map((url) => url?.trim()) - .filter((url): url is string => Boolean(url)) - .map((url) => deps.createImageMessage(url)); + const useLineSpecificMedia = hasLineSpecificMediaOptions(lineData); + const mediaOpts = { + mediaKind: lineData.mediaKind, + previewImageUrl: lineData.previewImageUrl, + durationMs: lineData.durationMs, + trackingId: lineData.trackingId, + }; + const mediaMessages: messagingApi.Message[] = []; + let richMediaError: unknown; + for (const rawUrl of mediaUrls) { + const url = rawUrl?.trim(); + if (!url) { + continue; + } + if (!useLineSpecificMedia) { + mediaMessages.push(deps.createImageMessage(url)); + continue; + } + try { + mediaMessages.push(await deps.buildMediaMessage(url, mediaOpts, to)); + } catch (err) { + richMediaError ??= err; + } + } if (chunks.length > 0) { const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0; @@ -186,12 +226,11 @@ export async function deliverLineAutoReply(params: { // failure instead of swallowing it: the text still sends below, but a lost // rich/media bubble must surface as a partial delivery, not silent success. const sendRichBeforeText = hasQuickReplies && hasRichOrMedia; - let richMediaError: unknown; if (sendRichBeforeText) { try { await sendLineMessages([...richMessages, ...mediaMessages], false); } catch (err) { - richMediaError = err; + richMediaError ??= err; } } const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({ @@ -216,19 +255,9 @@ export async function deliverLineAutoReply(params: { await sendLineMessages(mediaMessages, false); } } catch (err) { - richMediaError = err; + richMediaError ??= err; } } - if (richMediaError !== undefined) { - // Preserve both generic send evidence and foreground visibility: downstream - // callers must surface the failure without retrying text the user already saw. - return { - status: "partial", - replyTokenUsed, - visibleReplySent: true, - error: markLineVisibleDeliveryError(richMediaError), - }; - } } else { const combined = [...richMessages, ...mediaMessages]; if (hasQuickReplies && combined.length === 0) { @@ -261,5 +290,22 @@ export async function deliverLineAutoReply(params: { } } + if (richMediaError !== undefined) { + if (!visibleReplySent) { + // No user-visible content landed, so this is a full delivery failure. + // Throwing lets the caller surface or replace it instead of recording a + // successful empty reply. + throw toLineDeliveryError(richMediaError); + } + // Other visible content landed; preserve that evidence so downstream + // recovery does not replay text the user already saw. + return { + status: "partial", + replyTokenUsed, + visibleReplySent: true, + error: markLineVisibleDeliveryError(richMediaError), + }; + } + return { status: "delivered", replyTokenUsed, visibleReplySent }; } diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 6a3a6fb3be23..ebcafe9a16ad 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -26,6 +26,7 @@ import { deliverLineAutoReply } from "./auto-reply-delivery.js"; import { createLineBot } from "./bot.js"; import { processLineMessage } from "./markdown-to-line.js"; import { resolveLineDurableReplyOptions } from "./monitor-durable.js"; +import { buildLineMediaMessage } from "./outbound-media.js"; import { sendLineReplyChunks } from "./reply-chunks.js"; import { getLineRuntime } from "./runtime.js"; import { @@ -231,6 +232,7 @@ export async function monitorLineProvider( pushMessagesLine, createFlexMessage, createImageMessage, + buildMediaMessage: buildLineMediaMessage, createLocationMessage, onReplyError: (replyErr) => { logVerbose( diff --git a/extensions/line/src/outbound-media.test.ts b/extensions/line/src/outbound-media.test.ts index 50b27f81bf3e..46417899dff0 100644 --- a/extensions/line/src/outbound-media.test.ts +++ b/extensions/line/src/outbound-media.test.ts @@ -14,7 +14,12 @@ afterAll(() => { vi.resetModules(); }); -import { resolveLineOutboundMedia, validateLineMediaUrl } from "./outbound-media.js"; +import { + buildLineMediaMessage, + hasLineSpecificMediaOptions, + resolveLineOutboundMedia, + validateLineMediaUrl, +} from "./outbound-media.js"; describe("validateLineMediaUrl", () => { beforeEach(() => { @@ -167,3 +172,82 @@ describe("resolveLineOutboundMedia", () => { ); }); }); + +describe("hasLineSpecificMediaOptions", () => { + it("is false for empty or text-only channel data", () => { + expect(hasLineSpecificMediaOptions({})).toBe(false); + expect(hasLineSpecificMediaOptions({ quickReplies: ["A"] })).toBe(false); + expect(hasLineSpecificMediaOptions({ previewImageUrl: " " })).toBe(false); + }); + + it("is true when any LINE media option is set", () => { + expect(hasLineSpecificMediaOptions({ mediaKind: "video" })).toBe(true); + expect(hasLineSpecificMediaOptions({ previewImageUrl: "https://x/p.jpg" })).toBe(true); + expect(hasLineSpecificMediaOptions({ durationMs: 0 })).toBe(true); + expect(hasLineSpecificMediaOptions({ durationMs: 1000 })).toBe(true); + expect(hasLineSpecificMediaOptions({ trackingId: "t" })).toBe(true); + }); +}); + +describe("buildLineMediaMessage", () => { + it("builds a video message and gates trackingId on user targets", async () => { + const options = { + mediaKind: "video" as const, + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-1", + }; + await expect( + buildLineMediaMessage("https://example.com/clip.mp4", options, "line:user:Uabc"), + ).resolves.toEqual({ + type: "video", + originalContentUrl: "https://example.com/clip.mp4", + previewImageUrl: "https://example.com/preview.jpg", + trackingId: "track-1", + }); + await expect( + buildLineMediaMessage("https://example.com/clip.mp4", options, "line:group:Cabc"), + ).resolves.toEqual({ + type: "video", + originalContentUrl: "https://example.com/clip.mp4", + previewImageUrl: "https://example.com/preview.jpg", + }); + }); + + it("rejects a video missing its preview image", async () => { + await expect( + buildLineMediaMessage( + "https://example.com/clip.mp4", + { mediaKind: "video" }, + "line:user:Uabc", + ), + ).rejects.toThrow(/require previewImageUrl/i); + }); + + it("builds an audio message with a default duration", async () => { + await expect( + buildLineMediaMessage( + "https://example.com/voice.m4a", + { mediaKind: "audio" }, + "line:user:Uabc", + ), + ).resolves.toEqual({ + type: "audio", + originalContentUrl: "https://example.com/voice.m4a", + duration: 60000, + }); + }); + + it("defaults an image preview to the media URL", async () => { + await expect( + buildLineMediaMessage( + "https://example.com/photo.png", + { mediaKind: "image" }, + "line:user:Uabc", + ), + ).resolves.toEqual({ + type: "image", + originalContentUrl: "https://example.com/photo.png", + previewImageUrl: "https://example.com/photo.png", + }); + }); +}); diff --git a/extensions/line/src/outbound-media.ts b/extensions/line/src/outbound-media.ts index e673728ea68a..51681d2175e7 100644 --- a/extensions/line/src/outbound-media.ts +++ b/extensions/line/src/outbound-media.ts @@ -1,10 +1,12 @@ // 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 } from "./types.js"; type LineOutboundMediaKind = "image" | "video" | "audio"; -export type LineOutboundMediaResolved = { +type LineOutboundMediaResolved = { mediaUrl: string; mediaKind: LineOutboundMediaKind; previewImageUrl?: string; @@ -105,3 +107,67 @@ export async function resolveLineOutboundMedia( } 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 { + const resolved = await resolveLineOutboundMedia(mediaUrl, opts); + return buildLineMediaMessageObject(resolved, { + allowTrackingId: isLineUserTarget(target), + }); +} diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index 4d9d25c9e50e..088c81bea8bf 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -13,7 +13,11 @@ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js"; -import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js"; +import { + buildLineMediaMessage, + hasLineSpecificMediaOptions, + resolveLineOutboundMedia, +} from "./outbound-media.js"; import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js"; import { getLineRuntime } from "./runtime.js"; import { createLineSendReceipt } from "./send-receipt.js"; @@ -21,64 +25,6 @@ import type { LineChannelData, LineSendResult } from "./types.js"; const loadLineOutboundRuntime = createLazyRuntimeModule(() => import("./outbound.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), @@ -87,7 +33,7 @@ export const lineOutboundAdapter: NonNullable sendPayload: async ({ to, payload, accountId, cfg, onDeliveryResult }) => { const runtime = getLineRuntime(); const outboundRuntime = await loadLineOutboundRuntime(); - const lineData = (payload.channelData?.line as LineChannelDataWithMedia | undefined) ?? {}; + const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; const lineRuntime = runtime.channel.line; const sendText = lineRuntime?.pushMessageLine ?? outboundRuntime.pushMessageLine; const sendBatch = lineRuntime?.pushMessagesLine ?? outboundRuntime.pushMessagesLine; @@ -303,14 +249,17 @@ export const lineOutboundAdapter: NonNullable }); 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) }), + await buildLineMediaMessage( + trimmed, + { + mediaKind: lineData.mediaKind, + previewImageUrl: lineData.previewImageUrl, + durationMs: lineData.durationMs, + trackingId: lineData.trackingId, + }, + to, + ), ); } if (quickReplyMessages.length > 0 && quickReply) { diff --git a/extensions/line/src/types.ts b/extensions/line/src/types.ts index 9393fb3cf343..f9d3fefd3f06 100644 --- a/extensions/line/src/types.ts +++ b/extensions/line/src/types.ts @@ -120,6 +120,10 @@ export type LineTemplateMessagePayload = export type LineChannelData = { quickReplies?: string[]; + mediaKind?: "image" | "video" | "audio"; + previewImageUrl?: string; + durationMs?: number; + trackingId?: string; location?: { title: string; address: string;