feat: Add Line plugin (#1630)

* feat: add LINE plugin (#1630) (thanks @plum-dawg)

* feat: complete LINE plugin (#1630) (thanks @plum-dawg)

* chore: drop line plugin node_modules (#1630) (thanks @plum-dawg)

* test: mock /context report in commands test (#1630) (thanks @plum-dawg)

* test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg)

* test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
plum-dawg
2026-01-25 07:22:36 -05:00
committed by GitHub
parent 101d0f451f
commit c96ffa7186
85 changed files with 11365 additions and 60 deletions

199
src/line/accounts.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
resolveLineAccount,
listLineAccountIds,
resolveDefaultLineAccountId,
normalizeAccountId,
DEFAULT_ACCOUNT_ID,
} from "./accounts.js";
import type { ClawdbotConfig } from "../config/config.js";
describe("LINE accounts", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.LINE_CHANNEL_ACCESS_TOKEN;
delete process.env.LINE_CHANNEL_SECRET;
});
afterEach(() => {
process.env = originalEnv;
});
describe("resolveLineAccount", () => {
it("resolves account from config", () => {
const cfg: ClawdbotConfig = {
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: ClawdbotConfig = {
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: ClawdbotConfig = {
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("returns empty token when not configured", () => {
const cfg: ClawdbotConfig = {};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("");
expect(account.channelSecret).toBe("");
expect(account.tokenSource).toBe("none");
});
});
describe("listLineAccountIds", () => {
it("returns default account when configured at base level", () => {
const cfg: ClawdbotConfig = {
channels: {
line: {
channelAccessToken: "test-token",
},
},
};
const ids = listLineAccountIds(cfg);
expect(ids).toContain(DEFAULT_ACCOUNT_ID);
});
it("returns named accounts", () => {
const cfg: ClawdbotConfig = {
channels: {
line: {
accounts: {
business: { enabled: true },
personal: { enabled: true },
},
},
},
};
const ids = listLineAccountIds(cfg);
expect(ids).toContain("business");
expect(ids).toContain("personal");
});
it("returns default from env", () => {
process.env.LINE_CHANNEL_ACCESS_TOKEN = "env-token";
const cfg: ClawdbotConfig = {};
const ids = listLineAccountIds(cfg);
expect(ids).toContain(DEFAULT_ACCOUNT_ID);
});
});
describe("resolveDefaultLineAccountId", () => {
it("returns default when configured", () => {
const cfg: ClawdbotConfig = {
channels: {
line: {
channelAccessToken: "test-token",
},
},
};
const id = resolveDefaultLineAccountId(cfg);
expect(id).toBe(DEFAULT_ACCOUNT_ID);
});
it("returns first named account when default not configured", () => {
const cfg: ClawdbotConfig = {
channels: {
line: {
accounts: {
business: { enabled: true },
},
},
},
};
const id = resolveDefaultLineAccountId(cfg);
expect(id).toBe("business");
});
});
describe("normalizeAccountId", () => {
it("normalizes undefined to default", () => {
expect(normalizeAccountId(undefined)).toBe(DEFAULT_ACCOUNT_ID);
});
it("normalizes 'default' to DEFAULT_ACCOUNT_ID", () => {
expect(normalizeAccountId("default")).toBe(DEFAULT_ACCOUNT_ID);
});
it("preserves other account ids", () => {
expect(normalizeAccountId("business")).toBe("business");
});
it("lowercases account ids", () => {
expect(normalizeAccountId("Business")).toBe("business");
});
it("trims whitespace", () => {
expect(normalizeAccountId(" business ")).toBe("business");
});
});
});

179
src/line/accounts.ts Normal file
View File

@@ -0,0 +1,179 @@
import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js";
import type {
LineConfig,
LineAccountConfig,
ResolvedLineAccount,
LineTokenSource,
} from "./types.js";
export const DEFAULT_ACCOUNT_ID = "default";
function readFileIfExists(filePath: string | undefined): string | undefined {
if (!filePath) return undefined;
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch {
return undefined;
}
}
function resolveToken(params: {
accountId: string;
baseConfig?: LineConfig;
accountConfig?: LineAccountConfig;
}): { token: string; tokenSource: LineTokenSource } {
const { accountId, baseConfig, accountConfig } = params;
// Check account-level config first
if (accountConfig?.channelAccessToken?.trim()) {
return { token: accountConfig.channelAccessToken.trim(), tokenSource: "config" };
}
// Check account-level token file
const accountFileToken = readFileIfExists(accountConfig?.tokenFile);
if (accountFileToken) {
return { token: accountFileToken, tokenSource: "file" };
}
// For default account, check base config and env
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;
// Check account-level config first
if (accountConfig?.channelSecret?.trim()) {
return accountConfig.channelSecret.trim();
}
// Check account-level secret file
const accountFileSecret = readFileIfExists(accountConfig?.secretFile);
if (accountFileSecret) {
return accountFileSecret;
}
// For default account, check base config and env
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: ClawdbotConfig;
accountId?: string;
}): ResolvedLineAccount {
const { cfg, accountId = DEFAULT_ACCOUNT_ID } = params;
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const accountConfig = accountId !== DEFAULT_ACCOUNT_ID ? accounts?.[accountId] : undefined;
const { token, tokenSource } = resolveToken({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const secret = resolveSecret({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const mergedConfig: LineConfig & LineAccountConfig = {
...lineConfig,
...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: ClawdbotConfig): string[] {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const ids = new Set<string>();
// Add default account if configured at base level
if (
lineConfig?.channelAccessToken?.trim() ||
lineConfig?.tokenFile ||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim()
) {
ids.add(DEFAULT_ACCOUNT_ID);
}
// Add named accounts
if (accounts) {
for (const id of Object.keys(accounts)) {
ids.add(id);
}
}
return Array.from(ids);
}
export function resolveDefaultLineAccountId(cfg: ClawdbotConfig): string {
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 {
const trimmed = accountId?.trim().toLowerCase();
if (!trimmed || trimmed === "default") {
return DEFAULT_ACCOUNT_ID;
}
return trimmed;
}

View File

@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from "vitest";
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", () => {
it("uses reply token for text before sending rich messages", async () => {
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 lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
};
const result = await deliverLineAutoReply({
payload: { text: "hello", channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: (text) => ({ text, flexMessages: [] }),
chunkMarkdownText: (text) => [text],
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
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 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 lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
const result = await deliverLineAutoReply({
payload: { channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: () => ({ text: "", flexMessages: [] }),
chunkMarkdownText: () => [],
sendLineReplyChunks: vi.fn(async () => ({ replyTokenUsed: false })),
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
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 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,
quickReply: { items: ["A"] },
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
await deliverLineAutoReply({
payload: { text: "hello", channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: (text) => ({ text, flexMessages: [] }),
chunkMarkdownText: (text) => [text],
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
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);
});
});

View File

@@ -0,0 +1,180 @@
import type { messagingApi } from "@line/bot-sdk";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js";
import type { LineChannelData, LineTemplateMessagePayload } from "./types.js";
import type { LineReplyMessage, SendLineReplyChunksParams } from "./reply-chunks.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 }>;
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;
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;
onReplyError?: (err: unknown) => void;
};
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 as FlexContainer),
);
}
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
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 };
}

48
src/line/bot-access.ts Normal file
View File

@@ -0,0 +1,48 @@
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 normalizeAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])];
return normalizeAllowFrom(combined);
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
};
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
}): boolean => {
const { allow, senderId } = params;
if (!allow.hasEntries) return false;
if (allow.hasWildcard) return true;
if (!senderId) return false;
return allow.entries.includes(senderId);
};

View File

@@ -0,0 +1,173 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { MessageEvent } from "@line/bot-sdk";
const { buildLineMessageContextMock, buildLinePostbackContextMock } = vi.hoisted(() => ({
buildLineMessageContextMock: vi.fn(async () => ({
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
})),
buildLinePostbackContextMock: vi.fn(async () => null),
}));
vi.mock("./bot-message-context.js", () => ({
buildLineMessageContext: (...args: unknown[]) => buildLineMessageContextMock(...args),
buildLinePostbackContext: (...args: unknown[]) => buildLinePostbackContextMock(...args),
}));
const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
readAllowFromStoreMock: vi.fn(async () => [] as string[]),
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
}));
let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
describe("handleLineWebhookEvents", () => {
beforeAll(async () => {
({ handleLineWebhookEvents } = await import("./bot-handlers.js"));
});
beforeEach(() => {
buildLineMessageContextMock.mockClear();
buildLinePostbackContextMock.mockClear();
readAllowFromStoreMock.mockClear();
upsertPairingRequestMock.mockClear();
});
it("blocks group messages when groupPolicy is disabled", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m1", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-1" },
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "disabled" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "disabled" },
},
runtime: { error: vi.fn() },
mediaMaxBytes: 1,
processMessage,
});
expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
});
it("blocks group messages when allowlist is empty", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m2", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-2" },
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "allowlist" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "allowlist" },
},
runtime: { error: vi.fn() },
mediaMaxBytes: 1,
processMessage,
});
expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
});
it("allows group messages when sender is in groupAllowFrom", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m3", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-3" },
mode: "active",
webhookEventId: "evt-3",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: {
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] } },
},
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] },
},
runtime: { error: vi.fn() },
mediaMaxBytes: 1,
processMessage,
});
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("blocks group messages when wildcard group config disables groups", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m4", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-2", userId: "user-4" },
mode: "active",
webhookEventId: "evt-4",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "open", groups: { "*": { enabled: false } } },
},
runtime: { error: vi.fn() },
mediaMaxBytes: 1,
processMessage,
});
expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
});
});

337
src/line/bot-handlers.ts Normal file
View File

