mirror of https://github.com/openclaw/openclaw.git
feat(line): add outbound media support for image, video, and audio
pnpm install --frozen-lockfile pnpm build pnpm check pnpm vitest run extensions/line/src/channel.sendPayload.test.ts extensions/line/src/send.test.ts extensions/line/src/outbound-media.test.ts Co-authored-by: masatohoshino <246810661+masatohoshino@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
parent
f93ccc3443
commit
9449e54f4f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -49,9 +49,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = 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: "<userId|groupId|roomId>",
|
||||
|
|
@ -115,7 +112,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<LineOutboundMediaResolved> {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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<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),
|
||||
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<ChannelPlugin<ResolvedLineAccount>
|
|||
? 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<ChannelPlugin<ResolvedLineAccount>
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof resolveLineAccount>;
|
||||
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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue