fix(line): honor channelData.line.mediaKind on the reply-token path (#106515)

* fix(line): honor channelData.line.mediaKind on the reply-token path

The reply-token delivery built every media message with createImageMessage,
ignoring channelData.line.mediaKind (and previewImageUrl/durationMs/trackingId),
so a video/audio reply was silently downgraded to a broken image. The push path
already honored mediaKind via resolveLineOutboundMedia + buildLineMediaMessageObject.

Route reply-token media through those same helpers (relocated to outbound-media.ts
and reused by both paths) via an injected buildMediaMessage dep wired in monitor.ts,
preserving the delivery file's dependency-injection boundary. Generic media without
LINE-specific options keeps the image route; a media that cannot be built surfaces
as a visible partial delivery so the text still reaches the user.

* refactor(line): unify reply media delivery

* fix(line): normalize media delivery failures

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Eden
2026-07-16 09:06:26 +08:00
committed by GitHub
parent f66786f0cb
commit bc93fe9f25
8 changed files with 420 additions and 91 deletions
+1
View File
@@ -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.
+178 -1
View File
@@ -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,
});
});
});
+67 -21
View File
@@ -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<LineChannelData, "mediaKind" | "previewImageUrl" | "durationMs" | "trackingId">,
target: string,
) => Promise<messagingApi.Message>;
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 };
}
+2
View File
@@ -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(
+85 -1
View File
@@ -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",
});
});
});
+67 -1
View File
@@ -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<messagingApi.Message> {
const resolved = await resolveLineOutboundMedia(mediaUrl, opts);
return buildLineMediaMessageObject(resolved, {
allowTrackingId: isLineUserTarget(target),
});
}
+16 -67
View File
@@ -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<string, unknown> {
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<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
@@ -87,7 +33,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
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<ChannelPlugin<ResolvedLineAccount>
});
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) {
+4
View File
@@ -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;