@@ -0,0 +1,337 @@
import type {
WebhookEvent,
MessageEvent,
FollowEvent,
UnfollowEvent,
JoinEvent,
LeaveEvent,
PostbackEvent,
EventSource,
} from "@line/bot-sdk";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildLineMessageContext,
buildLinePostbackContext,
type LineInboundContext,
} from "./bot-message-context.js";
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { downloadLineMedia } from "./download.js";
import { pushMessageLine, replyMessageLine } from "./send.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
interface MediaRef {
path: string;
contentType?: string;
}
export interface LineHandlerContext {
cfg: ClawdbotConfig;
account: ResolvedLineAccount;
runtime: RuntimeEnv;
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
}
type LineSourceInfo = {
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
};
function getSourceInfo(source: EventSource): LineSourceInfo {
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 resolveLineGroupConfig(params: {
config: ResolvedLineAccount["config"];
groupId?: string;
roomId?: string;
}): LineGroupConfig | undefined {
const groups = params.config.groups ?? {};
if (params.groupId) {
return groups[params.groupId] ?? groups[`group:${params.groupId}`] ?? groups["*"];
}
if (params.roomId) {
return groups[params.roomId] ?? groups[`room:${params.roomId}`] ?? groups["*"];
}
return groups["*"];
}
async function sendLinePairingReply(params: {
senderId: string;
replyToken?: string;
context: LineHandlerContext;
}): Promise<void> {
const { senderId, replyToken, context } = params;
const { code, created } = await upsertChannelPairingRequest({
channel: "line",
id: senderId,
});
if (!created) return;
logVerbose(`line pairing request sender=${senderId}`);
const idLabel = (() => {
try {
return resolvePairingIdLabel("line");
} catch {
return "lineUserId";
}
})();
const text = buildPairingReply({
channel: "line",
idLine: `Your ${idLabel}: ${senderId}`,
code,
});
try {
if (replyToken) {
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<boolean> {
const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getSourceInfo(event.source);
const senderId = userId ?? "";
const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []);
const effectiveDmAllow = normalizeAllowFromWithStore({
allowFrom: account.config.allowFrom,
storeAllowFrom,
});
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 = normalizeAllowFromWithStore({
allowFrom: groupAllowFrom,
storeAllowFrom,
});
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return false;
}
if (typeof groupAllowOverride !== "undefined") {
if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return false;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return false;
}
}
if (groupPolicy === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)");
return false;
}
if (groupPolicy === "allowlist") {
if (!senderId) {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return false;
}
if (!effectiveGroupAllow.hasEntries) {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return false;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return false;
}
}
return true;
}
if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)");
return false;
}
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 false;
}
await sendLinePairingReply({
senderId,
replyToken: "replyToken" in event ? event.replyToken : undefined,
context,
});
} else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
}
return false;
}
return true;
}
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message;
if (!(await shouldProcessLineEvent(event, context))) return;
// Download media if applicable
const allMedia: MediaRef[] = [];
if (message.type === "image" || message.type === "video" || message.type === "audio") {
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}`);
// Continue without media
} else {
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
}
}
}
const messageContext = await buildLineMessageContext({
event,
allMedia,
cfg,
account,
});
if (!messageContext) {
logVerbose("line: skipping empty message");
return;
}
await processMessage(messageContext);
}
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
const userId = event.source.type === "user" ? event.source.userId : undefined;
logVerbose(`line: user ${userId ?? "unknown"} followed`);
// Could implement welcome message here
}
async function handleUnfollowEvent(
event: UnfollowEvent,
_context: LineHandlerContext,
): Promise<void> {
const userId = event.source.type === "user" ? event.source.userId : undefined;
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
}
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
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}`);
if (!(await shouldProcessLineEvent(event, context))) return;
const postbackContext = await buildLinePostbackContext({
event,
cfg: context.cfg,
account: context.account,
});
if (!postbackContext) return;
await context.processMessage(postbackContext);
}
export async function handleLineWebhookEvents(
events: WebhookEvent[],
context: LineHandlerContext,
): Promise<void> {
for (const event of events) {
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}`);
}
} catch (err) {
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
}
}
}

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
import type { ClawdbotConfig } from "../config/config.js";
import type { ResolvedLineAccount } from "./types.js";
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
describe("buildLineMessageContext", () => {
let tmpDir: string;
let storePath: string;
let cfg: ClawdbotConfig;
const account: ResolvedLineAccount = {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {},
};
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-line-context-"));
storePath = path.join(tmpDir, "sessions.json");
cfg = { session: { store: storePath } };
});
afterEach(async () => {
await fs.rm(tmpDir, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50,
});
});
it("routes group message replies to the group id", async () => {
const event = {
type: "message",
message: { id: "1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-1" },
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
});
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 = {
type: "postback",
postback: { data: "action=select" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-2", userId: "user-2" },
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
} as PostbackEvent;
const context = await buildLinePostbackContext({
event,
cfg,
account,
});
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
expect(context?.ctxPayload.To).toBe("line:group:group-2");
});
});

View File

@@ -0,0 +1,465 @@
import type {
MessageEvent,
TextEventMessage,
StickerEventMessage,
LocationEventMessage,
EventSource,
PostbackEvent,
} from "@line/bot-sdk";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { ResolvedLineAccount } from "./types.js";
interface MediaRef {
path: string;
contentType?: string;
}
interface BuildLineMessageContextParams {
event: MessageEvent;
allMedia: MediaRef[];
cfg: ClawdbotConfig;
account: ResolvedLineAccount;
}
function getSourceInfo(source: EventSource): {
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
} {
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.type === "group" && source.groupId) {
return `group:${source.groupId}`;
}
if (source.type === "room" && source.roomId) {
return `room:${source.roomId}`;
}
if (source.type === "user" && source.userId) {
return source.userId;
}
return "unknown";
}
// Common LINE sticker package descriptions
const STICKER_PACKAGES: Record<string, string> = {
"1": "Moon & James",
"2": "Cony & Brown",
"3": "Brown & Friends",
"4": "Moon Special",
"11537": "Cony",
"11538": "Brown",
"11539": "Moon",
"6136": "Cony's Happy Life",
"6325": "Brown's Life",
"6359": "Choco",
"6362": "Sally",
"6370": "Edward",
"789": "LINE Characters",
};
function describeStickerKeywords(sticker: StickerEventMessage): string {
// Use sticker keywords if available (LINE provides these for some stickers)
const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
if (keywords && keywords.length > 0) {
return keywords.slice(0, 3).join(", ");
}
// Use sticker text if available
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 as TextEventMessage).text;
}
if (message.type === "location") {
const loc = message as LocationEventMessage;
return (
formatLocationText({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
}) ?? ""
);
}
if (message.type === "sticker") {
const sticker = message as StickerEventMessage;
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 "";
}
}
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
const { event, allMedia, cfg, account } = params;
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "inbound",
});
const source = event.source;
const { userId, groupId, roomId, isGroup } = getSourceInfo(source);
const peerId = buildPeerId(source);
const route = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
const message = event.message;
const messageId = message.id;
const timestamp = event.timestamp;
// Build message body
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;
}
// Build sender info
const senderId = userId ?? "unknown";
const senderLabel = userId ? `user:${userId}` : "unknown";
// Build conversation label
const conversationLabel = isGroup
? groupId
? `group:${groupId}`
: roomId
? `room:${roomId}`
: "unknown-group"
: senderLabel;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "LINE",
from: conversationLabel,
timestamp,
body: rawBody,
chatType: isGroup ? "group" : "direct",
sender: {
id: senderId,
},
previousTimestamp,
envelope: envelopeOptions,
});
// Build location context if applicable
let locationContext: ReturnType<typeof toLocationContext> | undefined;
if (message.type === "location") {
const loc = message as LocationEventMessage;
locationContext = toLocationContext({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
});
}
const fromAddress = isGroup
? groupId
? `line:group:${groupId}`
: roomId
? `line:room:${roomId}`
: `line:${peerId}`
: `line:${userId ?? peerId}`;
const toAddress = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const originatingTo = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: fromAddress,
To: toAddress,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (groupId ?? roomId) : undefined,
SenderId: senderId,
Provider: "line",
Surface: "line",
MessageSid: messageId,
Timestamp: timestamp,
MediaPath: allMedia[0]?.path,
MediaType: allMedia[0]?.contentType,
MediaUrl: allMedia[0]?.path,
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaTypes:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
...locationContext,
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
});
if (!isGroup) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "line",
to: userId ?? peerId,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
logVerbose(
`line inbound: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
);
}
return {
ctxPayload,
event,
userId,
groupId,
roomId,
isGroup,
route,
replyToken: event.replyToken,
accountId: account.accountId,
};
}
export async function buildLinePostbackContext(params: {
event: PostbackEvent;
cfg: ClawdbotConfig;
account: ResolvedLineAccount;
}) {
const { event, cfg, account } = params;
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "inbound",
});
const source = event.source;
const { userId, groupId, roomId, isGroup } = getSourceInfo(source);
const peerId = buildPeerId(source);
const route = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
const timestamp = event.timestamp;
const rawData = event.postback?.data?.trim() ?? "";
if (!rawData) return null;
let rawBody = rawData;
if (rawData.includes("line.action=")) {
const params = new URLSearchParams(rawData);
const action = params.get("line.action") ?? "";
const device = params.get("line.device");
rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
}
const senderId = userId ?? "unknown";
const senderLabel = userId ? `user:${userId}` : "unknown";
const conversationLabel = isGroup
? groupId
? `group:${groupId}`
: roomId
? `room:${roomId}`
: "unknown-group"
: senderLabel;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "LINE",
from: conversationLabel,
timestamp,
body: rawBody,
chatType: isGroup ? "group" : "direct",
sender: {
id: senderId,
},
previousTimestamp,
envelope: envelopeOptions,
});
const fromAddress = isGroup
? groupId
? `line:group:${groupId}`
: roomId
? `line:room:${roomId}`
: `line:${peerId}`
: `line:${userId ?? peerId}`;
const toAddress = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const originatingTo = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: fromAddress,
To: toAddress,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (groupId ?? roomId) : undefined,
SenderId: senderId,
Provider: "line",
Surface: "line",
MessageSid: event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`,
Timestamp: timestamp,
MediaPath: "",
MediaType: undefined,
MediaUrl: "",
MediaPaths: undefined,
MediaUrls: undefined,
MediaTypes: undefined,
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
});
if (!isGroup) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "line",
to: userId ?? peerId,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`line postback: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
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;

