重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,16 @@
type LineCredentialAccount = {
channelAccessToken?: string;
channelSecret?: string;
};
export function hasLineCredentials(account: LineCredentialAccount): boolean {
return Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim());
}
export function parseLineAllowFromId(raw: string): string | null {
const trimmed = raw.trim().replace(/^line:(?:user:)?/i, "");
if (!/^U[a-f0-9]{32}$/i.test(trimmed)) {
return null;
}
return trimmed;
}

View file

@ -0,0 +1,290 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
resolveLineAccount,
resolveDefaultLineAccountId,
normalizeAccountId,
DEFAULT_ACCOUNT_ID,
} from "./accounts.js";
describe("LINE accounts", () => {
const originalEnv = { ...process.env };
const tempDirs: string[] = [];
const createSecretFile = (fileName: string, contents: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
tempDirs.push(dir);
const filePath = path.join(dir, fileName);
fs.writeFileSync(filePath, contents, "utf8");
return filePath;
};
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.LINE_CHANNEL_ACCESS_TOKEN;
delete process.env.LINE_CHANNEL_SECRET;
});
afterEach(() => {
process.env = originalEnv;
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("resolveLineAccount", () => {
it("resolves account from config", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
enabled: true,
channelAccessToken: "test-token",
channelSecret: "test-secret",
name: "Test Bot",
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
expect(account.enabled).toBe(true);
expect(account.channelAccessToken).toBe("test-token");
expect(account.channelSecret).toBe("test-secret");
expect(account.name).toBe("Test Bot");
expect(account.tokenSource).toBe("config");
});
it("resolves account from environment variables", () => {
process.env.LINE_CHANNEL_ACCESS_TOKEN = "env-token";
process.env.LINE_CHANNEL_SECRET = "env-secret";
const cfg: OpenClawConfig = {
channels: {
line: {
enabled: true,
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("env-token");
expect(account.channelSecret).toBe("env-secret");
expect(account.tokenSource).toBe("env");
});
it("resolves named account", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
enabled: true,
accounts: {
business: {
enabled: true,
channelAccessToken: "business-token",
channelSecret: "business-secret",
name: "Business Bot",
},
},
},
},
};
const account = resolveLineAccount({ cfg, accountId: "business" });
expect(account.accountId).toBe("business");
expect(account.enabled).toBe(true);
expect(account.channelAccessToken).toBe("business-token");
expect(account.channelSecret).toBe("business-secret");
expect(account.name).toBe("Business Bot");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
defaultAccount: "business",
accounts: {
business: {
enabled: true,
channelAccessToken: "business-token",
channelSecret: "business-secret",
name: "Business Bot",
},
},
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.accountId).toBe("business");
expect(account.enabled).toBe(true);
expect(account.channelAccessToken).toBe("business-token");
expect(account.channelSecret).toBe("business-secret");
expect(account.name).toBe("Business Bot");
});
it("returns empty token when not configured", () => {
const cfg: OpenClawConfig = {};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("");
expect(account.channelSecret).toBe("");
expect(account.tokenSource).toBe("none");
});
it("resolves default account credentials from files", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
tokenFile: createSecretFile("token.txt", "file-token\n"),
secretFile: createSecretFile("secret.txt", "file-secret\n"),
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("file-token");
expect(account.channelSecret).toBe("file-secret");
expect(account.tokenSource).toBe("file");
});
it("resolves named account credentials from account-level files", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
accounts: {
business: {
tokenFile: createSecretFile("business-token.txt", "business-file-token\n"),
secretFile: createSecretFile("business-secret.txt", "business-file-secret\n"),
},
},
},
},
};
const account = resolveLineAccount({ cfg, accountId: "business" });
expect(account.channelAccessToken).toBe("business-file-token");
expect(account.channelSecret).toBe("business-file-secret");
expect(account.tokenSource).toBe("file");
});
it.runIf(process.platform !== "win32")("rejects symlinked token and secret files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
tempDirs.push(dir);
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
const secretFile = path.join(dir, "secret.txt");
const secretLink = path.join(dir, "secret-link.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
fs.writeFileSync(secretFile, "file-secret\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
fs.symlinkSync(secretFile, secretLink);
const cfg: OpenClawConfig = {
channels: {
line: {
tokenFile: tokenLink,
secretFile: secretLink,
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("");
expect(account.channelSecret).toBe("");
expect(account.tokenSource).toBe("none");
});
});
describe("resolveDefaultLineAccountId", () => {
it.each([
{
name: "prefers channels.line.defaultAccount when configured",
cfg: {
channels: {
line: {
defaultAccount: "business",
accounts: {
business: { enabled: true },
support: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expected: "business",
},
{
name: "normalizes channels.line.defaultAccount before lookup",
cfg: {
channels: {
line: {
defaultAccount: "Business Ops",
accounts: {
"business-ops": { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expected: "business-ops",
},
{
name: "returns first named account when default not configured",
cfg: {
channels: {
line: {
accounts: {
business: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expected: "business",
},
{
name: "falls back when channels.line.defaultAccount is missing",
cfg: {
channels: {
line: {
defaultAccount: "missing",
accounts: {
business: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expected: "business",
},
{
name: "prefers the default account when base credentials are configured",
cfg: {
channels: {
line: {
channelAccessToken: "base-token",
accounts: {
business: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expected: DEFAULT_ACCOUNT_ID,
},
])("$name", ({ cfg, expected }) => {
expect(resolveDefaultLineAccountId(cfg)).toBe(expected);
});
});
describe("normalizeAccountId", () => {
it("trims and lowercases account ids", () => {
expect(normalizeAccountId(" Business ")).toBe("business");
});
});
});

View file

@ -0,0 +1,187 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId as normalizeSharedAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import type {
LineAccountConfig,
LineConfig,
LineTokenSource,
ResolvedLineAccount,
} from "./types.js";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
function readFileIfExists(filePath: string | undefined): string | undefined {
return tryReadSecretFileSync(filePath, "LINE credential file", { rejectSymlink: true });
}
function resolveToken(params: {
accountId: string;
baseConfig?: LineConfig;
accountConfig?: LineAccountConfig;
}): { token: string; tokenSource: LineTokenSource } {
const { accountId, baseConfig, accountConfig } = params;
if (accountConfig?.channelAccessToken?.trim()) {
return { token: accountConfig.channelAccessToken.trim(), tokenSource: "config" };
}
const accountFileToken = readFileIfExists(accountConfig?.tokenFile);
if (accountFileToken) {
return { token: accountFileToken, tokenSource: "file" };
}
if (accountId === DEFAULT_ACCOUNT_ID) {
if (baseConfig?.channelAccessToken?.trim()) {
return { token: baseConfig.channelAccessToken.trim(), tokenSource: "config" };
}
const baseFileToken = readFileIfExists(baseConfig?.tokenFile);
if (baseFileToken) {
return { token: baseFileToken, tokenSource: "file" };
}
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim();
if (envToken) {
return { token: envToken, tokenSource: "env" };
}
}
return { token: "", tokenSource: "none" };
}
function resolveSecret(params: {
accountId: string;
baseConfig?: LineConfig;
accountConfig?: LineAccountConfig;
}): string {
const { accountId, baseConfig, accountConfig } = params;
if (accountConfig?.channelSecret?.trim()) {
return accountConfig.channelSecret.trim();
}
const accountFileSecret = readFileIfExists(accountConfig?.secretFile);
if (accountFileSecret) {
return accountFileSecret;
}
if (accountId === DEFAULT_ACCOUNT_ID) {
if (baseConfig?.channelSecret?.trim()) {
return baseConfig.channelSecret.trim();
}
const baseFileSecret = readFileIfExists(baseConfig?.secretFile);
if (baseFileSecret) {
return baseFileSecret;
}
const envSecret = process.env.LINE_CHANNEL_SECRET?.trim();
if (envSecret) {
return envSecret;
}
}
return "";
}
export function resolveLineAccount(params: {
cfg: OpenClawConfig;
accountId?: string;
}): ResolvedLineAccount {
const cfg = params.cfg;
const accountId = normalizeSharedAccountId(params.accountId ?? resolveDefaultLineAccountId(cfg));
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const accountConfig =
accountId !== DEFAULT_ACCOUNT_ID ? resolveAccountEntry(accounts, accountId) : undefined;
const { token, tokenSource } = resolveToken({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const secret = resolveSecret({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const {
accounts: _ignoredAccounts,
defaultAccount: _ignoredDefaultAccount,
...lineBase
} = (lineConfig ?? {}) as LineConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const mergedConfig: LineConfig & LineAccountConfig = {
...lineBase,
...accountConfig,
};
const enabled =
accountConfig?.enabled ??
(accountId === DEFAULT_ACCOUNT_ID ? (lineConfig?.enabled ?? true) : false);
const name =
accountConfig?.name ?? (accountId === DEFAULT_ACCOUNT_ID ? lineConfig?.name : undefined);
return {
accountId,
name,
enabled,
channelAccessToken: token,
channelSecret: secret,
tokenSource,
config: mergedConfig,
};
}
export function listLineAccountIds(cfg: OpenClawConfig): string[] {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const ids = new Set<string>();
if (
lineConfig?.channelAccessToken?.trim() ||
lineConfig?.tokenFile ||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim()
) {
ids.add(DEFAULT_ACCOUNT_ID);
}
if (accounts) {
for (const id of Object.keys(accounts)) {
ids.add(id);
}
}
return Array.from(ids);
}
export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string {
const preferred = normalizeOptionalAccountId(
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
);
if (
preferred &&
listLineAccountIds(cfg).some((accountId) => normalizeSharedAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listLineAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId: string | undefined): string {
return normalizeSharedAccountId(accountId);
}

View file

@ -0,0 +1,61 @@
import type { messagingApi } from "@line/bot-sdk";
export type Action = messagingApi.Action;
/**
* Create a message action (sends text when tapped)
*/
export function messageAction(label: string, text?: string): Action {
return {
type: "message",
label: label.slice(0, 20),
text: text ?? label,
};
}
/**
* Create a URI action (opens a URL when tapped)
*/
export function uriAction(label: string, uri: string): Action {
return {
type: "uri",
label: label.slice(0, 20),
uri,
};
}
/**
* Create a postback action (sends data to webhook when tapped)
*/
export function postbackAction(label: string, data: string, displayText?: string): Action {
return {
type: "postback",
label: label.slice(0, 20),
data: data.slice(0, 300),
displayText: displayText?.slice(0, 300),
};
}
/**
* Create a datetime picker action
*/
export function datetimePickerAction(
label: string,
data: string,
mode: "date" | "time" | "datetime",
options?: {
initial?: string;
max?: string;
min?: string;
},
): Action {
return {
type: "datetimepicker",
label: label.slice(0, 20),
data: data.slice(0, 300),
mode,
initial: options?.initial,
max: options?.max,
min: options?.min,
};
}

View file

@ -0,0 +1,209 @@
import { describe, expect, it, vi } from "vitest";
import type { LineAutoReplyDeps } from "./auto-reply-delivery.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
const createFlexMessage = (altText: string, contents: unknown) => ({
type: "flex" as const,
altText,
contents,
});
const createImageMessage = (url: string) => ({
type: "image" as const,
originalContentUrl: url,
previewImageUrl: url,
});
const createLocationMessage = (location: {
title: string;
address: string;
latitude: number;
longitude: number;
}) => ({
type: "location" as const,
...location,
});
describe("deliverLineAutoReply", () => {
const baseDeliveryParams = {
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
};
function createDeps(overrides?: Partial<LineAutoReplyDeps>) {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string) => ({
type: "text" as const,
text,
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const deps: LineAutoReplyDeps = {
buildTemplateMessageFromPayload: () => null,
processLineMessage: (text) => ({ text, flexMessages: [] }),
chunkMarkdownText: (text) => [text],
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems: createQuickReplyItems as LineAutoReplyDeps["createQuickReplyItems"],
pushMessagesLine,
createFlexMessage: createFlexMessage as LineAutoReplyDeps["createFlexMessage"],
createImageMessage,
createLocationMessage,
...overrides,
};
return {
deps,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
};
}
it("uses reply token for text before sending rich messages", async () => {
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
};
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps();
const result = await deliverLineAutoReply({
...baseDeliveryParams,
payload: { text: "hello", channelData: { line: lineData } },
lineData,
deps,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], {
accountId: "acc",
});
expect(pushMessagesLine).toHaveBeenCalledTimes(1);
expect(pushMessagesLine).toHaveBeenCalledWith(
"line:user:1",
[createFlexMessage("Card", { type: "bubble" })],
{ accountId: "acc" },
);
expect(createQuickReplyItems).not.toHaveBeenCalled();
});
it("uses reply token for rich-only payloads", async () => {
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps({
processLineMessage: () => ({ text: "", flexMessages: [] }),
chunkMarkdownText: () => [],
sendLineReplyChunks: vi.fn(async () => ({ replyTokenUsed: false })),
});
const result = await deliverLineAutoReply({
...baseDeliveryParams,
payload: { channelData: { line: lineData } },
lineData,
deps,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{
...createFlexMessage("Card", { type: "bubble" }),
quickReply: { items: ["A"] },
},
],
{ accountId: "acc" },
);
expect(pushMessagesLine).not.toHaveBeenCalled();
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
});
it("sends rich messages before quick-reply text so quick replies remain visible", async () => {
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
quickReply: { items: ["A"] },
}));
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
const { deps, pushMessagesLine, replyMessageLine } = createDeps({
createTextMessageWithQuickReplies:
createTextMessageWithQuickReplies as LineAutoReplyDeps["createTextMessageWithQuickReplies"],
});
await deliverLineAutoReply({
...baseDeliveryParams,
payload: { text: "hello", channelData: { line: lineData } },
lineData,
deps,
});
expect(pushMessagesLine).toHaveBeenCalledWith(
"line:user:1",
[createFlexMessage("Card", { type: "bubble" })],
{ accountId: "acc" },
);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{
type: "text",
text: "hello",
quickReply: { items: ["A"] },
},
],
{ accountId: "acc" },
);
const pushOrder = pushMessagesLine.mock.invocationCallOrder[0];
const replyOrder = replyMessageLine.mock.invocationCallOrder[0];
expect(pushOrder).toBeLessThan(replyOrder);
});
it("falls back to push when reply token delivery fails", async () => {
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
};
const failingReplyMessageLine = vi.fn(async () => {
throw new Error("reply failed");
});
const { deps, pushMessagesLine } = createDeps({
processLineMessage: () => ({ text: "", flexMessages: [] }),
chunkMarkdownText: () => [],
replyMessageLine: failingReplyMessageLine as LineAutoReplyDeps["replyMessageLine"],
});
const result = await deliverLineAutoReply({
...baseDeliveryParams,
payload: { channelData: { line: lineData } },
lineData,
deps,
});
expect(result.replyTokenUsed).toBe(true);
expect(failingReplyMessageLine).toHaveBeenCalledTimes(1);
expect(pushMessagesLine).toHaveBeenCalledWith(
"line:user:1",
[createFlexMessage("Card", { type: "bubble" })],
{ accountId: "acc" },
);
});
});

View file

@ -0,0 +1,176 @@
import type { messagingApi } from "@line/bot-sdk";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js";
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
import type { LineChannelData, LineTemplateMessagePayload } from "./types.js";
export type LineAutoReplyDeps = {
buildTemplateMessageFromPayload: (
payload: LineTemplateMessagePayload,
) => messagingApi.TemplateMessage | null;
processLineMessage: (text: string) => ProcessedLineMessage;
chunkMarkdownText: (text: string, limit: number) => string[];
sendLineReplyChunks: (params: SendLineReplyChunksParams) => Promise<{ replyTokenUsed: boolean }>;
createQuickReplyItems: (labels: string[]) => messagingApi.QuickReply;
pushMessagesLine: (
to: string,
messages: messagingApi.Message[],
opts?: { accountId?: string },
) => Promise<unknown>;
createFlexMessage: (altText: string, contents: FlexContainer) => messagingApi.FlexMessage;
createImageMessage: (
originalContentUrl: string,
previewImageUrl?: string,
) => messagingApi.ImageMessage;
createLocationMessage: (location: {
title: string;
address: string;
latitude: number;
longitude: number;
}) => messagingApi.LocationMessage;
} & Pick<
SendLineReplyChunksParams,
| "replyMessageLine"
| "pushMessageLine"
| "pushTextMessageWithQuickReplies"
| "createTextMessageWithQuickReplies"
| "onReplyError"
>;
export async function deliverLineAutoReply(params: {
payload: ReplyPayload;
lineData: LineChannelData;
to: string;
replyToken?: string | null;
replyTokenUsed: boolean;
accountId?: string;
textLimit: number;
deps: LineAutoReplyDeps;
}): Promise<{ replyTokenUsed: boolean }> {
const { payload, lineData, replyToken, accountId, to, textLimit, deps } = params;
let replyTokenUsed = params.replyTokenUsed;
const pushLineMessages = async (messages: messagingApi.Message[]): Promise<void> => {
if (messages.length === 0) {
return;
}
for (let i = 0; i < messages.length; i += 5) {
await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
accountId,
});
}
};
const sendLineMessages = async (
messages: messagingApi.Message[],
allowReplyToken: boolean,
): Promise<void> => {
if (messages.length === 0) {
return;
}
let remaining = messages;
if (allowReplyToken && replyToken && !replyTokenUsed) {
const replyBatch = remaining.slice(0, 5);
try {
await deps.replyMessageLine(replyToken, replyBatch, {
accountId,
});
} catch (err) {
deps.onReplyError?.(err);
await pushLineMessages(replyBatch);
}
replyTokenUsed = true;
remaining = remaining.slice(replyBatch.length);
}
if (remaining.length > 0) {
await pushLineMessages(remaining);
}
};
const richMessages: messagingApi.Message[] = [];
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
if (lineData.flexMessage) {
richMessages.push(
deps.createFlexMessage(
lineData.flexMessage.altText.slice(0, 400),
lineData.flexMessage.contents as FlexContainer,
),
);
}
if (lineData.templateMessage) {
const templateMsg = deps.buildTemplateMessageFromPayload(lineData.templateMessage);
if (templateMsg) {
richMessages.push(templateMsg);
}
}
if (lineData.location) {
richMessages.push(deps.createLocationMessage(lineData.location));
}
const processed = payload.text
? deps.processLineMessage(payload.text)
: { text: "", flexMessages: [] };
for (const flexMsg of processed.flexMessages) {
richMessages.push(deps.createFlexMessage(flexMsg.altText.slice(0, 400), flexMsg.contents));
}
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
const mediaMessages = mediaUrls
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url))
.map((url) => deps.createImageMessage(url));
if (chunks.length > 0) {
const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0;
if (hasQuickReplies && hasRichOrMedia) {
try {
await sendLineMessages([...richMessages, ...mediaMessages], false);
} catch (err) {
deps.onReplyError?.(err);
}
}
const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
to,
chunks,
quickReplies: lineData.quickReplies,
replyToken,
replyTokenUsed,
accountId,
replyMessageLine: deps.replyMessageLine,
pushMessageLine: deps.pushMessageLine,
pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
});
replyTokenUsed = nextReplyTokenUsed;
if (!hasQuickReplies || !hasRichOrMedia) {
await sendLineMessages(richMessages, false);
if (mediaMessages.length > 0) {
await sendLineMessages(mediaMessages, false);
}
}
} else {
const combined = [...richMessages, ...mediaMessages];
if (hasQuickReplies && combined.length > 0) {
const quickReply = deps.createQuickReplyItems(lineData.quickReplies!);
const targetIndex =
replyToken && !replyTokenUsed ? Math.min(4, combined.length - 1) : combined.length - 1;
const target = combined[targetIndex] as messagingApi.Message & {
quickReply?: messagingApi.QuickReply;
};
combined[targetIndex] = { ...target, quickReply };
}
await sendLineMessages(combined, true);
}
return { replyTokenUsed };
}

View file

@ -0,0 +1,65 @@
function normalizeLineConversationId(raw?: string | null): string | null {
const trimmed = raw?.trim() ?? "";
if (!trimmed) {
return null;
}
const prefixed = trimmed.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1];
return (prefixed ?? trimmed).trim() || null;
}
function resolveLineCommandConversation(params: {
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const conversationId =
normalizeLineConversationId(params.originatingTo) ??
normalizeLineConversationId(params.commandTo) ??
normalizeLineConversationId(params.fallbackTo);
return conversationId ? { conversationId } : null;
}
function resolveLineInboundConversation(params: { to?: string; conversationId?: string }) {
const conversationId =
normalizeLineConversationId(params.conversationId) ?? normalizeLineConversationId(params.to);
return conversationId ? { conversationId } : null;
}
export const lineBindingsAdapter = {
compileConfiguredBinding: ({ conversationId }: { conversationId?: string }) => {
const normalized = normalizeLineConversationId(conversationId);
return normalized ? { conversationId: normalized } : null;
},
matchInboundConversation: ({
compiledBinding,
conversationId,
}: {
compiledBinding: { conversationId: string };
conversationId?: string;
}) => {
const normalizedIncoming = normalizeLineConversationId(conversationId);
if (!normalizedIncoming || compiledBinding.conversationId !== normalizedIncoming) {
return null;
}
return {
conversationId: normalizedIncoming,
matchPriority: 2,
};
},
resolveCommandConversation: ({
originatingTo,
commandTo,
fallbackTo,
}: {
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) =>
resolveLineCommandConversation({
originatingTo,
commandTo,
fallbackTo,
}),
resolveInboundConversation: ({ to, conversationId }: { to?: string; conversationId?: string }) =>
resolveLineInboundConversation({ to, conversationId }),
};

View file

@ -0,0 +1,48 @@
import {
firstDefined,
isSenderIdAllowed,
mergeDmAllowFromSources,
} from "openclaw/plugin-sdk/allow-from";
export type NormalizedAllowFrom = {
entries: string[];
hasWildcard: boolean;
hasEntries: boolean;
};
function normalizeAllowEntry(value: string | number): string {
const trimmed = String(value).trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed.replace(/^line:(?:user:)?/i, "");
}
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean);
const hasWildcard = entries.includes("*");
return {
entries,
hasWildcard,
hasEntries: entries.length > 0,
};
};
export const normalizeDmAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
dmPolicy?: string;
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
}): boolean => {
const { allow, senderId } = params;
return isSenderIdAllowed(allow, senderId, false);
};
export { firstDefined };

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,640 @@
import type { webhook } from "@line/bot-sdk";
import {
buildMentionRegexes,
matchesMentionPatterns,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { hasControlCommand, resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import {
readChannelAllowFromStore,
resolvePairingIdLabel,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import {
buildLineMessageContext,
buildLinePostbackContext,
getLineSourceInfo,
type LineInboundContext,
} from "./bot-message-context.js";
import { downloadLineMedia } from "./download.js";
import { resolveLineGroupConfigEntry } from "./group-keys.js";
import { pushMessageLine, replyMessageLine } from "./send.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
type FollowEvent = webhook.FollowEvent;
type JoinEvent = webhook.JoinEvent;
type LeaveEvent = webhook.LeaveEvent;
type MessageEvent = webhook.MessageEvent;
type PostbackEvent = webhook.PostbackEvent;
type UnfollowEvent = webhook.UnfollowEvent;
type WebhookEvent = webhook.Event;
interface MediaRef {
path: string;
contentType?: string;
}
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
"image",
"video",
"audio",
"file",
]);
function isDownloadableLineMessageType(
messageType: MessageEvent["message"]["type"],
): messageType is "image" | "video" | "audio" | "file" {
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
}
export interface LineHandlerContext {
cfg: OpenClawConfig;
account: ResolvedLineAccount;
runtime: RuntimeEnv;
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
replayCache?: LineWebhookReplayCache;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
export type LineWebhookReplayCache = ClaimableDedupe;
export class LineRetryableWebhookError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "LineRetryableWebhookError";
}
}
export function createLineWebhookReplayCache(): LineWebhookReplayCache {
return createClaimableDedupe({
ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES,
});
}
function buildLineWebhookReplayKey(
event: WebhookEvent,
accountId: string,
): { key: string; eventId: string } | null {
if (event.type === "message") {
const messageId = event.message?.id?.trim();
if (messageId) {
return {
key: `${accountId}|message:${messageId}`,
eventId: `message:${messageId}`,
};
}
}
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
if (!eventId) {
return null;
}
const source = (
event as {
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
}
).source;
const sourceId =
source?.type === "group"
? `group:${source.groupId ?? ""}`
: source?.type === "room"
? `room:${source.roomId ?? ""}`
: `user:${source?.userId ?? ""}`;
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
}
type LineReplayCandidate = {
key: string;
eventId: string;
cache: LineWebhookReplayCache;
};
function getLineReplayCandidate(
event: WebhookEvent,
context: LineHandlerContext,
): LineReplayCandidate | null {
const replay = buildLineWebhookReplayKey(event, context.account.accountId);
const cache = context.replayCache;
if (!replay || !cache) {
return null;
}
return { key: replay.key, eventId: replay.eventId, cache };
}
async function claimLineReplayEvent(
candidate: LineReplayCandidate,
): Promise<{ skip: true; inFlightResult?: Promise<void> } | { skip: false }> {
const claim = await candidate.cache.claim(candidate.key);
if (claim.kind === "claimed") {
return { skip: false };
}
if (claim.kind === "inflight") {
logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
return { skip: true, inFlightResult: claim.pending.then(() => undefined) };
}
logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
return { skip: true };
}
function resolveLineGroupConfig(params: {
config: ResolvedLineAccount["config"];
groupId?: string;
roomId?: string;
}): LineGroupConfig | undefined {
return resolveLineGroupConfigEntry(params.config.groups, {
groupId: params.groupId,
roomId: params.roomId,
});
}
async function sendLinePairingReply(params: {
senderId: string;
replyToken?: string;
context: LineHandlerContext;
}): Promise<void> {
const { senderId, replyToken, context } = params;
const idLabel = (() => {
try {
return resolvePairingIdLabel("line");
} catch {
return "lineUserId";
}
})();
await createChannelPairingChallengeIssuer({
channel: "line",
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "line",
id,
accountId: context.account.accountId,
meta,
}),
})({
senderId,
senderIdLine: `Your ${idLabel}: ${senderId}`,
onCreated: () => {
logVerbose(`line pairing request sender=${senderId}`);
},
sendPairingReply: async (text) => {
if (replyToken) {
try {
await replyMessageLine(replyToken, [{ type: "text", text }], {
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
return;
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
}
try {
await pushMessageLine(`line:${senderId}`, text, {
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
},
});
}
async function shouldProcessLineEvent(
event: MessageEvent | PostbackEvent,
context: LineHandlerContext,
): Promise<{ allowed: boolean; commandAuthorized: boolean }> {
const denied = { allowed: false, commandAuthorized: false };
const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
const senderId = userId ?? "";
const dmPolicy = account.config.dmPolicy ?? "pairing";
const storeAllowFrom = await readChannelAllowFromStore(
"line",
undefined,
account.accountId,
).catch(() => []);
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: account.config.allowFrom,
storeAllowFrom,
dmPolicy,
});
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const groupAllowOverride = groupConfig?.allowFrom;
const fallbackGroupAllowFrom = account.config.allowFrom?.length
? account.config.allowFrom
: undefined;
const groupAllowFrom = firstDefined(
groupAllowOverride,
account.config.groupAllowFrom,
fallbackGroupAllowFrom,
);
const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.line !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "line",
accountId: account.accountId,
log: (message) => logVerbose(message),
});
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return denied;
}
if (typeof groupAllowOverride !== "undefined") {
if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return denied;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return denied;
}
}
const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({
groupPolicy,
requireMatchInput: true,
hasMatchInput: Boolean(senderId),
allowlistConfigured: effectiveGroupAllow.entries.length > 0,
allowlistMatched:
Boolean(senderId) &&
isSenderAllowed({
allow: effectiveGroupAllow,
senderId,
}),
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "missing_match_input") {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "not_allowlisted") {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return denied;
}
return {
allowed: true,
commandAuthorized: resolveLineCommandAuthorized({
cfg,
event,
senderId,
allow: effectiveGroupAllow,
}),
};
}
if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)");
return denied;
}
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
if (!dmAllowed) {
if (dmPolicy === "pairing") {
if (!senderId) {
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
return denied;
}
await sendLinePairingReply({
senderId,
replyToken: "replyToken" in event ? event.replyToken : undefined,
context,
});
} else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
}
return denied;
}
return {
allowed: true,
commandAuthorized: resolveLineCommandAuthorized({
cfg,
event,
senderId,
allow: effectiveDmAllow,
}),
};
}
function getLineMentionees(
message: MessageEvent["message"],
): Array<{ type?: string; isSelf?: boolean }> {
if (message.type !== "text") {
return [];
}
const mentionees = (
message as Record<string, unknown> & {
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
}
).mention?.mentionees;
return Array.isArray(mentionees) ? mentionees : [];
}
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
}
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).length > 0;
}
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
if (event.type === "message") {
const msg = event.message;
if (msg.type === "text") {
return msg.text;
}
return "";
}
if (event.type === "postback") {
return event.postback?.data?.trim() ?? "";
}
return "";
}
function resolveLineCommandAuthorized(params: {
cfg: OpenClawConfig;
event: MessageEvent | PostbackEvent;
senderId?: string;
allow: NormalizedAllowFrom;
}): boolean {
const senderAllowedForCommands = isSenderAllowed({
allow: params.allow,
senderId: params.senderId,
});
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(params.event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, params.cfg),
});
return commandGate.commandAuthorized;
}
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message;
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
if (isGroup) {
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const requireMention = groupConfig?.requireMention !== false;
const rawText = message.type === "text" ? message.text : "";
const sourceInfo = getLineSourceInfo(event.source);
const peerId = groupId ?? roomId ?? sourceInfo.userId ?? "unknown";
const { agentId } = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: { kind: "group", id: peerId },
});
const mentionRegexes = buildMentionRegexes(cfg, agentId);
const wasMentionedByNative = isLineBotMentioned(message);
const wasMentionedByPattern =
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: message.type === "text",
wasMentioned,
hasAnyMention: hasAnyLineMention(message),
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
commandAuthorized: decision.commandAuthorized,
},
});
if (mentionDecision.shouldSkip) {
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
const historyKey = groupId ?? roomId;
const senderId = sourceInfo.userId ?? "unknown";
if (historyKey && context.groupHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
entry: {
sender: `user:${senderId}`,
body: rawText || `<${message.type}>`,
timestamp: event.timestamp,
},
});
}
return;
}
}
const allMedia: MediaRef[] = [];
if (isDownloadableLineMessageType(message.type)) {
try {
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
allMedia.push({
path: media.path,
contentType: media.contentType,
});
} catch (err) {
const errMsg = String(err);
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
logVerbose(`line: media exceeds size limit for message ${message.id}`);
} else {
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
}
}
}
const messageContext = await buildLineMessageContext({
event,
allMedia,
cfg,
account,
commandAuthorized: decision.commandAuthorized,
groupHistories: context.groupHistories,
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
if (!messageContext) {
logVerbose("line: skipping empty message");
return;
}
await processMessage(messageContext);
if (isGroup && context.groupHistories) {
const historyKey = groupId ?? roomId;
if (historyKey && context.groupHistories.has(historyKey)) {
clearHistoryEntriesIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
}
}
}
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
const { userId } = getLineSourceInfo(event.source);
logVerbose(`line: user ${userId ?? "unknown"} followed`);
}
async function handleUnfollowEvent(
event: UnfollowEvent,
_context: LineHandlerContext,
): Promise<void> {
const { userId } = getLineSourceInfo(event.source);
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
}
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
const { groupId, roomId } = getLineSourceInfo(event.source);
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
const { groupId, roomId } = getLineSourceInfo(event.source);
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handlePostbackEvent(
event: PostbackEvent,
context: LineHandlerContext,
): Promise<void> {
const data = event.postback.data;
logVerbose(`line: received postback: ${data}`);
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
const postbackContext = await buildLinePostbackContext({
event,
cfg: context.cfg,
account: context.account,
commandAuthorized: decision.commandAuthorized,
});
if (!postbackContext) {
return;
}
await context.processMessage(postbackContext);
}
export async function handleLineWebhookEvents(
events: WebhookEvent[],
context: LineHandlerContext,
): Promise<void> {
let firstError: unknown;
for (const event of events) {
const replayCandidate = getLineReplayCandidate(event, context);
const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
if (replaySkip?.skip) {
if (replaySkip.inFlightResult) {
try {
await replaySkip.inFlightResult;
} catch (err) {
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
firstError ??= err;
}
}
continue;
}
try {
switch (event.type) {
case "message":
await handleMessageEvent(event, context);
break;
case "follow":
await handleFollowEvent(event, context);
break;
case "unfollow":
await handleUnfollowEvent(event, context);
break;
case "join":
await handleJoinEvent(event, context);
break;
case "leave":
await handleLeaveEvent(event, context);
break;
case "postback":
await handlePostbackEvent(event, context);
break;
default:
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
}
if (replayCandidate) {
await replayCandidate.cache.commit(replayCandidate.key);
}
} catch (err) {
if (replayCandidate) {
if (err instanceof LineRetryableWebhookError) {
replayCandidate.cache.release(replayCandidate.key, { error: err });
} else {
await replayCandidate.cache.commit(replayCandidate.key);
}
}
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
firstError ??= err;
}
}
if (firstError) {
throw firstError;
}
}

