mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 00:17:29 +00:00
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:
@@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
### 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.
|
- 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,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
expect(mocks.sendMessageLine).toHaveBeenCalledWith(
|
||||||
verbose: false,
|
"line:user:3",
|
||||||
mediaUrl: "https://example.com/img.jpg",
|
"",
|
||||||
accountId: "default",
|
expect.objectContaining({
|
||||||
cfg,
|
verbose: false,
|
||||||
});
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||||
"line:user:3",
|
"line:user:3",
|
||||||
"Hello",
|
"Hello",
|
||||||
@@ -275,6 +279,63 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
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 () => {
|
it("uses configured text chunk limit for payloads", async () => {
|
||||||
const { runtime, mocks } = createRuntime();
|
const { runtime, mocks } = createRuntime();
|
||||||
setLineRuntime(runtime);
|
setLineRuntime(runtime);
|
||||||
@@ -305,6 +366,114 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
});
|
});
|
||||||
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
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", () => {
|
describe("linePlugin config.formatAllowFrom", () => {
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return false;
|
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);
|
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||||
},
|
},
|
||||||
hint: "<userId|groupId|roomId>",
|
hint: "<userId|groupId|roomId>",
|
||||||
@@ -115,7 +112,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
|||||||
text: {
|
text: {
|
||||||
idLabel: "lineUserId",
|
idLabel: "lineUserId",
|
||||||
message: "OpenClaw: your access has been approved.",
|
message: "OpenClaw: your access has been approved.",
|
||||||
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
|
|
||||||
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
|
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
|
||||||
notify: async ({ cfg, id, message }) => {
|
notify: async ({ cfg, id, message }) => {
|
||||||
const line = getLineRuntime().channel.line;
|
const line = getLineRuntime().channel.line;
|
||||||
|
|||||||
137
extensions/line/src/outbound-media.test.ts
Normal file
137
extensions/line/src/outbound-media.test.ts
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
extensions/line/src/outbound-media.ts
Normal file
110
extensions/line/src/outbound-media.ts
Normal file
@@ -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 LineChannelData,
|
||||||
type ResolvedLineAccount,
|
type ResolvedLineAccount,
|
||||||
} from "../api.js";
|
} from "../api.js";
|
||||||
|
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
|
||||||
import { getLineRuntime } from "./runtime.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"]> = {
|
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
textChunkLimit: 5000,
|
textChunkLimit: 5000,
|
||||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||||
const runtime = getLineRuntime();
|
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 sendText = runtime.channel.line.pushMessageLine;
|
||||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
@@ -61,12 +120,36 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
|
|||||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||||
: [];
|
: [];
|
||||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||||
|
const useLineSpecificMedia = hasLineSpecificMediaOptions(lineData);
|
||||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||||
const sendMediaMessages = async () => {
|
const sendMediaMessages = async () => {
|
||||||
for (const url of mediaUrls) {
|
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, "", {
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: url,
|
mediaUrl: resolved.mediaUrl,
|
||||||
|
mediaKind: resolved.mediaKind,
|
||||||
|
previewImageUrl: resolved.previewImageUrl,
|
||||||
|
durationMs: resolved.durationMs,
|
||||||
|
trackingId: resolved.trackingId,
|
||||||
cfg,
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -170,11 +253,23 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
quickReplyMessages.push({
|
if (!useLineSpecificMedia) {
|
||||||
type: "image",
|
quickReplyMessages.push({
|
||||||
originalContentUrl: trimmed,
|
type: "image",
|
||||||
previewImageUrl: trimmed,
|
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) {
|
if (quickReplyMessages.length > 0 && quickReply) {
|
||||||
const lastIndex = quickReplyMessages.length - 1;
|
const lastIndex = quickReplyMessages.length - 1;
|
||||||
|
|||||||
@@ -169,6 +169,64 @@ describe("LINE send helpers", () => {
|
|||||||
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
|
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 () => {
|
it("throws when push messages are empty", async () => {
|
||||||
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
|
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
|
||||||
"Message must be non-empty for LINE sends",
|
"Message must be non-empty for LINE sends",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { LineSendResult } from "./types.js";
|
|||||||
type Message = messagingApi.Message;
|
type Message = messagingApi.Message;
|
||||||
type TextMessage = messagingApi.TextMessage;
|
type TextMessage = messagingApi.TextMessage;
|
||||||
type ImageMessage = messagingApi.ImageMessage;
|
type ImageMessage = messagingApi.ImageMessage;
|
||||||
|
type VideoMessage = messagingApi.VideoMessage & { trackingId?: string };
|
||||||
|
type AudioMessage = messagingApi.AudioMessage;
|
||||||
type LocationMessage = messagingApi.LocationMessage;
|
type LocationMessage = messagingApi.LocationMessage;
|
||||||
type FlexMessage = messagingApi.FlexMessage;
|
type FlexMessage = messagingApi.FlexMessage;
|
||||||
type FlexContainer = messagingApi.FlexContainer;
|
type FlexContainer = messagingApi.FlexContainer;
|
||||||
@@ -28,6 +30,10 @@ interface LineSendOpts {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
|
mediaKind?: "image" | "video" | "audio";
|
||||||
|
previewImageUrl?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
trackingId?: string;
|
||||||
replyToken?: string;
|
replyToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +68,10 @@ function normalizeTarget(to: string): string {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLineUserChatId(chatId: string): boolean {
|
||||||
|
return /^U/i.test(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
function createLineMessagingClient(opts: LineClientOpts): {
|
function createLineMessagingClient(opts: LineClientOpts): {
|
||||||
account: ReturnType<typeof resolveLineAccount>;
|
account: ReturnType<typeof resolveLineAccount>;
|
||||||
client: messagingApi.MessagingApiClient;
|
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: {
|
export function createLocationMessage(location: {
|
||||||
title: string;
|
title: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -215,8 +246,27 @@ export async function sendMessageLine(
|
|||||||
const chatId = normalizeTarget(to);
|
const chatId = normalizeTarget(to);
|
||||||
const messages: Message[] = [];
|
const messages: Message[] = [];
|
||||||
|
|
||||||
if (opts.mediaUrl?.trim()) {
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
messages.push(createImageMessage(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()) {
|
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 { createEmptyPluginRegistry } from "./registry.js";
|
||||||
|
import type { PluginHttpRouteRegistration } from "./registry.js";
|
||||||
import {
|
import {
|
||||||
getActivePluginHttpRouteRegistryVersion,
|
getActivePluginHttpRouteRegistryVersion,
|
||||||
getActivePluginRegistryVersion,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user