82
src/line/bot.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveLineAccount } from "./accounts.js";
import { handleLineWebhookEvents } from "./bot-handlers.js";
import type { LineInboundContext } from "./bot-message-context.js";
import { startLineWebhook } from "./webhook.js";
import type { ResolvedLineAccount } from "./types.js";
export interface LineBotOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
runtime?: RuntimeEnv;
config?: ClawdbotConfig;
mediaMaxMb?: number;
onMessage?: (ctx: LineInboundContext) => Promise<void>;
}
export interface LineBot {
handleWebhook: (body: WebhookRequestBody) => Promise<void>;
account: ResolvedLineAccount;
}
export function createLineBot(opts: LineBotOptions): LineBot {
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
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 handleWebhook = async (body: WebhookRequestBody): Promise<void> => {
if (!body.events || body.events.length === 0) {
return;
}
await handleLineWebhookEvents(body.events, {
cfg,
account,
runtime,
mediaMaxBytes,
processMessage,
});
};
return {
handleWebhook,
account,
};
}
export function createLineWebhookCallback(
bot: LineBot,
channelSecret: string,
path = "/line/webhook",
) {
const { handler } = startLineWebhook({
channelSecret,
onEvents: bot.handleWebhook,
path,
});
return { path, handler };
}

53
src/line/config-schema.ts Normal file
View File

@@ -0,0 +1,53 @@
import { z } from "zod";
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
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 = 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"),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
})
.strict();
export const LineConfigSchema = 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"),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
})
.strict();
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;

120
src/line/download.ts Normal file
View File

@@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { messagingApi } from "@line/bot-sdk";
import { logVerbose } from "../globals.js";
interface DownloadResult {
path: string;
contentType?: string;
size: number;
}
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);
// response is a Readable stream
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);
// Determine content type from magic bytes
const contentType = detectContentType(buffer);
const ext = getExtensionForContentType(contentType);
// Write to temp file
const tempDir = os.tmpdir();
const fileName = `line-media-${messageId}-${Date.now()}${ext}`;
const filePath = path.join(tempDir, fileName);
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 {
// Check magic bytes
if (buffer.length >= 2) {
// JPEG
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
// PNG
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return "image/png";
}
// GIF
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return "image/gif";
}
// WebP
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";
}
// MP4
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "video/mp4";
}
// M4A/AAC
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x00) {
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "audio/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,499 @@
import { describe, expect, it } from "vitest";
import {
createInfoCard,
createListCard,
createImageCard,
createActionCard,
createCarousel,
createNotificationBubble,
createReceiptCard,
createEventCard,
createAgendaCard,
createMediaPlayerCard,
createAppleTvRemoteCard,
createDeviceControlCard,
toFlexMessage,
} from "./flex-templates.js";
describe("createInfoCard", () => {
it("creates a bubble with title and body", () => {
const card = createInfoCard("Test Title", "Test body content");
expect(card.type).toBe("bubble");
expect(card.size).toBe("mega");
expect(card.body).toBeDefined();
expect(card.body?.type).toBe("box");
});
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
expect(card.footer).toBeDefined();
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
it("omits footer when not provided", () => {
const card = createInfoCard("Title", "Body");
expect(card.footer).toBeUndefined();
});
});
describe("createListCard", () => {
it("creates a list with title and items", () => {
const items = [{ title: "Item 1", subtitle: "Description 1" }, { title: "Item 2" }];
const card = createListCard("My List", items);
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("limits 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[] }> };
// The list items are in the third content (after title and separator)
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
it("includes actions on items when provided", () => {
const items = [
{
title: "Clickable",
action: { type: "message" as const, label: "Click", text: "clicked" },
},
];
const card = createListCard("List", items);
expect(card.body).toBeDefined();
});
});
describe("createImageCard", () => {
it("creates a card with hero image", () => {
const card = createImageCard("https://example.com/image.jpg", "Image Title");
expect(card.type).toBe("bubble");
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/image.jpg");
});
it("includes 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("applies custom aspect ratio", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", undefined, {
aspectRatio: "16:9",
});
expect((card.hero as { aspectRatio: string }).aspectRatio).toBe("16:9");
});
});
describe("createActionCard", () => {
it("creates a card with action buttons", () => {
const actions = [
{ label: "Action 1", action: { type: "message" as const, label: "Act1", text: "action1" } },
{
label: "Action 2",
action: { type: "uri" as const, label: "Act2", uri: "https://example.com" },
},
];
const card = createActionCard("Title", "Description", actions);
expect(card.type).toBe("bubble");
expect(card.footer).toBeDefined();
const footer = card.footer as { contents: Array<{ type: string }> };
expect(footer.contents.length).toBe(2);
});
it("limits 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("includes hero image when provided", () => {
const card = createActionCard("Title", "Body", [], {
imageUrl: "https://example.com/hero.jpg",
});
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/hero.jpg");
});
});
describe("createCarousel", () => {
it("creates a carousel from bubbles", () => {
const bubbles = [createInfoCard("Card 1", "Body 1"), createInfoCard("Card 2", "Body 2")];
const carousel = createCarousel(bubbles);
expect(carousel.type).toBe("carousel");
expect(carousel.contents.length).toBe(2);
});
it("limits 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);
});
});
describe("createNotificationBubble", () => {
it("creates a simple notification", () => {
const bubble = createNotificationBubble("Hello world");
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("applies notification type styling", () => {
const successBubble = createNotificationBubble("Success!", { type: "success" });
const errorBubble = createNotificationBubble("Error!", { type: "error" });
expect(successBubble.body).toBeDefined();
expect(errorBubble.body).toBeDefined();
});
it("includes title when provided", () => {
const bubble = createNotificationBubble("Details here", {
title: "Alert Title",
});
expect(bubble.body).toBeDefined();
});
});
describe("createReceiptCard", () => {
it("creates a receipt with items", () => {
const card = createReceiptCard({
title: "Order Receipt",
items: [
{ name: "Item A", value: "$10" },
{ name: "Item B", value: "$20" },
],
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes total when provided", () => {
const card = createReceiptCard({
title: "Receipt",
items: [{ name: "Item", value: "$10" }],
total: { label: "Total", value: "$10" },
});
expect(card.body).toBeDefined();
});
it("includes footer when provided", () => {
const card = createReceiptCard({
title: "Receipt",
items: [{ name: "Item", value: "$10" }],
footer: "Thank you!",
});
expect(card.footer).toBeDefined();
});
});
describe("createMediaPlayerCard", () => {
it("creates a basic player card", () => {
const card = createMediaPlayerCard({
title: "Bohemian Rhapsody",
subtitle: "Queen",
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes album art when provided", () => {
const card = createMediaPlayerCard({
title: "Track Name",
imageUrl: "https://example.com/album.jpg",
});
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/album.jpg");
});
it("shows playing status", () => {
const card = createMediaPlayerCard({
title: "Track",
isPlaying: true,
});
expect(card.body).toBeDefined();
});
it("includes playback controls", () => {
const card = createMediaPlayerCard({
title: "Track",
controls: {
previous: { data: "action=prev" },
play: { data: "action=play" },
pause: { data: "action=pause" },
next: { data: "action=next" },
},
});
expect(card.footer).toBeDefined();
});
it("includes extra actions", () => {
const card = createMediaPlayerCard({
title: "Track",
extraActions: [
{ label: "Add to Playlist", data: "action=add_playlist" },
{ label: "Share", data: "action=share" },
],
});
expect(card.footer).toBeDefined();
});
});
describe("createDeviceControlCard", () => {
it("creates a device card with controls", () => {
const card = createDeviceControlCard({
deviceName: "Apple TV",
deviceType: "Streaming Box",
controls: [
{ label: "Play/Pause", data: "action=playpause" },
{ label: "Menu", data: "action=menu" },
],
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
expect(card.footer).toBeDefined();
});
it("shows device status", () => {
const card = createDeviceControlCard({
deviceName: "Apple TV",
status: "Playing",
controls: [{ label: "Pause", data: "action=pause" }],
});
expect(card.body).toBeDefined();
});
it("includes device image", () => {
const card = createDeviceControlCard({
deviceName: "Device",
imageUrl: "https://example.com/device.jpg",
controls: [],
});
expect(card.hero).toBeDefined();
});
it("limits controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
expect(card.footer).toBeDefined();
// Should have max 3 rows of 2 buttons
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
});
describe("createAppleTvRemoteCard", () => {
it("creates an Apple TV remote card with controls", () => {
const card = createAppleTvRemoteCard({
deviceName: "Apple TV",
status: "Playing",
actionData: {
up: "action=up",
down: "action=down",
left: "action=left",
right: "action=right",
select: "action=select",
menu: "action=menu",
home: "action=home",
play: "action=play",
pause: "action=pause",
volumeUp: "action=volume_up",
volumeDown: "action=volume_down",
mute: "action=mute",
},
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
});
describe("createEventCard", () => {
it("creates an event card with required fields", () => {
const card = createEventCard({
title: "Team Meeting",
date: "January 24, 2026",
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes time when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
time: "2:00 PM - 3:00 PM",
});
expect(card.body).toBeDefined();
});
it("includes location when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
location: "Conference Room A",
});
expect(card.body).toBeDefined();
});
it("includes description when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
description: "Discuss Q1 roadmap",
});
expect(card.body).toBeDefined();
});
it("includes all 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.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes action when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
action: { type: "uri", label: "Join", uri: "https://meet.google.com/abc" },
});
expect(card.body).toBeDefined();
expect((card.body as { action?: unknown }).action).toBeDefined();
});
it("includes calendar name when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
calendar: "Work Calendar",
});
expect(card.body).toBeDefined();
});
it("uses mega size for better readability", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
});
expect(card.size).toBe("mega");
});
});
describe("createAgendaCard", () => {
it("creates an agenda card with title and events", () => {
const card = createAgendaCard({
title: "Today's Schedule",
events: [
{ title: "Team Meeting", time: "9:00 AM" },
{ title: "Lunch", time: "12:00 PM" },
],
});
expect(card.type).toBe("bubble");
expect(card.size).toBe("mega");
expect(card.body).toBeDefined();
});
it("limits events to 8", () => {
const manyEvents = Array.from({ length: 15 }, (_, i) => ({
title: `Event ${i + 1}`,
}));
const card = createAgendaCard({
title: "Many Events",
events: manyEvents,
});
expect(card.body).toBeDefined();
});
it("includes footer when provided", () => {
const card = createAgendaCard({
title: "Today",
events: [{ title: "Event" }],
footer: "Synced from Google Calendar",
});
expect(card.footer).toBeDefined();
});
it("shows event metadata (time, location, calendar)", () => {
const card = createAgendaCard({
title: "Schedule",
events: [
{
title: "Meeting",
time: "10:00 AM",
location: "Room A",
calendar: "Work",
},
],
});
expect(card.body).toBeDefined();
});
});
describe("toFlexMessage", () => {
it("wraps a container in a FlexMessage", () => {
const bubble = createInfoCard("Title", "Body");
const message = toFlexMessage("Alt text", bubble);
expect(message.type).toBe("flex");
expect(message.altText).toBe("Alt text");
expect(message.contents).toBe(bubble);
});
});