View file

@ -0,0 +1,405 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { webhook } from "@line/bot-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { __testing as sessionBindingTesting } from "openclaw/plugin-sdk/conversation-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createTestRegistry,
setActivePluginRegistry,
} from "../../../test/helpers/plugins/plugin-registry.js";
import { lineBindingsAdapter } from "./bindings.js";
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
import type { ResolvedLineAccount } from "./types.js";
type MessageEvent = webhook.MessageEvent;
type PostbackEvent = webhook.PostbackEvent;
const lineBindingsPlugin = {
id: "line",
bindings: lineBindingsAdapter,
conversationBindings: {
defaultTopLevelPlacement: "current",
supportsCurrentConversationBinding: true,
},
};
describe("buildLineMessageContext", () => {
let tmpDir: string;
let storePath: string;
let cfg: OpenClawConfig;
const account: ResolvedLineAccount = {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {},
};
const createMessageEvent = (
source: MessageEvent["source"],
overrides?: Partial<MessageEvent>,
): MessageEvent =>
({
type: "message",
message: { id: "1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source,
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
...overrides,
}) as MessageEvent;
const createPostbackEvent = (
source: PostbackEvent["source"],
overrides?: Partial<PostbackEvent>,
): PostbackEvent =>
({
type: "postback",
postback: { data: "action=select" },
replyToken: "reply-token",
timestamp: Date.now(),
source,
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
...overrides,
}) as PostbackEvent;
beforeEach(async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: lineBindingsPlugin.id,
plugin: lineBindingsPlugin,
source: "test",
},
]),
);
sessionBindingTesting.resetSessionBindingAdaptersForTests();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
storePath = path.join(tmpDir, "sessions.json");
cfg = { session: { store: storePath } };
});
afterEach(async () => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
await fs.rm(tmpDir, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50,
});
});
it("routes group message replies to the group id", async () => {
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
if (!context) {
throw new Error("context missing");
}
expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1");
expect(context.ctxPayload.To).toBe("line:group:group-1");
});
it("routes group postback replies to the group id", async () => {
const event = createPostbackEvent({ type: "group", groupId: "group-2", userId: "user-2" });
const context = await buildLinePostbackContext({
event,
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
expect(context?.ctxPayload.To).toBe("line:group:group-2");
});
it("routes room postback replies to the room id", async () => {
const event = createPostbackEvent({ type: "room", roomId: "room-1", userId: "user-3" });
const context = await buildLinePostbackContext({
event,
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1");
expect(context?.ctxPayload.To).toBe("line:room:room-1");
});
it("resolves prefixed-only group config through the inbound message context", async () => {
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account: {
...account,
config: {
groups: {
"group:group-1": {
systemPrompt: "Use the prefixed group config",
},
},
},
},
commandAuthorized: true,
});
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed group config");
});
it("resolves prefixed-only room config through the inbound message context", async () => {
const event = createMessageEvent({ type: "room", roomId: "room-1", userId: "user-1" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account: {
...account,
config: {
groups: {
"room:room-1": {
systemPrompt: "Use the prefixed room config",
},
},
},
},
commandAuthorized: true,
});
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed room config");
});
it("keeps non-text message contexts fail-closed for command auth", async () => {
const event = createMessageEvent(
{ type: "user", userId: "user-audio" },
{
message: { id: "audio-1", type: "audio", duration: 1000 } as MessageEvent["message"],
},
);
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: false,
});
expect(context).not.toBeNull();
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
});
it("sets CommandAuthorized=true when authorized", async () => {
const event = createMessageEvent({ type: "user", userId: "user-auth" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
});
it("sets CommandAuthorized=false when not authorized", async () => {
const event = createMessageEvent({ type: "user", userId: "user-noauth" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: false,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
});
it("sets CommandAuthorized on postback context", async () => {
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
const context = await buildLinePostbackContext({
event,
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
});
it("group peer binding matches raw groupId without prefix (#21907)", async () => {
const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret
const bindingCfg: OpenClawConfig = {
session: { store: storePath },
agents: {
list: [{ id: "main" }, { id: "line-group-agent" }],
},
bindings: [
{
agentId: "line-group-agent",
match: { channel: "line", peer: { kind: "group", id: groupId } },
},
],
};
const event = {
type: "message",
message: { id: "msg-1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId, userId: "user-1" },
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg: bindingCfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
expect(context!.route.agentId).toBe("line-group-agent");
expect(context!.route.matchedBy).toBe("binding.peer");
});
it("room peer binding matches raw roomId without prefix (#21907)", async () => {
const roomId = "Rr1234567890abcdef";
const bindingCfg: OpenClawConfig = {
session: { store: storePath },
agents: {
list: [{ id: "main" }, { id: "line-room-agent" }],
},
bindings: [
{
agentId: "line-room-agent",
match: { channel: "line", peer: { kind: "group", id: roomId } },
},
],
};
const event = {
type: "message",
message: { id: "msg-2", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "room", roomId, userId: "user-2" },
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg: bindingCfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
expect(context!.route.agentId).toBe("line-room-agent");
expect(context!.route.matchedBy).toBe("binding.peer");
});
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", async () => {
const compiled = lineBindingsAdapter.compileConfiguredBinding({
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
});
expect(compiled).toEqual({
conversationId: "U1234567890abcdef1234567890abcdef",
});
expect(
lineBindingsAdapter.matchInboundConversation({
compiledBinding: compiled!,
conversationId: "U1234567890abcdef1234567890abcdef",
}),
).toEqual({
conversationId: "U1234567890abcdef1234567890abcdef",
matchPriority: 2,
});
});
it("normalizes canonical LINE targets through the plugin bindings surface", async () => {
const compiled = lineBindingsAdapter.compileConfiguredBinding({
conversationId: "line:U1234567890abcdef1234567890abcdef",
});
expect(compiled).toEqual({
conversationId: "U1234567890abcdef1234567890abcdef",
});
expect(
lineBindingsAdapter.resolveCommandConversation({
originatingTo: "line:U1234567890abcdef1234567890abcdef",
}),
).toEqual({
conversationId: "U1234567890abcdef1234567890abcdef",
});
expect(
lineBindingsAdapter.matchInboundConversation({
compiledBinding: compiled!,
conversationId: "U1234567890abcdef1234567890abcdef",
}),
).toEqual({
conversationId: "U1234567890abcdef1234567890abcdef",
matchPriority: 2,
});
});
it("routes LINE conversations through active ACP session bindings", async () => {
const userId = "U1234567890abcdef1234567890abcdef";
await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:binding:line:default:test123",
targetKind: "session",
conversation: {
channel: "line",
accountId: "default",
conversationId: userId,
},
placement: "current",
metadata: {
agentId: "codex",
},
});
const event = createMessageEvent({ type: "user", userId });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
expect(context!.route.agentId).toBe("codex");
expect(context!.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123");
expect(context!.route.matchedBy).toBe("binding.channel");
});
});

View file

@ -0,0 +1,580 @@
import type { webhook } from "@line/bot-sdk";
import {
formatInboundEnvelope,
formatLocationText,
resolveInboundSessionEnvelopeContext,
toLocationContext,
} from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
ensureConfiguredBindingRouteReady,
getSessionBindingService,
recordInboundSession,
resolvePinnedMainDmOwnerFromAllowlist,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import {
deriveLastRoutePolicy,
resolveAgentIdFromSessionKey,
resolveAgentRoute,
} from "openclaw/plugin-sdk/routing";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeAllowFrom } from "./bot-access.js";
import { resolveLineGroupConfigEntry } from "./group-keys.js";
import type { ResolvedLineAccount } from "./types.js";
type EventSource = webhook.Source | undefined;
type MessageEvent = webhook.MessageEvent;
type PostbackEvent = webhook.PostbackEvent;
type StickerEventMessage = webhook.StickerMessageContent;
interface MediaRef {
path: string;
contentType?: string;
}
interface BuildLineMessageContextParams {
event: MessageEvent;
allMedia: MediaRef[];
cfg: OpenClawConfig;
account: ResolvedLineAccount;
commandAuthorized: boolean;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
export type LineSourceInfo = {
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
};
export function getLineSourceInfo(source: EventSource): LineSourceInfo {
if (!source) {
return { userId: undefined, groupId: undefined, roomId: undefined, isGroup: false };
}
const userId =
source.type === "user"
? source.userId
: source.type === "group"
? source.userId
: source.type === "room"
? source.userId
: undefined;
const groupId = source.type === "group" ? source.groupId : undefined;
const roomId = source.type === "room" ? source.roomId : undefined;
const isGroup = source.type === "group" || source.type === "room";
return { userId, groupId, roomId, isGroup };
}
function buildPeerId(source: EventSource): string {
if (!source) {
return "unknown";
}
const groupKey =
normalizeOptionalString(source.type === "group" ? source.groupId : undefined) ??
normalizeOptionalString(source.type === "room" ? source.roomId : undefined);
if (groupKey) {
return groupKey;
}
if (source.type === "user" && source.userId) {
return source.userId;
}
return "unknown";
}
async function resolveLineInboundRoute(params: {
source: EventSource;
cfg: OpenClawConfig;
account: ResolvedLineAccount;
}): Promise<{
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
peerId: string;
route: ReturnType<typeof resolveAgentRoute>;
}> {
recordChannelActivity({
channel: "line",
accountId: params.account.accountId,
direction: "inbound",
});
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
const peerId = buildPeerId(params.source);
let route = resolveAgentRoute({
cfg: params.cfg,
channel: "line",
accountId: params.account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
});
const configuredRoute = resolveConfiguredBindingRoute({
cfg: params.cfg,
route,
conversation: {
channel: "line",
accountId: params.account.accountId,
conversationId: peerId,
},
});
let configuredBinding = configuredRoute.bindingResolution;
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
route = configuredRoute.route;
const boundConversation = getSessionBindingService().resolveByConversation({
channel: "line",
accountId: params.account.accountId,
conversationId: peerId,
});
const boundSessionKey = boundConversation?.targetSessionKey?.trim();
if (boundConversation && boundSessionKey) {
route = {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
configuredBinding = null;
getSessionBindingService().touch(boundConversation.bindingId);
logVerbose(`line: routed via bound conversation ${peerId} -> ${boundSessionKey}`);
}
if (configuredBinding) {
const ensured = await ensureConfiguredBindingRouteReady({
cfg: params.cfg,
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`line: configured ACP binding unavailable for ${peerId} -> ${configuredBindingSessionKey}: ${ensured.error}`,
);
throw new Error(`Configured ACP binding unavailable: ${ensured.error}`);
}
logVerbose(
`line: using configured ACP binding for ${peerId} -> ${configuredBindingSessionKey}`,
);
}
return { userId, groupId, roomId, isGroup, peerId, route };
}
const STICKER_PACKAGES: Record<string, string> = {
"1": "Moon & James",
"2": "Cony & Brown",
"3": "Brown & Friends",
"4": "Moon Special",
"789": "LINE Characters",
"6136": "Cony's Happy Life",
"6325": "Brown's Life",
"6359": "Choco",
"6362": "Sally",
"6370": "Edward",
"11537": "Cony",
"11538": "Brown",
"11539": "Moon",
};
function describeStickerKeywords(sticker: StickerEventMessage): string {
const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
if (keywords && keywords.length > 0) {
return keywords.slice(0, 3).join(", ");
}
const stickerText = (sticker as StickerEventMessage & { text?: string }).text;
if (stickerText) {
return stickerText;
}
return "";
}
function extractMessageText(message: MessageEvent["message"]): string {
if (message.type === "text") {
return message.text;
}
if (message.type === "location") {
const loc = message;
return (
formatLocationText({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
}) ?? ""
);
}
if (message.type === "sticker") {
const sticker = message;
const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
const keywords = describeStickerKeywords(sticker);
if (keywords) {
return `[Sent a ${packageName} sticker: ${keywords}]`;
}
return `[Sent a ${packageName} sticker]`;
}
return "";
}
function extractMediaPlaceholder(message: MessageEvent["message"]): string {
switch (message.type) {
case "image":
return "<media:image>";
case "video":
return "<media:video>";
case "audio":
return "<media:audio>";
case "file":
return "<media:document>";
default:
return "";
}
}
type LineRouteInfo = ReturnType<typeof resolveAgentRoute>;
type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string };
function resolveLineConversationLabel(params: {
isGroup: boolean;
groupId?: string;
roomId?: string;
senderLabel: string;
}): string {
return params.isGroup
? params.groupId
? `group:${params.groupId}`
: params.roomId
? `room:${params.roomId}`
: "unknown-group"
: params.senderLabel;
}
function resolveLineAddresses(params: {
isGroup: boolean;
groupId?: string;
roomId?: string;
userId?: string;
peerId: string;
}): { fromAddress: string; toAddress: string; originatingTo: string } {
const fromAddress = params.isGroup
? params.groupId
? `line:group:${params.groupId}`
: params.roomId
? `line:room:${params.roomId}`
: `line:${params.peerId}`
: `line:${params.userId ?? params.peerId}`;
const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
return { fromAddress, toAddress, originatingTo };
}
async function finalizeLineInboundContext(params: {
cfg: OpenClawConfig;
account: ResolvedLineAccount;
event: MessageEvent | PostbackEvent;
route: LineRouteInfo;
source: LineSourceInfoWithPeerId;
rawBody: string;
timestamp: number;
messageSid: string;
commandAuthorized: boolean;
media: {
firstPath: string | undefined;
firstContentType?: string;
paths?: string[];
types?: string[];
};
locationContext?: ReturnType<typeof toLocationContext>;
verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
}) {
const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
isGroup: params.source.isGroup,
groupId: params.source.groupId,
roomId: params.source.roomId,
userId: params.source.userId,
peerId: params.source.peerId,
});
const senderId = params.source.userId ?? "unknown";
const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
const conversationLabel = resolveLineConversationLabel({
isGroup: params.source.isGroup,
groupId: params.source.groupId,
roomId: params.source.roomId,
senderLabel,
});
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg: params.cfg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "LINE",
from: conversationLabel,
timestamp: params.timestamp,
body: params.rawBody,
chatType: params.source.isGroup ? "group" : "direct",
sender: {
id: senderId,
},
previousTimestamp,
envelope: envelopeOptions,
});
const ctxPayload = finalizeInboundContext({
Body: body,
BodyForAgent: params.rawBody,
RawBody: params.rawBody,
CommandBody: params.rawBody,
From: fromAddress,
To: toAddress,
SessionKey: params.route.sessionKey,
AccountId: params.route.accountId,
ChatType: params.source.isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: params.source.isGroup
? (params.source.groupId ?? params.source.roomId)
: undefined,
SenderId: senderId,
Provider: "line",
Surface: "line",
MessageSid: params.messageSid,
Timestamp: params.timestamp,
MediaPath: params.media.firstPath,
MediaType: params.media.firstContentType,
MediaUrl: params.media.firstPath,
MediaPaths: params.media.paths,
MediaUrls: params.media.paths,
MediaTypes: params.media.types,
...params.locationContext,
CommandAuthorized: params.commandAuthorized,
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
GroupSystemPrompt: params.source.isGroup
? normalizeOptionalString(
resolveLineGroupConfigEntry(params.account.config.groups, {
groupId: params.source.groupId,
roomId: params.source.roomId,
})?.systemPrompt,
)
: undefined,
InboundHistory: params.inboundHistory,
});
const pinnedMainDmOwner = !params.source.isGroup
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: params.cfg.session?.dmScope,
allowFrom: params.account.config.allowFrom,
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
})
: null;
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
ctx: ctxPayload,
updateLastRoute: !params.source.isGroup
? {
sessionKey: params.route.mainSessionKey,
channel: "line",
to: params.source.userId ?? params.source.peerId,
accountId: params.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && params.source.userId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: params.source.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo =
params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1
? ` mediaCount=${params.verboseLog.mediaCount}`
: "";
const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
logVerbose(
`${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
);
}
return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken };
}
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
const source = event.source;
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
source,
cfg,
account,
});
const message = event.message;
const messageId = message.id;
const timestamp = event.timestamp;
const textContent = extractMessageText(message);
const placeholder = extractMediaPlaceholder(message);
let rawBody = textContent || placeholder;
if (!rawBody && allMedia.length > 0) {
rawBody = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
if (!rawBody && allMedia.length === 0) {
return null;
}
let locationContext: ReturnType<typeof toLocationContext> | undefined;
if (message.type === "location") {
const loc = message;
locationContext = toLocationContext({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
});
}
const historyKey = isGroup ? peerId : undefined;
const inboundHistory =
historyKey && groupHistories && (historyLimit ?? 0) > 0
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const { ctxPayload } = await finalizeLineInboundContext({
cfg,
account,
event,
route,
source: { userId, groupId, roomId, isGroup, peerId },
rawBody,
timestamp,
messageSid: messageId,
commandAuthorized,
media: {
firstPath: allMedia[0]?.path,
firstContentType: allMedia[0]?.contentType,
paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
types:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
},
locationContext,
verboseLog: { kind: "inbound", mediaCount: allMedia.length },
inboundHistory,
});
return {
ctxPayload,
event,
userId,
groupId,
roomId,
isGroup,
route,
replyToken: event.replyToken,
accountId: account.accountId,
};
}
export async function buildLinePostbackContext(params: {
event: PostbackEvent;
cfg: OpenClawConfig;
account: ResolvedLineAccount;
commandAuthorized: boolean;
}) {
const { event, cfg, account, commandAuthorized } = params;
const source = event.source;
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
source,
cfg,
account,
});
const timestamp = event.timestamp;
const rawData = event.postback?.data?.trim() ?? "";
if (!rawData) {
return null;
}
let rawBody = rawData;
if (rawData.includes("line.action=")) {
const searchParams = new URLSearchParams(rawData);
const action = searchParams.get("line.action") ?? "";
const device = searchParams.get("line.device");
rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
}
const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
const { ctxPayload } = await finalizeLineInboundContext({
cfg,
account,
event,
route,
source: { userId, groupId, roomId, isGroup, peerId },
rawBody,
timestamp,
messageSid,
commandAuthorized,
media: {
firstPath: "",
firstContentType: undefined,
paths: undefined,
types: undefined,
},
verboseLog: { kind: "postback" },
});
return {
ctxPayload,
event,
userId,
groupId,
roomId,
isGroup,
route,
replyToken: event.replyToken,
accountId: account.accountId,
};
}
export type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
export type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
export type LineInboundContext = LineMessageContext | LinePostbackContext;

View file

@ -0,0 +1,86 @@
import type { webhook } from "@line/bot-sdk";
import type { NextFunction, Request, Response } from "express";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import {
createNonExitingRuntime,
logVerbose,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { resolveLineAccount } from "./accounts.js";
import { createLineWebhookReplayCache, handleLineWebhookEvents } from "./bot-handlers.js";
import type { LineInboundContext } from "./bot-message-context.js";
import type { ResolvedLineAccount } from "./types.js";
import { startLineWebhook } from "./webhook.js";
export interface LineBotOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
runtime?: RuntimeEnv;
config?: OpenClawConfig;
mediaMaxMb?: number;
onMessage?: (ctx: LineInboundContext) => Promise<void>;
}
export interface LineBot {
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
account: ResolvedLineAccount;
}
export function createLineBot(opts: LineBotOptions): LineBot {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
const cfg = opts.config ?? loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const mediaMaxBytes = (opts.mediaMaxMb ?? account.config.mediaMaxMb ?? 10) * 1024 * 1024;
const processMessage =
opts.onMessage ??
(async () => {
logVerbose("line: no message handler configured");
});
const replayCache = createLineWebhookReplayCache();
const groupHistories = new Map<string, HistoryEntry[]>();
const handleWebhook = async (body: webhook.CallbackRequest): Promise<void> => {
if (!body.events || body.events.length === 0) {
return;
}
await handleLineWebhookEvents(body.events, {
cfg,
account,
runtime,
mediaMaxBytes,
processMessage,
replayCache,
groupHistories,
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
};
return {
handleWebhook,
account,
};
}
export function createLineWebhookCallback(
bot: LineBot,
channelSecret: string,
path = "/line/webhook",
): { path: string; handler: (req: Request, res: Response, _next: NextFunction) => Promise<void> } {
const { handler } = startLineWebhook({
channelSecret,
onEvents: bot.handleWebhook,
path,
});
return { path, handler };
}

View file

@ -0,0 +1,347 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
createActionCard,
createImageCard,
createInfoCard,
createListCard,
createReceiptCard,
type CardAction,
type ListItem,
} from "./flex-templates.js";
import type { LineChannelData } from "./types.js";
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
Types:
info "Title" "Body" ["Footer"]
image "Title" "Caption" --url <image-url>
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
list "Title" "Item1|Desc1,Item2|Desc2"
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
confirm "Question?" --yes "Yes|data" --no "No|data"
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
Examples:
/card info "Welcome" "Thanks for joining!"
/card image "Product" "Check it out" --url https://example.com/img.jpg
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
function buildLineReply(lineData: LineChannelData): ReplyPayload {
return {
channelData: {
line: lineData,
},
};
}
/**
* Parse action string format: "Label|data,Label2|data2"
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
*/
function parseActions(actionsStr: string | undefined): CardAction[] {
if (!actionsStr) {
return [];
}
const results: CardAction[] = [];
for (const part of actionsStr.split(",")) {
const [label, data] = part
.trim()
.split("|")
.map((s) => s.trim());
if (!label) {
continue;
}
const actionData = data || label;
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
results.push({
label,
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
});
} else if (actionData.includes("=")) {
results.push({
label,
action: {
type: "postback",
label: label.slice(0, 20),
data: actionData.slice(0, 300),
displayText: label,
},
});
} else {
results.push({
label,
action: { type: "message", label: label.slice(0, 20), text: actionData },
});
}
}
return results;
}
/**
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
*/
function parseListItems(itemsStr: string): ListItem[] {
return itemsStr
.split(",")
.map((part) => {
const [title, subtitle] = part
.trim()
.split("|")
.map((s) => s.trim());
return { title: title || "", subtitle };
})
.filter((item) => item.title);
}
/**
* Parse receipt items format: "Item1:$10,Item2:$20"
*/
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
return itemsStr
.split(",")
.map((part) => {
const colonIndex = part.lastIndexOf(":");
if (colonIndex === -1) {
return { name: part.trim(), value: "" };
}
return {
name: part.slice(0, colonIndex).trim(),
value: part.slice(colonIndex + 1).trim(),
};
})
.filter((item) => item.name);
}
/**
* Parse quoted arguments from command string
* Supports: /card type "arg1" "arg2" "arg3" --flag value
*/
function parseCardArgs(argsStr: string): {
type: string;
args: string[];
flags: Record<string, string>;
} {
const result: { type: string; args: string[]; flags: Record<string, string> } = {
type: "",
args: [],
flags: {},
};
// Extract type (first word)
const typeMatch = argsStr.match(/^(\w+)/);
if (typeMatch) {
result.type = normalizeLowercaseStringOrEmpty(typeMatch[1]);
argsStr = argsStr.slice(typeMatch[0].length).trim();
}
// Extract quoted arguments
const quotedRegex = /"([^"]*?)"/g;
let match;
while ((match = quotedRegex.exec(argsStr)) !== null) {
result.args.push(match[1]);
}
// Extract flags (--key value or --key "value")
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
while ((match = flagRegex.exec(argsStr)) !== null) {
result.flags[match[1]] = match[2] ?? match[3];
}
return result;
}
export function registerLineCardCommand(api: OpenClawPluginApi): void {
api.registerCommand({
name: "card",
description: "Send a rich card message (LINE).",
acceptsArgs: true,
requireAuth: false,
handler: async (ctx) => {
const argsStr = ctx.args?.trim() ?? "";
if (!argsStr) {
return { text: CARD_USAGE };
}
const parsed = parseCardArgs(argsStr);
const { type, args, flags } = parsed;
if (!type) {
return { text: CARD_USAGE };
}
// Only LINE supports rich cards; fallback to text elsewhere.
if (ctx.channel !== "line") {
const fallbackText = args.join(" - ");
return { text: `[${type} card] ${fallbackText}`.trim() };
}
try {
switch (type) {
case "info": {
const [title = "Info", body = "", footer] = args;
const bubble = createInfoCard(title, body, footer);
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
contents: bubble,
},
});
}
case "image": {
const [title = "Image", caption = ""] = args;
const imageUrl = flags.url || flags.image;
if (!imageUrl) {
return { text: "Error: Image card requires --url <image-url>" };
}
const bubble = createImageCard(imageUrl, title, caption);
return buildLineReply({
flexMessage: {
altText: `${title}: ${caption}`.slice(0, 400),
contents: bubble,
},
});
}
case "action": {
const [title = "Actions", body = ""] = args;
const actions = parseActions(flags.actions);
if (actions.length === 0) {
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
}
const bubble = createActionCard(title, body, actions, {
imageUrl: flags.url || flags.image,
});
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
contents: bubble,
},
});
}
case "list": {
const [title = "List", itemsStr = ""] = args;
const items = parseListItems(itemsStr || flags.items || "");
if (items.length === 0) {
return {
text: 'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
};
}
const bubble = createListCard(title, items);
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
contents: bubble,
},
});
}
case "receipt": {
const [title = "Receipt", itemsStr = ""] = args;
const items = parseReceiptItems(itemsStr || flags.items || "");
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
const footer = flags.footer;
if (items.length === 0) {
return {
text: 'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
};
}
const bubble = createReceiptCard({ title, items, total, footer });
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
0,
400,
),
contents: bubble,
},
});
}
case "confirm": {
const [question = "Confirm?"] = args;
const yesStr = flags.yes || "Yes|yes";
const noStr = flags.no || "No|no";
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
return buildLineReply({
templateMessage: {
type: "confirm",
text: question,
confirmLabel: yesLabel || "Yes",
confirmData: yesData || "yes",
cancelLabel: noLabel || "No",
cancelData: noData || "no",
altText: question,
},
});
}
case "buttons": {
const [title = "Menu", text = "Choose an option"] = args;
const actionsStr = flags.actions || "";
const actionParts = parseActions(actionsStr);
if (actionParts.length === 0) {
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
}
const templateActions: Array<{
type: "message" | "uri" | "postback";
label: string;
data?: string;
uri?: string;
}> = actionParts.map((a) => {
const action = a.action;
const label = action.label ?? a.label;
if (action.type === "uri") {
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
}
if (action.type === "postback") {
return {
type: "postback" as const,
label,
data: (action as { data: string }).data,
};
}
return {
type: "message" as const,
label,
data: (action as { text: string }).text,
};
});
return buildLineReply({
templateMessage: {
type: "buttons",
title,
text,
thumbnailImageUrl: flags.url || flags.image,
actions: templateActions,
},
});
}
default:
return {
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
};
}
} catch (err) {
return { text: `Error creating card: ${String(err)}` };
}
},
});
}

View file

@ -0,0 +1,14 @@
export function resolveLineChannelAccessToken(
explicit: string | undefined,
params: { accountId: string; channelAccessToken: string },
): string {
if (explicit?.trim()) {
return explicit.trim();
}
if (!params.channelAccessToken) {
throw new Error(
`LINE channel access token missing for account "${params.accountId}" (set channels.line.channelAccessToken or LINE_CHANNEL_ACCESS_TOKEN).`,
);
}
return params.channelAccessToken.trim();
}

View file

@ -0,0 +1,23 @@
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
listLineAccountIds,
normalizeAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "./accounts.js";
import { resolveExactLineGroupConfigKey } from "./group-keys.js";
import type { LineConfig, ResolvedLineAccount } from "./types.js";
export {
DEFAULT_ACCOUNT_ID,
listLineAccountIds,
normalizeAccountId,
resolveDefaultLineAccountId,
resolveExactLineGroupConfigKey,
resolveLineAccount,
};
export type { ChannelPlugin, LineConfig, OpenClawConfig, ResolvedLineAccount };

View file

@ -0,0 +1,59 @@
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js";
import {
resolveLineAccount,
type ChannelPlugin,
type OpenClawConfig,
type ResolvedLineAccount,
} from "./channel-api.js";
import { lineConfigAdapter } from "./config-adapter.js";
import { LineChannelConfigSchema } from "./config-schema.js";
export const lineChannelMeta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
} as const;
export const lineChannelPluginCommon = {
meta: {
...lineChannelMeta,
quickstartAllowFrom: true,
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: LineChannelConfigSchema,
config: {
...lineConfigAdapter,
isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account),
describeAccount: (account: ResolvedLineAccount) =>
describeWebhookAccountSnapshot({
account,
configured: hasLineCredentials(account),
extra: {
tokenSource: account.tokenSource ?? undefined,
},
}),
},
} satisfies Pick<
ChannelPlugin<ResolvedLineAccount>,
"meta" | "capabilities" | "reload" | "configSchema" | "config"
>;
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {
return hasLineCredentials(resolveLineAccount({ cfg, accountId }));
}
export { parseLineAllowFromId };

View file

@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
import { lineGatewayAdapter } from "./gateway.js";
import { setLineRuntime } from "./runtime.js";
const DEFAULT_ACCOUNT_ID = "default";
type LineRuntimeMocks = {
writeConfigFile: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const writeConfigFile = vi.fn(async () => {});
const resolveLineAccount = vi.fn(
({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
const lineConfig = (cfg.channels?.line ?? {}) as {
tokenFile?: string;
secretFile?: string;
channelAccessToken?: string;
channelSecret?: string;
accounts?: Record<string, Record<string, unknown>>;
};
const entry =
accountId && accountId !== DEFAULT_ACCOUNT_ID
? (lineConfig.accounts?.[accountId] ?? {})
: lineConfig;
const hasToken =
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
const hasSecret = Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
},
);
const runtime = {
config: { writeConfigFile },
} as unknown as PluginRuntime;
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
function resolveAccount(
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
cfg: OpenClawConfig,
accountId: string,
): ResolvedLineAccount {
const resolver = resolveLineAccount as unknown as (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => ResolvedLineAccount;
return resolver({ cfg, accountId });
}
async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{
result: Awaited<ReturnType<NonNullable<typeof lineGatewayAdapter.logoutAccount>>>;
mocks: LineRuntimeMocks;
}> {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId);
const result = await lineGatewayAdapter.logoutAccount!({
accountId: params.accountId,
cfg: params.cfg,
account,
runtime: createRuntimeEnv(),
});
return { result, mocks };
}
describe("linePlugin gateway.logoutAccount", () => {
beforeEach(() => {
setLineRuntime(createRuntime().runtime);
});
it("clears tokenFile/secretFile on default account logout", async () => {
const cfg: OpenClawConfig = {
channels: {
line: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
};
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: DEFAULT_ACCOUNT_ID,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("clears tokenFile/secretFile on account logout", async () => {
const cfg: OpenClawConfig = {
channels: {
line: {
accounts: {
primary: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
},
},
};
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: "primary",
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("does not write config when account has no token/secret fields", async () => {
const cfg: OpenClawConfig = {
channels: {
line: {
accounts: {
primary: {
name: "Primary",
},
},
},
},
};
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: "primary",
});
expect(result.cleared).toBe(false);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,3 @@
export { monitorLineProvider } from "./monitor.js";
export { probeLineBot } from "./probe.js";
export { pushMessageLine } from "./send.js";

View file

@ -0,0 +1,519 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../api.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
type LineRuntimeMocks = {
pushMessageLine: ReturnType<typeof vi.fn>;
pushMessagesLine: ReturnType<typeof vi.fn>;
pushFlexMessage: ReturnType<typeof vi.fn>;
pushTemplateMessage: ReturnType<typeof vi.fn>;
pushLocationMessage: ReturnType<typeof vi.fn>;
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
createQuickReplyItems: ReturnType<typeof vi.fn>;
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
sendMessageLine: ReturnType<typeof vi.fn>;
chunkMarkdownText: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
messageId: "m-quick",
chatId: "c1",
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
const chunkMarkdownText = vi.fn((text: string) => [text]);
const resolveTextChunkLimit = vi.fn(() => 123);
const resolveLineAccount = vi.fn(
({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
const resolved = accountId ?? "default";
const lineConfig = (cfg.channels?.line ?? {}) as {
accounts?: Record<string, Record<string, unknown>>;
};
const accountConfig = resolved !== "default" ? (lineConfig.accounts?.[resolved] ?? {}) : {};
return {
accountId: resolved,
config: { ...lineConfig, ...accountConfig },
};
},
);
const runtime = {
channel: {
line: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
resolveLineAccount,
},
text: {
chunkMarkdownText,
resolveTextChunkLimit,
},
},
} as unknown as PluginRuntime;
return {
runtime,
mocks: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
chunkMarkdownText,
resolveLineAccount,
resolveTextChunkLimit,
},
};
}
describe("linePlugin outbound.sendPayload", () => {
it("preserves resolved accountId when pairing notifications push directly", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = {
channels: {
line: {
accounts: {
primary: {
channelAccessToken: "token-primary",
},
},
},
},
} as OpenClawConfig;
mocks.resolveLineAccount.mockReturnValue({
accountId: "primary",
channelAccessToken: "token-primary",
config: {},
});
await linePlugin.pairing!.notifyApproval!({
cfg,
id: "line:user:1",
});
expect(mocks.pushMessageLine).toHaveBeenCalledWith(
"line:user:1",
"OpenClaw: your access has been approved.",
{
accountId: "primary",
channelAccessToken: "token-primary",
},
);
});
it("sends flex message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "Now playing:",
channelData: {
line: {
flexMessage: {
altText: "Now playing",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:group:1",
text: payload.text,
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
verbose: false,
accountId: "default",
cfg,
});
});
it("sends template message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "Choose one:",
channelData: {
line: {
templateMessage: {
type: "confirm",
text: "Continue?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
},
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:user:1",
text: payload.text,
payload,
accountId: "default",
cfg,
});
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
verbose: false,
accountId: "default",
cfg,
});
});
it("attaches quick replies when no text chunks are present", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
channelData: {
line: {
quickReplies: ["One", "Two"],
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:user:2",
text: "",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:user:2",
[
{
type: "flex",
altText: "Card",
contents: { type: "bubble" },
quickReply: { items: ["One", "Two"] },
},
],
{ verbose: false, accountId: "default", cfg },
);
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
});
it("sends media before quick-reply text so buttons stay visible", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "Hello",
mediaUrl: "https://example.com/img.jpg",
channelData: {
line: {
quickReplies: ["One", "Two"],
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:user:3",
text: payload.text,
payload,
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",
["One", "Two"],
{ verbose: false, accountId: "default", cfg },
);
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
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);
const cfg = { channels: { line: { textChunkLimit: 123 } } } as OpenClawConfig;
const payload = {
text: "Hello world",
channelData: {
line: {
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:user:3",
text: payload.text,
payload,
accountId: "primary",
cfg,
});
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(cfg, "line", "primary", {
fallbackLimit: 5000,
});
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", () => {
it("strips line:user: prefixes without lowercasing", () => {
const formatted = linePlugin.config.formatAllowFrom!({
cfg: {} as OpenClawConfig,
allowFrom: ["line:user:UABC", "line:UDEF"],
});
expect(formatted).toEqual(["UABC", "UDEF"]);
});
});
describe("linePlugin groups.resolveRequireMention", () => {
it("uses account-level group settings when provided", () => {
const { runtime } = createRuntime();
setLineRuntime(runtime);
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: false },
},
accounts: {
primary: {
groups: {
"group-1": { requireMention: true },
},
},
},
},
},
} as OpenClawConfig;
const requireMention = linePlugin.groups!.resolveRequireMention!({
cfg,
accountId: "primary",
groupId: "group-1",
});
expect(requireMention).toBe(true);
});
});

View file

@ -0,0 +1,11 @@
import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupWizard } from "./setup-surface.js";
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
...lineChannelPluginCommon,
setupWizard: lineSetupWizard,
setup: lineSetupAdapter,
};

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { ChannelAccountSnapshot } from "../api.js";
import { lineStatusAdapter } from "./status.js";
function collectIssues(accounts: ChannelAccountSnapshot[]) {
const collect = lineStatusAdapter.collectStatusIssues;
if (!collect) {
throw new Error("LINE plugin status collector is unavailable");
}
return collect(accounts);
}
describe("linePlugin status.collectStatusIssues", () => {
it("does not warn when a sanitized snapshot is configured", () => {
expect(
collectIssues([
{
accountId: "default",
configured: true,
tokenSource: "env",
},
]),
).toEqual([]);
});
it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
expect(
collectIssues([
{
accountId: "default",
configured: false,
tokenSource: "none",
},
]),
).toEqual([
{
channel: "line",
accountId: "default",
kind: "config",
message: "LINE channel access token not configured",
},
]);
});
it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
expect(
collectIssues([
{
accountId: "default",
configured: false,
tokenSource: "env",
},
]),
).toEqual([
{
channel: "line",
accountId: "default",
kind: "config",
message: "LINE channel secret not configured",
},
]);
});
});

View file

@ -0,0 +1,152 @@
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveLineAccount } from "./accounts.js";
import { lineBindingsAdapter } from "./bindings.js";
import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineGatewayAdapter } from "./gateway.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { lineOutboundAdapter } from "./outbound.js";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
import { getLineRuntime } from "./runtime.js";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupWizard } from "./setup-surface.js";
import { lineStatusAdapter } from "./status.js";
const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
channelKey: "line",
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "LINE groups",
openScope: "any member in groups",
groupPolicyPath: "channels.line.groupPolicy",
groupAllowFromPath: "channels.line.groupAllowFrom",
mentionGated: false,
policyPathSuffix: "dmPolicy",
approveHint: "openclaw pairing approve line <code>",
normalizeDmEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
});
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelPlugin({
base: {
id: "line",
...lineChannelPluginCommon,
setupWizard: lineSetupWizard,
groups: {
resolveRequireMention: resolveLineGroupRequireMention,
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) {
return undefined;
}
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
resolveInboundConversation: lineBindingsAdapter.resolveInboundConversation,
transformReplyPayload: ({ payload }) => {
if (!payload.text || !hasLineDirectives(payload.text)) {
return payload;
}
return parseLineDirectives(payload);
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) {
return false;
}
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
},
},
directory: createEmptyChannelDirectoryAdapter(),
setup: lineSetupAdapter,
status: lineStatusAdapter,
gateway: lineGatewayAdapter,
bindings: lineBindingsAdapter,
conversationBindings: {
defaultTopLevelPlacement: "current",
},
agentPrompt: {
messageToolHints: () => [
"",
"### LINE Rich Messages",
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
"",
"**Quick Replies** (bottom button suggestions):",
" [[quick_replies: Option 1, Option 2, Option 3]]",
"",
"**Location** (map pin):",
" [[location: Place Name | Address | latitude | longitude]]",
"",
"**Confirm Dialog** (yes/no prompt):",
" [[confirm: Question text? | Yes Label | No Label]]",
"",
"**Button Menu** (title + text + buttons):",
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
"",
"**Media Player Card** (music status):",
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
" - Status: 'playing' or 'paused' (optional)",
"",
"**Event Card** (calendar events, meetings):",
" [[event: Event Title | Date | Time | Location | Description]]",
" - Time, Location, Description are optional",
"",
"**Agenda Card** (multiple events/schedule):",
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
"",
"**Device Control Card** (smart devices, TVs, etc.):",
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
"",
"**Apple TV Remote** (full D-pad + transport):",
" [[appletv_remote: Apple TV | Playing]]",
"",
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
"",
"When to use rich messages:",
"- Use [[quick_replies:...]] when offering 2-4 clear options",
"- Use [[confirm:...]] for yes/no decisions",
"- Use [[buttons:...]] for menus with actions/links",
"- Use [[location:...]] when sharing a place",
"- Use [[media_player:...]] when showing what's playing",
"- Use [[event:...]] for calendar event details",
"- Use [[agenda:...]] for a day's schedule or event list",
"- Use [[device:...]] for smart device status/controls",
"- Tables/code in your response auto-convert to visual cards",
],
},
},
pairing: {
text: {
idLabel: "lineUserId",
message: "OpenClaw: your access has been approved.",
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
notify: async ({ cfg, id, message }) => {
const account = (getLineRuntime().channel.line?.resolveLineAccount ?? resolveLineAccount)({
cfg,
});
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
const pushMessageLine =
getLineRuntime().channel.line?.pushMessageLine ??
(await loadLineChannelRuntime()).pushMessageLine;
await pushMessageLine(id, message, {
accountId: account.accountId,
channelAccessToken: account.channelAccessToken,
});
},
},
},
security: lineSecurityAdapter,
outbound: lineOutboundAdapter,
});

View file

@ -0,0 +1,29 @@
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
type ResolvedLineAccount,
} from "./channel-api.js";
export function normalizeLineAllowFrom(entry: string): string {
return entry.replace(/^line:(?:user:)?/i, "");
}
export const lineConfigAdapter = createScopedChannelConfigAdapter<
ResolvedLineAccount,
ResolvedLineAccount
>({
sectionKey: "line",
listAccountIds: listLineAccountIds,
resolveAccount: (cfg, accountId) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: resolveDefaultLineAccountId,
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map(normalizeLineAllowFrom),
});

View file

@ -0,0 +1,55 @@
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "openclaw/plugin-sdk/zod";
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
const ThreadBindingsSchema = z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().optional(),
maxAgeHours: z.number().optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.strict();
const LineCommonConfigSchema = z.object({
enabled: z.boolean().optional(),
channelAccessToken: z.string().optional(),
channelSecret: z.string().optional(),
tokenFile: z.string().optional(),
secretFile: z.string().optional(),
name: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
threadBindings: ThreadBindingsSchema.optional(),
});
const LineGroupConfigSchema = z
.object({
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
systemPrompt: z.string().optional(),
skills: z.array(z.string()).optional(),
})
.strict();
const LineAccountConfigSchema = LineCommonConfigSchema.extend({
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
}).strict();
export const LineConfigSchema = LineCommonConfigSchema.extend({
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
defaultAccount: z.string().optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
}).strict();
export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;

View file

@ -0,0 +1,112 @@
import fs from "node:fs";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const getMessageContentMock = vi.hoisted(() => vi.fn());
vi.mock("@line/bot-sdk", () => ({
messagingApi: {
MessagingApiBlobClient: class {
getMessageContent(messageId: string) {
return getMessageContentMock(messageId);
}
},
},
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
createSubsystemLogger: () => {
const logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
child: () => logger,
};
return logger;
},
logVerbose: () => {},
}));
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
for (const part of parts) {
yield part;
}
}
describe("downloadLineMedia", () => {
beforeAll(async () => {
({ downloadLineMedia } = await import("./download.js"));
});
beforeEach(() => {
vi.restoreAllMocks();
getMessageContentMock.mockReset();
});
it("does not derive temp file path from external messageId", async () => {
const messageId = "a/../../../../etc/passwd";
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia(messageId, "token");
const writtenPath = writeSpy.mock.calls[0]?.[0];
expect(result.size).toBe(jpeg.length);
expect(result.contentType).toBe("image/jpeg");
expect(typeof writtenPath).toBe("string");
if (typeof writtenPath !== "string") {
throw new Error("expected string temp file path");
}
expect(result.path).toBe(writtenPath);
expect(writtenPath).toContain("line-media-");
expect(writtenPath).toMatch(/\.jpg$/);
expect(writtenPath).not.toContain(messageId);
expect(writtenPath).not.toContain("..");
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const rel = path.relative(tmpRoot, path.resolve(writtenPath));
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
});
it("rejects oversized media before writing to disk", async () => {
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
expect(writeSpy).not.toHaveBeenCalled();
});
it("classifies M4A ftyp major brand as audio/mp4", async () => {
const m4aHeader = Buffer.from([
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
]);
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia("mid-audio", "token");
const writtenPath = writeSpy.mock.calls[0]?.[0];
expect(result.contentType).toBe("audio/mp4");
expect(result.path).toMatch(/\.m4a$/);
expect(writtenPath).toBe(result.path);
});
it("detects MP4 video from ftyp major brand (isom)", async () => {
const mp4 = Buffer.from([
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
]);
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia("mid-mp4", "token");
expect(result.contentType).toBe("video/mp4");
expect(result.path).toMatch(/\.mp4$/);
});
});

View file

@ -0,0 +1,112 @@
import fs from "node:fs";
import { messagingApi } from "@line/bot-sdk";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
interface DownloadResult {
path: string;
contentType?: string;
size: number;
}
const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
export async function downloadLineMedia(
messageId: string,
channelAccessToken: string,
maxBytes = 10 * 1024 * 1024,
): Promise<DownloadResult> {
const client = new messagingApi.MessagingApiBlobClient({
channelAccessToken,
});
const response = await client.getMessageContent(messageId);
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of response as AsyncIterable<Buffer>) {
totalSize += chunk.length;
if (totalSize > maxBytes) {
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const contentType = detectContentType(buffer);
const ext = getExtensionForContentType(contentType);
const filePath = buildRandomTempFilePath({ prefix: "line-media", extension: ext });
await fs.promises.writeFile(filePath, buffer);
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
return {
path: filePath,
contentType,
size: buffer.length,
};
}
function detectContentType(buffer: Buffer): string {
const hasFtypBox =
buffer.length >= 12 &&
buffer[4] === 0x66 &&
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70;
if (buffer.length >= 2) {
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return "image/png";
}
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return "image/gif";
}
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return "image/webp";
}
if (hasFtypBox) {
const majorBrand = lowercasePreservingWhitespace(buffer.toString("ascii", 8, 12));
if (AUDIO_BRANDS.has(majorBrand)) {
return "audio/mp4";
}
return "video/mp4";
}
}
return "application/octet-stream";
}
function getExtensionForContentType(contentType: string): string {
switch (contentType) {
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "video/mp4":
return ".mp4";
case "audio/mp4":
return ".m4a";
case "audio/mpeg":
return ".mp3";
default:
return ".bin";
}
}

View file

@ -0,0 +1,33 @@
export {
createActionCard,
createCarousel,
createImageCard,
createInfoCard,
createListCard,
createNotificationBubble,
} from "./flex-templates/basic-cards.js";
export {
createAgendaCard,
createEventCard,
createReceiptCard,
} from "./flex-templates/schedule-cards.js";
export {
createAppleTvRemoteCard,
createDeviceControlCard,
createMediaPlayerCard,
} from "./flex-templates/media-control-cards.js";
export { toFlexMessage } from "./flex-templates/message.js";
export type {
Action,
CardAction,
FlexBox,
FlexBubble,
FlexButton,
FlexCarousel,
FlexComponent,
FlexContainer,
FlexImage,
FlexText,
ListItem,
} from "./flex-templates/types.js";

View file

@ -0,0 +1,395 @@
import { attachFooterText } from "./common.js";
import type {
Action,
CardAction,
FlexBox,
FlexBubble,
FlexButton,
FlexCarousel,
FlexComponent,
FlexImage,
FlexText,
ListItem,
} from "./types.js";
/**
* Create an info card with title, body, and optional footer
*
* Editorial design: Clean hierarchy with accent bar, generous spacing,
* and subtle background zones for visual separation.
*/
export function createInfoCard(title: string, body: string, footer?: string): FlexBubble {
const bubble: FlexBubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: [
// Title with accent bar
{
type: "box",
layout: "horizontal",
contents: [
{
type: "box",
layout: "vertical",
contents: [],
width: "4px",
backgroundColor: "#06C755",
cornerRadius: "2px",
} as FlexBox,
{
type: "text",
text: title,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
flex: 1,
margin: "lg",
} as FlexText,
],
} as FlexBox,
// Body text in subtle container
{
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: body,
size: "md",
color: "#444444",
wrap: true,
lineSpacing: "6px",
} as FlexText,
],
margin: "xl",
paddingAll: "lg",
backgroundColor: "#F8F9FA",
cornerRadius: "lg",
} as FlexBox,
],
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
if (footer) {
attachFooterText(bubble, footer);
}
return bubble;
}
/**
* Create a list card with title and multiple items
*
* Editorial design: Numbered/bulleted list with clear visual hierarchy,
* accent dots for each item, and generous spacing.
*/
export function createListCard(title: string, items: ListItem[]): FlexBubble {
const itemContents: FlexComponent[] = items.slice(0, 8).map((item, index) => {
const itemContents: FlexComponent[] = [
{
type: "text",
text: item.title,
size: "md",
weight: "bold",
color: "#1a1a1a",
wrap: true,
} as FlexText,
];
if (item.subtitle) {
itemContents.push({
type: "text",
text: item.subtitle,
size: "sm",
color: "#888888",
wrap: true,
margin: "xs",
} as FlexText);
}
const itemBox: FlexBox = {
type: "box",
layout: "horizontal",
contents: [
// Accent dot
{
type: "box",
layout: "vertical",
contents: [
{
type: "box",
layout: "vertical",
contents: [],
width: "8px",
height: "8px",
backgroundColor: index === 0 ? "#06C755" : "#DDDDDD",
cornerRadius: "4px",
} as FlexBox,
],
width: "20px",
alignItems: "center",
paddingTop: "sm",
} as FlexBox,
// Item content
{
type: "box",
layout: "vertical",
contents: itemContents,
flex: 1,
} as FlexBox,
],
margin: index > 0 ? "lg" : undefined,
};
if (item.action) {
itemBox.action = item.action;
}
return itemBox;
});
return {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: title,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
} as FlexText,
{
type: "separator",
margin: "lg",
color: "#EEEEEE",
},
{
type: "box",
layout: "vertical",
contents: itemContents,
margin: "lg",
} as FlexBox,
],
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
}
/**
* Create an image card with image, title, and optional body text
*/
export function createImageCard(
imageUrl: string,
title: string,
body?: string,
options?: {
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
aspectMode?: "cover" | "fit";
action?: Action;
},
): FlexBubble {
const bubble: FlexBubble = {
type: "bubble",
hero: {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: options?.aspectRatio ?? "20:13",
aspectMode: options?.aspectMode ?? "cover",
action: options?.action,
} as FlexImage,
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: title,
weight: "bold",
size: "xl",
wrap: true,
} as FlexText,
],
paddingAll: "lg",
},
};
if (body && bubble.body) {
bubble.body.contents.push({
type: "text",
text: body,
size: "md",
wrap: true,
margin: "md",
color: "#666666",
} as FlexText);
}
return bubble;
}
/**
* Create an action card with title, body, and action buttons
*/
export function createActionCard(
title: string,
body: string,
actions: CardAction[],
options?: {
imageUrl?: string;
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
},
): FlexBubble {
const bubble: FlexBubble = {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: title,
weight: "bold",
size: "xl",
wrap: true,
} as FlexText,
{
type: "text",
text: body,
size: "md",
wrap: true,
margin: "md",
color: "#666666",
} as FlexText,
],
paddingAll: "lg",
},
footer: {
type: "box",
layout: "vertical",
contents: actions.slice(0, 4).map(
(action, index) =>
({
type: "button",
action: action.action,
style: index === 0 ? "primary" : "secondary",
margin: index > 0 ? "sm" : undefined,
}) as FlexButton,
),
paddingAll: "md",
},
};
if (options?.imageUrl) {
bubble.hero = {
type: "image",
url: options.imageUrl,
size: "full",
aspectRatio: options.aspectRatio ?? "20:13",
aspectMode: "cover",
} as FlexImage;
}
return bubble;
}
/**
* Create a carousel container from multiple bubbles
* LINE allows max 12 bubbles in a carousel
*/
export function createCarousel(bubbles: FlexBubble[]): FlexCarousel {
return {
type: "carousel",
contents: bubbles.slice(0, 12),
};
}
/**
* Create a notification bubble (for alerts, status updates)
*
* Editorial design: Bold status indicator with accent color,
* clear typography, optional icon for context.
*/
export function createNotificationBubble(
text: string,
options?: {
icon?: string;
type?: "info" | "success" | "warning" | "error";
title?: string;
},
): FlexBubble {
// Color based on notification type
const colors = {
info: { accent: "#3B82F6", bg: "#EFF6FF" },
success: { accent: "#06C755", bg: "#F0FDF4" },
warning: { accent: "#F59E0B", bg: "#FFFBEB" },
error: { accent: "#EF4444", bg: "#FEF2F2" },
};
const typeColors = colors[options?.type ?? "info"];
const contents: FlexComponent[] = [];
// Accent bar
contents.push({
type: "box",
layout: "vertical",
contents: [],
width: "4px",
backgroundColor: typeColors.accent,
cornerRadius: "2px",
} as FlexBox);
// Content section
const textContents: FlexComponent[] = [];
if (options?.title) {
textContents.push({
type: "text",
text: options.title,
size: "md",
weight: "bold",
color: "#111111",
wrap: true,
} as FlexText);
}
textContents.push({
type: "text",
text,
size: options?.title ? "sm" : "md",
color: options?.title ? "#666666" : "#333333",
wrap: true,
margin: options?.title ? "sm" : undefined,
} as FlexText);
contents.push({
type: "box",
layout: "vertical",
contents: textContents,
flex: 1,
paddingStart: "lg",
} as FlexBox);
return {
type: "bubble",
body: {
type: "box",
layout: "horizontal",
contents,
paddingAll: "xl",
backgroundColor: typeColors.bg,
},
};
}

View file

@ -0,0 +1,20 @@
import type { FlexBox, FlexBubble, FlexText } from "./types.js";
export function attachFooterText(bubble: FlexBubble, footer: string) {
bubble.footer = {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: footer,
size: "xs",
color: "#AAAAAA",
wrap: true,
align: "center",
} as FlexText,
],
paddingAll: "lg",
backgroundColor: "#FAFAFA",
} as FlexBox;
}

View file

@ -0,0 +1,555 @@
import type {
FlexBox,
FlexBubble,
FlexButton,
FlexComponent,
FlexImage,
FlexText,
} from "./types.js";
/**
* Create a media player card for Sonos, Spotify, Apple Music, etc.
*
* Editorial design: Album art hero with gradient overlay for text,
* prominent now-playing indicator, refined playback controls.
*/
export function createMediaPlayerCard(params: {
title: string;
subtitle?: string;
source?: string;
imageUrl?: string;
isPlaying?: boolean;
progress?: string;
controls?: {
previous?: { data: string };
play?: { data: string };
pause?: { data: string };
next?: { data: string };
};
extraActions?: Array<{ label: string; data: string }>;
}): FlexBubble {
const { title, subtitle, source, imageUrl, isPlaying, progress, controls, extraActions } = params;
// Track info section
const trackInfo: FlexComponent[] = [
{
type: "text",
text: title,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
} as FlexText,
];
if (subtitle) {
trackInfo.push({
type: "text",
text: subtitle,
size: "md",
color: "#666666",
wrap: true,
margin: "sm",
} as FlexText);
}
// Status row with source and playing indicator
const statusItems: FlexComponent[] = [];
if (isPlaying !== undefined) {
statusItems.push({
type: "box",
layout: "horizontal",
contents: [
{
type: "box",
layout: "vertical",
contents: [],
width: "8px",
height: "8px",
backgroundColor: isPlaying ? "#06C755" : "#CCCCCC",
cornerRadius: "4px",
} as FlexBox,
{
type: "text",
text: isPlaying ? "Now Playing" : "Paused",
size: "xs",
color: isPlaying ? "#06C755" : "#888888",
weight: "bold",
margin: "sm",
} as FlexText,
],
alignItems: "center",
} as FlexBox);
}
if (source) {
statusItems.push({
type: "text",
text: source,
size: "xs",
color: "#AAAAAA",
margin: statusItems.length > 0 ? "lg" : undefined,
} as FlexText);
}
if (progress) {
statusItems.push({
type: "text",
text: progress,
size: "xs",
color: "#888888",
align: "end",
flex: 1,
} as FlexText);
}
const bodyContents: FlexComponent[] = [
{
type: "box",
layout: "vertical",
contents: trackInfo,
} as FlexBox,
];
if (statusItems.length > 0) {
bodyContents.push({
type: "box",
layout: "horizontal",
contents: statusItems,
margin: "lg",
alignItems: "center",
} as FlexBox);
}
const bubble: FlexBubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: bodyContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
// Album art hero
if (imageUrl) {
bubble.hero = {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: "1:1",
aspectMode: "cover",
} as FlexImage;
}
// Control buttons in footer
if (controls || extraActions?.length) {
const footerContents: FlexComponent[] = [];
// Main playback controls with refined styling
if (controls) {
const controlButtons: FlexComponent[] = [];
if (controls.previous) {
controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏮",
data: controls.previous.data,
},
style: "secondary",
flex: 1,
height: "sm",
} as FlexButton);
}
if (controls.play) {
controlButtons.push({
type: "button",
action: {
type: "postback",
label: "▶",
data: controls.play.data,
},
style: isPlaying ? "secondary" : "primary",
flex: 1,
height: "sm",
margin: controls.previous ? "md" : undefined,
} as FlexButton);
}
if (controls.pause) {
controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏸",
data: controls.pause.data,
},
style: isPlaying ? "primary" : "secondary",
flex: 1,
height: "sm",
margin: controlButtons.length > 0 ? "md" : undefined,
} as FlexButton);
}
if (controls.next) {
controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏭",
data: controls.next.data,
},
style: "secondary",
flex: 1,
height: "sm",
margin: controlButtons.length > 0 ? "md" : undefined,
} as FlexButton);
}
if (controlButtons.length > 0) {
footerContents.push({
type: "box",
layout: "horizontal",
contents: controlButtons,
} as FlexBox);
}
}
// Extra actions
if (extraActions?.length) {
footerContents.push({
type: "box",
layout: "horizontal",
contents: extraActions.slice(0, 2).map(
(action, index) =>
({
type: "button",
action: {
type: "postback",
label: action.label.slice(0, 15),
data: action.data,
},
style: "secondary",
flex: 1,
height: "sm",
margin: index > 0 ? "md" : undefined,
}) as FlexButton,
),
margin: "md",
} as FlexBox);
}
if (footerContents.length > 0) {
bubble.footer = {
type: "box",
layout: "vertical",
contents: footerContents,
paddingAll: "lg",
backgroundColor: "#FAFAFA",
};
}
}
return bubble;
}
/**
* Create an Apple TV remote card with a D-pad and control rows.
*/
export function createAppleTvRemoteCard(params: {
deviceName: string;
status?: string;
actionData: {
up: string;
down: string;
left: string;
right: string;
select: string;
menu: string;
home: string;
play: string;
pause: string;
volumeUp: string;
volumeDown: string;
mute: string;
};
}): FlexBubble {
const { deviceName, status, actionData } = params;
const headerContents: FlexComponent[] = [
{
type: "text",
text: deviceName,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
} as FlexText,
];
if (status) {
headerContents.push({
type: "text",
text: status,
size: "sm",
color: "#666666",
wrap: true,
margin: "sm",
} as FlexText);
}
const makeButton = (
label: string,
data: string,
style: "primary" | "secondary" = "secondary",
): FlexButton => ({
type: "button",
action: {
type: "postback",
label,
data,
},
style,
height: "sm",
flex: 1,
});
const dpadRows: FlexComponent[] = [
{
type: "box",
layout: "horizontal",
contents: [{ type: "filler" }, makeButton("↑", actionData.up), { type: "filler" }],
} as FlexBox,
{
type: "box",
layout: "horizontal",
contents: [
makeButton("←", actionData.left),
makeButton("OK", actionData.select, "primary"),
makeButton("→", actionData.right),
],
margin: "md",
} as FlexBox,
{
type: "box",
layout: "horizontal",
contents: [{ type: "filler" }, makeButton("↓", actionData.down), { type: "filler" }],
margin: "md",
} as FlexBox,
];
const menuRow: FlexComponent = {
type: "box",
layout: "horizontal",
contents: [makeButton("Menu", actionData.menu), makeButton("Home", actionData.home)],
margin: "lg",
} as FlexBox;
const playbackRow: FlexComponent = {
type: "box",
layout: "horizontal",
contents: [makeButton("Play", actionData.play), makeButton("Pause", actionData.pause)],
margin: "md",
} as FlexBox;
const volumeRow: FlexComponent = {
type: "box",
layout: "horizontal",
contents: [
makeButton("Vol +", actionData.volumeUp),
makeButton("Mute", actionData.mute),
makeButton("Vol -", actionData.volumeDown),
],
margin: "md",
} as FlexBox;
return {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "box",
layout: "vertical",
contents: headerContents,
} as FlexBox,
{
type: "separator",
margin: "lg",
color: "#EEEEEE",
},
...dpadRows,
menuRow,
playbackRow,
volumeRow,
],
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
}
/**
* Create a device control card for Apple TV, smart home devices, etc.
*
* Editorial design: Device-focused header with status indicator,
* clean control grid with clear visual hierarchy.
*/
export function createDeviceControlCard(params: {
deviceName: string;
deviceType?: string;
status?: string;
isOnline?: boolean;
imageUrl?: string;
controls: Array<{
label: string;
icon?: string;
data: string;
style?: "primary" | "secondary";
}>;
}): FlexBubble {
const { deviceName, deviceType, status, isOnline, imageUrl, controls } = params;
// Device header with status indicator
const headerContents: FlexComponent[] = [
{
type: "box",
layout: "horizontal",
contents: [
// Status dot
{
type: "box",
layout: "vertical",
contents: [],
width: "10px",
height: "10px",
backgroundColor: isOnline !== false ? "#06C755" : "#FF5555",
cornerRadius: "5px",
} as FlexBox,
{
type: "text",
text: deviceName,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
flex: 1,
margin: "md",
} as FlexText,
],
alignItems: "center",
} as FlexBox,
];
if (deviceType) {
headerContents.push({
type: "text",
text: deviceType,
size: "sm",
color: "#888888",
margin: "sm",
} as FlexText);
}
if (status) {
headerContents.push({
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: status,
size: "sm",
color: "#444444",
wrap: true,
} as FlexText,
],
margin: "lg",
paddingAll: "md",
backgroundColor: "#F8F9FA",
cornerRadius: "md",
} as FlexBox);
}
const bubble: FlexBubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: headerContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
if (imageUrl) {
bubble.hero = {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: "16:9",
aspectMode: "cover",
} as FlexImage;
}
// Control buttons in refined grid layout (2 per row)
if (controls.length > 0) {
const rows: FlexComponent[] = [];
const limitedControls = controls.slice(0, 6);
for (let i = 0; i < limitedControls.length; i += 2) {
const rowButtons: FlexComponent[] = [];
for (let j = i; j < Math.min(i + 2, limitedControls.length); j++) {
const ctrl = limitedControls[j];
const buttonLabel = ctrl.icon ? `${ctrl.icon} ${ctrl.label}` : ctrl.label;
rowButtons.push({
type: "button",
action: {
type: "postback",
label: buttonLabel.slice(0, 18),
data: ctrl.data,
},
style: ctrl.style ?? "secondary",
flex: 1,
height: "sm",
margin: j > i ? "md" : undefined,
} as FlexButton);
}
// If odd number of controls in last row, add spacer
if (rowButtons.length === 1) {
rowButtons.push({
type: "filler",
});
}
rows.push({
type: "box",
layout: "horizontal",
contents: rowButtons,
margin: i > 0 ? "md" : undefined,
} as FlexBox);
}
bubble.footer = {
type: "box",
layout: "vertical",
contents: rows,
paddingAll: "lg",
backgroundColor: "#FAFAFA",
};
}
return bubble;
}

View file

@ -0,0 +1,13 @@
import type { messagingApi } from "@line/bot-sdk";
import type { FlexContainer } from "./types.js";
/**
* Wrap a FlexContainer in a FlexMessage
*/
export function toFlexMessage(altText: string, contents: FlexContainer): messagingApi.FlexMessage {
return {
type: "flex",
altText,
contents,
};
}

View file

@ -0,0 +1,467 @@
import { attachFooterText } from "./common.js";
import type { Action, FlexBox, FlexBubble, FlexComponent, FlexText } from "./types.js";
function buildTitleSubtitleHeader(params: { title: string; subtitle?: string }): FlexComponent[] {
const { title, subtitle } = params;
const headerContents: FlexComponent[] = [
{
type: "text",
text: title,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
} as FlexText,
];
if (subtitle) {
headerContents.push({
type: "text",
text: subtitle,
size: "sm",
color: "#888888",
margin: "sm",
wrap: true,
} as FlexText);
}
return headerContents;
}
function buildCardHeaderSections(headerContents: FlexComponent[]): FlexComponent[] {
return [
{
type: "box",
layout: "vertical",
contents: headerContents,
paddingBottom: "lg",
} as FlexBox,
{
type: "separator",
color: "#EEEEEE",
},
];
}
function createMegaBubbleWithFooter(params: {
bodyContents: FlexComponent[];
footer?: string;
}): FlexBubble {
const bubble: FlexBubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: params.bodyContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF",
},
};
if (params.footer) {
attachFooterText(bubble, params.footer);
}
return bubble;
}
/**
* Create a receipt/summary card (for orders, transactions, data tables)
*
* Editorial design: Clean table layout with alternating row backgrounds,
* prominent total section, and clear visual hierarchy.
*/
export function createReceiptCard(params: {
title: string;
subtitle?: string;
items: Array<{ name: string; value: string; highlight?: boolean }>;
total?: { label: string; value: string };
footer?: string;
}): FlexBubble {
const { title, subtitle, items, total, footer } = params;
const itemRows: FlexComponent[] = items.slice(0, 12).map(
(item, index) =>
({
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: item.name,
size: "sm",
color: item.highlight ? "#111111" : "#666666",
weight: item.highlight ? "bold" : "regular",
flex: 3,
wrap: true,
} as FlexText,
{
type: "text",
text: item.value,
size: "sm",
color: item.highlight ? "#06C755" : "#333333",
weight: item.highlight ? "bold" : "regular",
flex: 2,
align: "end",
wrap: true,
} as FlexText,
],
paddingAll: "md",
backgroundColor: index % 2 === 0 ? "#FFFFFF" : "#FAFAFA",
}) as FlexBox,
);
// Header section
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
const bodyContents: FlexComponent[] = [
...buildCardHeaderSections(headerContents),
{
type: "box",
layout: "vertical",
contents: itemRows,
margin: "md",
cornerRadius: "md",
borderWidth: "light",
borderColor: "#EEEEEE",
} as FlexBox,
];
// Total section with emphasis
if (total) {
bodyContents.push({
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: total.label,
size: "lg",
weight: "bold",
color: "#111111",
flex: 2,
} as FlexText,
{
type: "text",
text: total.value,
size: "xl",
weight: "bold",
color: "#06C755",
flex: 2,
align: "end",
} as FlexText,
],
margin: "xl",
paddingAll: "lg",
backgroundColor: "#F0FDF4",
cornerRadius: "lg",
} as FlexBox);
}
return createMegaBubbleWithFooter({ bodyContents, footer });
}
/**
* Create a calendar event card (for meetings, appointments, reminders)
*
* Editorial design: Date as hero, strong typographic hierarchy,
* color-blocked zones, full text wrapping for readability.
*/
export function createEventCard(params: {
title: string;
date: string;
time?: string;
location?: string;
description?: string;
calendar?: string;
isAllDay?: boolean;
action?: Action;
}): FlexBubble {
const { title, date, time, location, description, calendar, isAllDay, action } = params;
// Hero date block - the most important information
const dateBlock: FlexBox = {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: date.toUpperCase(),
size: "sm",
weight: "bold",
color: "#06C755",
wrap: true,
} as FlexText,
{
type: "text",
text: isAllDay ? "ALL DAY" : (time ?? ""),
size: "xxl",
weight: "bold",
color: "#111111",
wrap: true,
margin: "xs",
} as FlexText,
],
paddingBottom: "lg",
borderWidth: "none",
};
// If no time and not all day, hide the time display
if (!time && !isAllDay) {
dateBlock.contents = [
{
type: "text",
text: date,
size: "xl",
weight: "bold",
color: "#111111",
wrap: true,
} as FlexText,
];
}
// Event title with accent bar
const titleBlock: FlexBox = {
type: "box",
layout: "horizontal",
contents: [
{
type: "box",
layout: "vertical",
contents: [],
width: "4px",
backgroundColor: "#06C755",
cornerRadius: "2px",
} as FlexBox,
{
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: title,
size: "lg",
weight: "bold",
color: "#1a1a1a",
wrap: true,
} as FlexText,
...(calendar
? [
{
type: "text",
text: calendar,
size: "xs",
color: "#888888",
margin: "sm",
wrap: true,
} as FlexText,
]
: []),
],
flex: 1,
paddingStart: "lg",
} as FlexBox,
],
paddingTop: "lg",
paddingBottom: "lg",
borderWidth: "light",
borderColor: "#EEEEEE",
};
const bodyContents: FlexComponent[] = [dateBlock, titleBlock];
// Details section (location + description) in subtle background
const hasDetails = location || description;
if (hasDetails) {
const detailItems: FlexComponent[] = [];
if (location) {
detailItems.push({
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: "📍",
size: "sm",
flex: 0,
} as FlexText,
{
type: "text",
text: location,
size: "sm",
color: "#444444",
margin: "md",
flex: 1,
wrap: true,
} as FlexText,
],
alignItems: "flex-start",
} as FlexBox);
}
if (description) {
detailItems.push({
type: "text",
text: description,
size: "sm",
color: "#666666",
wrap: true,
margin: location ? "lg" : "none",
} as FlexText);
}
bodyContents.push({
type: "box",
layout: "vertical",
contents: detailItems,
margin: "lg",
paddingAll: "lg",
backgroundColor: "#F8F9FA",
cornerRadius: "lg",
} as FlexBox);
}
return {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: bodyContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF",
action,
},
};
}
/**
* Create a calendar agenda card showing multiple events
*
* Editorial timeline design: Time-focused left column with event details
* on the right. Visual accent bars indicate event priority/recency.
*/
export function createAgendaCard(params: {
title: string;
subtitle?: string;
events: Array<{
title: string;
time?: string;
location?: string;
calendar?: string;
isNow?: boolean;
}>;
footer?: string;
}): FlexBubble {
const { title, subtitle, events, footer } = params;
// Header with title and optional subtitle
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
// Event timeline items
const eventItems: FlexComponent[] = events.slice(0, 6).map((event, index) => {
const isActive = event.isNow || index === 0;
const accentColor = isActive ? "#06C755" : "#E5E5E5";
// Time column (fixed width)
const timeColumn: FlexBox = {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: event.time ?? "—",
size: "sm",
weight: isActive ? "bold" : "regular",
color: isActive ? "#06C755" : "#666666",
align: "end",
wrap: true,
} as FlexText,
],
width: "65px",
justifyContent: "flex-start",
};
// Accent dot
const dotColumn: FlexBox = {
type: "box",
layout: "vertical",
contents: [
{
type: "box",
layout: "vertical",
contents: [],
width: "10px",
height: "10px",
backgroundColor: accentColor,
cornerRadius: "5px",
} as FlexBox,
],
width: "24px",
alignItems: "center",
justifyContent: "flex-start",
paddingTop: "xs",
};
// Event details column
const detailContents: FlexComponent[] = [
{
type: "text",
text: event.title,
size: "md",
weight: "bold",
color: "#1a1a1a",
wrap: true,
} as FlexText,
];
// Secondary info line
const secondaryParts: string[] = [];
if (event.location) {
secondaryParts.push(event.location);
}
if (event.calendar) {
secondaryParts.push(event.calendar);
}
if (secondaryParts.length > 0) {
detailContents.push({
type: "text",
text: secondaryParts.join(" · "),
size: "xs",
color: "#888888",
wrap: true,
margin: "xs",
} as FlexText);
}
const detailColumn: FlexBox = {
type: "box",
layout: "vertical",
contents: detailContents,
flex: 1,
};
return {
type: "box",
layout: "horizontal",
contents: [timeColumn, dotColumn, detailColumn],
margin: index > 0 ? "xl" : undefined,
alignItems: "flex-start",
} as FlexBox;
});
const bodyContents: FlexComponent[] = [
...buildCardHeaderSections(headerContents),
{
type: "box",
layout: "vertical",
contents: eventItems,
paddingTop: "xl",
} as FlexBox,
];
return createMegaBubbleWithFooter({ bodyContents, footer });
}

View file

@ -0,0 +1,22 @@
import type { messagingApi } from "@line/bot-sdk";
export type FlexContainer = messagingApi.FlexContainer;
export type FlexBubble = messagingApi.FlexBubble;
export type FlexCarousel = messagingApi.FlexCarousel;
export type FlexBox = messagingApi.FlexBox;
export type FlexText = messagingApi.FlexText;
export type FlexImage = messagingApi.FlexImage;
export type FlexButton = messagingApi.FlexButton;
export type FlexComponent = messagingApi.FlexComponent;
export type Action = messagingApi.Action;
export interface ListItem {
title: string;
subtitle?: string;
action?: Action;
}
export interface CardAction {
label: string;
action: Action;
}

View file

@ -0,0 +1,126 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveLineAccount } from "./accounts.js";
import {
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
type LineConfig,
type OpenClawConfig,
type ResolvedLineAccount,
} from "./channel-api.js";
import { getLineRuntime } from "./runtime.js";
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
const loadLineMonitorRuntime = createLazyRuntimeModule(() => import("./monitor.runtime.js"));
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
if (!token) {
throw new Error(
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
);
}
if (!secret) {
throw new Error(
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
);
}
let lineBotLabel = "";
try {
const probe = await (await loadLineProbeRuntime()).probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) {
lineBotLabel = ` (${displayName})`;
}
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
const monitorLineProvider =
getLineRuntime().channel.line?.monitorLineProvider ??
(await loadLineMonitorRuntime()).monitorLineProvider;
return await monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as OpenClawConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accountCleanup = clearAccountEntryFields({
accounts: nextLine.accounts,
accountId,
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
markClearedOnFieldPresence: true,
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
if (accountCleanup.nextAccounts) {
nextLine.accounts = accountCleanup.nextAccounts;
} else {
delete nextLine.accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
};

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
resolveExactLineGroupConfigKey,
resolveLineGroupConfigEntry,
resolveLineGroupLookupIds,
resolveLineGroupsConfig,
} from "./group-keys.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
describe("resolveLineGroupLookupIds", () => {
it("expands raw ids to both prefixed candidates", () => {
expect(resolveLineGroupLookupIds("abc123")).toEqual(["abc123", "group:abc123", "room:abc123"]);
});
it("preserves prefixed ids while also checking the raw id", () => {
expect(resolveLineGroupLookupIds("room:abc123")).toEqual(["abc123", "room:abc123"]);
expect(resolveLineGroupLookupIds("group:abc123")).toEqual(["abc123", "group:abc123"]);
});
});
describe("resolveLineGroupConfigEntry", () => {
it("matches raw, prefixed, and wildcard group config entries", () => {
const groups = {
"group:g1": { requireMention: false },
"room:r1": { systemPrompt: "Room prompt" },
"*": { requireMention: true },
};
expect(resolveLineGroupConfigEntry(groups, { groupId: "g1" })).toEqual({
requireMention: false,
});
expect(resolveLineGroupConfigEntry(groups, { roomId: "r1" })).toEqual({
systemPrompt: "Room prompt",
});
expect(resolveLineGroupConfigEntry(groups, { groupId: "missing" })).toEqual({
requireMention: true,
});
});
});
describe("account-scoped LINE groups", () => {
it("resolves the effective account-scoped groups map", () => {
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: true },
},
accounts: {
work: {
groups: {
"group:g1": { requireMention: false },
},
},
},
},
},
} as any;
expect(resolveLineGroupsConfig(cfg, "work")).toEqual({
"group:g1": { requireMention: false },
});
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe(
"group:g1",
);
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe(
undefined,
);
});
});
describe("line group policy", () => {
it("matches raw and prefixed LINE group keys for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"room:r123": {
requireMention: false,
},
"group:g123": {
requireMention: false,
},
"*": {
requireMention: true,
},
},
},
},
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
});
it("uses account-scoped prefixed LINE group config for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {
groups: {
"group:g123": {
requireMention: false,
},
},
},
},
},
},
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
});
});

View file

@ -0,0 +1,65 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution";
import type { LineConfig, LineGroupConfig } from "./types.js";
export function resolveLineGroupLookupIds(groupId?: string | null): string[] {
const normalized = groupId?.trim();
if (!normalized) {
return [];
}
if (normalized.startsWith("group:") || normalized.startsWith("room:")) {
const rawId = normalized.split(":").slice(1).join(":");
return rawId ? [rawId, normalized] : [normalized];
}
return [normalized, `group:${normalized}`, `room:${normalized}`];
}
export function resolveLineGroupConfigEntry<T>(
groups: Record<string, T | undefined> | undefined,
params: { groupId?: string | null; roomId?: string | null },
): T | undefined {
if (!groups) {
return undefined;
}
for (const candidate of resolveLineGroupLookupIds(params.groupId)) {
const hit = groups[candidate];
if (hit) {
return hit;
}
}
for (const candidate of resolveLineGroupLookupIds(params.roomId)) {
const hit = groups[candidate];
if (hit) {
return hit;
}
}
return groups["*"];
}
export function resolveLineGroupsConfig(
cfg: OpenClawConfig,
accountId?: string | null,
): Record<string, LineGroupConfig | undefined> | undefined {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
if (!lineConfig) {
return undefined;
}
const normalizedAccountId = normalizeAccountId(accountId);
const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups;
return accountGroups ?? lineConfig.groups;
}
export function resolveExactLineGroupConfigKey(params: {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
}): string | undefined {
const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
if (!groups) {
return undefined;
}
return resolveLineGroupLookupIds(params.groupId).find((candidate) =>
Object.hasOwn(groups, candidate),
);
}

View file

@ -0,0 +1,22 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "./channel-api.js";
type LineGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
};
export function resolveLineGroupRequireMention(params: LineGroupContext): boolean {
const exactGroupId = resolveExactLineGroupConfigKey({
cfg: params.cfg,
accountId: params.accountId,
groupId: params.groupId,
});
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "line",
groupId: exactGroupId ?? params.groupId,
accountId: params.accountId,
});
}

View file

@ -0,0 +1,348 @@
import { describe, expect, it } from "vitest";
import {
extractMarkdownTables,
extractCodeBlocks,
extractLinks,
stripMarkdown,
processLineMessage,
convertTableToFlexBubble,
convertCodeBlockToFlexBubble,
hasMarkdownToConvert,
} from "./markdown-to-line.js";
describe("extractMarkdownTables", () => {
it("extracts a simple 2-column table", () => {
const text = `Here is a table:
| Name | Value |
|------|-------|
| foo | 123 |
| bar | 456 |
And some more text.`;
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Name", "Value"]);
expect(tables[0].rows).toEqual([
["foo", "123"],
["bar", "456"],
]);
expect(textWithoutTables).toContain("Here is a table:");
expect(textWithoutTables).toContain("And some more text.");
expect(textWithoutTables).not.toContain("|");
});
it("extracts multiple tables", () => {
const text = `Table 1:
| A | B |
|---|---|
| 1 | 2 |
Table 2:
| X | Y |
|---|---|
| 3 | 4 |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(2);
expect(tables[0].headers).toEqual(["A", "B"]);
expect(tables[1].headers).toEqual(["X", "Y"]);
});
it("handles tables with alignment markers", () => {
const text = `| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
});
it("returns empty when no tables present", () => {
const text = "Just some plain text without tables.";
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(0);
expect(textWithoutTables).toBe(text);
});
});
describe("extractCodeBlocks", () => {
it("extracts code blocks across language/no-language/multiple variants", () => {
const withLanguage = `Here is some code:
\`\`\`javascript
const x = 1;
console.log(x);
\`\`\`
And more text.`;
const withLanguageResult = extractCodeBlocks(withLanguage);
expect(withLanguageResult.codeBlocks).toHaveLength(1);
expect(withLanguageResult.codeBlocks[0].language).toBe("javascript");
expect(withLanguageResult.codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
expect(withLanguageResult.textWithoutCode).toContain("Here is some code:");
expect(withLanguageResult.textWithoutCode).toContain("And more text.");
expect(withLanguageResult.textWithoutCode).not.toContain("```");
const withoutLanguage = `\`\`\`
plain code
\`\`\``;
const withoutLanguageResult = extractCodeBlocks(withoutLanguage);
expect(withoutLanguageResult.codeBlocks).toHaveLength(1);
expect(withoutLanguageResult.codeBlocks[0].language).toBeUndefined();
expect(withoutLanguageResult.codeBlocks[0].code).toBe("plain code");
const multiple = `\`\`\`python
print("hello")
\`\`\`
Some text
\`\`\`bash
echo "world"
\`\`\``;
const multipleResult = extractCodeBlocks(multiple);
expect(multipleResult.codeBlocks).toHaveLength(2);
expect(multipleResult.codeBlocks[0].language).toBe("python");
expect(multipleResult.codeBlocks[1].language).toBe("bash");
});
});
describe("extractLinks", () => {
it("extracts markdown links", () => {
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
expect(textWithLinks).toBe("Check out Google and GitHub.");
});
});
describe("stripMarkdown", () => {
it("strips inline markdown marker variants", () => {
const cases = [
["strips bold **", "This is **bold** text", "This is bold text"],
["strips bold __", "This is __bold__ text", "This is bold text"],
["strips italic *", "This is *italic* text", "This is italic text"],
["strips italic _", "This is _italic_ text", "This is italic text"],
["strips strikethrough", "This is ~~deleted~~ text", "This is deleted text"],
["removes hr ---", "Above\n---\nBelow", "Above\n\nBelow"],
["removes hr ***", "Above\n***\nBelow", "Above\n\nBelow"],
["strips inline code markers", "Use `const` keyword", "Use const keyword"],
] as const;
for (const [name, input, expected] of cases) {
expect(stripMarkdown(input), name).toBe(expected);
}
});
it("preserves underscores inside words", () => {
expect(stripMarkdown("here_is_a_message")).toBe("here_is_a_message");
expect(stripMarkdown("snake_case_var")).toBe("snake_case_var");
expect(stripMarkdown("use foo_bar_baz in code")).toBe("use foo_bar_baz in code");
});
it("still strips proper italic _text_", () => {
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
expect(stripMarkdown("_italic_ at start")).toBe("italic at start");
expect(stripMarkdown("end _italic_")).toBe("end italic");
});
it("strips italic between underscored words", () => {
expect(stripMarkdown("foo_bar _italic_ baz_qux")).toBe("foo_bar italic baz_qux");
});
it("preserves underscores inside non-Latin words", () => {
expect(stripMarkdown("привет_мир_тест")).toBe("привет_мир_тест");
expect(stripMarkdown("東京_駅_前")).toBe("東京_駅_前");
expect(stripMarkdown("var_123_end")).toBe("var_123_end");
});
it("strips standalone italic between non-Latin words", () => {
expect(stripMarkdown("こんにちは _italic_ テスト")).toBe("こんにちは italic テスト");
});
it("handles complex markdown", () => {
const input = `# Title
This is **bold** and *italic* text.
> A quote
Some ~~deleted~~ content.`;
const result = stripMarkdown(input);
expect(result).toContain("Title");
expect(result).toContain("This is bold and italic text.");
expect(result).toContain("A quote");
expect(result).toContain("Some deleted content.");
expect(result).not.toContain("#");
expect(result).not.toContain("**");
expect(result).not.toContain("~~");
expect(result).not.toContain(">");
});
});
describe("convertTableToFlexBubble", () => {
it("replaces empty cells with placeholders", () => {
const table = {
headers: ["A", "B"],
rows: [["", ""]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
};
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
expect(rowsBox.contents[0].contents[0].text).toBe("-");
expect(rowsBox.contents[0].contents[1].text).toBe("-");
});
it("strips bold markers and applies weight for fully bold cells", () => {
const table = {
headers: ["**Name**", "Status"],
rows: [["**Alpha**", "OK"]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
};
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
expect(headerRow.contents[0].text).toBe("Name");
expect(headerRow.contents[0].weight).toBe("bold");
expect(dataRow.contents[0].text).toBe("Alpha");
expect(dataRow.contents[0].weight).toBe("bold");
});
});
describe("convertCodeBlockToFlexBubble", () => {
it("creates a code card with language label", () => {
const block = { language: "typescript", code: "const x = 1;" };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code (typescript)");
});
it("creates a code card without language", () => {
const block = { code: "plain code" };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code");
});
it("truncates very long code", () => {
const longCode = "x".repeat(3000);
const block = { code: longCode };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
const codeText = body.contents[1].contents[0].text;
expect(codeText.length).toBeLessThan(longCode.length);
expect(codeText).toContain("...");
});
});
describe("processLineMessage", () => {
it("processes text with code blocks", () => {
const text = `Check this code:
\`\`\`js
console.log("hi");
\`\`\`
That's it.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.text).toContain("Check this code:");
expect(result.text).toContain("That's it.");
expect(result.text).not.toContain("```");
});
it("handles mixed content", () => {
const text = `# Summary
Here's **important** info:
| Item | Count |
|------|-------|
| A | 5 |
\`\`\`python
print("done")
\`\`\`
> Note: Check the link [here](https://example.com).`;
const result = processLineMessage(text);
// Should have 2 flex messages (table + code)
expect(result.flexMessages).toHaveLength(2);
// Text should be cleaned
expect(result.text).toContain("Summary");
expect(result.text).toContain("important");
expect(result.text).toContain("Note: Check the link here.");
expect(result.text).not.toContain("#");
expect(result.text).not.toContain("**");
expect(result.text).not.toContain("|");
expect(result.text).not.toContain("```");
expect(result.text).not.toContain("[here]");
});
it("handles plain text unchanged", () => {
const text = "Just plain text with no markdown.";
const result = processLineMessage(text);
expect(result.text).toBe(text);
expect(result.flexMessages).toHaveLength(0);
});
});
describe("hasMarkdownToConvert", () => {
it("detects supported markdown patterns", () => {
const cases = [
`| A | B |
|---|---|
| 1 | 2 |`,
"```js\ncode\n```",
"**bold**",
"~~deleted~~",
"# Title",
"> quote",
];
for (const text of cases) {
expect(hasMarkdownToConvert(text)).toBe(true);
}
});
it("returns false for plain text", () => {
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
});
});

View file

@ -0,0 +1,416 @@
import type { messagingApi } from "@line/bot-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
type FlexMessage = messagingApi.FlexMessage;
type FlexComponent = messagingApi.FlexComponent;
type FlexText = messagingApi.FlexText;
type FlexBox = messagingApi.FlexBox;
export interface ProcessedLineMessage {
/** The processed text with markdown stripped */
text: string;
/** Flex messages extracted from tables/code blocks */
flexMessages: FlexMessage[];
}
/**
* Regex patterns for markdown detection
*/
const MARKDOWN_TABLE_REGEX = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm;
const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
/**
* Detect and extract markdown tables from text
*/
export function extractMarkdownTables(text: string): {
tables: MarkdownTable[];
textWithoutTables: string;
} {
const tables: MarkdownTable[] = [];
let textWithoutTables = text;
// Reset regex state
MARKDOWN_TABLE_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
const matches: { fullMatch: string; table: MarkdownTable }[] = [];
while ((match = MARKDOWN_TABLE_REGEX.exec(text)) !== null) {
const fullMatch = match[0];
const headerLine = match[1];
const bodyLines = match[2];
const headers = parseTableRow(headerLine);
const rows = bodyLines
.trim()
.split(/[\r\n]+/)
.filter((line) => line.trim())
.map(parseTableRow);
if (headers.length > 0 && rows.length > 0) {
matches.push({
fullMatch,
table: { headers, rows },
});
}
}
// Remove tables from text in reverse order to preserve indices
for (let i = matches.length - 1; i >= 0; i--) {
const { fullMatch, table } = matches[i];
tables.unshift(table);
textWithoutTables = textWithoutTables.replace(fullMatch, "");
}
return { tables, textWithoutTables };
}
export interface MarkdownTable {
headers: string[];
rows: string[][];
}
/**
* Parse a single table row (pipe-separated values)
*/
function parseTableRow(row: string): string[] {
return row
.split("|")
.map((cell) => cell.trim())
.filter((cell, index, arr) => {
// Filter out empty cells at start/end (from leading/trailing pipes)
if (index === 0 && cell === "") {
return false;
}
if (index === arr.length - 1 && cell === "") {
return false;
}
return true;
});
}
/**
* Convert a markdown table to a LINE Flex Message bubble
*/
export function convertTableToFlexBubble(table: MarkdownTable): FlexBubble {
const parseCell = (
value: string | undefined,
): { text: string; bold: boolean; hasMarkup: boolean } => {
const raw = value?.trim() ?? "";
if (!raw) {
return { text: "-", bold: false, hasMarkup: false };
}
let hasMarkup = false;
const stripped = raw.replace(/\*\*(.+?)\*\*/g, (_, inner) => {
hasMarkup = true;
return String(inner);
});
const text = stripped.trim() || "-";
const bold = /^\*\*.+\*\*$/.test(raw);
return { text, bold, hasMarkup };
};
const headerCells = table.headers.map((header) => parseCell(header));
const rowCells = table.rows.map((row) => row.map((cell) => parseCell(cell)));
const hasInlineMarkup =
headerCells.some((cell) => cell.hasMarkup) ||
rowCells.some((row) => row.some((cell) => cell.hasMarkup));
// For simple 2-column tables, use receipt card format
if (table.headers.length === 2 && !hasInlineMarkup) {
const items = rowCells.map((row) => ({
name: row[0]?.text ?? "-",
value: row[1]?.text ?? "-",
}));
return createReceiptCard({
title: headerCells.map((cell) => cell.text).join(" / "),
items,
});
}
// For multi-column tables, create a custom layout
const headerRow: FlexComponent = {
type: "box",
layout: "horizontal",
contents: headerCells.map((cell) => ({
type: "text",
text: cell.text,
weight: "bold",
size: "sm",
color: "#333333",
flex: 1,
wrap: true,
})) as FlexText[],
paddingBottom: "sm",
} as FlexBox;
const dataRows: FlexComponent[] = rowCells.slice(0, 10).map((row, rowIndex) => {
const rowContents = table.headers.map((_, colIndex) => {
const cell = row[colIndex] ?? { text: "-", bold: false, hasMarkup: false };
return {
type: "text",
text: cell.text,
size: "sm",
color: "#666666",
flex: 1,
wrap: true,
weight: cell.bold ? "bold" : undefined,
};
}) as FlexText[];
return {
type: "box",
layout: "horizontal",
contents: rowContents,
margin: rowIndex === 0 ? "md" : "sm",
} as FlexBox;
});
return {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [headerRow, { type: "separator", margin: "sm" }, ...dataRows],
paddingAll: "lg",
},
};
}
/**
* Detect and extract code blocks from text
*/
export function extractCodeBlocks(text: string): {
codeBlocks: CodeBlock[];
textWithoutCode: string;
} {
const codeBlocks: CodeBlock[] = [];
let textWithoutCode = text;
// Reset regex state
MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
const matches: { fullMatch: string; block: CodeBlock }[] = [];
while ((match = MARKDOWN_CODE_BLOCK_REGEX.exec(text)) !== null) {
const fullMatch = match[0];
const language = match[1] || undefined;
const code = match[2];
matches.push({
fullMatch,
block: { language, code: code.trim() },
});
}
// Remove code blocks in reverse order
for (let i = matches.length - 1; i >= 0; i--) {
const { fullMatch, block } = matches[i];
codeBlocks.unshift(block);
textWithoutCode = textWithoutCode.replace(fullMatch, "");
}
return { codeBlocks, textWithoutCode };
}
export interface CodeBlock {
language?: string;
code: string;
}
/**
* Convert a code block to a LINE Flex Message bubble
*/
export function convertCodeBlockToFlexBubble(block: CodeBlock): FlexBubble {
const titleText = block.language ? `Code (${block.language})` : "Code";
// Truncate very long code to fit LINE's limits
const displayCode = block.code.length > 2000 ? block.code.slice(0, 2000) + "\n..." : block.code;
return {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: titleText,
weight: "bold",
size: "sm",
color: "#666666",
} as FlexText,
{
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: displayCode,
size: "xs",
color: "#333333",
wrap: true,
} as FlexText,
],
backgroundColor: "#F5F5F5",
paddingAll: "md",
cornerRadius: "md",
margin: "sm",
} as FlexBox,
],
paddingAll: "lg",
},
};
}
/**
* Extract markdown links from text
*/
export function extractLinks(text: string): { links: MarkdownLink[]; textWithLinks: string } {
const links: MarkdownLink[] = [];
// Reset regex state
MARKDOWN_LINK_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) {
links.push({
text: match[1],
url: match[2],
});
}
// Replace markdown links with just the text (for plain text output)
const textWithLinks = text.replace(MARKDOWN_LINK_REGEX, "$1");
return { links, textWithLinks };
}
export interface MarkdownLink {
text: string;
url: string;
}
/**
* Create a Flex Message with tappable link buttons
*/
export function convertLinksToFlexBubble(links: MarkdownLink[]): FlexBubble {
const buttons: FlexComponent[] = links.slice(0, 4).map((link, index) => ({
type: "button",
action: {
type: "uri",
label: link.text.slice(0, 20), // LINE button label limit
uri: link.url,
},
style: index === 0 ? "primary" : "secondary",
margin: index > 0 ? "sm" : undefined,
}));
return {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: "Links",
weight: "bold",
size: "md",
color: "#333333",
} as FlexText,
],
paddingAll: "lg",
paddingBottom: "sm",
},
footer: {
type: "box",
layout: "vertical",
contents: buttons,
paddingAll: "md",
},
};
}
/**
* Main function: Process text for LINE output
* - Extracts tables → Flex Messages
* - Extracts code blocks → Flex Messages
* - Strips remaining markdown
* - Returns processed text + Flex Messages
*/
export function processLineMessage(text: string): ProcessedLineMessage {
const flexMessages: FlexMessage[] = [];
let processedText = text;
// 1. Extract and convert tables
const { tables, textWithoutTables } = extractMarkdownTables(processedText);
processedText = textWithoutTables;
for (const table of tables) {
const bubble = convertTableToFlexBubble(table);
flexMessages.push(toFlexMessage("Table", bubble));
}
// 2. Extract and convert code blocks
const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText);
processedText = textWithoutCode;
for (const block of codeBlocks) {
const bubble = convertCodeBlockToFlexBubble(block);
flexMessages.push(toFlexMessage("Code", bubble));
}
// 3. Handle links - convert [text](url) to plain text for display
// (We could also create link buttons, but that can get noisy)
const { textWithLinks } = extractLinks(processedText);
processedText = textWithLinks;
// 4. Strip remaining markdown formatting
processedText = stripMarkdown(processedText);
return {
text: processedText,
flexMessages,
};
}
/**
* Check if text contains markdown that needs conversion
*/
export function hasMarkdownToConvert(text: string): boolean {
// Check for tables
MARKDOWN_TABLE_REGEX.lastIndex = 0;
if (MARKDOWN_TABLE_REGEX.test(text)) {
return true;
}
// Check for code blocks
MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) {
return true;
}
// Check for other markdown patterns
if (/\*\*[^*]+\*\*/.test(text)) {
return true;
} // bold
if (/~~[^~]+~~/.test(text)) {
return true;
} // strikethrough
if (/^#{1,6}\s+/m.test(text)) {
return true;
} // headers
if (/^>\s+/m.test(text)) {
return true;
} // blockquotes
return false;
}

