mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user