1507
src/line/flex-templates.ts Normal file

File diff suppressed because it is too large Load Diff

45
src/line/http-registry.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export type LineHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
type RegisterLineHttpHandlerArgs = {
path?: string | null;
handler: LineHttpRequestHandler;
log?: (message: string) => void;
accountId?: string;
};
const lineHttpRoutes = new Map<string, LineHttpRequestHandler>();
export function normalizeLineWebhookPath(path?: string | null): string {
const trimmed = path?.trim();
if (!trimmed) return "/line/webhook";
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function registerLineHttpHandler(params: RegisterLineHttpHandlerArgs): () => void {
const normalizedPath = normalizeLineWebhookPath(params.path);
if (lineHttpRoutes.has(normalizedPath)) {
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
params.log?.(`line: webhook path ${normalizedPath} already registered${suffix}`);
return () => {};
}
lineHttpRoutes.set(normalizedPath, params.handler);
return () => {
lineHttpRoutes.delete(normalizedPath);
};
}
export async function handleLineHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const handler = lineHttpRoutes.get(url.pathname);
if (!handler) return false;
await handler(req, res);
return true;
}

155
src/line/index.ts Normal file
View File

@@ -0,0 +1,155 @@
export {
createLineBot,
createLineWebhookCallback,
type LineBot,
type LineBotOptions,
} from "./bot.js";
export {
monitorLineProvider,
getLineRuntimeState,
type MonitorLineProviderOptions,
type LineProviderMonitor,
} from "./monitor.js";
export {
sendMessageLine,
pushMessageLine,
pushMessagesLine,
replyMessageLine,
createImageMessage,
createLocationMessage,
createFlexMessage,
createQuickReplyItems,
createTextMessageWithQuickReplies,
showLoadingAnimation,
getUserProfile,
getUserDisplayName,
pushImageMessage,
pushLocationMessage,
pushFlexMessage,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
} from "./send.js";
export {
startLineWebhook,
createLineWebhookMiddleware,
type LineWebhookOptions,
type StartLineWebhookOptions,
} from "./webhook.js";
export {
handleLineHttpRequest,
registerLineHttpHandler,
normalizeLineWebhookPath,
} from "./http-registry.js";
export {
resolveLineAccount,
listLineAccountIds,
resolveDefaultLineAccountId,
normalizeAccountId,
DEFAULT_ACCOUNT_ID,
} from "./accounts.js";
export { probeLineBot } from "./probe.js";
export { downloadLineMedia } from "./download.js";
export { LineConfigSchema, type LineConfigSchemaType } from "./config-schema.js";
export { buildLineMessageContext } from "./bot-message-context.js";
export { handleLineWebhookEvents, type LineHandlerContext } from "./bot-handlers.js";
// Flex Message templates
export {
createInfoCard,
createListCard,
createImageCard,
createActionCard,
createCarousel,
createNotificationBubble,
createReceiptCard,
createEventCard,
createMediaPlayerCard,
createAppleTvRemoteCard,
createDeviceControlCard,
toFlexMessage,
type ListItem,
type CardAction,
type FlexContainer,
type FlexBubble,
type FlexCarousel,
} from "./flex-templates.js";
// Markdown to LINE conversion
export {
processLineMessage,
hasMarkdownToConvert,
stripMarkdown,
extractMarkdownTables,
extractCodeBlocks,
extractLinks,
convertTableToFlexBubble,
convertCodeBlockToFlexBubble,
convertLinksToFlexBubble,
type ProcessedLineMessage,
type MarkdownTable,
type CodeBlock,
type MarkdownLink,
} from "./markdown-to-line.js";
// Rich Menu operations
export {
createRichMenu,
uploadRichMenuImage,
setDefaultRichMenu,
cancelDefaultRichMenu,
getDefaultRichMenuId,
linkRichMenuToUser,
linkRichMenuToUsers,
unlinkRichMenuFromUser,
unlinkRichMenuFromUsers,
getRichMenuIdOfUser,
getRichMenuList,
getRichMenu,
deleteRichMenu,
createRichMenuAlias,
deleteRichMenuAlias,
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
type CreateRichMenuParams,
type RichMenuSize,
type RichMenuAreaRequest,
} from "./rich-menu.js";
// Template messages (Button, Confirm, Carousel)
export {
createConfirmTemplate,
createButtonTemplate,
createTemplateCarousel,
createCarouselColumn,
createImageCarousel,
createImageCarouselColumn,
createYesNoConfirm,
createButtonMenu,
createLinkMenu,
createProductCarousel,
messageAction as templateMessageAction,
uriAction as templateUriAction,
postbackAction as templatePostbackAction,
datetimePickerAction as templateDatetimePickerAction,
type TemplateMessage,
type ConfirmTemplate,
type ButtonsTemplate,
type CarouselTemplate,
type CarouselColumn,
} from "./template-messages.js";
export type {
LineConfig,
LineAccountConfig,
LineGroupConfig,
ResolvedLineAccount,
LineTokenSource,
LineMessageType,
LineWebhookContext,
LineSendResult,
LineProbeResult,
} from "./types.js";

View File

@@ -0,0 +1,449 @@
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 a multi-column table", () => {
const text = `| Col A | Col B | Col C |
|-------|-------|-------|
| 1 | 2 | 3 |
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Col A", "Col B", "Col C"]);
expect(tables[0].rows).toHaveLength(2);
});
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 a code block with language", () => {
const text = `Here is some code:
\`\`\`javascript
const x = 1;
console.log(x);
\`\`\`
And more text.`;
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBe("javascript");
expect(codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
expect(textWithoutCode).toContain("Here is some code:");
expect(textWithoutCode).toContain("And more text.");
expect(textWithoutCode).not.toContain("```");
});
it("extracts a code block without language", () => {
const text = `\`\`\`
plain code
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBeUndefined();
expect(codeBlocks[0].code).toBe("plain code");
});
it("extracts multiple code blocks", () => {
const text = `\`\`\`python
print("hello")
\`\`\`
Some text
\`\`\`bash
echo "world"
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(2);
expect(codeBlocks[0].language).toBe("python");
expect(codeBlocks[1].language).toBe("bash");
});
it("returns empty when no code blocks present", () => {
const text = "No code here, just text.";
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(0);
expect(textWithoutCode).toBe(text);
});
});
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.");
});
it("handles text without links", () => {
const text = "No links here.";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(0);
expect(textWithLinks).toBe(text);
});
});
describe("stripMarkdown", () => {
it("strips bold markers", () => {
expect(stripMarkdown("This is **bold** text")).toBe("This is bold text");
expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text");
});
it("strips italic markers", () => {
expect(stripMarkdown("This is *italic* text")).toBe("This is italic text");
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
});
it("strips strikethrough markers", () => {
expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text");
});
it("strips headers", () => {
expect(stripMarkdown("# Heading 1")).toBe("Heading 1");
expect(stripMarkdown("## Heading 2")).toBe("Heading 2");
expect(stripMarkdown("### Heading 3")).toBe("Heading 3");
});
it("strips blockquotes", () => {
expect(stripMarkdown("> This is a quote")).toBe("This is a quote");
expect(stripMarkdown(">This is also a quote")).toBe("This is also a quote");
});
it("removes horizontal rules", () => {
expect(stripMarkdown("Above\n---\nBelow")).toBe("Above\n\nBelow");
expect(stripMarkdown("Above\n***\nBelow")).toBe("Above\n\nBelow");
});
it("strips inline code markers", () => {
expect(stripMarkdown("Use `const` keyword")).toBe("Use const keyword");
});
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("creates a receipt-style card for 2-column tables", () => {
const table = {
headers: ["Item", "Price"],
rows: [
["Apple", "$1"],
["Banana", "$2"],
],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("creates a multi-column layout for 3+ column tables", () => {
const table = {
headers: ["A", "B", "C"],
rows: [["1", "2", "3"]],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
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);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
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 tables", () => {
const text = `Here's the data:
| Key | Value |
|-----|-------|
| a | 1 |
Done.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.flexMessages[0].type).toBe("flex");
expect(result.text).toContain("Here's the data:");
expect(result.text).toContain("Done.");
expect(result.text).not.toContain("|");
});
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("processes text with markdown formatting", () => {
const text = "This is **bold** and *italic* text.";
const result = processLineMessage(text);
expect(result.text).toBe("This is bold and italic text.");
expect(result.flexMessages).toHaveLength(0);
});
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 tables", () => {
const text = `| A | B |
|---|---|
| 1 | 2 |`;
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects code blocks", () => {
const text = "```js\ncode\n```";
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects bold", () => {
expect(hasMarkdownToConvert("**bold**")).toBe(true);
});
it("detects strikethrough", () => {
expect(hasMarkdownToConvert("~~deleted~~")).toBe(true);
});
it("detects headers", () => {
expect(hasMarkdownToConvert("# Title")).toBe(true);
});
it("detects blockquotes", () => {
expect(hasMarkdownToConvert("> quote")).toBe(true);
});
it("returns false for plain text", () => {
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
});
});