View file

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
createActionCard,
createCarousel,
createDeviceControlCard,
createEventCard,
createImageCard,
createInfoCard,
createListCard,
} from "./flex-templates.js";
import {
createConfirmTemplate,
createButtonTemplate,
createTemplateCarousel,
createCarouselColumn,
createImageCarousel,
createImageCarouselColumn,
createProductCarousel,
messageAction,
} from "./template-messages.js";
describe("createConfirmTemplate", () => {
it("truncates text to 240 characters", () => {
const longText = "x".repeat(300);
const template = createConfirmTemplate(longText, messageAction("Yes"), messageAction("No"));
expect((template.template as { text: string }).text.length).toBe(240);
});
});
describe("createButtonTemplate", () => {
it("limits actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => messageAction(`Button ${i}`));
const template = createButtonTemplate("Title", "Text", actions);
expect((template.template as { actions: unknown[] }).actions.length).toBe(4);
});
it("truncates title to 40 characters", () => {
const longTitle = "x".repeat(50);
const template = createButtonTemplate(longTitle, "Text", [messageAction("OK")]);
expect((template.template as { title: string }).title.length).toBe(40);
});
it("truncates text to 60 chars when no thumbnail is provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);
expect((template.template as { text: string }).text.length).toBe(60);
});
it("keeps longer text when thumbnail is provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
thumbnailImageUrl: "https://example.com/thumb.jpg",
});
expect((template.template as { text: string }).text.length).toBe(100);
});
});
describe("createCarouselColumn", () => {
it("limits actions to 3", () => {
const column = createCarouselColumn({
text: "Text",
actions: [
messageAction("A1"),
messageAction("A2"),
messageAction("A3"),
messageAction("A4"),
messageAction("A5"),
],
});
expect(column.actions.length).toBe(3);
});
it("truncates text to 120 characters", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
expect(column.text.length).toBe(120);
});
});
describe("carousel column limits", () => {
it.each([
{
createTemplate: () =>
createTemplateCarousel(
Array.from({ length: 15 }, () =>
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
),
),
},
{
createTemplate: () =>
createImageCarousel(
Array.from({ length: 15 }, (_, i) =>
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
),
),
},
])("limits columns to 10", ({ createTemplate }) => {
const template = createTemplate();
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
});
});
describe("createProductCarousel", () => {
it.each([
{
title: "Product",
description: "Desc",
actionLabel: "Buy",
actionUrl: "https://shop.com/buy",
expectedType: "uri",
},
{
title: "Product",
description: "Desc",
actionLabel: "Select",
actionData: "product_id=123",
expectedType: "postback",
},
])("uses expected action type for product action", ({ expectedType, ...item }) => {
const template = createProductCarousel([item]);
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
.columns;
expect(columns[0].actions[0].type).toBe(expectedType);
});
});
describe("flex cards", () => {
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
it("limits list items to 8", () => {
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
const card = createListCard("List", items);
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
it("includes image-card body text when provided", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
const body = card.body as { contents: Array<{ text: string }> };
expect(body.contents.length).toBe(2);
expect(body.contents[1].text).toBe("Body text");
});
it("limits action-card actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => ({
label: `Action ${i}`,
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
}));
const card = createActionCard("Title", "Body", actions);
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBe(4);
});
it("limits carousels to 12 bubbles", () => {
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
const carousel = createCarousel(bubbles);
expect(carousel.contents.length).toBe(12);
});
it("limits device controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
it("keeps event-card optional fields together", () => {
const card = createEventCard({
title: "Team Offsite",
date: "February 15, 2026",
time: "9:00 AM - 5:00 PM",
location: "Mountain View Office",
description: "Annual team building event",
});
expect(card.size).toBe("mega");
const body = card.body as { contents: Array<{ type: string }> };
expect(body.contents).toHaveLength(3);
});
});

