Files
openclaw/extensions/line/src/send.test.ts
2026-05-31 18:59:02 +01:00

454 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
requireRuntimeConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
resolvePinnedHostnameWithPolicyMock,
} = vi.hoisted(() => {
const pushMessageMockLocal = vi.fn();
const replyMessageMockLocal = vi.fn();
const showLoadingAnimationMockLocal = vi.fn();
const getProfileMockLocal = vi.fn();
const MessagingApiClientMockLocal = vi.fn(function () {
return {
pushMessage: pushMessageMockLocal,
replyMessage: replyMessageMockLocal,
showLoadingAnimation: showLoadingAnimationMockLocal,
getProfile: getProfileMockLocal,
};
});
const requireRuntimeConfigMockLocal = vi.fn((cfg: unknown) => cfg ?? {});
const resolveLineAccountMockLocal = vi.fn(() => ({ accountId: "default" }));
const resolveLineChannelAccessTokenMockLocal = vi.fn(() => "line-token");
const recordChannelActivityMockLocal = vi.fn();
const logVerboseMockLocal = vi.fn();
const resolvePinnedHostnameWithPolicyMockLocal = vi.fn();
return {
pushMessageMock: pushMessageMockLocal,
replyMessageMock: replyMessageMockLocal,
showLoadingAnimationMock: showLoadingAnimationMockLocal,
getProfileMock: getProfileMockLocal,
MessagingApiClientMock: MessagingApiClientMockLocal,
requireRuntimeConfigMock: requireRuntimeConfigMockLocal,
resolveLineAccountMock: resolveLineAccountMockLocal,
resolveLineChannelAccessTokenMock: resolveLineChannelAccessTokenMockLocal,
recordChannelActivityMock: recordChannelActivityMockLocal,
logVerboseMock: logVerboseMockLocal,
resolvePinnedHostnameWithPolicyMock: resolvePinnedHostnameWithPolicyMockLocal,
};
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
requireRuntimeConfig: requireRuntimeConfigMock,
}));
vi.mock("./accounts.js", () => ({
resolveLineAccount: resolveLineAccountMock,
}));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("openclaw/plugin-sdk/channel-activity-runtime", () => ({
recordChannelActivity: recordChannelActivityMock,
}));
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
logVerbose: logVerboseMock,
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock,
}));
let sendModule: typeof import("./send.js");
const LINE_TEST_CFG = {
channels: {
line: {
accounts: {
default: {},
},
},
},
};
describe("LINE send helpers", () => {
const fixedSentAt = 1_800_000_000_000;
beforeAll(async () => {
sendModule = await import("./send.js");
});
afterAll(() => {
vi.doUnmock("@line/bot-sdk");
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
vi.doUnmock("./accounts.js");
vi.doUnmock("./channel-access-token.js");
vi.doUnmock("openclaw/plugin-sdk/channel-activity-runtime");
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
beforeEach(() => {
vi.setSystemTime(fixedSentAt);
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockReset();
requireRuntimeConfigMock.mockClear();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
resolvePinnedHostnameWithPolicyMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
hostname: "example.com",
addresses: ["93.184.216.34"],
});
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
it("limits quick reply items to 13", () => {
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
const quickReply = sendModule.createQuickReplyItems(labels);
expect(quickReply.items).toHaveLength(13);
});
it("pushes images via normalized LINE target", async () => {
const result = await sendModule.pushImageMessage(
"line:user:U123",
"https://example.com/original.jpg",
undefined,
{ cfg: LINE_TEST_CFG, verbose: true },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U123",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/original.jpg",
previewImageUrl: "https://example.com/original.jpg",
},
],
});
expect(recordChannelActivityMock).toHaveBeenCalledWith({
channel: "line",
accountId: "default",
direction: "outbound",
});
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
expect(result).toEqual({
chatId: "U123",
messageId: "push",
receipt: {
parts: [
{
index: 0,
kind: "media",
platformMessageId: "push",
raw: {
channel: "line",
chatId: "U123",
conversationId: "U123",
messageId: "push",
meta: { messageCount: 1 },
},
threadId: "U123",
},
],
platformMessageIds: ["push"],
primaryPlatformMessageId: "push",
raw: [
{
channel: "line",
chatId: "U123",
conversationId: "U123",
messageId: "push",
meta: { messageCount: 1 },
},
],
sentAt: fixedSentAt,
threadId: "U123",
},
});
});
it("replies when reply token is provided", async () => {
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
mediaUrl: "https://example.com/media.jpg",
verbose: true,
});
expect(replyMessageMock).toHaveBeenCalledTimes(1);
expect(pushMessageMock).not.toHaveBeenCalled();
expect(replyMessageMock).toHaveBeenCalledWith({
replyToken: "reply-token",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/media.jpg",
previewImageUrl: "https://example.com/media.jpg",
},
{
type: "text",
text: "Hello",
},
],
});
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
expect(result).toEqual({
chatId: "C1",
messageId: "reply",
receipt: {
parts: [
{
index: 0,
kind: "media",
platformMessageId: "reply",
raw: {
channel: "line",
chatId: "C1",
conversationId: "C1",
messageId: "reply",
meta: { messageCount: 2 },
},
threadId: "C1",
},
],
platformMessageIds: ["reply"],
primaryPlatformMessageId: "reply",
raw: [
{
channel: "line",
chatId: "C1",
conversationId: "C1",
messageId: "reply",
meta: { messageCount: 2 },
},
],
sentAt: fixedSentAt,
threadId: "C1",
},
});
});
it("sends video with explicit image preview URL", async () => {
await sendModule.sendMessageLine("line:user:U100", "Video", {
cfg: LINE_TEST_CFG,
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", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
}),
).rejects.toThrow(/require previewimageurl/i);
});
it("blocks private-network media URLs before calling LINE", async () => {
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
new Error("SSRF blocked private network target"),
);
await expect(
sendModule.sendMessageLine("line:user:U200", "Image", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://127.0.0.1/image.jpg",
}),
).rejects.toThrow(/private network/i);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it("omits trackingId for non-user destinations", async () => {
await sendModule.sendMessageLine("line:group:C100", "Video", {
cfg: LINE_TEST_CFG,
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", [], { cfg: LINE_TEST_CFG })).rejects.toThrow(
"Message must be non-empty for LINE sends",
);
});
it("rejects lowercased LINE-shaped recipients (#81628 safety net)", async () => {
// 33-char value with lowercase leading char — what an upstream session-key
// fragment looked like before the cron-tool fix. LINE rejects with HTTP 400
// anyway; throwing locally keeps the failure permanent so delivery-recovery
// moves the entry to failed/ immediately instead of silently retrying 5×.
await expect(
sendModule.pushMessagesLine(
"cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
),
).rejects.toThrow(/Recipient is not a valid LINE id/);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it("accepts case-exact LINE recipients with the leading capital preserved", async () => {
await sendModule.pushMessagesLine(
"Cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "Cabcdef0123456789abcdef0123456789",
messages: [{ type: "text", text: "hello" }],
});
});
it("logs HTTP body when push fails", async () => {
const err = new Error("LINE push failed") as Error & {
status: number;
statusText: string;
body: string;
};
err.status = 400;
err.statusText = "Bad Request";
err.body = "invalid flex payload";
pushMessageMock.mockRejectedValueOnce(err);
await expect(
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }], {
cfg: LINE_TEST_CFG,
}),
).rejects.toThrow("LINE push failed");
expect(logVerboseMock).toHaveBeenCalledWith(
"line: push message failed (400 Bad Request): invalid flex payload",
);
});
it("caches profile results by default", async () => {
getProfileMock.mockResolvedValue({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
const first = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
expect(first).toEqual({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
expect(second).toEqual(first);
expect(getProfileMock).toHaveBeenCalledTimes(1);
});
it("continues when loading animation is unsupported", async () => {
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
await expect(
sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }),
).resolves.toBeUndefined();
expect(logVerboseMock).toHaveBeenCalledWith(
"line: loading animation failed (non-fatal): Error: unsupported",
);
});
it("pushes quick-reply text and caps to 13 buttons", async () => {
await sendModule.pushTextMessageWithQuickReplies(
"U-quick",
"Pick one",
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
{ cfg: LINE_TEST_CFG },
);
expect(pushMessageMock).toHaveBeenCalledTimes(1);
const firstCall = pushMessageMock.mock.calls.at(0) as [
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
];
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
});
});