View File

@@ -0,0 +1,433 @@
import type { messagingApi } from "@line/bot-sdk";
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
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",
},
};
}
/**
* Strip markdown formatting from text (for plain text output)
* Handles: bold, italic, strikethrough, headers, blockquotes, horizontal rules
*/
export function stripMarkdown(text: string): string {
let result = text;
// Remove bold: **text** or __text__
result = result.replace(/\*\*(.+?)\*\*/g, "$1");
result = result.replace(/__(.+?)__/g, "$1");
// Remove italic: *text* or _text_ (but not already processed)
result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
result = result.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, "$1");
// Remove strikethrough: ~~text~~
result = result.replace(/~~(.+?)~~/g, "$1");
// Remove headers: # Title, ## Title, etc.
result = result.replace(/^#{1,6}\s+(.+)$/gm, "$1");
// Remove blockquotes: > text
result = result.replace(/^>\s?(.*)$/gm, "$1");
// Remove horizontal rules: ---, ***, ___
result = result.replace(/^[-*_]{3,}$/gm, "");
// Remove inline code: `code`
result = result.replace(/`([^`]+)`/g, "$1");
// Clean up extra whitespace
result = result.replace(/\n{3,}/g, "\n\n");
result = result.trim();
return result;
}
/**
* 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;
}

376
src/line/monitor.ts Normal file
View File

@@ -0,0 +1,376 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { IncomingMessage, ServerResponse } from "node:http";
import crypto from "node:crypto";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLineBot } from "./bot.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import {
replyMessageLine,
showLoadingAnimation,
getUserDisplayName,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushTextMessageWithQuickReplies,
pushMessageLine,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
} from "./send.js";
import { buildTemplateMessageFromPayload } from "./template-messages.js";
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { processLineMessage } from "./markdown-to-line.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
export interface MonitorLineProviderOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
config: ClawdbotConfig;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
webhookUrl?: string;
webhookPath?: string;
}
export interface LineProviderMonitor {
account: ResolvedLineAccount;
handleWebhook: (body: WebhookRequestBody) => Promise<void>;
stop: () => void;
}
// Track runtime state in memory (simplified version)
const runtimeState = new Map<
string,
{
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
}
>();
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}`);
}
function validateLineSignature(body: string, signature: string, channelSecret: string): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
return hash === signature;
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
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 ?? "default";
// Record starting state
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: true,
lastStartAt: Date.now(),
},
});
// Create the bot
const bot = createLineBot({
channelAccessToken,
channelSecret,
accountId,
runtime,
config,
onMessage: async (ctx) => {
if (!ctx) return;
const { ctxPayload, replyToken, route } = ctx;
// Record inbound activity
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastInboundAt: Date.now(),
},
});
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
// Fetch display name for logging (non-blocking)
const displayNamePromise = ctx.userId
? getUserDisplayName(ctx.userId, { accountId: ctx.accountId })
: Promise.resolve(ctxPayload.From);
// Show loading animation while processing (non-blocking, best-effort)
const stopLoading = shouldShowLoading
? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId })
: null;
const displayName = await displayNamePromise;
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
// Dispatch to auto-reply system for AI response
try {
const textLimit = 5000; // LINE max message length
let replyTokenUsed = false; // Track if we've used the one-time reply token
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId).responsePrefix,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
// Show loading animation before each delivery (non-blocking)
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: {},
});
if (!queuedFinal) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
}
} catch (err) {
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
// Send error message to user
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?.();
}
},
});
// Register HTTP webhook handler
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
const unregisterHttp = registerPluginHttpRoute({
path: normalizedPath,
pluginId: "line",
accountId: resolvedAccountId,
log: (msg) => logVerbose(msg),
handler: async (req: IncomingMessage, res: ServerResponse) => {
// Handle GET requests for webhook verification
if (req.method === "GET") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("OK");
return;
}
// Only accept POST requests
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "GET, POST");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
try {
const rawBody = await readRequestBody(req);
const signature = req.headers["x-line-signature"];
// Validate signature
if (!signature || typeof signature !== "string") {
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;
}
if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid signature" }));
return;
}
// Parse and process the webhook body
const body = JSON.parse(rawBody) as WebhookRequestBody;
// Respond immediately with 200 to avoid LINE timeout
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
// Process events asynchronously
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await bot.handleWebhook(body).catch((err) => {
runtime.error?.(danger(`line webhook handler failed: ${String(err)}`));
});
}
} catch (err) {
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" }));
}
}
},
});
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
// Handle abort signal
const stopHandler = () => {
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: false,
lastStopAt: Date.now(),
},
});
};
abortSignal?.addEventListener("abort", stopHandler);
return {
account: bot.account,
handleWebhook: bot.handleWebhook,
stop: () => {
stopHandler();
abortSignal?.removeEventListener("abort", stopHandler);
},
};
}

51
src/line/probe.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
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 },
}));
let probeLineBot: typeof import("./probe.js").probeLineBot;
afterEach(() => {
vi.useRealTimers();
getBotInfoMock.mockReset();
});
describe("probeLineBot", () => {
beforeAll(async () => {
({ probeLineBot } = await import("./probe.js"));
});
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: "Clawdbot",
userId: "U123",
basicId: "@clawdbot",
pictureUrl: "https://example.com/bot.png",
});
const result = await probeLineBot("token", 50);
expect(result.ok).toBe(true);
expect(result.bot?.userId).toBe("U123");
});
});

43
src/line/probe.ts Normal file
View File

@@ -0,0 +1,43 @@
import { messagingApi } from "@line/bot-sdk";
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 = err instanceof Error ? err.message : String(err);
return { ok: false, error: message };
}
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
if (!timeoutMs || timeoutMs <= 0) return promise;
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import { sendLineReplyChunks } from "./reply-chunks.js";
describe("sendLineReplyChunks", () => {
it("uses reply token for all chunks when possible", async () => {
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,
}));
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 = 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,
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 = 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,
}));
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();
});
});

101
src/line/reply-chunks.ts Normal file
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 };
}

247
src/line/rich-menu.test.ts Normal file
View File

@@ -0,0 +1,247 @@
import { describe, expect, it } from "vitest";
import {
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
} from "./rich-menu.js";
describe("messageAction", () => {
it("creates a message action", () => {
const action = messageAction("Help", "/help");
expect(action.type).toBe("message");
expect(action.label).toBe("Help");
expect((action as { text: string }).text).toBe("/help");
});
it("uses label as text when text not provided", () => {
const action = messageAction("Click");
expect((action as { text: string }).text).toBe("Click");
});
it("truncates label to 20 characters", () => {
const action = messageAction("This is a very long label text");
expect(action.label.length).toBe(20);
expect(action.label).toBe("This is a very long ");
});
});
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");
});
it("truncates label to 20 characters", () => {
const action = uriAction("Click here to visit our website", "https://example.com");
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("truncates data to 300 characters", () => {
const longData = "x".repeat(400);
const action = postbackAction("Test", longData);
expect((action as { data: string }).data.length).toBe(300);
});
it("truncates displayText to 300 characters", () => {
const longText = "y".repeat(400);
const action = postbackAction("Test", "data", longText);
expect((action as { displayText: string }).displayText?.length).toBe(300);
});
it("omits displayText when not provided", () => {
const action = postbackAction("Test", "data");
expect((action as { displayText?: string }).displayText).toBeUndefined();
});
});
describe("datetimePickerAction", () => {
it("creates a date picker action", () => {
const action = datetimePickerAction("Pick date", "date_picked", "date");
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe("Pick date");
expect((action as { mode: string }).mode).toBe("date");
expect((action as { data: string }).data).toBe("date_picked");
});
it("creates a time picker action", () => {
const action = datetimePickerAction("Pick time", "time_picked", "time");
expect((action as { mode: string }).mode).toBe("time");
});
it("creates a datetime picker action", () => {
const action = datetimePickerAction("Pick datetime", "datetime_picked", "datetime");
expect((action as { mode: string }).mode).toBe("datetime");
});
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", () => {
it("creates a 2x3 grid layout for tall menu", () => {
const actions = [
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>,
];
const areas = createGridLayout(1686, actions);
expect(areas.length).toBe(6);
// Check first row positions
expect(areas[0].bounds.x).toBe(0);
expect(areas[0].bounds.y).toBe(0);
expect(areas[1].bounds.x).toBe(833);
expect(areas[1].bounds.y).toBe(0);
expect(areas[2].bounds.x).toBe(1666);
expect(areas[2].bounds.y).toBe(0);
// Check second row positions
expect(areas[3].bounds.y).toBe(843);
expect(areas[4].bounds.y).toBe(843);
expect(areas[5].bounds.y).toBe(843);
});
it("creates a 2x3 grid layout for short menu", () => {
const actions = [
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>,
];
const areas = createGridLayout(843, actions);
expect(areas.length).toBe(6);
// Row height should be half of 843
expect(areas[0].bounds.height).toBe(421);
expect(areas[3].bounds.y).toBe(421);
});
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("has message actions for all areas", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.action.type).toBe("message");
}
});
it("has expected default commands", () => {
const config = createDefaultMenuConfig();
const commands = config.areas.map((a) => (a.action as { text: string }).text);
expect(commands).toContain("/help");
expect(commands).toContain("/status");
expect(commands).toContain("/settings");
});
});