View file

@ -0,0 +1,267 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
const {
createLineBotMock,
createLineNodeWebhookHandlerMock,
registerPluginHttpRouteMock,
unregisterHttpMock,
} = vi.hoisted(() => ({
createLineBotMock: vi.fn(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn(),
})),
createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
vi.fn<LineNodeWebhookHandler>(async () => {}),
),
registerPluginHttpRouteMock: vi.fn(),
unregisterHttpMock: vi.fn(),
}));
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
vi.mock("./bot.js", () => ({
createLineBot: createLineBotMock,
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
chunkMarkdownText: vi.fn(),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
}));
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,
danger: (value: unknown) => String(value),
logVerbose: vi.fn(),
waitForAbortSignal: vi.fn(),
};
});
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
createChannelReplyPipeline: vi.fn(() => ({})),
}));
vi.mock("openclaw/plugin-sdk/webhook-ingress", () => ({
normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback,
registerPluginHttpRoute: registerPluginHttpRouteMock,
}));
vi.mock("./webhook-node.js", () => ({
createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
}));
vi.mock("./auto-reply-delivery.js", () => ({
deliverLineAutoReply: vi.fn(),
}));
vi.mock("./markdown-to-line.js", () => ({
processLineMessage: vi.fn(),
}));
vi.mock("./reply-chunks.js", () => ({
sendLineReplyChunks: vi.fn(),
}));
vi.mock("./send.js", () => ({
createFlexMessage: vi.fn(),
createImageMessage: vi.fn(),
createLocationMessage: vi.fn(),
createQuickReplyItems: vi.fn(),
createTextMessageWithQuickReplies: vi.fn(),
getUserDisplayName: vi.fn(),
pushMessageLine: vi.fn(),
pushMessagesLine: vi.fn(),
pushTextMessageWithQuickReplies: vi.fn(),
replyMessageLine: vi.fn(),
showLoadingAnimation: vi.fn(),
}));
vi.mock("./template-messages.js", () => ({
buildTemplateMessageFromPayload: vi.fn(),
}));
describe("monitorLineProvider lifecycle", () => {
beforeAll(async () => {
({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
await import("./monitor.js"));
});
beforeEach(() => {
clearLineRuntimeStateForTests();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
handleWebhook: vi.fn(),
});
innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
createLineNodeWebhookHandlerMock
.mockReset()
.mockImplementation(() => innerLineWebhookHandlerMock);
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
});
const createRouteResponse = () => {
const resObj = {
statusCode: 0,
headersSent: false,
setHeader: vi.fn(),
end: vi.fn(() => {
resObj.headersSent = true;
}),
};
return resObj as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
};
it("waits for abort before resolving", async () => {
const abort = new AbortController();
let resolved = false;
const task = monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
abortSignal: abort.signal,
}).then((monitor) => {
resolved = true;
return monitor;
});
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
expect(registerPluginHttpRouteMock).toHaveBeenCalledWith(
expect.objectContaining({ auth: "plugin" }),
);
expect(resolved).toBe(false);
abort.abort();
await task;
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("stops immediately when signal is already aborted", async () => {
const abort = new AbortController();
abort.abort();
await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
abortSignal: abort.signal,
});
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("returns immediately without abort signal and stop is idempotent", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
expect(unregisterHttpMock).not.toHaveBeenCalled();
monitor.stop();
monitor.stop();
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("records startup state under configured defaultAccount when accountId is omitted", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {
channels: {
line: {
defaultAccount: "work",
accounts: {
work: {
channelAccessToken: "work-token",
channelSecret: "work-secret",
},
},
},
},
} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
expect(getLineRuntimeState("work")).toEqual(
expect.objectContaining({
running: true,
}),
);
expect(getLineRuntimeState("default")).toBeUndefined();
monitor.stop();
});
it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
const releaseRequests: Array<() => void> = [];
let reachLimit!: () => void;
const reachedLimit = new Promise<void>((resolve) => {
reachLimit = resolve;
});
innerLineWebhookHandlerMock.mockImplementation(
async (_req: IncomingMessage, res: ServerResponse) => {
if (releaseRequests.length === limit - 1) {
reachLimit();
}
await new Promise<void>((resolve) => {
releaseRequests.push(resolve);
});
res.statusCode = 200;
res.end();
},
);
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
const route = registerPluginHttpRouteMock.mock.calls[0]?.[0] as
| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
| undefined;
expect(route).toBeDefined();
const createPostRequest = () =>
({
method: "POST",
headers: {},
}) as IncomingMessage;
const firstRequests = Array.from({ length: limit }, () =>
route!.handler(createPostRequest(), createRouteResponse()),
);
await reachedLimit;
const overflowResponse = createRouteResponse();
await route!.handler(createPostRequest(), overflowResponse);
expect(innerLineWebhookHandlerMock).toHaveBeenCalledTimes(limit);
expect(overflowResponse.statusCode).toBe(429);
expect(overflowResponse.end).toHaveBeenCalledWith("Too Many Requests");
releaseRequests.splice(0).forEach((release) => release());
await Promise.all(firstRequests);
monitor.stop();
});
});

