Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

841 lines
27 KiB
TypeScript

/**
* Exercises the LINE plugin surface and its message-formatting helpers (text
* chunking, markdown/code-block/table extraction, user + chat id shaping)
* against a mocked runtime — no live LINE Messaging API calls.
*/
import type { IAgentRuntime } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { normalizeAccountId } from "../src/accounts";
import linePlugin, {
chunkLineText,
extractCodeBlocks,
extractLinks,
extractMarkdownTables,
formatCodeBlockAsText,
formatLineUser,
formatTableAsText,
getChatId,
getChatType,
getChatTypeFromId,
hasMarkdownContent,
isGroupChat,
isValidLineId,
LineApiError,
LineConfigurationError,
LineEventTypes,
LineService,
markdownToLineChunks,
normalizeLineTarget,
processLineMessage,
resolveLineSystemLocation,
splitMessageForLine,
stripMarkdown,
truncateText,
} from "../src/index";
import { LineWorkflowCredentialProvider } from "../src/workflow-credential-provider";
// ===========================================================================
// Plugin metadata
// ===========================================================================
describe("Plugin metadata", () => {
it("does not register legacy LINE message router actions", () => {
expect(Array.isArray(linePlugin.actions)).toBe(true);
expect(linePlugin.actions?.length).toBe(0);
});
it("uses core platform context providers", () => {
expect(Array.isArray(linePlugin.providers)).toBe(true);
expect(linePlugin.providers?.length).toBe(0);
});
});
// ===========================================================================
// Config validation
// ===========================================================================
describe("Config validation", () => {
it("creates LineConfigurationError with field", () => {
const err = new LineConfigurationError("Token required", "LINE_CHANNEL_ACCESS_TOKEN");
expect(err.name).toBe("LineConfigurationError");
expect(err.message).toBe("Token required");
expect(err.field).toBe("LINE_CHANNEL_ACCESS_TOKEN");
expect(err instanceof Error).toBe(true);
});
it("creates LineConfigurationError without field", () => {
const err = new LineConfigurationError("General error");
expect(err.field).toBeUndefined();
});
it("creates LineApiError with status code", () => {
const err = new LineApiError("Not found", 404);
expect(err.name).toBe("LineApiError");
expect(err.message).toBe("Not found");
expect(err.statusCode).toBe(404);
expect(err instanceof Error).toBe(true);
});
it("creates LineApiError without status code", () => {
const err = new LineApiError("Unknown error");
expect(err.statusCode).toBeUndefined();
});
});
// ===========================================================================
// Type utilities (types.ts)
// ===========================================================================
describe("Type utilities", () => {
describe("isValidLineId", () => {
it("accepts valid user IDs", () => {
expect(isValidLineId("U1234567890abcdef1234567890abcdef")).toBe(true);
expect(isValidLineId("u1234567890abcdef1234567890abcdef")).toBe(true);
});
it("accepts valid group IDs", () => {
expect(isValidLineId("C1234567890abcdef1234567890abcdef")).toBe(true);
expect(isValidLineId("c1234567890abcdef1234567890abcdef")).toBe(true);
});
it("accepts valid room IDs", () => {
expect(isValidLineId("R1234567890abcdef1234567890abcdef")).toBe(true);
expect(isValidLineId("r1234567890abcdef1234567890abcdef")).toBe(true);
});
it("rejects invalid IDs", () => {
expect(isValidLineId("")).toBe(false);
expect(isValidLineId("X12345")).toBe(false);
expect(isValidLineId("U123")).toBe(false);
expect(isValidLineId("invalid")).toBe(false);
});
});
describe("normalizeLineTarget", () => {
it("returns valid IDs unchanged", () => {
const id = "U1234567890abcdef1234567890abcdef";
expect(normalizeLineTarget(id)).toBe(id);
});
it("trims whitespace", () => {
const id = "U1234567890abcdef1234567890abcdef";
expect(normalizeLineTarget(` ${id} `)).toBe(id);
});
it("returns null for empty strings", () => {
expect(normalizeLineTarget("")).toBeNull();
expect(normalizeLineTarget(" ")).toBeNull();
});
it("returns null for invalid IDs", () => {
expect(normalizeLineTarget("invalid_id")).toBeNull();
});
});
describe("getChatTypeFromId", () => {
it("returns user for U-prefix IDs", () => {
expect(getChatTypeFromId("U123")).toBe("user");
});
it("returns group for C-prefix IDs", () => {
expect(getChatTypeFromId("C123")).toBe("group");
expect(getChatTypeFromId("c123")).toBe("group");
});
it("returns room for R-prefix IDs", () => {
expect(getChatTypeFromId("R123")).toBe("room");
expect(getChatTypeFromId("r123")).toBe("room");
});
it("defaults to user for unknown prefixes", () => {
expect(getChatTypeFromId("X123")).toBe("user");
});
});
describe("splitMessageForLine", () => {
it("returns single chunk for short messages", () => {
expect(splitMessageForLine("Hello")).toEqual(["Hello"]);
});
it("returns empty array for empty string", () => {
expect(splitMessageForLine("")).toEqual([]);
});
it("returns single chunk at exactly 5000 chars", () => {
const text = "a".repeat(5000);
const chunks = splitMessageForLine(text);
expect(chunks).toHaveLength(1);
});
it("splits messages over 5000 chars", () => {
const text = "a".repeat(6000);
const chunks = splitMessageForLine(text);
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(5000);
}
});
it("prefers splitting at newlines", () => {
const first = "a".repeat(3000);
const second = "b".repeat(3000);
const text = `${first}\n${second}`;
const chunks = splitMessageForLine(text);
expect(chunks).toHaveLength(2);
});
it("prefers splitting at spaces", () => {
const first = "a".repeat(3000);
const second = "b".repeat(3000);
const text = `${first} ${second}`;
const chunks = splitMessageForLine(text);
expect(chunks).toHaveLength(2);
});
});
});
// ===========================================================================
// Messaging utilities
// ===========================================================================
describe("Messaging utilities", () => {
describe("chunkLineText", () => {
it("returns empty array for empty/whitespace text", () => {
expect(chunkLineText("")).toEqual([]);
expect(chunkLineText(" ")).toEqual([]);
});
it("returns single chunk for short text", () => {
expect(chunkLineText("Hello")).toEqual(["Hello"]);
});
it("respects custom limit", () => {
const text = "Hello World, this is a test message.";
const chunks = chunkLineText(text, { limit: 15 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(15);
}
});
});
describe("extractCodeBlocks", () => {
it("extracts code blocks with language", () => {
const text = "Before\n```python\nprint('hello')\n```\nAfter";
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBe("python");
expect(codeBlocks[0].code).toBe("print('hello')");
expect(textWithoutCode).not.toContain("```");
});
it("extracts code blocks without language", () => {
const text = "```\nsome code\n```";
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBeUndefined();
});
it("handles text with no code blocks", () => {
const text = "No code here";
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(0);
expect(textWithoutCode).toBe(text);
});
});
describe("extractLinks", () => {
it("extracts markdown links", () => {
const text = "Check [this link](https://example.com) out";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(1);
expect(links[0].text).toBe("this link");
expect(links[0].url).toBe("https://example.com");
expect(textWithLinks).toBe("Check this link out");
});
it("handles text with no links", () => {
const text = "No links here";
const { links } = extractLinks(text);
expect(links).toHaveLength(0);
});
});
describe("extractMarkdownTables", () => {
it("extracts simple tables", () => {
const text = "Before\n| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\nAfter";
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["A", "B"]);
expect(tables[0].rows).toHaveLength(2);
expect(textWithoutTables).not.toContain("|");
});
it("handles text with no tables", () => {
const text = "No tables here";
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(0);
});
});
describe("stripMarkdown", () => {
it("removes bold formatting", () => {
expect(stripMarkdown("**bold text**")).toBe("bold text");
expect(stripMarkdown("__bold text__")).toBe("bold text");
});
it("removes strikethrough", () => {
expect(stripMarkdown("~~deleted~~")).toBe("deleted");
});
it("removes headers", () => {
expect(stripMarkdown("# Title")).toBe("Title");
expect(stripMarkdown("## Subtitle")).toBe("Subtitle");
});
it("removes blockquotes", () => {
expect(stripMarkdown("> quoted text")).toBe("quoted text");
});
it("removes inline code", () => {
expect(stripMarkdown("use `code` here")).toBe("use code here");
});
it("preserves plain text", () => {
expect(stripMarkdown("plain text")).toBe("plain text");
});
});
describe("hasMarkdownContent", () => {
it("detects bold", () => {
expect(hasMarkdownContent("**bold**")).toBe(true);
});
it("detects headers", () => {
expect(hasMarkdownContent("# Header")).toBe(true);
});
it("detects blockquotes", () => {
expect(hasMarkdownContent("> quote")).toBe(true);
});
it("returns false for plain text", () => {
expect(hasMarkdownContent("plain text")).toBe(false);
});
});
describe("processLineMessage", () => {
it("processes text with markdown content", () => {
const result = processLineMessage("**Hello** [link](https://example.com)");
expect(result.text).toContain("Hello");
expect(result.links).toHaveLength(1);
});
it("processes plain text", () => {
const result = processLineMessage("Just plain text");
expect(result.text).toBe("Just plain text");
expect(result.tables).toHaveLength(0);
expect(result.codeBlocks).toHaveLength(0);
});
});
describe("markdownToLineChunks", () => {
it("processes and chunks markdown", () => {
const result = markdownToLineChunks("Simple message");
expect(result.textChunks).toEqual(["Simple message"]);
});
});
describe("formatTableAsText", () => {
it("formats table with headers and rows", () => {
const result = formatTableAsText({
headers: ["Name", "Age"],
rows: [
["Alice", "30"],
["Bob", "25"],
],
});
expect(result).toContain("Name");
expect(result).toContain("Alice");
expect(result).toContain("Bob");
});
});
describe("formatCodeBlockAsText", () => {
it("formats with language label", () => {
const result = formatCodeBlockAsText({
language: "python",
code: "print(1)",
});
expect(result).toContain("[python]");
expect(result).toContain("print(1)");
});
it("formats without language", () => {
const result = formatCodeBlockAsText({ code: "hello" });
expect(result).toContain("[code]");
});
});
describe("truncateText", () => {
it("returns text unchanged if within limit", () => {
expect(truncateText("hello", 10)).toBe("hello");
});
it("truncates with ellipsis", () => {
expect(truncateText("hello world", 8)).toBe("hello...");
});
it("handles very short max length", () => {
expect(truncateText("hello", 3)).toBe("...");
});
});
describe("formatLineUser", () => {
it("returns display name if provided", () => {
expect(formatLineUser("Alice", "U123456")).toBe("Alice");
});
it("returns fallback with user ID if no display name", () => {
expect(formatLineUser("", "U1234567890abcdef")).toContain("User(");
expect(formatLineUser("", "U1234567890abcdef")).toContain("U1234567");
});
});
describe("resolveLineSystemLocation", () => {
it("formats user chat location", () => {
const result = resolveLineSystemLocation({
chatType: "user",
chatId: "U12345678",
chatName: "Alice",
});
expect(result).toBe("LINE user:Alice");
});
it("falls back to truncated chat ID", () => {
const result = resolveLineSystemLocation({
chatType: "group",
chatId: "C1234567890abcdef",
});
expect(result).toContain("LINE group:");
});
});
describe("isGroupChat", () => {
it("returns true for group", () => {
expect(isGroupChat({ groupId: "C123" })).toBe(true);
});
it("returns true for room", () => {
expect(isGroupChat({ roomId: "R123" })).toBe(true);
});
it("returns false for DM", () => {
expect(isGroupChat({})).toBe(false);
});
});
describe("getChatId", () => {
it("prefers groupId", () => {
expect(getChatId({ userId: "U1", groupId: "C1", roomId: "R1" })).toBe("C1");
});
it("falls back to roomId", () => {
expect(getChatId({ userId: "U1", roomId: "R1" })).toBe("R1");
});
it("falls back to userId", () => {
expect(getChatId({ userId: "U1" })).toBe("U1");
});
});
describe("getChatType", () => {
it("returns group when groupId present", () => {
expect(getChatType({ groupId: "C1" })).toBe("group");
});
it("returns room when roomId present", () => {
expect(getChatType({ roomId: "R1" })).toBe("room");
});
it("returns user when neither present", () => {
expect(getChatType({})).toBe("user");
});
});
});
// ===========================================================================
// Accounts utilities
// ===========================================================================
describe("Accounts utilities", () => {
it("normalizeAccountId returns default for null/undefined", () => {
expect(normalizeAccountId(null)).toBe("default");
expect(normalizeAccountId(undefined)).toBe("default");
expect(normalizeAccountId("")).toBe("default");
});
it("normalizeAccountId lowercases and trims", () => {
expect(normalizeAccountId(" MyAccount ")).toBe("myaccount");
});
it("normalizeAccountId returns default for 'default' input", () => {
expect(normalizeAccountId("default")).toBe("default");
expect(normalizeAccountId("DEFAULT")).toBe("default");
});
});
// ===========================================================================
// Webhook handling
// ===========================================================================
describe("Webhook handling", () => {
function createServiceWithRuntime() {
const runtime = {
emitEvent: vi.fn(),
};
const service = new LineService();
(service as unknown as { runtime: typeof runtime }).runtime = runtime;
return { runtime, service };
}
it("emits structured payloads for valid webhook events", async () => {
const { runtime, service } = createServiceWithRuntime();
await service.handleWebhookEvents([
{
type: "message",
timestamp: 1234567890,
source: { type: "group", userId: "U123", groupId: "C123" },
replyToken: "rt4",
message: { id: "msg1", type: "text", text: "Hello!" },
},
{
type: "postback",
timestamp: 1234567891,
source: { type: "user", userId: "U456" },
replyToken: "rt3",
postback: { data: "action=buy", params: { date: "2024-01-01" } },
},
] as Parameters<LineService["handleWebhookEvents"]>[0]);
expect(runtime.emitEvent).toHaveBeenCalledWith(
[LineEventTypes.MESSAGE_RECEIVED],
expect.objectContaining({
lineSource: expect.objectContaining({ groupId: "C123" }),
message: expect.objectContaining({
groupId: "C123",
id: "msg1",
text: "Hello!",
type: "text",
}),
replyToken: "rt4",
})
);
expect(runtime.emitEvent).toHaveBeenCalledWith(
[LineEventTypes.POSTBACK],
expect.objectContaining({
data: "action=buy",
params: { date: "2024-01-01" },
userId: "U456",
})
);
});
it("ignores malformed or hostile webhook payloads without emitting", async () => {
const { runtime, service } = createServiceWithRuntime();
await service.handleWebhookEvents([
null,
{ type: "message", timestamp: 1, source: { type: "user", userId: "U1" } },
{ type: "message", timestamp: 1, message: { type: "text", text: "missing id" } },
{ type: "postback", timestamp: 1, postback: null },
{ type: "postback", timestamp: 1, postback: { params: { date: "2024-01-01" } } },
{ type: "unknown", timestamp: 1 },
] as Parameters<LineService["handleWebhookEvents"]>[0]);
expect(runtime.emitEvent).not.toHaveBeenCalled();
});
});
// ===========================================================================
// Send payload validation
// ===========================================================================
describe("Send payload validation", () => {
function createServiceWithClient() {
const client = {
pushMessage: vi.fn().mockResolvedValue(undefined),
};
const service = new LineService();
(service as unknown as { client: typeof client }).client = client;
return { client, service };
}
it("rejects invalid location coordinates before sending", async () => {
const { client, service } = createServiceWithClient();
const result = await service.sendLocationMessage("U1234567890abcdef1234567890abcdef", {
type: "location",
title: "Invalid place",
address: "Unknown",
latitude: Number.NaN,
longitude: 139.7454,
});
expect(result.success).toBe(false);
expect(result.error).toContain("latitude");
expect(client.pushMessage).not.toHaveBeenCalled();
});
it("rejects unsafe template URLs before sending", async () => {
const { client, service } = createServiceWithClient();
const result = await service.sendTemplateMessage("U1234567890abcdef1234567890abcdef", {
altText: "Choose",
template: {
type: "buttons",
text: "Pick one",
thumbnailImageUrl: "javascript:alert(1)",
actions: [{ type: "uri", label: "Open", uri: "https://example.com" }],
},
});
expect(result.success).toBe(false);
expect(result.error).toContain("HTTPS");
expect(client.pushMessage).not.toHaveBeenCalled();
});
it("rejects unsafe template URI actions before sending", async () => {
const { client, service } = createServiceWithClient();
const result = await service.sendTemplateMessage("U1234567890abcdef1234567890abcdef", {
altText: "Choose",
template: {
type: "confirm",
text: "Pick one",
actions: [{ type: "uri", label: "Open", uri: "http://example.com" }],
},
});
expect(result.success).toBe(false);
expect(result.error).toContain("HTTPS");
expect(client.pushMessage).not.toHaveBeenCalled();
});
it("batches long text sends and attaches quick replies only to the final chunk", async () => {
const { client, service } = createServiceWithClient();
const text = "a".repeat(25_001);
const result = await service.sendMessage("U1234567890abcdef1234567890abcdef", text, {
quickReplyItems: [
{
type: "action",
action: { type: "message", label: "Yes", text: "yes" },
},
],
});
expect(result.success).toBe(true);
expect(client.pushMessage).toHaveBeenCalledTimes(2);
expect(client.pushMessage.mock.calls[0][0].messages).toHaveLength(5);
expect(client.pushMessage.mock.calls[0][0].messages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ quickReply: expect.anything() })])
);
expect(client.pushMessage.mock.calls[1][0].messages).toEqual([
expect.objectContaining({
quickReply: {
items: [
{
type: "action",
action: { type: "message", label: "Yes", text: "yes" },
},
],
},
}),
]);
});
});
// ===========================================================================
// Connector registration behavior
// ===========================================================================
describe("Connector registration behavior", () => {
function registerConnectorWithRooms() {
const roomA = {
id: "room-a",
name: "Line Support",
source: "line",
channelId: "C1234567890abcdef1234567890abcdef",
type: "group",
};
const roomB = {
id: "room-b",
name: "Personal",
source: "line",
channelId: "U1234567890abcdef1234567890abcdef",
type: "dm",
};
const runtime = {
agentId: "agent-id",
registerMessageConnector: vi.fn(),
registerSendHandler: vi.fn(),
getRoomsForParticipant: vi.fn().mockResolvedValue(["room-a", "room-b", "foreign-room"]),
getRoom: vi.fn(async (roomId: string) => {
if (roomId === "room-a") return roomA;
if (roomId === "room-b") return roomB;
return {
id: "foreign-room",
name: "Other",
source: "discord",
channelId: "D123",
type: "group",
};
}),
getMemories: vi.fn(async ({ roomId }: { roomId: string }) => {
if (roomId === "room-a") {
return [
{ id: "newer", content: { text: "incident followup" }, createdAt: 300 },
{ id: "older", content: { text: "hello" }, createdAt: 100 },
];
}
return [{ id: "dm", content: { text: "private incident" }, createdAt: 200 }];
}),
} as unknown as IAgentRuntime;
const service = Object.create(LineService.prototype) as LineService;
vi.spyOn(service, "leaveChat").mockResolvedValue(undefined);
LineService.registerSendHandlers(runtime, service);
return {
registration: vi.mocked(runtime.registerMessageConnector).mock.calls[0][0],
runtime,
service,
};
}
it("resolves exact LINE IDs ahead of stored rooms and ignores foreign rooms", async () => {
const { registration, runtime } = registerConnectorWithRooms();
const targets = await registration.resolveTargets("C1234567890abcdef1234567890abcdef", {
runtime,
});
expect(targets[0]).toEqual(
expect.objectContaining({
label: "C1234567890abcdef1234567890abcdef",
kind: "group",
score: 1,
})
);
expect(targets).toHaveLength(2);
expect(targets.map((target) => target.label)).toContain("Line Support");
expect(targets.map((target) => target.label)).not.toContain("Other");
});
it("fetches and searches stored LINE message memories with hostile limits normalized", async () => {
const { registration, runtime } = registerConnectorWithRooms();
const fetched = await registration.fetchMessages?.(
{ runtime },
{ limit: Number.POSITIVE_INFINITY }
);
const searched = await registration.searchMessages?.(
{ runtime },
{ query: "incident", limit: -1 }
);
expect(fetched?.map((memory) => memory.id)).toEqual(["newer", "dm", "older"]);
expect(searched?.map((memory) => memory.id)).toEqual(["newer", "dm"]);
});
it("rejects leave requests for user targets before calling the LINE API", async () => {
const { registration, runtime, service } = registerConnectorWithRooms();
await expect(
registration.leaveHandler?.(runtime, {
target: { source: "line", channelId: "U1234567890abcdef1234567890abcdef" },
})
).rejects.toThrow("requires a group or room target");
expect(service.leaveChat).not.toHaveBeenCalled();
});
it("resolves leave targets from stored room channel IDs", async () => {
const { registration, runtime, service } = registerConnectorWithRooms();
await registration.leaveHandler?.(runtime, {
target: { source: "line", roomId: "room-a" },
});
expect(service.leaveChat).toHaveBeenCalledWith("C1234567890abcdef1234567890abcdef", "group");
});
});
// ===========================================================================
// Workflow credentials
// ===========================================================================
describe("Workflow credential provider", () => {
it("returns bearer header credentials for supported workflow credential requests", async () => {
const runtime = {
getSetting: vi.fn(() => " line-token "),
} as unknown as IAgentRuntime;
const provider = new LineWorkflowCredentialProvider(runtime);
await expect(provider.resolve("user-1", "httpHeaderAuth")).resolves.toEqual({
status: "credential_data",
data: { name: "Authorization", value: "Bearer line-token" },
});
});
it("declines unsupported workflow credential types", async () => {
const runtime = {
getSetting: vi.fn(() => "line-token"),
} as unknown as IAgentRuntime;
const provider = new LineWorkflowCredentialProvider(runtime);
await expect(provider.resolve("user-1", "oauth2")).resolves.toBeNull();
expect(runtime.getSetting).not.toHaveBeenCalled();
});
it("returns null when runtime credential lookup fails", async () => {
const runtime = {
getSetting: vi.fn(() => {
throw new Error("settings unavailable");
}),
} as unknown as IAgentRuntime;
const provider = new LineWorkflowCredentialProvider(runtime);
await expect(provider.resolve("user-1", "httpHeaderAuth")).resolves.toBeNull();
});
});
// ===========================================================================
// Service lifecycle
// ===========================================================================
describe("Service lifecycle", () => {
it("can be constructed without runtime", () => {
const service = new LineService();
expect(service.isConnected()).toBe(false);
});
it("returns false from isConnected before start", () => {
const service = new LineService();
expect(service.isConnected()).toBe(false);
});
it("returns null settings before configuration", () => {
const service = new LineService();
expect(service.getSettings()).toBeNull();
});
it("stop works even when not started", async () => {
const service = new LineService();
await service.stop();
expect(service.isConnected()).toBe(false);
expect(service.getSettings()).toBeNull();
});
});