463
src/line/rich-menu.ts Normal file
View File

@@ -0,0 +1,463 @@
import { messagingApi } from "@line/bot-sdk";
import { readFile } from "node:fs/promises";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveLineAccount } from "./accounts.js";
type RichMenuRequest = messagingApi.RichMenuRequest;
type RichMenuResponse = messagingApi.RichMenuResponse;
type RichMenuArea = messagingApi.RichMenuArea;
type Action = messagingApi.Action;
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 resolveToken(
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();
}
function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
return new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
}
function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
return new messagingApi.MessagingApiBlobClient({
channelAccessToken: token,
});
}
/**
* Create a new rich menu
* @returns The rich menu ID
*/
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), // LINE limit
chatBarText: menu.chatBarText.slice(0, 14), // LINE limit
areas: menu.areas as RichMenuArea[],
};
const response = await client.createRichMenu(richMenuRequest);
if (opts.verbose) {
logVerbose(`line: created rich menu ${response.richMenuId}`);
}
return response.richMenuId;
}
/**
* Upload an image for a rich menu
* Image requirements:
* - Format: JPEG or PNG
* - Size: Must match the rich menu size (2500x1686 or 2500x843)
* - Max file size: 1MB
*/
export async function uploadRichMenuImage(
richMenuId: string,
imagePath: string,
opts: RichMenuOpts = {},
): Promise<void> {
const blobClient = getBlobClient(opts);
const imageData = await readFile(imagePath);
const contentType = imagePath.toLowerCase().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}`);
}
}
/**
* Set the default rich menu for all users
*/
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}`);
}
}
/**
* Cancel the default rich menu
*/
export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise<void> {
const client = getClient(opts);
await client.cancelDefaultRichMenu();
if (opts.verbose) {
logVerbose(`line: cancelled default rich menu`);
}
}
/**
* Get the default rich menu ID
*/
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;
}
}
/**
* Link a rich menu to a specific user
*/
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}`);
}
}
/**
* Link a rich menu to multiple users (up to 500)
*/
export async function linkRichMenuToUsers(
userIds: string[],
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
// LINE allows max 500 users per request
const batches = [];
for (let i = 0; i < userIds.length; i += 500) {
batches.push(userIds.slice(i, i + 500));
}
for (const batch of batches) {
await client.linkRichMenuIdToUsers({
richMenuId,
userIds: batch,
});
}
if (opts.verbose) {
logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`);
}
}
/**
* Unlink a rich menu from a specific user
*/
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}`);
}
}
/**
* Unlink rich menus from multiple users (up to 500)
*/
export async function unlinkRichMenuFromUsers(
userIds: string[],
opts: RichMenuOpts = {},
): Promise<void> {
const client = getClient(opts);
// LINE allows max 500 users per request
const batches = [];
for (let i = 0; i < userIds.length; i += 500) {
batches.push(userIds.slice(i, i + 500));
}
for (const batch of batches) {
await client.unlinkRichMenuIdFromUsers({
userIds: batch,
});
}
if (opts.verbose) {
logVerbose(`line: unlinked rich menu from ${userIds.length} users`);
}
}
/**
* Get the rich menu linked to a specific user
*/
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;
}
}
/**
* Get a list of all rich menus
*/
export async function getRichMenuList(opts: RichMenuOpts = {}): Promise<RichMenuResponse[]> {
const client = getClient(opts);
const response = await client.getRichMenuList();
return response.richmenus ?? [];
}
/**
* Get a specific rich menu by ID
*/
export async function getRichMenu(
richMenuId: string,
opts: RichMenuOpts = {},
): Promise<RichMenuResponse | null> {
const client = getClient(opts);
try {
return await client.getRichMenu(richMenuId);
} catch {
return null;
}
}
/**
* Delete a rich menu
*/
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}`);
}
}
/**
* Create a rich menu alias
*/
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}`);
}
}
/**
* Delete a rich menu alias
*/
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}`);
}
}
// ============================================================================
// Default Menu Template Helpers
// ============================================================================
/**
* Create a standard 2x3 grid layout for rich menu areas
* Returns 6 areas in a 2-row, 3-column layout
*/
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 [
// Top row
{ 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] },
// Bottom row
{ 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],
},
];
}
/**
* 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,
};
}
/**
* Create a default help/status/settings menu
* This is a convenience function to quickly set up a standard menu
*/
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"),
]),
};
}
// Re-export types
export type { RichMenuRequest, RichMenuResponse, RichMenuArea, Action };