View file

@ -0,0 +1 @@
export { monitorLineProvider } from "./monitor.js";

View file

@ -0,0 +1,369 @@
import type { webhook } from "@line/bot-sdk";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
dispatchReplyWithBufferedBlockDispatcher,
chunkMarkdownText,
} from "openclaw/plugin-sdk/reply-runtime";
import {
danger,
logVerbose,
waitForAbortSignal,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import {
normalizePluginHttpPath,
registerPluginHttpRoute,
} from "openclaw/plugin-sdk/webhook-ingress";
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
} from "openclaw/plugin-sdk/webhook-request-guards";
import { resolveDefaultLineAccountId } from "./accounts.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
import { createLineBot } from "./bot.js";
import { processLineMessage } from "./markdown-to-line.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
import {
createFlexMessage,
createImageMessage,
createLocationMessage,
createQuickReplyItems,
createTextMessageWithQuickReplies,
getUserDisplayName,
pushMessageLine,
pushMessagesLine,
pushTextMessageWithQuickReplies,
replyMessageLine,
showLoadingAnimation,
} from "./send.js";
import { buildTemplateMessageFromPayload } from "./template-messages.js";
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
import { createLineNodeWebhookHandler } from "./webhook-node.js";
export interface MonitorLineProviderOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
config: OpenClawConfig;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
webhookUrl?: string;
webhookPath?: string;
}
export interface LineProviderMonitor {
account: ResolvedLineAccount;
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
stop: () => void;
}
const runtimeState = new Map<
string,
{
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
}
>();
const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
function recordChannelRuntimeState(params: {
channel: string;
accountId: string;
state: Partial<{
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
lastInboundAt: number | null;
lastOutboundAt: number | null;
}>;
}): void {
const key = `${params.channel}:${params.accountId}`;
const existing = runtimeState.get(key) ?? {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
runtimeState.set(key, { ...existing, ...params.state });
}
export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
export function clearLineRuntimeStateForTests() {
runtimeState.clear();
}
function startLineLoadingKeepalive(params: {
userId: string;
accountId?: string;
intervalMs?: number;
loadingSeconds?: number;
}): () => void {
const intervalMs = params.intervalMs ?? 18_000;
const loadingSeconds = params.loadingSeconds ?? 20;
let stopped = false;
const trigger = () => {
if (stopped) {
return;
}
void showLoadingAnimation(params.userId, {
accountId: params.accountId,
loadingSeconds,
}).catch(() => {});
};
trigger();
const timer = setInterval(trigger, intervalMs);
return () => {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
};
}
export async function monitorLineProvider(
opts: MonitorLineProviderOptions,
): Promise<LineProviderMonitor> {
const {
channelAccessToken,
channelSecret,
accountId,
config,
runtime,
abortSignal,
webhookPath,
} = opts;
const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
const token = channelAccessToken.trim();
const secret = channelSecret.trim();
if (!token) {
throw new Error("LINE webhook mode requires a non-empty channel access token.");
}
if (!secret) {
throw new Error("LINE webhook mode requires a non-empty channel secret.");
}
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: true,
lastStartAt: Date.now(),
},
});
const bot = createLineBot({
channelAccessToken: token,
channelSecret: secret,
accountId,
runtime,
config,
onMessage: async (ctx) => {
if (!ctx) {
return;
}
const { ctxPayload, replyToken, route } = ctx;
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastInboundAt: Date.now(),
},
});
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
const displayNamePromise = ctx.userId
? getUserDisplayName(ctx.userId, { accountId: ctx.accountId })
: Promise.resolve(ctxPayload.From);
const stopLoading = shouldShowLoading
? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId })
: null;
const displayName = await displayNamePromise;
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
try {
const textLimit = 5000;
let replyTokenUsed = false;
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "line",
accountId: route.accountId,
});
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...replyPipeline,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
if (ctx.userId && !ctx.isGroup) {
void showLoadingAnimation(ctx.userId, { accountId: ctx.accountId }).catch(() => {});
}
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
payload,
lineData,
to: ctxPayload.From,
replyToken,
replyTokenUsed,
accountId: ctx.accountId,
textLimit,
deps: {
buildTemplateMessageFromPayload,
processLineMessage,
chunkMarkdownText,
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
onReplyError: (replyErr) => {
logVerbose(
`line: reply token failed, falling back to push: ${String(replyErr)}`,
);
},
},
});
replyTokenUsed = nextReplyTokenUsed;
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastOutboundAt: Date.now(),
},
});
},
onError: (err, info) => {
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
onModelSelected,
},
});
if (!queuedFinal) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
}
} catch (err) {
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
if (replyToken) {
try {
await replyMessageLine(
replyToken,
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
{ accountId: ctx.accountId },
);
} catch (replyErr) {
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
}
}
} finally {
stopLoading?.();
}
},
});
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
const createScopedLineWebhookHandler = (onRequestAuthenticated?: () => void) =>
createLineNodeWebhookHandler({
channelSecret: secret,
bot,
runtime,
onRequestAuthenticated,
});
const unregisterHttp = registerPluginHttpRoute({
path: normalizedPath,
auth: "plugin",
replaceExisting: true,
pluginId: "line",
accountId: resolvedAccountId,
log: (msg) => logVerbose(msg),
handler: async (req, res) => {
if (req.method !== "POST") {
await createScopedLineWebhookHandler()(req, res);
return;
}
const requestLifecycle = beginWebhookRequestPipelineOrReject({
req,
res,
inFlightLimiter: lineWebhookInFlightLimiter,
inFlightKey: `line:${resolvedAccountId}`,
});
if (!requestLifecycle.ok) {
return;
}
try {
await createScopedLineWebhookHandler(requestLifecycle.release)(req, res);
} finally {
requestLifecycle.release();
}
},
});
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
let stopped = false;
const stopHandler = () => {
if (stopped) {
return;
}
stopped = true;
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: false,
lastStopAt: Date.now(),
},
});
};
if (abortSignal?.aborted) {
stopHandler();
} else if (abortSignal) {
abortSignal.addEventListener("abort", stopHandler, { once: true });
await waitForAbortSignal(abortSignal);
}
return {
account: bot.account,
handleWebhook: bot.handleWebhook,
stop: () => {
stopHandler();
abortSignal?.removeEventListener("abort", stopHandler);
},
};
}

View file

@ -0,0 +1,141 @@
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,
);
});
});

View file

@ -0,0 +1,112 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
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 = normalizeLowercaseStringOrEmpty(mimeType);
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 = normalizeLowercaseStringOrEmpty(new URL(url).pathname);
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");
}

View file

@ -0,0 +1,12 @@
export { processLineMessage } from "./markdown-to-line.js";
export {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "./send.js";
export { buildTemplateMessageFromPayload } from "./template-messages.js";

View file

@ -0,0 +1,344 @@
import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
import { getLineRuntime } from "./runtime.js";
import type { LineChannelData } from "./types.js";
const loadLineOutboundRuntime = createLazyRuntimeModule(() => import("./outbound.runtime.js"));
type LineChannelDataWithMedia = LineChannelData & {
mediaKind?: "image" | "video" | "audio";
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
function isLineUserTarget(target: string): boolean {
const normalized = target
.trim()
.replace(/^line:(group|room|user):/i, "")
.replace(/^line:/i, "");
return /^U/i.test(normalized);
}
function hasLineSpecificMediaOptions(lineData: LineChannelDataWithMedia): boolean {
return Boolean(
lineData.mediaKind ??
lineData.previewImageUrl?.trim() ??
(typeof lineData.durationMs === "number" ? lineData.durationMs : undefined) ??
lineData.trackingId?.trim(),
);
}
function buildLineMediaMessageObject(
resolved: LineOutboundMediaResolved,
opts?: { allowTrackingId?: boolean },
): Record<string, unknown> {
switch (resolved.mediaKind) {
case "video": {
const previewImageUrl = resolved.previewImageUrl?.trim();
if (!previewImageUrl) {
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
}
return {
type: "video",
originalContentUrl: resolved.mediaUrl,
previewImageUrl,
...(opts?.allowTrackingId && resolved.trackingId
? { trackingId: resolved.trackingId }
: {}),
};
}
case "audio":
return {
type: "audio",
originalContentUrl: resolved.mediaUrl,
duration: resolved.durationMs ?? 60000,
};
default:
return {
type: "image",
originalContentUrl: resolved.mediaUrl,
previewImageUrl: resolved.previewImageUrl ?? resolved.mediaUrl,
};
}
}
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000,
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const outboundRuntime = await loadLineOutboundRuntime();
const lineData = (payload.channelData?.line as LineChannelDataWithMedia | undefined) ?? {};
const lineRuntime = runtime.channel.line;
const sendText = lineRuntime?.pushMessageLine ?? outboundRuntime.pushMessageLine;
const sendBatch = lineRuntime?.pushMessagesLine ?? outboundRuntime.pushMessagesLine;
const sendFlex = lineRuntime?.pushFlexMessage ?? outboundRuntime.pushFlexMessage;
const sendTemplate = lineRuntime?.pushTemplateMessage ?? outboundRuntime.pushTemplateMessage;
const sendLocation = lineRuntime?.pushLocationMessage ?? outboundRuntime.pushLocationMessage;
const sendQuickReplies =
lineRuntime?.pushTextMessageWithQuickReplies ??
outboundRuntime.pushTextMessageWithQuickReplies;
const buildTemplate =
lineRuntime?.buildTemplateMessageFromPayload ??
outboundRuntime.buildTemplateMessageFromPayload;
let lastResult: { messageId: string; chatId: string } | null = null;
const quickReplies = lineData.quickReplies ?? [];
const hasQuickReplies = quickReplies.length > 0;
const quickReply = hasQuickReplies
? (lineRuntime?.createQuickReplyItems ?? outboundRuntime.createQuickReplyItems)(quickReplies)
: undefined;
// LINE SDK expects Message[] but we build dynamically.
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) {
return;
}
for (let i = 0; i < messages.length; i += 5) {
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
const result = await sendBatch(to, batch, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? outboundRuntime.processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
fallbackLimit: 5000,
}) ?? 5000;
const chunks = processed.text
? 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 (lineRuntime?.sendMessageLine ?? outboundRuntime.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 (lineRuntime?.sendMessageLine ?? outboundRuntime.sendMessageLine)(
to,
"",
{
verbose: false,
mediaUrl: resolved.mediaUrl,
mediaKind: resolved.mediaKind,
previewImageUrl: resolved.previewImageUrl,
durationMs: resolved.durationMs,
trackingId: resolved.trackingId,
cfg,
accountId: accountId ?? undefined,
},
);
}
};
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents;
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
await sendMediaMessages();
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i], {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) {
continue;
}
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;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
await sendMediaMessages();
}
if (lastResult) {
return createEmptyChannelResult("line", { ...lastResult });
}
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
},
...createAttachedChannelResultAdapter({
channel: "line",
sendText: async ({ cfg, to, text, accountId }) => {
const outboundRuntime = await loadLineOutboundRuntime();
const sendText = outboundRuntime.pushMessageLine;
const sendFlex = outboundRuntime.pushFlexMessage;
const processed = outboundRuntime.processLineMessage(text);
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
} else {
result = { messageId: "processed", chatId: to };
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents;
await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
return result;
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
await (
await loadLineOutboundRuntime()
).sendMessageLine(to, text, {
verbose: false,
mediaUrl,
cfg,
accountId: accountId ?? undefined,
}),
}),
};

View file

@ -0,0 +1 @@
export { probeLineBot } from "./probe.js";

View file

@ -0,0 +1,34 @@
import { messagingApi } from "@line/bot-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { withTimeout } from "openclaw/plugin-sdk/text-runtime";
import type { LineProbeResult } from "./types.js";
export async function probeLineBot(
channelAccessToken: string,
timeoutMs = 5000,
): Promise<LineProbeResult> {
if (!channelAccessToken?.trim()) {
return { ok: false, error: "Channel access token not configured" };
}
const client = new messagingApi.MessagingApiClient({
channelAccessToken: channelAccessToken.trim(),
});
try {
const profile = await withTimeout(client.getBotInfo(), timeoutMs);
return {
ok: true,
bot: {
displayName: profile.displayName,
userId: profile.userId,
basicId: profile.basicId,
pictureUrl: profile.pictureUrl,
},
};
} catch (err) {
const message = formatErrorMessage(err);
return { ok: false, error: message };
}
}

View file

@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";
import { sendLineReplyChunks } from "./reply-chunks.js";
function createReplyChunksHarness() {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
}));
return {
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
};
}
describe("sendLineReplyChunks", () => {
it("uses reply token for all chunks when possible", async () => {
const {
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
} = createReplyChunksHarness();
const result = await sendLineReplyChunks({
to: "line:group:1",
chunks: ["one", "two", "three"],
quickReplies: ["A", "B"],
replyToken: "token",
replyTokenUsed: false,
accountId: "default",
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("three", ["A", "B"]);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{ type: "text", text: "one" },
{ type: "text", text: "two" },
{ type: "text", text: "three" },
],
{ accountId: "default" },
);
expect(pushMessageLine).not.toHaveBeenCalled();
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
it("attaches quick replies to a single reply chunk", async () => {
const { replyMessageLine, pushMessageLine, pushTextMessageWithQuickReplies } =
createReplyChunksHarness();
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
quickReply: { items: [] },
}));
const result = await sendLineReplyChunks({
to: "line:user:1",
chunks: ["only"],
quickReplies: ["A"],
replyToken: "token",
replyTokenUsed: false,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("only", ["A"]);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(pushMessageLine).not.toHaveBeenCalled();
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
it("replies with up to five chunks before pushing the rest", async () => {
const {
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
} = createReplyChunksHarness();
const chunks = ["1", "2", "3", "4", "5", "6", "7"];
const result = await sendLineReplyChunks({
to: "line:group:1",
chunks,
quickReplies: ["A"],
replyToken: "token",
replyTokenUsed: false,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{ type: "text", text: "1" },
{ type: "text", text: "2" },
{ type: "text", text: "3" },
{ type: "text", text: "4" },
{ type: "text", text: "5" },
],
{ accountId: undefined },
);
expect(pushMessageLine).toHaveBeenCalledTimes(1);
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { accountId: undefined });
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1);
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], {
accountId: undefined,
});
expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
it("falls back to push flow when replying fails", async () => {
const {
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
} = createReplyChunksHarness();
const onReplyError = vi.fn();
replyMessageLine.mockRejectedValueOnce(new Error("reply failed"));
const result = await sendLineReplyChunks({
to: "line:group:1",
chunks: ["1", "2", "3"],
quickReplies: ["A"],
replyToken: "token",
replyTokenUsed: false,
accountId: "default",
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
onReplyError,
});
expect(result.replyTokenUsed).toBe(true);
expect(onReplyError).toHaveBeenCalledWith(expect.any(Error));
expect(pushMessageLine).toHaveBeenNthCalledWith(1, "line:group:1", "1", {
accountId: "default",
});
expect(pushMessageLine).toHaveBeenNthCalledWith(2, "line:group:1", "2", {
accountId: "default",
});
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "3", ["A"], {
accountId: "default",
});
});
});

View file

@ -0,0 +1,101 @@
import type { messagingApi } from "@line/bot-sdk";
export type LineReplyMessage = messagingApi.TextMessage;
export type SendLineReplyChunksParams = {
to: string;
chunks: string[];
quickReplies?: string[];
replyToken?: string | null;
replyTokenUsed?: boolean;
accountId?: string;
replyMessageLine: (
replyToken: string,
messages: messagingApi.Message[],
opts?: { accountId?: string },
) => Promise<unknown>;
pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise<unknown>;
pushTextMessageWithQuickReplies: (
to: string,
text: string,
quickReplies: string[],
opts?: { accountId?: string },
) => Promise<unknown>;
createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage;
onReplyError?: (err: unknown) => void;
};
export async function sendLineReplyChunks(
params: SendLineReplyChunksParams,
): Promise<{ replyTokenUsed: boolean }> {
const hasQuickReplies = Boolean(params.quickReplies?.length);
let replyTokenUsed = Boolean(params.replyTokenUsed);
if (params.chunks.length === 0) {
return { replyTokenUsed };
}
if (params.replyToken && !replyTokenUsed) {
try {
const replyBatch = params.chunks.slice(0, 5);
const remaining = params.chunks.slice(replyBatch.length);
const replyMessages: LineReplyMessage[] = replyBatch.map((chunk) => ({
type: "text",
text: chunk,
}));
if (hasQuickReplies && remaining.length === 0 && replyMessages.length > 0) {
const lastIndex = replyMessages.length - 1;
replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(
replyBatch[lastIndex],
params.quickReplies!,
);
}
await params.replyMessageLine(params.replyToken, replyMessages, {
accountId: params.accountId,
});
replyTokenUsed = true;
for (let i = 0; i < remaining.length; i += 1) {
const isLastChunk = i === remaining.length - 1;
if (isLastChunk && hasQuickReplies) {
await params.pushTextMessageWithQuickReplies(
params.to,
remaining[i],
params.quickReplies!,
{ accountId: params.accountId },
);
} else {
await params.pushMessageLine(params.to, remaining[i], {
accountId: params.accountId,
});
}
}
return { replyTokenUsed };
} catch (err) {
params.onReplyError?.(err);
replyTokenUsed = true;
}
}
for (let i = 0; i < params.chunks.length; i += 1) {
const isLastChunk = i === params.chunks.length - 1;
if (isLastChunk && hasQuickReplies) {
await params.pushTextMessageWithQuickReplies(
params.to,
params.chunks[i],
params.quickReplies!,
{ accountId: params.accountId },
);
} else {
await params.pushMessageLine(params.to, params.chunks[i], {
accountId: params.accountId,
});
}
}
return { replyTokenUsed };
}

View file

@ -0,0 +1,387 @@
import { describe, expect, it } from "vitest";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
describe("hasLineDirectives", () => {
it("matches expected detection across directive patterns", () => {
const cases: Array<{ text: string; expected: boolean }> = [
{ text: "Here are options [[quick_replies: A, B, C]]", expected: true },
{ text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true },
{ text: "[[confirm: Continue? | Yes | No]]", expected: true },
{ text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true },
{ text: "Just regular text", expected: false },
{ text: "[[not_a_directive: something]]", expected: false },
{ text: "[[media_player: Song | Artist | Speaker]]", expected: true },
{ text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true },
{ text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true },
{ text: "[[device: TV | Room]]", expected: true },
{ text: "[[appletv_remote: Apple TV | Playing]]", expected: true },
];
for (const testCase of cases) {
expect(hasLineDirectives(testCase.text)).toBe(testCase.expected);
}
});
});
describe("parseLineDirectives", () => {
describe("quick_replies", () => {
it("parses quick replies variants", () => {
const cases: Array<{
text: string;
channelData?: { line: { quickReplies: string[] } };
quickReplies: string[];
outputText?: string;
}> = [
{
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
quickReplies: ["Option A", "Option B", "Option C"],
outputText: "Choose one:",
},
{
text: "Before [[quick_replies: A, B]] After",
quickReplies: ["A", "B"],
outputText: "Before After",
},
{
text: "Text [[quick_replies: C, D]]",
channelData: { line: { quickReplies: ["A", "B"] } },
quickReplies: ["A", "B", "C", "D"],
outputText: "Text",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({
text: testCase.text,
channelData: testCase.channelData,
});
expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies);
if (testCase.outputText !== undefined) {
expect(result.text).toBe(testCase.outputText);
}
}
});
});
describe("location", () => {
it("parses location variants", () => {
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
const cases: Array<{
text: string;
channelData?: { line: { location: typeof existing } };
location?: typeof existing;
outputText?: string;
}> = [
{
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
location: {
title: "Tokyo Station",
address: "Tokyo, Japan",
latitude: 35.6812,
longitude: 139.7671,
},
outputText: "Here's the location:",
},
{
text: "[[location: Place | Address | invalid | 139.7]]",
location: undefined,
},
{
text: "[[location: New | New Addr | 35.6 | 139.7]]",
channelData: { line: { location: existing } },
location: existing,
},
];
for (const testCase of cases) {
const result = parseLineDirectives({
text: testCase.text,
channelData: testCase.channelData,
});
expect(getLineData(result).location).toEqual(testCase.location);
if (testCase.outputText !== undefined) {
expect(result.text).toBe(testCase.outputText);
}
}
});
});
describe("confirm", () => {
it("parses confirm directives with default and custom action payloads", () => {
const cases = [
{
name: "default yes/no data",
text: "[[confirm: Delete this item? | Yes | No]]",
expectedTemplate: {
type: "confirm",
text: "Delete this item?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
altText: "Delete this item?",
},
expectedText: undefined,
},
{
name: "custom action data",
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
expectedTemplate: {
type: "confirm",
text: "Proceed?",
confirmLabel: "OK",
confirmData: "action=confirm",
cancelLabel: "Cancel",
cancelData: "action=cancel",
altText: "Proceed?",
},
expectedText: undefined,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
expect(getLineData(result).templateMessage, testCase.name).toEqual(
testCase.expectedTemplate,
);
expect(result.text, testCase.name).toBe(testCase.expectedText);
}
});
});
describe("buttons", () => {
it("parses message/uri/postback button actions and enforces action caps", () => {
const cases = [
{
name: "message actions",
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
expectedTemplate: {
type: "buttons",
title: "Menu",
text: "Select an option",
actions: [
{ type: "message", label: "Help", data: "/help" },
{ type: "message", label: "Status", data: "/status" },
],
altText: "Menu: Select an option",
},
},
{
name: "uri action",
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
expectedFirstAction: {
type: "uri",
label: "Site",
uri: "https://example.com",
},
},
{
name: "postback action",
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
expectedFirstAction: {
type: "postback",
label: "Select",
data: "action=select&id=1",
},
},
{
name: "action cap",
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
expectedActionCount: 4,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type, testCase.name).toBe("buttons");
if ("expectedTemplate" in testCase) {
expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate);
}
if ("expectedFirstAction" in testCase) {
expect(templateMessage?.actions?.[0], testCase.name).toEqual(
testCase.expectedFirstAction,
);
}
if ("expectedActionCount" in testCase) {
expect(templateMessage?.actions?.length, testCase.name).toBe(
testCase.expectedActionCount,
);
}
}
});
});
describe("media_player", () => {
it("parses media_player directives across full/minimal/paused variants", () => {
const cases = [
{
name: "all fields",
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
expectedAltText: "🎵 Bohemian Rhapsody - Queen",
expectedText: "Now playing:",
expectFooter: true,
expectBodyContents: false,
},
{
name: "minimal",
text: "[[media_player: Unknown Track]]",
expectedAltText: "🎵 Unknown Track",
expectedText: undefined,
expectFooter: false,
expectBodyContents: false,
},
{
name: "paused status",
text: "[[media_player: Song | Artist | Player | | paused]]",
expectedAltText: undefined,
expectedText: undefined,
expectFooter: false,
expectBodyContents: true,
},
] as const;
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as {
altText?: string;
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
};
expect(flexMessage, testCase.name).toBeDefined();
if (testCase.expectedAltText !== undefined) {
expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText);
}
if (testCase.expectedText !== undefined) {
expect(result.text, testCase.name).toBe(testCase.expectedText);
}
if (testCase.expectFooter) {
expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
}
if ("expectBodyContents" in testCase && testCase.expectBodyContents) {
expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined();
}
}
});
});
describe("event", () => {
it("parses event variants", () => {
const cases = [
{
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM",
},
{
text: "[[event: Birthday Party | March 15]]",
altText: "📅 Birthday Party - March 15",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("agenda", () => {
it("parses agenda variants", () => {
const cases = [
{
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
altText: "📋 Today's Schedule (3 events)",
},
{
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
altText: "📋 Tasks (3 events)",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("device", () => {
it("parses device variants", () => {
const cases = [
{
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
altText: "📱 TV: Playing",
},
{
text: "[[device: Speaker]]",
altText: "📱 Speaker",
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe(testCase.altText);
}
});
});
describe("appletv_remote", () => {
it("parses appletv remote variants", () => {
const cases = [
{
text: "[[appletv_remote: Apple TV | Playing]]",
contains: "Apple TV",
},
{
text: "[[appletv_remote: Apple TV]]",
contains: undefined,
},
];
for (const testCase of cases) {
const result = parseLineDirectives({ text: testCase.text });
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
if (testCase.contains) {
expect(flexMessage?.altText).toContain(testCase.contains);
}
}
});
});
describe("combined directives", () => {
it("handles text with no directives", () => {
const result = parseLineDirectives({
text: "Just plain text here",
});
expect(result.text).toBe("Just plain text here");
expect(getLineData(result).quickReplies).toBeUndefined();
expect(getLineData(result).location).toBeUndefined();
expect(getLineData(result).templateMessage).toBeUndefined();
});
it("preserves other payload fields", () => {
const result = parseLineDirectives({
text: "Hello [[quick_replies: A, B]]",
mediaUrl: "https://example.com/image.jpg",
replyToId: "msg123",
});
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
expect(result.replyToId).toBe("msg123");
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
});
});
});

View file

@ -0,0 +1,317 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
createAgendaCard,
createAppleTvRemoteCard,
createDeviceControlCard,
createEventCard,
createMediaPlayerCard,
} from "./flex-templates.js";
import type { LineChannelData } from "./types.js";
/**
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
*
* Supported directives:
* - [[quick_replies: option1, option2, option3]]
* - [[location: title | address | latitude | longitude]]
* - [[confirm: question | yes_label | no_label]]
* - [[buttons: title | text | btn1:data1, btn2:data2]]
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
* - [[event: title | date | time | location | description]]
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
* - [[appletv_remote: name | status]]
*/
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
let text = payload.text;
if (!text) {
return payload;
}
const result: ReplyPayload = { ...payload };
const lineData: LineChannelData = {
...(result.channelData?.line as LineChannelData | undefined),
};
const toSlug = (value: string): string =>
normalizeLowercaseStringOrEmpty(value)
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action: string, extras?: Record<string, string>): string => {
const base = [`line.action=${encodeURIComponent(action)}`];
if (extras) {
for (const [key, value] of Object.entries(extras)) {
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return base.join("&");
};
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
if (quickRepliesMatch) {
const options = quickRepliesMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (options.length > 0) {
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
}
text = text.replace(quickRepliesMatch[0], "").trim();
}
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
if (locationMatch && !lineData.location) {
const parts = locationMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 4) {
const [title, address, latStr, lonStr] = parts;
const latitude = parseFloat(latStr);
const longitude = parseFloat(lonStr);
if (!isNaN(latitude) && !isNaN(longitude)) {
lineData.location = {
title: title || "Location",
address: address || "",
latitude,
longitude,
};
}
}
text = text.replace(locationMatch[0], "").trim();
}
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
if (confirmMatch && !lineData.templateMessage) {
const parts = confirmMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [question, yesPart, noPart] = parts;
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, normalizeLowercaseStringOrEmpty(yesPart)];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, normalizeLowercaseStringOrEmpty(noPart)];
lineData.templateMessage = {
type: "confirm",
text: question,
confirmLabel: yesLabel,
confirmData: yesData,
cancelLabel: noLabel,
cancelData: noData,
altText: question,
};
}
text = text.replace(confirmMatch[0], "").trim();
}
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
if (buttonsMatch && !lineData.templateMessage) {
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [title, bodyText, actionsStr] = parts;
const actions = actionsStr.split(",").map((actionStr) => {
const trimmed = actionStr.trim();
const colonIndex = (() => {
const index = trimmed.indexOf(":");
if (index === -1) {
return -1;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return -1;
}
return index;
})();
let label: string;
let data: string;
if (colonIndex === -1) {
label = trimmed;
data = trimmed;
} else {
label = trimmed.slice(0, colonIndex).trim();
data = trimmed.slice(colonIndex + 1).trim();
}
if (data.startsWith("http://") || data.startsWith("https://")) {
return { type: "uri" as const, label, uri: data };
}
if (data.includes("=")) {
return { type: "postback" as const, label, data };
}
return { type: "message" as const, label, data: data || label };
});
if (actions.length > 0) {
lineData.templateMessage = {
type: "buttons",
title,
text: bodyText,
actions: actions.slice(0, 4),
altText: `${title}: ${bodyText}`,
};
}
}
text = text.replace(buttonsMatch[0], "").trim();
}
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
if (mediaPlayerMatch && !lineData.flexMessage) {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = normalizeLowercaseStringOrEmpty(statusStr) === "playing";
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
subtitle: artist || undefined,
source: source || undefined,
imageUrl: validImageUrl,
isPlaying: statusStr ? isPlaying : undefined,
controls: {
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
play: { data: lineActionData("play", { "line.device": deviceKey }) },
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
next: { data: lineActionData("next", { "line.device": deviceKey }) },
},
});
lineData.flexMessage = {
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
contents: card,
};
}
text = text.replace(mediaPlayerMatch[0], "").trim();
}
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
if (eventMatch && !lineData.flexMessage) {
const parts = eventMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, date, time, location, description] = parts;
const card = createEventCard({
title: title || "Event",
date: date || "TBD",
time: time || undefined,
location: location || undefined,
description: description || undefined,
});
lineData.flexMessage = {
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
contents: card,
};
}
text = text.replace(eventMatch[0], "").trim();
}
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
if (appleTvMatch && !lineData.flexMessage) {
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, status] = parts;
const deviceKey = toSlug(deviceName || "apple_tv");
const card = createAppleTvRemoteCard({
deviceName: deviceName || "Apple TV",
status: status || undefined,
actionData: {
up: lineActionData("up", { "line.device": deviceKey }),
down: lineActionData("down", { "line.device": deviceKey }),
left: lineActionData("left", { "line.device": deviceKey }),
right: lineActionData("right", { "line.device": deviceKey }),
select: lineActionData("select", { "line.device": deviceKey }),
menu: lineActionData("menu", { "line.device": deviceKey }),
home: lineActionData("home", { "line.device": deviceKey }),
play: lineActionData("play", { "line.device": deviceKey }),
pause: lineActionData("pause", { "line.device": deviceKey }),
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
mute: lineActionData("mute", { "line.device": deviceKey }),
},
});
lineData.flexMessage = {
altText: `📺 ${deviceName || "Apple TV"} Remote`,
contents: card,
};
}
text = text.replace(appleTvMatch[0], "").trim();
}
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
if (agendaMatch && !lineData.flexMessage) {
const parts = agendaMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, eventsStr] = parts;
const events = eventsStr.split(",").map((eventStr) => {
const trimmed = eventStr.trim();
const colonIdx = trimmed.lastIndexOf(":");
if (colonIdx > 0) {
return {
title: trimmed.slice(0, colonIdx).trim(),
time: trimmed.slice(colonIdx + 1).trim(),
};
}
return { title: trimmed };
});
const card = createAgendaCard({
title: title || "Agenda",
events,
});
lineData.flexMessage = {
altText: `📋 ${title} (${events.length} events)`,
contents: card,
};
}
text = text.replace(agendaMatch[0], "").trim();
}
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
if (deviceMatch && !lineData.flexMessage) {
const parts = deviceMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, deviceType, status, controlsStr] = parts;
const deviceKey = toSlug(deviceName || "device");
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_");
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
})
: [];
const card = createDeviceControlCard({
deviceName: deviceName || "Device",
deviceType: deviceType || undefined,
status: status || undefined,
controls,
});
lineData.flexMessage = {
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
contents: card,
};
}
text = text.replace(deviceMatch[0], "").trim();
}
text = text.replace(/\n{3,}/g, "\n\n").trim();
result.text = text || undefined;
if (Object.keys(lineData).length > 0) {
result.channelData = { ...result.channelData, line: lineData };
}
return result;
}
export function hasLineDirectives(text: string): boolean {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
text,
);
}

View file

@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import {
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
} from "./rich-menu.js";
describe("messageAction", () => {
it("creates message actions with explicit or default text", () => {
const cases = [
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
{ name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
] as const;
for (const testCase of cases) {
const action = testCase.text
? messageAction(testCase.label, testCase.text)
: messageAction(testCase.label);
expect(action.type, testCase.name).toBe("message");
expect(action.label, testCase.name).toBe(testCase.label);
expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
}
});
});
describe("uriAction", () => {
it("creates a URI action", () => {
const action = uriAction("Open", "https://example.com");
expect(action.type).toBe("uri");
expect(action.label).toBe("Open");
expect((action as { uri: string }).uri).toBe("https://example.com");
});
});
describe("action label truncation", () => {
it.each([
{
createAction: () => messageAction("This is a very long label text"),
expectedLabel: "This is a very long ",
},
{
createAction: () => uriAction("Click here to visit our website", "https://example.com"),
expectedLabel: "Click here to visit ",
},
])("truncates labels to 20 characters", ({ createAction, expectedLabel }) => {
const action = createAction();
expect(action.label).toBe(expectedLabel);
expect((action.label ?? "").length).toBe(20);
});
});
describe("postbackAction", () => {
it("creates a postback action", () => {
const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
expect(action.type).toBe("postback");
expect(action.label).toBe("Select");
expect((action as { data: string }).data).toBe("action=select&item=1");
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
});
it("applies postback payload truncation and displayText behavior", () => {
const truncatedData = postbackAction("Test", "x".repeat(400));
expect((truncatedData as { data: string }).data.length).toBe(300);
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
const noDisplayText = postbackAction("Test", "data");
expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
});
});
describe("datetimePickerAction", () => {
it("creates picker actions for all supported modes", () => {
const cases = [
{ label: "Pick date", data: "date_picked", mode: "date" as const },
{ label: "Pick time", data: "time_picked", mode: "time" as const },
{ label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
];
for (const testCase of cases) {
const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe(testCase.label);
expect((action as { mode: string }).mode).toBe(testCase.mode);
expect((action as { data: string }).data).toBe(testCase.data);
}
});
it("includes initial/min/max when provided", () => {
const action = datetimePickerAction("Pick", "data", "date", {
initial: "2024-06-15",
min: "2024-01-01",
max: "2024-12-31",
});
expect((action as { initial: string }).initial).toBe("2024-06-15");
expect((action as { min: string }).min).toBe("2024-01-01");
expect((action as { max: string }).max).toBe("2024-12-31");
});
});
describe("createGridLayout", () => {
function createSixSimpleActions() {
return [
messageAction("A1"),
messageAction("A2"),
messageAction("A3"),
messageAction("A4"),
messageAction("A5"),
messageAction("A6"),
] as [
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
];
}
it("computes expected 2x3 layout for supported menu heights", () => {
const actions = createSixSimpleActions();
const cases = [
{ height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
] as const;
for (const testCase of cases) {
const areas = createGridLayout(testCase.height, actions);
expect(areas.length).toBe(6);
expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
expect(areas[0]?.bounds.x).toBe(0);
expect(areas[1]?.bounds.x).toBe(833);
expect(areas[2]?.bounds.x).toBe(1666);
}
});
it("assigns correct actions to areas", () => {
const actions = [
messageAction("Help", "/help"),
messageAction("Status", "/status"),
messageAction("Settings", "/settings"),
messageAction("About", "/about"),
messageAction("Feedback", "/feedback"),
messageAction("Contact", "/contact"),
] as [
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
];
const areas = createGridLayout(843, actions);
expect((areas[0].action as { text: string }).text).toBe("/help");
expect((areas[1].action as { text: string }).text).toBe("/status");
expect((areas[2].action as { text: string }).text).toBe("/settings");
expect((areas[3].action as { text: string }).text).toBe("/about");
expect((areas[4].action as { text: string }).text).toBe("/feedback");
expect((areas[5].action as { text: string }).text).toBe("/contact");
});
});
describe("createDefaultMenuConfig", () => {
it("creates a valid default menu configuration", () => {
const config = createDefaultMenuConfig();
expect(config.size.width).toBe(2500);
expect(config.size.height).toBe(843);
expect(config.selected).toBe(false);
expect(config.name).toBe("Default Menu");
expect(config.chatBarText).toBe("Menu");
expect(config.areas.length).toBe(6);
});
it("has valid area bounds", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.bounds.x).toBeGreaterThanOrEqual(0);
expect(area.bounds.y).toBeGreaterThanOrEqual(0);
expect(area.bounds.width).toBeGreaterThan(0);
expect(area.bounds.height).toBeGreaterThan(0);
expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
}
});
it("uses message actions with expected default commands", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.action.type).toBe("message");
}
const commands = config.areas.map((a) => (a.action as { text: string }).text);
expect(commands).toContain("/help");
expect(commands).toContain("/status");
expect(commands).toContain("/settings");
});
});

View file