95
src/line/send.test.ts Normal file
View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
createFlexMessage,
createQuickReplyItems,
createTextMessageWithQuickReplies,
} from "./send.js";
describe("createFlexMessage", () => {
it("creates a flex message with alt text and contents", () => {
const contents = {
type: "bubble" as const,
body: {
type: "box" as const,
layout: "vertical" as const,
contents: [],
},
};
const message = createFlexMessage("Alt text for flex", contents);
expect(message.type).toBe("flex");
expect(message.altText).toBe("Alt text for flex");
expect(message.contents).toBe(contents);
});
});
describe("createQuickReplyItems", () => {
it("creates quick reply items from labels", () => {
const quickReply = createQuickReplyItems(["Option 1", "Option 2", "Option 3"]);
expect(quickReply.items).toHaveLength(3);
expect(quickReply.items[0].type).toBe("action");
expect((quickReply.items[0].action as { label: string }).label).toBe("Option 1");
expect((quickReply.items[0].action as { text: string }).text).toBe("Option 1");
});
it("limits items to 13 (LINE maximum)", () => {
const labels = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`);
const quickReply = createQuickReplyItems(labels);
expect(quickReply.items).toHaveLength(13);
});
it("truncates labels to 20 characters", () => {
const quickReply = createQuickReplyItems([
"This is a very long option label that exceeds the limit",
]);
expect((quickReply.items[0].action as { label: string }).label).toBe("This is a very long ");
// Text is not truncated
expect((quickReply.items[0].action as { text: string }).text).toBe(
"This is a very long option label that exceeds the limit",
);
});
it("creates message actions for each item", () => {
const quickReply = createQuickReplyItems(["A", "B"]);
expect((quickReply.items[0].action as { type: string }).type).toBe("message");
expect((quickReply.items[1].action as { type: string }).type).toBe("message");
});
});
describe("createTextMessageWithQuickReplies", () => {
it("creates a text message with quick replies attached", () => {
const message = createTextMessageWithQuickReplies("Choose an option:", ["Yes", "No"]);
expect(message.type).toBe("text");
expect(message.text).toBe("Choose an option:");
expect(message.quickReply).toBeDefined();
expect(message.quickReply.items).toHaveLength(2);
});
it("preserves text content", () => {
const longText =
"This is a longer message that asks the user to select from multiple options below.";
const message = createTextMessageWithQuickReplies(longText, ["A", "B", "C"]);
expect(message.text).toBe(longText);
});
it("handles empty quick replies array", () => {
const message = createTextMessageWithQuickReplies("No options", []);
expect(message.quickReply.items).toHaveLength(0);
});
it("quick replies use label as both label and text", () => {
const message = createTextMessageWithQuickReplies("Pick one:", ["Apple", "Banana"]);
const firstAction = message.quickReply.items[0].action as { label: string; text: string };
expect(firstAction.label).toBe("Apple");
expect(firstAction.text).toBe("Apple");
});
});

629
src/line/send.ts Normal file
View File

@@ -0,0 +1,629 @@
import { messagingApi } from "@line/bot-sdk";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveLineAccount } from "./accounts.js";
import type { LineSendResult } from "./types.js";
// Use the messaging API types directly
type Message = messagingApi.Message;
type TextMessage = messagingApi.TextMessage;
type ImageMessage = messagingApi.ImageMessage;
type LocationMessage = messagingApi.LocationMessage;
type FlexMessage = messagingApi.FlexMessage;
type FlexContainer = messagingApi.FlexContainer;
type TemplateMessage = messagingApi.TemplateMessage;
type QuickReply = messagingApi.QuickReply;
type QuickReplyItem = messagingApi.QuickReplyItem;
// Cache for user profiles
const userProfileCache = new Map<
string,
{ displayName: string; pictureUrl?: string; fetchedAt: number }
>();
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface LineSendOpts {
channelAccessToken?: string;
accountId?: string;
verbose?: boolean;
mediaUrl?: string;
replyToken?: string;
}
function resolveToken(
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();
}
function normalizeTarget(to: string): string {
const trimmed = to.trim();
if (!trimmed) throw new Error("Recipient is required for LINE sends");
// Strip internal prefixes
let 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 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 createLocationMessage(location: {
title: string;
address: string;
latitude: number;
longitude: number;
}): LocationMessage {
return {
type: "location",
title: location.title.slice(0, 100), // LINE limit
address: location.address.slice(0, 100), // LINE limit
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}`);
}
}
export async function sendMessageLine(
to: string,
text: string,
opts: LineSendOpts = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const messages: Message[] = [];
// Add media if provided
if (opts.mediaUrl?.trim()) {
messages.push(createImageMessage(opts.mediaUrl.trim()));
}
// Add text message
if (text?.trim()) {
messages.push(createTextMessage(text.trim()));
}
if (messages.length === 0) {
throw new Error("Message must be non-empty for LINE sends");
}
// Use reply if we have a reply token, otherwise push
if (opts.replyToken) {
await client.replyMessage({
replyToken: opts.replyToken,
messages,
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: replied to ${chatId}`);
}
return {
messageId: "reply",
chatId,
};
}
// Push message (for proactive messaging)
await client.pushMessage({
to: chatId,
messages,
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
export async function pushMessageLine(
to: string,
text: string,
opts: LineSendOpts = {},
): Promise<LineSendResult> {
// Force push (no reply token)
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
}
export async function replyMessageLine(
replyToken: string,
messages: Message[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<void> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
await client.replyMessage({
replyToken,
messages,
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: replied with ${messages.length} messages`);
}
}
export async function pushMessagesLine(
to: string,
messages: Message[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
if (messages.length === 0) {
throw new Error("Message must be non-empty for LINE sends");
}
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
await client
.pushMessage({
to: chatId,
messages,
})
.catch((err) => {
logLineHttpError(err, "push message");
throw err;
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed ${messages.length} messages to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
export function createFlexMessage(
altText: string,
contents: messagingApi.FlexContainer,
): messagingApi.FlexMessage {
return {
type: "flex",
altText,
contents,
};
}
/**
* Push an image message to a user/group
*/
export async function pushImageMessage(
to: string,
originalContentUrl: string,
previewImageUrl?: string,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const imageMessage = createImageMessage(originalContentUrl, previewImageUrl);
await client.pushMessage({
to: chatId,
messages: [imageMessage],
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed image to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
/**
* Push a location message to a user/group
*/
export async function pushLocationMessage(
to: string,
location: {
title: string;
address: string;
latitude: number;
longitude: number;
},
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const locationMessage = createLocationMessage(location);
await client.pushMessage({
to: chatId,
messages: [locationMessage],
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed location to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
/**
* Push a Flex Message to a user/group
*/
export async function pushFlexMessage(
to: string,
altText: string,
contents: FlexContainer,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const flexMessage: FlexMessage = {
type: "flex",
altText: altText.slice(0, 400), // LINE limit
contents,
};
await client
.pushMessage({
to: chatId,
messages: [flexMessage],
})
.catch((err) => {
logLineHttpError(err, "push flex message");
throw err;
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed flex message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
/**
* Push a Template Message to a user/group
*/
export async function pushTemplateMessage(
to: string,
template: TemplateMessage,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
await client.pushMessage({
to: chatId,
messages: [template],
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed template message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
/**
* Push a text message with quick reply buttons
*/
export async function pushTextMessageWithQuickReplies(
to: string,
text: string,
quickReplyLabels: string[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {},
): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
await client.pushMessage({
to: chatId,
messages: [message],
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed message with quick replies to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
}
/**
* Create quick reply buttons to attach to a message
*/
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), // LINE limit: 20 chars
text: label,
},
}));
return { items };
}
/**
* Create a text message with quick reply buttons
*/
export function createTextMessageWithQuickReplies(
text: string,
quickReplyLabels: string[],
): TextMessage & { quickReply: QuickReply } {
return {
type: "text",
text,
quickReply: createQuickReplyItems(quickReplyLabels),
};
}
/**
* Show loading animation to user (lasts up to 20 seconds or until next message)
*/
export async function showLoadingAnimation(
chatId: string,
opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {},
): Promise<void> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
try {
await client.showLoadingAnimation({
chatId: normalizeTarget(chatId),
loadingSeconds: opts.loadingSeconds ?? 20,
});
logVerbose(`line: showing loading animation to ${chatId}`);
} catch (err) {
// Loading animation may fail for groups or unsupported clients - ignore
logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`);
}
}
/**
* Fetch user profile (display name, picture URL)
*/
export async function getUserProfile(
userId: string,
opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {},
): Promise<{ displayName: string; pictureUrl?: string } | null> {
const useCache = opts.useCache ?? true;
// Check cache first
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 cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
try {
const profile = await client.getProfile(userId);
const result = {
displayName: profile.displayName,
pictureUrl: profile.pictureUrl,
};
// Cache the result
userProfileCache.set(userId, {
...result,
fetchedAt: Date.now(),
});
return result;
} catch (err) {
logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`);
return null;
}
}
/**
* Get user's display name (with fallback to userId)
*/
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,391 @@
import { describe, expect, it } from "vitest";
import {
createConfirmTemplate,
createButtonTemplate,
createTemplateCarousel,
createCarouselColumn,
createImageCarousel,
createImageCarouselColumn,
createYesNoConfirm,
createButtonMenu,
createLinkMenu,
createProductCarousel,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
} from "./template-messages.js";
describe("messageAction", () => {
it("creates a message action", () => {
const action = messageAction("Click me", "clicked");
expect(action.type).toBe("message");
expect(action.label).toBe("Click me");
expect((action as { text: string }).text).toBe("clicked");
});
it("uses label as text when text not provided", () => {
const action = messageAction("Click");
expect((action as { text: string }).text).toBe("Click");
});
it("truncates label to 20 characters", () => {
const action = messageAction("This is a very long label that exceeds the limit");
expect(action.label).toBe("This is a very long ");
});
});
describe("uriAction", () => {
it("creates a URI action", () => {
const action = uriAction("Visit", "https://example.com");
expect(action.type).toBe("uri");
expect(action.label).toBe("Visit");
expect((action as { uri: string }).uri).toBe("https://example.com");
});
});
describe("postbackAction", () => {
it("creates a postback action", () => {
const action = postbackAction("Select", "action=select&id=1");
expect(action.type).toBe("postback");
expect(action.label).toBe("Select");
expect((action as { data: string }).data).toBe("action=select&id=1");
});
it("includes displayText when provided", () => {
const action = postbackAction("Select", "data", "Selected!");
expect((action as { displayText: string }).displayText).toBe("Selected!");
});
it("truncates data to 300 characters", () => {
const longData = "x".repeat(400);
const action = postbackAction("Test", longData);
expect((action as { data: string }).data.length).toBe(300);
});
});
describe("datetimePickerAction", () => {
it("creates a datetime picker action", () => {
const action = datetimePickerAction("Pick date", "date_selected", "date");
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe("Pick date");
expect((action as { mode: string }).mode).toBe("date");
});
it("includes min/max/initial when provided", () => {
const action = datetimePickerAction("Pick", "data", "datetime", {
initial: "2024-01-01T12:00",
min: "2024-01-01T00:00",
max: "2024-12-31T23:59",
});
expect((action as { initial: string }).initial).toBe("2024-01-01T12:00");
expect((action as { min: string }).min).toBe("2024-01-01T00:00");
expect((action as { max: string }).max).toBe("2024-12-31T23:59");
});
});
describe("createConfirmTemplate", () => {
it("creates a confirm template", () => {
const confirm = messageAction("Yes");
const cancel = messageAction("No");
const template = createConfirmTemplate("Are you sure?", confirm, cancel);
expect(template.type).toBe("template");
expect(template.template.type).toBe("confirm");
expect((template.template as { text: string }).text).toBe("Are you sure?");
});
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);
});
it("uses custom altText when provided", () => {
const template = createConfirmTemplate(
"Question?",
messageAction("Yes"),
messageAction("No"),
"Custom alt",
);
expect(template.altText).toBe("Custom alt");
});
});
describe("createButtonTemplate", () => {
it("creates a button template", () => {
const actions = [messageAction("Button 1"), messageAction("Button 2")];
const template = createButtonTemplate("Title", "Description", actions);
expect(template.type).toBe("template");
expect(template.template.type).toBe("buttons");
expect((template.template as { title: string }).title).toBe("Title");
expect((template.template as { text: string }).text).toBe("Description");
});
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("includes thumbnail when provided", () => {
const template = createButtonTemplate("Title", "Text", [messageAction("OK")], {
thumbnailImageUrl: "https://example.com/thumb.jpg",
});
expect((template.template as { thumbnailImageUrl: string }).thumbnailImageUrl).toBe(
"https://example.com/thumb.jpg",
);
});
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("createTemplateCarousel", () => {
it("creates a carousel template", () => {
const columns = [
createCarouselColumn({ text: "Column 1", actions: [messageAction("Select")] }),
createCarouselColumn({ text: "Column 2", actions: [messageAction("Select")] }),
];
const template = createTemplateCarousel(columns);
expect(template.type).toBe("template");
expect(template.template.type).toBe("carousel");
expect((template.template as { columns: unknown[] }).columns.length).toBe(2);
});
it("limits columns to 10", () => {
const columns = Array.from({ length: 15 }, () =>
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
);
const template = createTemplateCarousel(columns);
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
});
});
describe("createCarouselColumn", () => {
it("creates a carousel column", () => {
const column = createCarouselColumn({
title: "Item",
text: "Description",
actions: [messageAction("View")],
thumbnailImageUrl: "https://example.com/img.jpg",
});
expect(column.title).toBe("Item");
expect(column.text).toBe("Description");
expect(column.thumbnailImageUrl).toBe("https://example.com/img.jpg");
expect(column.actions.length).toBe(1);
});
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("createImageCarousel", () => {
it("creates an image carousel", () => {
const columns = [
createImageCarouselColumn("https://example.com/1.jpg", messageAction("View 1")),
createImageCarouselColumn("https://example.com/2.jpg", messageAction("View 2")),
];
const template = createImageCarousel(columns);
expect(template.type).toBe("template");
expect(template.template.type).toBe("image_carousel");
});
it("limits columns to 10", () => {
const columns = Array.from({ length: 15 }, (_, i) =>
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
);
const template = createImageCarousel(columns);
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
});
});
describe("createImageCarouselColumn", () => {
it("creates an image carousel column", () => {
const action = uriAction("Visit", "https://example.com");
const column = createImageCarouselColumn("https://example.com/img.jpg", action);
expect(column.imageUrl).toBe("https://example.com/img.jpg");
expect(column.action).toBe(action);
});
});
describe("createYesNoConfirm", () => {
it("creates a yes/no confirmation with defaults", () => {
const template = createYesNoConfirm("Continue?");
expect(template.type).toBe("template");
expect(template.template.type).toBe("confirm");
const actions = (template.template as { actions: Array<{ label: string }> }).actions;
expect(actions[0].label).toBe("Yes");
expect(actions[1].label).toBe("No");
});
it("allows custom button text", () => {
const template = createYesNoConfirm("Delete?", {
yesText: "Delete",
noText: "Cancel",
});
const actions = (template.template as { actions: Array<{ label: string }> }).actions;
expect(actions[0].label).toBe("Delete");
expect(actions[1].label).toBe("Cancel");
});
it("uses postback actions when data provided", () => {
const template = createYesNoConfirm("Confirm?", {
yesData: "action=confirm",
noData: "action=cancel",
});
const actions = (template.template as { actions: Array<{ type: string }> }).actions;
expect(actions[0].type).toBe("postback");
expect(actions[1].type).toBe("postback");
});
});
describe("createButtonMenu", () => {
it("creates a button menu with text buttons", () => {
const template = createButtonMenu("Menu", "Choose an option", [
{ label: "Option 1" },
{ label: "Option 2", text: "selected option 2" },
]);
expect(template.type).toBe("template");
expect(template.template.type).toBe("buttons");
const actions = (template.template as { actions: Array<{ type: string }> }).actions;
expect(actions.length).toBe(2);
expect(actions[0].type).toBe("message");
});
});
describe("createLinkMenu", () => {
it("creates a button menu with URL links", () => {
const template = createLinkMenu("Links", "Visit our sites", [
{ label: "Site 1", url: "https://site1.com" },
{ label: "Site 2", url: "https://site2.com" },
]);
expect(template.type).toBe("template");
const actions = (template.template as { actions: Array<{ type: string }> }).actions;
expect(actions[0].type).toBe("uri");
expect(actions[1].type).toBe("uri");
});
});
describe("createProductCarousel", () => {
it("creates a product carousel", () => {
const template = createProductCarousel([
{ title: "Product 1", description: "Desc 1", price: "$10" },
{ title: "Product 2", description: "Desc 2", imageUrl: "https://example.com/p2.jpg" },
]);
expect(template.type).toBe("template");
expect(template.template.type).toBe("carousel");
const columns = (template.template as { columns: unknown[] }).columns;
expect(columns.length).toBe(2);
});
it("uses URI action when actionUrl provided", () => {
const template = createProductCarousel([
{
title: "Product",
description: "Desc",
actionLabel: "Buy",
actionUrl: "https://shop.com/buy",
},
]);
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
.columns;
expect(columns[0].actions[0].type).toBe("uri");
});
it("uses postback action when actionData provided", () => {
const template = createProductCarousel([
{
title: "Product",
description: "Desc",
actionLabel: "Select",
actionData: "product_id=123",
},
]);
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
.columns;
expect(columns[0].actions[0].type).toBe("postback");
});
it("limits to 10 products", () => {
const products = Array.from({ length: 15 }, (_, i) => ({
title: `Product ${i}`,
description: `Desc ${i}`,
}));
const template = createProductCarousel(products);
const columns = (template.template as { columns: unknown[] }).columns;
expect(columns.length).toBe(10);
});
});

View File

@@ -0,0 +1,401 @@
import type { messagingApi } from "@line/bot-sdk";
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 Action = messagingApi.Action;
/**
* 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,
};
}
// ============================================================================
// Action Helpers (same as rich-menu but re-exported for convenience)
// ============================================================================
/**
* 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,
};
}
// ============================================================================
// Convenience Builders
// ============================================================================
/**
* 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[] = [];
// Add main 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 });
}
// ============================================================================
// ReplyPayload Conversion
// ============================================================================
import type { LineTemplateMessagePayload } from "./types.js";
/**
* 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) => {
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);
}
// Default to message action
return messageAction(action.label, action.data ?? action.label);
});
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) => {
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);
});
return createCarouselColumn({
title: col.title,
text: col.text,
thumbnailImageUrl: col.thumbnailImageUrl,
actions: colActions,
});
});
return createTemplateCarousel(columns, { altText: payload.altText });
}
default:
return null;
}
}
// Re-export types
export type {
TemplateMessage,
ConfirmTemplate,
ButtonsTemplate,
CarouselTemplate,
CarouselColumn,
ImageCarouselTemplate,
ImageCarouselColumn,
Action,
};

150
src/line/types.ts Normal file
View File

@@ -0,0 +1,150 @@
import type {
WebhookEvent,
TextMessage,
ImageMessage,
VideoMessage,
AudioMessage,
StickerMessage,
LocationMessage,
} from "@line/bot-sdk";
export type LineTokenSource = "config" | "env" | "file" | "none";
export interface LineConfig {
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";
mediaMaxMb?: number;
webhookPath?: string;
accounts?: Record<string, LineAccountConfig>;
groups?: Record<string, LineGroupConfig>;
}
export interface LineAccountConfig {
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";
mediaMaxMb?: number;
webhookPath?: string;
groups?: Record<string, LineGroupConfig>;
}
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 =
| TextMessage
| ImageMessage
| VideoMessage
| AudioMessage
| StickerMessage
| LocationMessage;
export interface LineWebhookContext {
event: WebhookEvent;
replyToken?: string;
userId?: string;
groupId?: string;
roomId?: string;
}
export interface LineSendResult {
messageId: string;
chatId: string;
}
export interface LineProbeResult {
ok: boolean;
bot?: {
displayName?: string;
userId?: string;
basicId?: string;
pictureUrl?: string;
};
error?: 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;
};

73
src/line/webhook.test.ts Normal file
View File

@@ -0,0 +1,73 @@
import crypto from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { createLineWebhookMiddleware } from "./webhook.js";
const sign = (body: string, secret: string) =>
crypto.createHmac("SHA256", secret).update(body).digest("base64");
const createRes = () => {
const res = {
status: vi.fn(),
json: vi.fn(),
headersSent: false,
} as any;
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res;
};
describe("createLineWebhookMiddleware", () => {
it("parses JSON from raw string body", async () => {
const onEvents = vi.fn(async () => {});
const secret = "secret";
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents });
const req = {
headers: { "x-line-signature": sign(rawBody, secret) },
body: rawBody,
} as any;
const res = createRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
});
it("parses JSON from raw buffer body", async () => {
const onEvents = vi.fn(async () => {});
const secret = "secret";
const rawBody = JSON.stringify({ events: [{ type: "follow" }] });
const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents });
const req = {
headers: { "x-line-signature": sign(rawBody, secret) },
body: Buffer.from(rawBody, "utf-8"),
} as any;
const res = createRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
});
it("rejects invalid JSON payloads", async () => {
const onEvents = vi.fn(async () => {});
const secret = "secret";
const rawBody = "not json";
const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents });
const req = {
headers: { "x-line-signature": sign(rawBody, secret) },
body: rawBody,
} as any;
const res = createRes();
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
});

102
src/line/webhook.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { Request, Response, NextFunction } from "express";
import crypto from "node:crypto";
import type { WebhookRequestBody } from "@line/bot-sdk";
import { logVerbose, danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
export interface LineWebhookOptions {
channelSecret: string;
onEvents: (body: WebhookRequestBody) => Promise<void>;
runtime?: RuntimeEnv;
}
function validateSignature(body: string, signature: string, channelSecret: string): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
return hash === signature;
}
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(req: Request, rawBody: string): WebhookRequestBody | null {
if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
return req.body as WebhookRequestBody;
}
try {
return JSON.parse(rawBody) as WebhookRequestBody;
} catch {
return null;
}
}
export function createLineWebhookMiddleware(options: LineWebhookOptions) {
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 (!validateSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.status(401).json({ error: "Invalid signature" });
return;
}
const body = parseWebhookBody(req, rawBody);
if (!body) {
res.status(400).json({ error: "Invalid webhook payload" });
return;
}
// Respond immediately to avoid timeout
res.status(200).json({ status: "ok" });
// Process events asynchronously
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await onEvents(body).catch((err) => {
runtime?.error?.(danger(`line webhook handler failed: ${String(err)}`));
});
}
} 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: WebhookRequestBody) => Promise<void>;
runtime?: RuntimeEnv;
path?: string;
}
export function startLineWebhook(options: StartLineWebhookOptions) {
const path = options.path ?? "/line/webhook";
const middleware = createLineWebhookMiddleware({
channelSecret: options.channelSecret,
onEvents: options.onEvents,
runtime: options.runtime,
});
return { path, handler: middleware };
}