@ -0,0 +1,322 @@
import { readFile } from "node:fs/promises";
import { messagingApi } from "@line/bot-sdk";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveLineAccount } from "./accounts.js";
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
type RichMenuRequest = messagingApi.RichMenuRequest;
type RichMenuResponse = messagingApi.RichMenuResponse;
type RichMenuArea = messagingApi.RichMenuArea;
type Action = messagingApi.Action;
const USER_BATCH_SIZE = 500;
export interface RichMenuSize {
width: 2500;
height: 1686 | 843;
}
export interface RichMenuAreaRequest {
bounds: {
x: number;
y: number;
width: number;
height: number;
};
action: Action;
}
export interface CreateRichMenuParams {
size: RichMenuSize;
selected?: boolean;
name: string;
chatBarText: string;
areas: RichMenuAreaRequest[];
}
interface RichMenuOpts {
channelAccessToken?: string;
accountId?: string;
verbose?: boolean;
}
function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient {
const account = resolveLineAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
return new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
}
function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient {
const account = resolveLineAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
return new messagingApi.MessagingApiBlobClient({
channelAccessToken: token,
});
}
function chunkUserIds(userIds: string[]): string[][] {
const batches: string[][] = [];
for (let i = 0; i < userIds.length; i += USER_BATCH_SIZE) {
batches.push(userIds.slice(i, i + USER_BATCH_SIZE));
}
return batches;
}
export async function createRichMenu(
menu: CreateRichMenuParams,
opts: RichMenuOpts = {},
): Promise<string> {
const client = getClient(opts);
const richMenuRequest: RichMenuRequest = {
size: menu.size,
selected: menu.selected ?? false,
name: menu.name.slice(0, 300),
chatBarText: menu.chatBarText.slice(0, 14),
areas: menu.areas as RichMenuArea[],
};
const response = await client.createRichMenu(richMenuRequest);
if (opts.verbose) {
logVerbose(`line: created rich menu ${response.richMenuId}`);
}
return response.richMenuId;
}
export async function uploadRichMenuImage(
richMenuId: string,
imagePath: string,
opts: RichMenuOpts = {},
): Promise<void> {
const blobClient = getBlobClient(opts);
const imageData = await readFile(imagePath);
const contentType = normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
? "image/png"
: "image/jpeg";
await blobClient.setRichMenuImage(richMenuId, new Blob([imageData], { type: contentType }));
if (opts.verbose) {
logVerbose(`line: uploaded image to rich menu ${richMenuId}`);
}
}
export async function setDefaultRichMenu(
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
await client.setDefaultRichMenu(richMenuId);
if (opts.verbose) {
logVerbose(`line: set default rich menu to ${richMenuId}`);
}
}
export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise<void> {
const client = getClient(opts);
await client.cancelDefaultRichMenu();
if (opts.verbose) {
logVerbose("line: cancelled default rich menu");
}
}
export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise<string | null> {
const client = getClient(opts);
try {
const response = await client.getDefaultRichMenuId();
return response.richMenuId ?? null;
} catch {
return null;
}
}
export async function linkRichMenuToUser(
userId: string,
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
await client.linkRichMenuIdToUser(userId, richMenuId);
if (opts.verbose) {
logVerbose(`line: linked rich menu ${richMenuId} to user ${userId}`);
}
}
export async function linkRichMenuToUsers(
userIds: string[],
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
for (const batch of chunkUserIds(userIds)) {
await client.linkRichMenuIdToUsers({
richMenuId,
userIds: batch,
});
}
if (opts.verbose) {
logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`);
}
}
export async function unlinkRichMenuFromUser(
userId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
await client.unlinkRichMenuIdFromUser(userId);
if (opts.verbose) {
logVerbose(`line: unlinked rich menu from user ${userId}`);
}
}
export async function unlinkRichMenuFromUsers(
userIds: string[],
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
for (const batch of chunkUserIds(userIds)) {
await client.unlinkRichMenuIdFromUsers({
userIds: batch,
});
}
if (opts.verbose) {
logVerbose(`line: unlinked rich menu from ${userIds.length} users`);
}
}
export async function getRichMenuIdOfUser(
userId: string,
opts: RichMenuOpts = {},
): Promise<string | null> {
const client = getClient(opts);
try {
const response = await client.getRichMenuIdOfUser(userId);
return response.richMenuId ?? null;
} catch {
return null;
}
}
export async function getRichMenuList(opts: RichMenuOpts = {}): Promise<RichMenuResponse[]> {
const client = getClient(opts);
const response = await client.getRichMenuList();
return response.richmenus ?? [];
}
export async function getRichMenu(
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<RichMenuResponse | null> {
const client = getClient(opts);
try {
return await client.getRichMenu(richMenuId);
} catch {
return null;
}
}
export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}): Promise<void> {
const client = getClient(opts);
await client.deleteRichMenu(richMenuId);
if (opts.verbose) {
logVerbose(`line: deleted rich menu ${richMenuId}`);
}
}
export async function createRichMenuAlias(
richMenuId: string,
aliasId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
await client.createRichMenuAlias({
richMenuId,
richMenuAliasId: aliasId,
});
if (opts.verbose) {
logVerbose(`line: created alias ${aliasId} for rich menu ${richMenuId}`);
}
}
export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts = {}): Promise<void> {
const client = getClient(opts);
await client.deleteRichMenuAlias(aliasId);
if (opts.verbose) {
logVerbose(`line: deleted alias ${aliasId}`);
}
}
export function createGridLayout(
height: 1686 | 843,
actions: [Action, Action, Action, Action, Action, Action],
): RichMenuAreaRequest[] {
const colWidth = Math.floor(2500 / 3);
const rowHeight = Math.floor(height / 2);
return [
{ bounds: { x: 0, y: 0, width: colWidth, height: rowHeight }, action: actions[0] },
{ bounds: { x: colWidth, y: 0, width: colWidth, height: rowHeight }, action: actions[1] },
{ bounds: { x: colWidth * 2, y: 0, width: colWidth, height: rowHeight }, action: actions[2] },
{ bounds: { x: 0, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[3] },
{
bounds: { x: colWidth, y: rowHeight, width: colWidth, height: rowHeight },
action: actions[4],
},
{
bounds: { x: colWidth * 2, y: rowHeight, width: colWidth, height: rowHeight },
action: actions[5],
},
];
}
export { datetimePickerAction, messageAction, postbackAction, uriAction };
export function createDefaultMenuConfig(): CreateRichMenuParams {
return {
size: { width: 2500, height: 843 },
selected: false,
name: "Default Menu",
chatBarText: "Menu",
areas: createGridLayout(843, [
messageAction("Help", "/help"),
messageAction("Status", "/status"),
messageAction("Settings", "/settings"),
messageAction("About", "/about"),
messageAction("Feedback", "/feedback"),
messageAction("Contact", "/contact"),
]),
};
}
export type { RichMenuRequest, RichMenuResponse, RichMenuArea, Action };

View file

@ -0,0 +1,32 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
type LineChannelRuntime = {
buildTemplateMessageFromPayload?: typeof import("./template-messages.js").buildTemplateMessageFromPayload;
createQuickReplyItems?: typeof import("./send.js").createQuickReplyItems;
monitorLineProvider?: typeof import("./monitor.js").monitorLineProvider;
pushFlexMessage?: typeof import("./send.js").pushFlexMessage;
pushLocationMessage?: typeof import("./send.js").pushLocationMessage;
pushMessageLine?: typeof import("./send.js").pushMessageLine;
pushMessagesLine?: typeof import("./send.js").pushMessagesLine;
pushTemplateMessage?: typeof import("./send.js").pushTemplateMessage;
pushTextMessageWithQuickReplies?: typeof import("./send.js").pushTextMessageWithQuickReplies;
resolveLineAccount?: typeof import("./accounts.js").resolveLineAccount;
sendMessageLine?: typeof import("./send.js").sendMessageLine;
};
export type LineRuntime = PluginRuntime & {
channel: PluginRuntime["channel"] & {
line?: LineChannelRuntime;
};
};
const {
setRuntime: setLineRuntime,
clearRuntime: clearLineRuntime,
getRuntime: getLineRuntime,
} = createPluginRuntimeStore<LineRuntime>({
pluginId: "line",
errorMessage: "LINE runtime not initialized - plugin not registered",
});
export { clearLineRuntime, getLineRuntime, setLineRuntime };

View file

@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
} = vi.hoisted(() => {
const pushMessageMock = vi.fn();
const replyMessageMock = vi.fn();
const showLoadingAnimationMock = vi.fn();
const getProfileMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
const loadConfigMock = vi.fn(() => ({}));
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
const recordChannelActivityMock = vi.fn();
const logVerboseMock = vi.fn();
return {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
};
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
loadConfig: loadConfigMock,
}));
vi.mock("./accounts.js", () => ({
resolveLineAccount: resolveLineAccountMock,
}));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("openclaw/plugin-sdk/infra-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,
};
});
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeEach(async () => {
vi.resetModules();
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockReset();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
sendModule = await import("./send.js");
});
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,
{ 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({ messageId: "push", chatId: "U123" });
});
it("replies when reply token is provided", async () => {
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
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({ 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",
);
});
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" }]),
).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");
const second = await sendModule.getUserProfile("U-cache");
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")).resolves.toBeUndefined();
expect(logVerboseMock).toHaveBeenCalledWith(
expect.stringContaining("line: loading animation failed (non-fatal)"),
);
});
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}`),
);
expect(pushMessageMock).toHaveBeenCalledTimes(1);
const firstCall = pushMessageMock.mock.calls[0] as [
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
];
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
});
});

View file

@ -0,0 +1,480 @@
import { messagingApi } from "@line/bot-sdk";
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveLineAccount } from "./accounts.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
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;
type TemplateMessage = messagingApi.TemplateMessage;
type QuickReply = messagingApi.QuickReply;
type QuickReplyItem = messagingApi.QuickReplyItem;
const userProfileCache = new Map<
string,
{ displayName: string; pictureUrl?: string; fetchedAt: number }
>();
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
interface LineSendOpts {
cfg?: OpenClawConfig;
channelAccessToken?: string;
accountId?: string;
verbose?: boolean;
mediaUrl?: string;
mediaKind?: "image" | "video" | "audio";
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
replyToken?: string;
}
type LineClientOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
type LinePushOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId" | "verbose">;
interface LinePushBehavior {
errorContext?: string;
verboseMessage?: (chatId: string, messageCount: number) => string;
}
interface LineReplyBehavior {
verboseMessage?: (messageCount: number) => string;
}
function normalizeTarget(to: string): string {
const trimmed = to.trim();
if (!trimmed) {
throw new Error("Recipient is required for LINE sends");
}
const normalized = trimmed
.replace(/^line:group:/i, "")
.replace(/^line:room:/i, "")
.replace(/^line:user:/i, "")
.replace(/^line:/i, "");
if (!normalized) {
throw new Error("Recipient is required for LINE sends");
}
return normalized;
}
function isLineUserChatId(chatId: string): boolean {
return /^U/i.test(chatId);
}
function createLineMessagingClient(opts: LineClientOpts): {
account: ReturnType<typeof resolveLineAccount>;
client: messagingApi.MessagingApiClient;
} {
const cfg = opts.cfg ?? loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
return { account, client };
}
function createLinePushContext(
to: string,
opts: LineClientOpts,
): {
account: ReturnType<typeof resolveLineAccount>;
client: messagingApi.MessagingApiClient;
chatId: string;
} {
const { account, client } = createLineMessagingClient(opts);
const chatId = normalizeTarget(to);
return { account, client, chatId };
}
function createTextMessage(text: string): TextMessage {
return { type: "text", text };
}
export function createImageMessage(
originalContentUrl: string,
previewImageUrl?: string,
): ImageMessage {
return {
type: "image",
originalContentUrl,
previewImageUrl: previewImageUrl ?? originalContentUrl,
};
}
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;
latitude: number;
longitude: number;
}): LocationMessage {
return {
type: "location",
title: location.title.slice(0, 100),
address: location.address.slice(0, 100),
latitude: location.latitude,
longitude: location.longitude,
};
}
function logLineHttpError(err: unknown, context: string): void {
if (!err || typeof err !== "object") {
return;
}
const { status, statusText, body } = err as {
status?: number;
statusText?: string;
body?: string;
};
if (typeof body === "string") {
const summary = status ? `${status} ${statusText ?? ""}`.trim() : "unknown status";
logVerbose(`line: ${context} failed (${summary}): ${body}`);
}
}
function recordLineOutboundActivity(accountId: string): void {
recordChannelActivity({
channel: "line",
accountId,
direction: "outbound",
});
}
async function pushLineMessages(
to: string,
messages: Message[],
opts: LinePushOpts = {},
behavior: LinePushBehavior = {},
): Promise<LineSendResult> {
if (messages.length === 0) {
throw new Error("Message must be non-empty for LINE sends");
}
const { account, client, chatId } = createLinePushContext(to, opts);
const pushRequest = client.pushMessage({
to: chatId,
messages,
});
if (behavior.errorContext) {
await pushRequest.catch((err) => {
logLineHttpError(err, behavior.errorContext!);
throw err;
});
} else {
await pushRequest;
}
recordLineOutboundActivity(account.accountId);
if (opts.verbose) {
const logMessage =
behavior.verboseMessage?.(chatId, messages.length) ??
`line: pushed ${messages.length} messages to ${chatId}`;
logVerbose(logMessage);
}
return {
messageId: "push",
chatId,
};
}
async function replyLineMessages(
replyToken: string,
messages: Message[],
opts: LinePushOpts = {},
behavior: LineReplyBehavior = {},
): Promise<void> {
const { account, client } = createLineMessagingClient(opts);
await client.replyMessage({
replyToken,
messages,
});
recordLineOutboundActivity(account.accountId);
if (opts.verbose) {
logVerbose(
behavior.verboseMessage?.(messages.length) ??
`line: replied with ${messages.length} messages`,
);
}
}
export async function sendMessageLine(
to: string,
text: string,
opts: LineSendOpts = {},
): Promise<LineSendResult> {
const chatId = normalizeTarget(to);
const messages: Message[] = [];
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()) {
messages.push(createTextMessage(text.trim()));
}
if (messages.length === 0) {
throw new Error("Message must be non-empty for LINE sends");
}
if (opts.replyToken) {
await replyLineMessages(opts.replyToken, messages, opts, {
verboseMessage: () => `line: replied to ${chatId}`,
});
return {
messageId: "reply",
chatId,
};
}
return pushLineMessages(chatId, messages, opts, {
verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`,
});
}
export async function pushMessageLine(
to: string,
text: string,
opts: LineSendOpts = {},
): Promise<LineSendResult> {
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
}
export async function replyMessageLine(
replyToken: string,
messages: Message[],
opts: LinePushOpts = {},
): Promise<void> {
await replyLineMessages(replyToken, messages, opts);
}
export async function pushMessagesLine(
to: string,
messages: Message[],
opts: LinePushOpts = {},
): Promise<LineSendResult> {
return pushLineMessages(to, messages, opts, {
errorContext: "push message",
});
}
export function createFlexMessage(
altText: string,
contents: messagingApi.FlexContainer,
): messagingApi.FlexMessage {
return {
type: "flex",
altText,
contents,
};
}
export async function pushImageMessage(
to: string,
originalContentUrl: string,
previewImageUrl?: string,
opts: LinePushOpts = {},
): Promise<LineSendResult> {
return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, {
verboseMessage: (chatId) => `line: pushed image to ${chatId}`,
});
}
export async function pushLocationMessage(
to: string,
location: {
title: string;
address: string;
latitude: number;
longitude: number;
},
opts: LinePushOpts = {},
): Promise<LineSendResult> {
return pushLineMessages(to, [createLocationMessage(location)], opts, {
verboseMessage: (chatId) => `line: pushed location to ${chatId}`,
});
}
export async function pushFlexMessage(
to: string,
altText: string,
contents: FlexContainer,
opts: LinePushOpts = {},
): Promise<LineSendResult> {
const flexMessage: FlexMessage = {
type: "flex",
altText: altText.slice(0, 400),
contents,
};
return pushLineMessages(to, [flexMessage], opts, {
errorContext: "push flex message",
verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`,
});
}
export async function pushTemplateMessage(
to: string,
template: TemplateMessage,
opts: LinePushOpts = {},
): Promise<LineSendResult> {
return pushLineMessages(to, [template], opts, {
verboseMessage: (chatId) => `line: pushed template message to ${chatId}`,
});
}
export async function pushTextMessageWithQuickReplies(
to: string,
text: string,
quickReplyLabels: string[],
opts: LinePushOpts = {},
): Promise<LineSendResult> {
const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
return pushLineMessages(to, [message], opts, {
verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`,
});
}
export function createQuickReplyItems(labels: string[]): QuickReply {
const items: QuickReplyItem[] = labels.slice(0, 13).map((label) => ({
type: "action",
action: {
type: "message",
label: label.slice(0, 20),
text: label,
},
}));
return { items };
}
export function createTextMessageWithQuickReplies(
text: string,
quickReplyLabels: string[],
): TextMessage & { quickReply: QuickReply } {
return {
type: "text",
text,
quickReply: createQuickReplyItems(quickReplyLabels),
};
}
export async function showLoadingAnimation(
chatId: string,
opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {},
): Promise<void> {
const { client } = createLineMessagingClient(opts);
try {
await client.showLoadingAnimation({
chatId: normalizeTarget(chatId),
loadingSeconds: opts.loadingSeconds ?? 20,
});
logVerbose(`line: showing loading animation to ${chatId}`);
} catch (err) {
logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`);
}
}
export async function getUserProfile(
userId: string,
opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {},
): Promise<{ displayName: string; pictureUrl?: string } | null> {
const useCache = opts.useCache ?? true;
if (useCache) {
const cached = userProfileCache.get(userId);
if (cached && Date.now() - cached.fetchedAt < PROFILE_CACHE_TTL_MS) {
return { displayName: cached.displayName, pictureUrl: cached.pictureUrl };
}
}
const { client } = createLineMessagingClient(opts);
try {
const profile = await client.getProfile(userId);
const result = {
displayName: profile.displayName,
pictureUrl: profile.pictureUrl,
};
userProfileCache.set(userId, {
...result,
fetchedAt: Date.now(),
});
return result;
} catch (err) {
logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`);
return null;
}
}
export async function getUserDisplayName(
userId: string,
opts: { channelAccessToken?: string; accountId?: string } = {},
): Promise<string> {
const profile = await getUserProfile(userId, opts);
return profile?.displayName ?? userId;
}

View file

@ -0,0 +1,149 @@
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup";
import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js";
import {
DEFAULT_ACCOUNT_ID,
listLineAccountIds,
normalizeAccountId,
resolveLineAccount,
type LineConfig,
} from "./setup-runtime-api.js";
export function patchLineAccountConfig(params: {
cfg: OpenClawConfig;
accountId: string;
patch: Record<string, unknown>;
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig;
const clearFields = params.clearFields ?? [];
if (accountId === DEFAULT_ACCOUNT_ID) {
const nextLine = { ...lineConfig } as Record<string, unknown>;
for (const field of clearFields) {
delete nextLine[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
line: {
...nextLine,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
};
}
const nextAccount = {
...lineConfig.accounts?.[accountId],
} as Record<string, unknown>;
for (const field of clearFields) {
delete nextAccount[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
line: {
...lineConfig,
...(params.enabled ? { enabled: true } : {}),
accounts: {
...lineConfig.accounts,
[accountId]: {
...nextAccount,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
},
},
};
}
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {
return hasLineCredentials(resolveLineAccount({ cfg, accountId }));
}
export { parseLineAllowFromId };
export const lineSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
patchLineAccountConfig({
cfg,
accountId,
patch: name?.trim() ? { name: name.trim() } : {},
}),
validateInput: createSetupInputPresenceValidator({
defaultAccountOnlyEnvError:
"LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
whenNotUseEnv: [
{
someOf: ["channelAccessToken", "tokenFile"],
message: "LINE requires channelAccessToken or --token-file (or --use-env).",
},
{
someOf: ["channelSecret", "secretFile"],
message: "LINE requires channelSecret or --secret-file (or --use-env).",
},
],
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
const normalizedAccountId = normalizeAccountId(accountId);
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
return patchLineAccountConfig({
cfg,
accountId: normalizedAccountId,
enabled: true,
clearFields: typedInput.useEnv
? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"]
: undefined,
patch: typedInput.useEnv
? {}
: {
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
});
}
return patchLineAccountConfig({
cfg,
accountId: normalizedAccountId,
enabled: true,
patch: {
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
});
},
};
export { listLineAccountIds };

View file

@ -0,0 +1,9 @@
export {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js";
export type { LineConfig } from "./types.js";

View file

@ -0,0 +1,501 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { bundledPluginRoot } from "../../../test/helpers/bundled-plugin-paths.js";
import { loadRuntimeApiExportTypesViaJiti } from "../../../test/helpers/plugins/jiti-runtime-api.ts";
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/plugins/setup-wizard.js";
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
import { linePlugin } from "./channel.js";
import { lineGatewayAdapter } from "./gateway.js";
import { probeLineBot } from "./probe.js";
import { clearLineRuntime, setLineRuntime } from "./runtime.js";
import { lineSetupWizard } from "./setup-surface.js";
import { lineStatusAdapter } from "./status.js";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return { getBotInfo: getBotInfoMock };
});
return { getBotInfoMock, MessagingApiClientMock };
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
const lineConfigure = createPluginSetupWizardConfigure(linePlugin);
const LINE_SRC_PREFIX = `../../${bundledPluginRoot("line")}/src/`;
function normalizeModuleSpecifier(specifier: string): string | null {
if (specifier.startsWith("./src/")) {
return specifier;
}
if (specifier.startsWith(LINE_SRC_PREFIX)) {
return `./src/${specifier.slice(LINE_SRC_PREFIX.length)}`;
}
return null;
}
function collectModuleExportNames(filePath: string): string[] {
const sourcePath = filePath.replace(/\.js$/, ".ts");
const sourceText = readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
const names = new Set<string>();
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
names.add(element.name.text);
}
}
continue;
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (!isExported) {
continue;
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement)
) {
if (statement.name) {
names.add(statement.name.text);
}
}
}
return Array.from(names).toSorted();
}
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const preExports = new Set<string>();
let pluginSdkLineRuntimeSeen = false;
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
pluginSdkLineRuntimeSeen = true;
break;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (!normalized) {
continue;
}
if (!statement.exportClause) {
for (const name of collectModuleExportNames(
path.join(process.cwd(), "extensions", "line", normalized),
)) {
preExports.add(name);
}
continue;
}
if (!ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
preExports.add(element.name.text);
}
}
}
if (!pluginSdkLineRuntimeSeen) {
return [];
}
return Array.from(preExports).toSorted();
}
describe("line setup wizard", () => {
it("configures token and secret for the default account", async () => {
const prompter = createTestWizardPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enter LINE channel access token") {
return "line-token";
}
if (message === "Enter LINE channel secret") {
return "line-secret";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
});
const result = await runSetupWizardConfigure({
configure: lineConfigure,
cfg: {} as OpenClawConfig,
prompter,
options: {},
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.line?.enabled).toBe(true);
expect(result.cfg.channels?.line?.channelAccessToken).toBe("line-token");
expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret");
});
it("reads the named-account DM policy instead of the channel root", async () => {
expect(
lineSetupWizard.dmPolicy?.getCurrent(
{
channels: {
line: {
dmPolicy: "disabled",
accounts: {
work: {
channelAccessToken: "token",
channelSecret: "secret",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig,
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", async () => {
expect(lineSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual({
policyKey: "channels.line.accounts.work.dmPolicy",
allowFromKey: "channels.line.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", async () => {
const cfg = {
channels: {
line: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["Uroot"],
accounts: {
work: {
channelAccessToken: "token",
channelSecret: "secret",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig;
expect(lineSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
expect(lineSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.line.accounts.work.dmPolicy",
allowFromKey: "channels.line.accounts.work.allowFrom",
});
const next = lineSetupWizard.dmPolicy?.setPolicy(cfg, "open");
const workAccount = next?.channels?.line?.accounts?.work as
| {
dmPolicy?: string;
}
| undefined;
expect(next?.channels?.line?.dmPolicy).toBe("disabled");
expect(workAccount?.dmPolicy).toBe("open");
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
const next = lineSetupWizard.dmPolicy?.setPolicy(
{
channels: {
line: {
allowFrom: ["Uroot"],
accounts: {
work: {
channelAccessToken: "token",
channelSecret: "secret",
},
},
},
},
} as OpenClawConfig,
"open",
"work",
);
const workAccount = next?.channels?.line?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.line?.dmPolicy).toBeUndefined();
expect(next?.channels?.line?.allowFrom).toEqual(["Uroot"]);
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["Uroot", "*"]);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const configured = await lineSetupWizard.status.resolveConfigured({
cfg: {
channels: {
line: {
defaultAccount: "work",
channelAccessToken: "root-token",
channelSecret: "root-secret",
accounts: {
alerts: {
channelAccessToken: "alerts-token",
channelSecret: "alerts-secret",
},
work: {
channelAccessToken: "",
channelSecret: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
});
describe("probeLineBot", () => {
beforeEach(() => {
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
});
afterEach(() => {
clearLineRuntime();
vi.useRealTimers();
getBotInfoMock.mockClear();
});
it("returns timeout when bot info stalls", async () => {
vi.useFakeTimers();
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
const probePromise = probeLineBot("token", 10);
await vi.advanceTimersByTimeAsync(20);
const result = await probePromise;
expect(result.ok).toBe(false);
expect(result.error).toBe("timeout");
});
it("returns bot info when available", async () => {
getBotInfoMock.mockResolvedValue({
displayName: "OpenClaw",
userId: "U123",
basicId: "@openclaw",
pictureUrl: "https://example.com/bot.png",
});
const result = await probeLineBot("token", 50);
expect(result.ok).toBe(true);
expect(result.bot?.userId).toBe("U123");
});
});
describe("linePlugin status.probeAccount", () => {
it("falls back to the direct probe helper when runtime is not initialized", async () => {
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
getBotInfoMock.mockResolvedValue({
displayName: "OpenClaw",
userId: "U123",
basicId: "@openclaw",
pictureUrl: "https://example.com/bot.png",
});
const params = {
cfg: {} as OpenClawConfig,
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
} as ResolvedLineAccount,
timeoutMs: 50,
};
clearLineRuntime();
await expect(lineStatusAdapter.probeAccount!(params)).resolves.toEqual(
await probeLineBot("token", 50),
);
});
});
describe("line runtime api", () => {
it("loads through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"buildTemplateMessageFromPayload",
"downloadLineMedia",
"isSenderAllowed",
"probeLineBot",
"pushMessageLine",
],
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/line-runtime"],
}),
).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
}, 240_000);
it("keeps the LINE runtime barrel self-contained", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual([]);
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
expect(runtimeApiSource).not.toContain("openclaw/plugin-sdk/line-runtime");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual([]);
});
});
function createRuntime() {
const monitorLineProvider = vi.fn(async () => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}));
const runtime = {
channel: {
line: {
monitorLineProvider,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
return { runtime, monitorLineProvider };
}
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
return {
accountId: "default",
enabled: true,
channelAccessToken: params.token,
channelSecret: params.secret,
tokenSource: "config",
config: {} as ResolvedLineAccount["config"],
};
}
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
return {
monitorLineProvider,
task: lineGatewayAdapter.startAccount!(
createStartAccountContext({
account: params.account,
abortSignal: params.abortSignal,
}),
),
};
}
describe("linePlugin gateway.startAccount", () => {
it("fails startup when channel secret is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: " " }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel secret for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("fails startup when channel access token is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: " ", secret: "secret" }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel access token for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("starts provider when token and secret are present", async () => {
const abort = new AbortController();
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: "secret" }),
abortSignal: abort.signal,
});
await vi.waitFor(() => {
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
abort.abort();
await task;
});
});

View file

@ -0,0 +1,229 @@
import {
createAllowFromSection,
createStandardChannelSetupStatus,
mergeAllowFromEntries,
} from "openclaw/plugin-sdk/setup";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultLineAccountId } from "./accounts.js";
import {
isLineConfigured,
listLineAccountIds,
parseLineAllowFromId,
patchLineAccountConfig,
} from "./setup-core.js";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
resolveLineAccount,
setSetupChannelEnabled,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "./setup-runtime-api.js";
const channel = "line" as const;
const LINE_SETUP_HELP_LINES = [
"1) Open the LINE Developers Console and create or pick a Messaging API channel",
"2) Copy the channel access token and channel secret",
"3) Enable Use webhook in the Messaging API settings",
"4) Point the webhook at https://<gateway-host>/line/webhook",
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
];
const LINE_ALLOW_FROM_HELP_LINES = [
"Allowlist LINE DMs by user id.",
"LINE ids are case-sensitive.",
"Examples:",
"- U1234567890abcdef1234567890abcdef",
"- line:user:U1234567890abcdef1234567890abcdef",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
];
const lineDmPolicy: ChannelSetupDmPolicy = {
label: "LINE",
channel,
policyKey: "channels.line.dmPolicy",
allowFromKey: "channels.line.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultLineAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.line.dmPolicy",
allowFromKey: "channels.line.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveLineAccount({ cfg, accountId: accountId ?? resolveDefaultLineAccountId(cfg) }).config
.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) =>
patchLineAccountConfig({
cfg,
accountId: accountId ?? resolveDefaultLineAccountId(cfg),
enabled: true,
patch:
policy === "open"
? {
dmPolicy: "open",
allowFrom: mergeAllowFromEntries(
resolveLineAccount({
cfg,
accountId: accountId ?? resolveDefaultLineAccountId(cfg),
}).config.allowFrom,
["*"],
),
}
: { dmPolicy: policy },
clearFields: policy === "pairing" || policy === "disabled" ? ["allowFrom"] : undefined,
}),
};
export { lineSetupAdapter } from "./setup-core.js";
export const lineSetupWizard: ChannelSetupWizard = {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "LINE",
configuredLabel: "configured",
unconfiguredLabel: "needs token + secret",
configuredHint: "configured",
unconfiguredHint: "needs token + secret",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
}),
introNote: {
title: "LINE Messaging API",
lines: LINE_SETUP_HELP_LINES,
shouldShow: ({ cfg, accountId }) =>
!isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
},
credentials: [
{
inputKey: "token",
providerHint: channel,
credentialLabel: "channel access token",
preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN",
helpTitle: "LINE Messaging API",
helpLines: LINE_SETUP_HELP_LINES,
envPrompt: "LINE_CHANNEL_ACCESS_TOKEN detected. Use env var?",
keepPrompt: "LINE channel access token already configured. Keep it?",
inputPrompt: "Enter LINE channel access token",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const resolved = resolveLineAccount({ cfg, accountId });
return {
accountConfigured: Boolean(
normalizeOptionalString(resolved.channelAccessToken) &&
normalizeOptionalString(resolved.channelSecret),
),
hasConfiguredValue: Boolean(
normalizeOptionalString(resolved.config.channelAccessToken) ??
normalizeOptionalString(resolved.config.tokenFile),
),
resolvedValue: normalizeOptionalString(resolved.channelAccessToken),
envValue:
accountId === DEFAULT_ACCOUNT_ID
? normalizeOptionalString(process.env.LINE_CHANNEL_ACCESS_TOKEN)
: undefined,
};
},
applyUseEnv: ({ cfg, accountId }) =>
patchLineAccountConfig({
cfg,
accountId,
enabled: true,
clearFields: ["channelAccessToken", "tokenFile"],
patch: {},
}),
applySet: ({ cfg, accountId, resolvedValue }) =>
patchLineAccountConfig({
cfg,
accountId,
enabled: true,
clearFields: ["tokenFile"],
patch: { channelAccessToken: resolvedValue },
}),
},
{
inputKey: "password",
providerHint: "line-secret",
credentialLabel: "channel secret",
preferredEnvVar: "LINE_CHANNEL_SECRET",
helpTitle: "LINE Messaging API",
helpLines: LINE_SETUP_HELP_LINES,
envPrompt: "LINE_CHANNEL_SECRET detected. Use env var?",
keepPrompt: "LINE channel secret already configured. Keep it?",
inputPrompt: "Enter LINE channel secret",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const resolved = resolveLineAccount({ cfg, accountId });
return {
accountConfigured: Boolean(
normalizeOptionalString(resolved.channelAccessToken) &&
normalizeOptionalString(resolved.channelSecret),
),
hasConfiguredValue: Boolean(
normalizeOptionalString(resolved.config.channelSecret) ??
normalizeOptionalString(resolved.config.secretFile),
),
resolvedValue: normalizeOptionalString(resolved.channelSecret),
envValue:
accountId === DEFAULT_ACCOUNT_ID
? normalizeOptionalString(process.env.LINE_CHANNEL_SECRET)
: undefined,
};
},
applyUseEnv: ({ cfg, accountId }) =>
patchLineAccountConfig({
cfg,
accountId,
enabled: true,
clearFields: ["channelSecret", "secretFile"],
patch: {},
}),
applySet: ({ cfg, accountId, resolvedValue }) =>
patchLineAccountConfig({
cfg,
accountId,
enabled: true,
clearFields: ["secretFile"],
patch: { channelSecret: resolvedValue },
}),
},
],
allowFrom: createAllowFromSection({
helpTitle: "LINE allowlist",
helpLines: LINE_ALLOW_FROM_HELP_LINES,
message: "LINE allowFrom (user id)",
placeholder: "U1234567890abcdef1234567890abcdef",
invalidWithoutCredentialNote:
"LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.",
parseInputs: splitSetupEntries,
parseId: parseLineAllowFromId,
apply: ({ cfg, accountId, allowFrom }) =>
patchLineAccountConfig({
cfg,
accountId,
enabled: true,
patch: { dmPolicy: "allowlist", allowFrom },
}),
}),
dmPolicy: lineDmPolicy,
completionNote: {
title: "LINE webhook",
lines: [
"Enable Use webhook in the LINE console after saving credentials.",
"Default webhook URL: https://<gateway-host>/line/webhook",
"If you set channels.line.webhookPath, update the URL to match.",
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
],
},
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};

View file

@ -0,0 +1,34 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateLineSignature } from "./signature.js";
function sign(body: string, secret: string): string {
return crypto.createHmac("SHA256", secret).update(body).digest("base64");
}
describe("validateLineSignature", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("accepts a valid signature", () => {
const body = JSON.stringify({ events: [{ type: "message" }] });
const secret = "top-secret";
expect(validateLineSignature(body, sign(body, secret), secret)).toBe(true);
});
it("still performs timing-safe comparison when signature length mismatches", () => {
const body = JSON.stringify({ events: [{ type: "message" }] });
const secret = "top-secret";
const spy = vi.spyOn(crypto, "timingSafeEqual");
expect(validateLineSignature(body, "short", secret)).toBe(false);
expect(spy).toHaveBeenCalledTimes(1);
const [left, right] = spy.mock.calls[0] ?? [];
expect(left).toBeInstanceOf(Buffer);
expect(right).toBeInstanceOf(Buffer);
expect(left?.byteLength).toBe(right?.byteLength);
});
});

View file

@ -0,0 +1,24 @@
import crypto from "node:crypto";
export function validateLineSignature(
body: string,
signature: string,
channelSecret: string,
): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
const hashBuffer = Buffer.from(hash);
const signatureBuffer = Buffer.from(signature);
// Pad to equal length before constant-time comparison to prevent
// leaking length information via early-return timing.
const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
const paddedHash = Buffer.alloc(maxLen);
const paddedSig = Buffer.alloc(maxLen);
hashBuffer.copy(paddedHash);
signatureBuffer.copy(paddedSig);
// Call timingSafeEqual unconditionally to ensure constant-time execution
// regardless of length mismatch (avoids && short-circuit timing leak).
const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
return hashBuffer.length === signatureBuffer.length && timingResult;
}

View file

@ -0,0 +1,37 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
buildTokenChannelStatusSummary,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
createDependentCredentialStatusIssueCollector,
} from "openclaw/plugin-sdk/status-helpers";
import { hasLineCredentials } from "./account-helpers.js";
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
channel: "line",
dependencySourceKey: "tokenSource",
missingPrimaryMessage: "LINE channel access token not configured",
missingDependentMessage: "LINE channel secret not configured",
});
export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
createComputedAccountStatusAdapter<ResolvedLineAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: collectLineStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await (await loadLineProbeRuntime()).probeLineBot(account.channelAccessToken, timeoutMs),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: hasLineCredentials(account),
extra: {
tokenSource: account.tokenSource,
mode: "webhook",
},
}),
});

View file

@ -0,0 +1,340 @@
import type { messagingApi } from "@line/bot-sdk";
import {
datetimePickerAction,
messageAction,
postbackAction,
uriAction,
type Action,
} from "./actions.js";
import type { LineTemplateMessagePayload } from "./types.js";
export { datetimePickerAction, messageAction, postbackAction, uriAction };
type TemplateMessage = messagingApi.TemplateMessage;
type ConfirmTemplate = messagingApi.ConfirmTemplate;
type ButtonsTemplate = messagingApi.ButtonsTemplate;
type CarouselTemplate = messagingApi.CarouselTemplate;
type CarouselColumn = messagingApi.CarouselColumn;
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
type TemplatePayloadAction = {
type?: "uri" | "postback" | "message";
uri?: string;
data?: string;
label: string;
};
function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
if (action.type === "uri" && action.uri) {
return uriAction(action.label, action.uri);
}
if (action.type === "postback" && action.data) {
return postbackAction(action.label, action.data, action.label);
}
return messageAction(action.label, action.data ?? action.label);
}
/**
* Create a confirm template (yes/no style dialog)
*/
export function createConfirmTemplate(
text: string,
confirmAction: Action,
cancelAction: Action,
altText?: string,
): TemplateMessage {
const template: ConfirmTemplate = {
type: "confirm",
text: text.slice(0, 240), // LINE limit
actions: [confirmAction, cancelAction],
};
return {
type: "template",
altText: altText?.slice(0, 400) ?? text.slice(0, 400),
template,
};
}
/**
* Create a button template with title, text, and action buttons
*/
export function createButtonTemplate(
title: string,
text: string,
actions: Action[],
options?: {
thumbnailImageUrl?: string;
imageAspectRatio?: "rectangle" | "square";
imageSize?: "cover" | "contain";
imageBackgroundColor?: string;
defaultAction?: Action;
altText?: string;
},
): TemplateMessage {
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
const textLimit = hasThumbnail ? 160 : 60;
const template: ButtonsTemplate = {
type: "buttons",
title: title.slice(0, 40), // LINE limit
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
actions: actions.slice(0, 4), // LINE limit: max 4 actions
thumbnailImageUrl: options?.thumbnailImageUrl,
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
imageSize: options?.imageSize ?? "cover",
imageBackgroundColor: options?.imageBackgroundColor,
defaultAction: options?.defaultAction,
};
return {
type: "template",
altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400),
template,
};
}
/**
* Create a carousel template with multiple columns
*/
export function createTemplateCarousel(
columns: CarouselColumn[],
options?: {
imageAspectRatio?: "rectangle" | "square";
imageSize?: "cover" | "contain";
altText?: string;
},
): TemplateMessage {
const template: CarouselTemplate = {
type: "carousel",
columns: columns.slice(0, 10), // LINE limit: max 10 columns
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
imageSize: options?.imageSize ?? "cover",
};
return {
type: "template",
altText: options?.altText?.slice(0, 400) ?? "View carousel",
template,
};
}
/**
* Create a carousel column for use with createTemplateCarousel
*/
export function createCarouselColumn(params: {
title?: string;
text: string;
actions: Action[];
thumbnailImageUrl?: string;
imageBackgroundColor?: string;
defaultAction?: Action;
}): CarouselColumn {
return {
title: params.title?.slice(0, 40),
text: params.text.slice(0, 120), // LINE limit
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
thumbnailImageUrl: params.thumbnailImageUrl,
imageBackgroundColor: params.imageBackgroundColor,
defaultAction: params.defaultAction,
};
}
/**
* Create an image carousel template (simpler, image-focused carousel)
*/
export function createImageCarousel(
columns: ImageCarouselColumn[],
altText?: string,
): TemplateMessage {
const template: ImageCarouselTemplate = {
type: "image_carousel",
columns: columns.slice(0, 10), // LINE limit: max 10 columns
};
return {
type: "template",
altText: altText?.slice(0, 400) ?? "View images",
template,
};
}
/**
* Create an image carousel column for use with createImageCarousel
*/
export function createImageCarouselColumn(imageUrl: string, action: Action): ImageCarouselColumn {
return {
imageUrl,
action,
};
}
/**
* Create a simple yes/no confirmation dialog
*/
export function createYesNoConfirm(
question: string,
options?: {
yesText?: string;
noText?: string;
yesData?: string;
noData?: string;
altText?: string;
},
): TemplateMessage {
const yesAction: Action = options?.yesData
? postbackAction(options.yesText ?? "Yes", options.yesData, options.yesText ?? "Yes")
: messageAction(options?.yesText ?? "Yes");
const noAction: Action = options?.noData
? postbackAction(options.noText ?? "No", options.noData, options.noText ?? "No")
: messageAction(options?.noText ?? "No");
return createConfirmTemplate(question, yesAction, noAction, options?.altText);
}
/**
* Create a button menu with simple text buttons
*/
export function createButtonMenu(
title: string,
text: string,
buttons: Array<{ label: string; text?: string }>,
options?: {
thumbnailImageUrl?: string;
altText?: string;
},
): TemplateMessage {
const actions = buttons.slice(0, 4).map((btn) => messageAction(btn.label, btn.text));
return createButtonTemplate(title, text, actions, {
thumbnailImageUrl: options?.thumbnailImageUrl,
altText: options?.altText,
});
}
/**
* Create a button menu with URL links
*/
export function createLinkMenu(
title: string,
text: string,
links: Array<{ label: string; url: string }>,
options?: {
thumbnailImageUrl?: string;
altText?: string;
},
): TemplateMessage {
const actions = links.slice(0, 4).map((link) => uriAction(link.label, link.url));
return createButtonTemplate(title, text, actions, {
thumbnailImageUrl: options?.thumbnailImageUrl,
altText: options?.altText,
});
}
/**
* Create a simple product/item carousel
*/
export function createProductCarousel(
products: Array<{
title: string;
description: string;
imageUrl?: string;
price?: string;
actionLabel?: string;
actionUrl?: string;
actionData?: string;
}>,
altText?: string,
): TemplateMessage {
const columns = products.slice(0, 10).map((product) => {
const actions: Action[] = [];
if (product.actionUrl) {
actions.push(uriAction(product.actionLabel ?? "View", product.actionUrl));
} else if (product.actionData) {
actions.push(postbackAction(product.actionLabel ?? "Select", product.actionData));
} else {
actions.push(messageAction(product.actionLabel ?? "Select", product.title));
}
return createCarouselColumn({
title: product.title,
text: product.price
? `${product.description}\n${product.price}`.slice(0, 120)
: product.description,
thumbnailImageUrl: product.imageUrl,
actions,
});
});
return createTemplateCarousel(columns, { altText });
}
/**
* Convert a TemplateMessagePayload from ReplyPayload to a LINE TemplateMessage
*/
export function buildTemplateMessageFromPayload(
payload: LineTemplateMessagePayload,
): TemplateMessage | null {
switch (payload.type) {
case "confirm": {
const confirmAction = payload.confirmData.startsWith("http")
? uriAction(payload.confirmLabel, payload.confirmData)
: payload.confirmData.includes("=")
? postbackAction(payload.confirmLabel, payload.confirmData, payload.confirmLabel)
: messageAction(payload.confirmLabel, payload.confirmData);
const cancelAction = payload.cancelData.startsWith("http")
? uriAction(payload.cancelLabel, payload.cancelData)
: payload.cancelData.includes("=")
? postbackAction(payload.cancelLabel, payload.cancelData, payload.cancelLabel)
: messageAction(payload.cancelLabel, payload.cancelData);
return createConfirmTemplate(payload.text, confirmAction, cancelAction, payload.altText);
}
case "buttons": {
const actions: Action[] = payload.actions
.slice(0, 4)
.map((action) => buildTemplatePayloadAction(action));
return createButtonTemplate(payload.title, payload.text, actions, {
thumbnailImageUrl: payload.thumbnailImageUrl,
altText: payload.altText,
});
}
case "carousel": {
const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
const colActions: Action[] = col.actions
.slice(0, 3)
.map((action) => buildTemplatePayloadAction(action));
return createCarouselColumn({
title: col.title,
text: col.text,
thumbnailImageUrl: col.thumbnailImageUrl,
actions: colActions,
});
});
return createTemplateCarousel(columns, { altText: payload.altText });
}
default:
return null;
}
}
export type {
TemplateMessage,
ConfirmTemplate,
ButtonsTemplate,
CarouselTemplate,
CarouselColumn,
ImageCarouselTemplate,
ImageCarouselColumn,
Action,
};

View file

@ -0,0 +1,141 @@
import type { messagingApi, webhook } from "@line/bot-sdk";
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
export type LineTokenSource = "config" | "env" | "file" | "none";
export interface LineThreadBindingsConfig {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSubagentSessions?: boolean;
spawnAcpSessions?: boolean;
}
interface LineAccountBaseConfig {
enabled?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
name?: string;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
responsePrefix?: string;
mediaMaxMb?: number;
webhookPath?: string;
threadBindings?: LineThreadBindingsConfig;
groups?: Record<string, LineGroupConfig>;
}
export interface LineConfig extends LineAccountBaseConfig {
accounts?: Record<string, LineAccountConfig>;
defaultAccount?: string;
}
export interface LineAccountConfig extends LineAccountBaseConfig {}
export interface LineGroupConfig {
enabled?: boolean;
allowFrom?: Array<string | number>;
requireMention?: boolean;
systemPrompt?: string;
skills?: string[];
}
export interface ResolvedLineAccount {
accountId: string;
name?: string;
enabled: boolean;
channelAccessToken: string;
channelSecret: string;
tokenSource: LineTokenSource;
config: LineConfig & LineAccountConfig;
}
export type LineMessageType =
| messagingApi.TextMessage
| messagingApi.ImageMessage
| messagingApi.VideoMessage
| messagingApi.AudioMessage
| messagingApi.StickerMessage
| messagingApi.LocationMessage;
export interface LineWebhookContext {
event: webhook.Event;
replyToken?: string;
userId?: string;
groupId?: string;
roomId?: string;
}
export interface LineSendResult {
messageId: string;
chatId: string;
}
export type LineProbeResult = BaseProbeResult<string> & {
bot?: {
displayName?: string;
userId?: string;
basicId?: string;
pictureUrl?: string;
};
};
export type LineFlexMessagePayload = {
altText: string;
contents: unknown;
};
export type LineTemplateMessagePayload =
| {
type: "confirm";
text: string;
confirmLabel: string;
confirmData: string;
cancelLabel: string;
cancelData: string;
altText?: string;
}
| {
type: "buttons";
title: string;
text: string;
actions: Array<{
type: "message" | "uri" | "postback";
label: string;
data?: string;
uri?: string;
}>;
thumbnailImageUrl?: string;
altText?: string;
}
| {
type: "carousel";
columns: Array<{
title?: string;
text: string;
thumbnailImageUrl?: string;
actions: Array<{
type: "message" | "uri" | "postback";
label: string;
data?: string;
uri?: string;
}>;
}>;
altText?: string;
};
export type LineChannelData = {
quickReplies?: string[];
location?: {
title: string;
address: string;
latitude: number;
longitude: number;
};
flexMessage?: LineFlexMessagePayload;
templateMessage?: LineTemplateMessagePayload;
};

View file

@ -0,0 +1,513 @@
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
import { createLineWebhookMiddleware } from "./webhook.js";
const sign = (body: string, secret: string) =>
crypto.createHmac("SHA256", secret).update(body).digest("base64");
function createRes() {
const headers: Record<string, string> = {};
const resObj = {
statusCode: 0,
headersSent: false,
setHeader: (k: string, v: string) => {
headers[k.toLowerCase()] = v;
},
end: vi.fn((data?: unknown) => {
resObj.headersSent = true;
// Keep payload available for assertions
resObj.body = data;
}),
body: undefined as unknown,
};
const res = resObj as unknown as ServerResponse & { body?: unknown };
return { res, headers };
}
const SECRET = "secret";
function createMiddlewareRes() {
const res = {
status: vi.fn(),
json: vi.fn(),
headersSent: false,
} as any;
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res;
}
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const handler = createLineNodeWebhookHandler({
channelSecret: secret,
bot,
runtime,
readBody: async () => rawBody,
});
return { bot, handler, secret };
}
const runSignedPost = async (params: {
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
rawBody: string;
secret: string;
res: ServerResponse;
}) =>
await params.handler(
{
method: "POST",
headers: { "x-line-signature": sign(params.rawBody, params.secret) },
} as unknown as IncomingMessage,
params.res,
);
async function invokeWebhook(params: {
body: unknown;
headers?: Record<string, string>;
onEvents?: ReturnType<typeof vi.fn>;
autoSign?: boolean;
}) {
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents: onEventsMock as never,
});
const headers = { ...params.headers };
const autoSign = params.autoSign ?? true;
if (autoSign && !headers["x-line-signature"]) {
if (typeof params.body === "string") {
headers["x-line-signature"] = sign(params.body, SECRET);
} else if (Buffer.isBuffer(params.body)) {
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
}
}
const req = {
headers,
body: params.body,
} as any;
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
return { res, onEvents: onEventsMock };
}
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
const onEvents = vi.fn(async () => {});
const reqBody = {
events: [{ type: "message", source: { userId: "tampered-user" } }],
};
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const rawBodyText =
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
const req = {
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
rawBody: params.rawBody,
body: reqBody,
} as any;
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledTimes(1);
const processedBody = (
onEvents.mock.calls[0] as unknown as [{ events?: Array<{ source?: { userId?: string } }> }]
)?.[0];
expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId);
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
}
describe("createLineNodeWebhookHandler", () => {
it("returns 200 for GET", async () => {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody: async () => "",
});
const { res } = createRes();
await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("OK");
});
it("returns 204 for HEAD", async () => {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody: async () => "",
});
const { res } = createRes();
await handler({ method: "HEAD", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(204);
expect(res.body).toBeUndefined();
});
it("rejects verification-shaped requests without a signature", async () => {
const rawBody = JSON.stringify({ events: [] });
const { bot, handler } = createPostWebhookTestHarness(rawBody);
const { res, headers } = createRes();
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(400);
expect(headers["content-type"]).toBe("application/json");
expect(res.body).toBe(JSON.stringify({ error: "Missing X-Line-Signature header" }));
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("accepts signed verification-shaped requests without dispatching events", async () => {
const rawBody = JSON.stringify({ events: [] });
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
const { res, headers } = createRes();
await runSignedPost({ handler, rawBody, secret, res });
expect(res.statusCode).toBe(200);
expect(headers["content-type"]).toBe("application/json");
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("returns 405 for non-GET/HEAD/POST methods", async () => {
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
const { res, headers } = createRes();
await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(405);
expect(headers.allow).toBe("GET, HEAD, POST");
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("rejects missing signature when events are non-empty", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { bot, handler } = createPostWebhookTestHarness(rawBody);
const { res } = createRes();
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(400);
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("rejects unsigned POST requests before reading the body", async () => {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const readBody = vi.fn(async () => JSON.stringify({ events: [{ type: "message" }] }));
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody,
});
const { res } = createRes();
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(400);
expect(readBody).not.toHaveBeenCalled();
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("uses strict pre-auth limits for signed POST requests", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
expect(maxBytes).toBe(64 * 1024);
expect(timeoutMs).toBe(5_000);
return rawBody;
});
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody,
maxBodyBytes: 1024 * 1024,
});
const { res } = createRes();
await runSignedPost({ handler, rawBody, secret: "secret", res });
expect(res.statusCode).toBe(200);
expect(readBody).toHaveBeenCalledTimes(1);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
it("rejects invalid signature", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { bot, handler } = createPostWebhookTestHarness(rawBody);
const { res } = createRes();
await handler(
{ method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage,
res,
);
expect(res.statusCode).toBe(401);
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("accepts valid signature and dispatches events", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
const { res } = createRes();
await runSignedPost({ handler, rawBody, secret, res });
expect(res.statusCode).toBe(200);
expect(bot.handleWebhook).toHaveBeenCalledWith(
expect.objectContaining({ events: expect.any(Array) }),
);
});
it("releases authenticated requests before event processing completes", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
let releaseAuthenticated!: () => void;
const bot = {
handleWebhook: vi.fn(
async () =>
await new Promise<void>((resolve) => {
releaseAuthenticated = resolve;
}),
),
};
const onRequestAuthenticated = vi.fn();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const handler = createLineNodeWebhookHandler({
channelSecret: SECRET,
bot,
runtime,
readBody: async () => rawBody,
onRequestAuthenticated,
});
const { res } = createRes();
const request = runSignedPost({ handler, rawBody, secret: SECRET, res });
await vi.waitFor(() => {
expect(onRequestAuthenticated).toHaveBeenCalledTimes(1);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
expect(res.headersSent).toBe(false);
releaseAuthenticated();
await request;
expect(res.statusCode).toBe(200);
});
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { secret } = createPostWebhookTestHarness(rawBody);
const failingBot = {
handleWebhook: vi.fn(async () => {
throw new Error("transient failure");
}),
};
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const failingHandler = createLineNodeWebhookHandler({
channelSecret: secret,
bot: failingBot,
runtime,
readBody: async () => rawBody,
});
const { res } = createRes();
await runSignedPost({ handler: failingHandler, rawBody, secret, res });
expect(res.statusCode).toBe(500);
expect(res.body).toBe(JSON.stringify({ error: "Internal server error" }));
expect(failingBot.handleWebhook).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledTimes(1);
});
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
const rawBody = "not json";
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
const { res } = createRes();
await runSignedPost({ handler, rawBody, secret, res });
expect(res.statusCode).toBe(400);
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
});
describe("readLineWebhookRequestBody", () => {
it("reads body within limit", async () => {
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
const body = await readLineWebhookRequestBody(req, 1024);
expect(body).toContain('"events"');
});
it("rejects oversized body", async () => {
const req = createMockIncomingRequest(["x".repeat(2048)]);
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});
describe("createLineWebhookMiddleware", () => {
it.each([
["raw string body", JSON.stringify({ events: [{ type: "message" }] })],
["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")],
])("parses JSON from %s", async (_label, body) => {
const { res, onEvents } = await invokeWebhook({ body });
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
});
it("rejects invalid JSON payloads", async () => {
const { res, onEvents } = await invokeWebhook({ body: "not json" });
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects webhooks with invalid signatures", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: { "x-line-signature": "invalid-signature" },
});
expect(res.status).toHaveBeenCalledWith(401);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects verification-shaped requests without a signature", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("accepts signed verification-shaped requests without dispatching events", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects oversized signed payloads before JSON parsing", async () => {
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
const { res, onEvents } = await invokeWebhook({ body: largeBody });
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects missing signature when events are non-empty", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects signed requests when raw body is missing", async () => {
const { res, onEvents } = await invokeWebhook({
body: { events: [{ type: "message" }] },
headers: { "x-line-signature": "signed" },
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: "Missing raw request body for signature verification",
});
expect(onEvents).not.toHaveBeenCalled();
});
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: JSON.stringify({
events: [{ type: "message", source: { userId: "signed-user" } }],
}),
signedUserId: "signed-user",
});
});
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: Buffer.from(
JSON.stringify({
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
}),
"utf-8",
),
signedUserId: "signed-buffer-user",
});
});
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
const onEvents = vi.fn(async () => {});
const rawBody = "not-json";
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
rawBody,
body: { events: [{ type: "message" }] },
} as any;
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
expect(onEvents).not.toHaveBeenCalled();
});
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
const onEvents = vi.fn(async () => {
throw new Error("boom");
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
runtime,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
body: rawBody,
} as any;
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.status).not.toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
expect(runtime.error).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,131 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { webhook } from "@line/bot-sdk";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-request-guards";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
export async function readLineWebhookRequestBody(
req: IncomingMessage,
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
): Promise<string> {
return await readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs,
});
}
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
export function createLineNodeWebhookHandler(params: {
channelSecret: string;
bot: { handleWebhook: (body: webhook.CallbackRequest) => Promise<void> };
runtime: RuntimeEnv;
readBody?: ReadBodyFn;
maxBodyBytes?: number;
onRequestAuthenticated?: () => void;
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
const readBody = params.readBody ?? readLineWebhookRequestBody;
return async (req: IncomingMessage, res: ServerResponse) => {
if (req.method === "GET" || req.method === "HEAD") {
if (req.method === "HEAD") {
res.statusCode = 204;
res.end();
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("OK");
return;
}
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD, POST");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
try {
const signatureHeader = req.headers["x-line-signature"];
const signature =
typeof signatureHeader === "string"
? signatureHeader.trim()
: Array.isArray(signatureHeader)
? (signatureHeader[0] ?? "").trim()
: "";
if (!signature) {
logVerbose("line: webhook missing X-Line-Signature header");
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
return;
}
const rawBody = await readBody(
req,
Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES),
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
);
if (!validateLineSignature(rawBody, signature, params.channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid signature" }));
return;
}
const body = parseLineWebhookBody(rawBody);
if (!body) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
return;
}
params.onRequestAuthenticated?.();
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await params.bot.handleWebhook(body);
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
res.statusCode = 413;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Payload too large" }));
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
res.statusCode = 408;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
return;
}
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
};
}

View file

@ -0,0 +1,10 @@
import type { webhook } from "@line/bot-sdk";
export { validateLineSignature } from "./signature.js";
export function parseLineWebhookBody(rawBody: string): webhook.CallbackRequest | null {
try {
return JSON.parse(rawBody) as webhook.CallbackRequest;
} catch {
return null;
}
}

View file

@ -0,0 +1,111 @@
import type { webhook } from "@line/bot-sdk";
import type { NextFunction, Request, Response } from "express";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024;
export interface LineWebhookOptions {
channelSecret: string;
onEvents: (body: webhook.CallbackRequest) => Promise<void>;
runtime?: RuntimeEnv;
}
function readRawBody(req: Request): string | null {
const rawBody =
(req as { rawBody?: string | Buffer }).rawBody ??
(typeof req.body === "string" || Buffer.isBuffer(req.body) ? req.body : null);
if (!rawBody) {
return null;
}
return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody;
}
function parseWebhookBody(rawBody?: string | null): webhook.CallbackRequest | null {
if (!rawBody) {
return null;
}
return parseLineWebhookBody(rawBody);
}
export function createLineWebhookMiddleware(
options: LineWebhookOptions,
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
const { channelSecret, onEvents, runtime } = options;
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const signature = req.headers["x-line-signature"];
if (!signature || typeof signature !== "string") {
res.status(400).json({ error: "Missing X-Line-Signature header" });
return;
}
const rawBody = readRawBody(req);
if (!rawBody) {
res.status(400).json({ error: "Missing raw request body for signature verification" });
return;
}
if (Buffer.byteLength(rawBody, "utf-8") > LINE_WEBHOOK_MAX_RAW_BODY_BYTES) {
res.status(413).json({ error: "Payload too large" });
return;
}
if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.status(401).json({ error: "Invalid signature" });
return;
}
const body = parseWebhookBody(rawBody);
if (!body) {
res.status(400).json({ error: "Invalid webhook payload" });
return;
}
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await onEvents(body);
}
res.status(200).json({ status: "ok" });
} catch (err) {
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
};
}
export interface StartLineWebhookOptions {
channelSecret: string;
onEvents: (body: webhook.CallbackRequest) => Promise<void>;
runtime?: RuntimeEnv;
path?: string;
}
export function startLineWebhook(options: StartLineWebhookOptions): {
path: string;
handler: (req: Request, res: Response, _next: NextFunction) => Promise<void>;
} {
const channelSecret =
typeof options.channelSecret === "string" ? options.channelSecret.trim() : "";
if (!channelSecret) {
throw new Error(
"LINE webhook mode requires a non-empty channel secret. " +
"Set channels.line.channelSecret in your config.",
);
}
const path = options.path ?? "/line/webhook";
const middleware = createLineWebhookMiddleware({
channelSecret,
onEvents: options.onEvents,
runtime: options.runtime,
});
return { path, handler: middleware };
}