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:
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
|
||||
11
extensions/line/clawdbot.plugin.json
Normal file
11
extensions/line/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "line",
|
||||
"channels": [
|
||||
"line"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
20
extensions/line/index.ts
Normal file
20
extensions/line/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { linePlugin } from "./src/channel.js";
|
||||
import { registerLineCardCommand } from "./src/card-command.js";
|
||||
import { setLineRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "line",
|
||||
name: "LINE",
|
||||
description: "LINE Messaging API channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setLineRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: linePlugin });
|
||||
registerLineCardCommand(api);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
29
extensions/line/package.json
Normal file
29
extensions/line/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@clawdbot/line",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LINE channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "line",
|
||||
"label": "LINE",
|
||||
"selectionLabel": "LINE (Messaging API)",
|
||||
"docsPath": "/channels/line",
|
||||
"docsLabel": "line",
|
||||
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
"order": 75,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/line",
|
||||
"localPath": "extensions/line",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
338
extensions/line/src/card-command.ts
Normal file
338
extensions/line/src/card-command.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
createActionCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||
|
||||
Types:
|
||||
info "Title" "Body" ["Footer"]
|
||||
image "Title" "Caption" --url <image-url>
|
||||
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
|
||||
list "Title" "Item1|Desc1,Item2|Desc2"
|
||||
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
|
||||
confirm "Question?" --yes "Yes|data" --no "No|data"
|
||||
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
|
||||
|
||||
Examples:
|
||||
/card info "Welcome" "Thanks for joining!"
|
||||
/card image "Product" "Check it out" --url https://example.com/img.jpg
|
||||
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
|
||||
|
||||
function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
||||
return {
|
||||
channelData: {
|
||||
line: lineData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse action string format: "Label|data,Label2|data2"
|
||||
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||
*/
|
||||
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||
if (!actionsStr) return [];
|
||||
|
||||
const results: CardAction[] = [];
|
||||
|
||||
for (const part of actionsStr.split(",")) {
|
||||
const [label, data] = part
|
||||
.trim()
|
||||
.split("|")
|
||||
.map((s) => s.trim());
|
||||
if (!label) continue;
|
||||
|
||||
const actionData = data || label;
|
||||
|
||||
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
|
||||
results.push({
|
||||
label,
|
||||
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
|
||||
});
|
||||
} else if (actionData.includes("=")) {
|
||||
results.push({
|
||||
label,
|
||||
action: {
|
||||
type: "postback",
|
||||
label: label.slice(0, 20),
|
||||
data: actionData.slice(0, 300),
|
||||
displayText: label,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
label,
|
||||
action: { type: "message", label: label.slice(0, 20), text: actionData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
|
||||
*/
|
||||
function parseListItems(itemsStr: string): ListItem[] {
|
||||
return itemsStr
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const [title, subtitle] = part
|
||||
.trim()
|
||||
.split("|")
|
||||
.map((s) => s.trim());
|
||||
return { title: title || "", subtitle };
|
||||
})
|
||||
.filter((item) => item.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse receipt items format: "Item1:$10,Item2:$20"
|
||||
*/
|
||||
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
|
||||
return itemsStr
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const colonIndex = part.lastIndexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { name: part.trim(), value: "" };
|
||||
}
|
||||
return {
|
||||
name: part.slice(0, colonIndex).trim(),
|
||||
value: part.slice(colonIndex + 1).trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quoted arguments from command string
|
||||
* Supports: /card type "arg1" "arg2" "arg3" --flag value
|
||||
*/
|
||||
function parseCardArgs(argsStr: string): {
|
||||
type: string;
|
||||
args: string[];
|
||||
flags: Record<string, string>;
|
||||
} {
|
||||
const result: { type: string; args: string[]; flags: Record<string, string> } = {
|
||||
type: "",
|
||||
args: [],
|
||||
flags: {},
|
||||
};
|
||||
|
||||
// Extract type (first word)
|
||||
const typeMatch = argsStr.match(/^(\w+)/);
|
||||
if (typeMatch) {
|
||||
result.type = typeMatch[1].toLowerCase();
|
||||
argsStr = argsStr.slice(typeMatch[0].length).trim();
|
||||
}
|
||||
|
||||
// Extract quoted arguments
|
||||
const quotedRegex = /"([^"]*?)"/g;
|
||||
let match;
|
||||
while ((match = quotedRegex.exec(argsStr)) !== null) {
|
||||
result.args.push(match[1]);
|
||||
}
|
||||
|
||||
// Extract flags (--key value or --key "value")
|
||||
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
|
||||
while ((match = flagRegex.exec(argsStr)) !== null) {
|
||||
result.flags[match[1]] = match[2] ?? match[3];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerLineCardCommand(api: ClawdbotPluginApi): void {
|
||||
api.registerCommand({
|
||||
name: "card",
|
||||
description: "Send a rich card message (LINE).",
|
||||
acceptsArgs: true,
|
||||
requireAuth: false,
|
||||
handler: async (ctx) => {
|
||||
const argsStr = ctx.args?.trim() ?? "";
|
||||
if (!argsStr) return { text: CARD_USAGE };
|
||||
|
||||
const parsed = parseCardArgs(argsStr);
|
||||
const { type, args, flags } = parsed;
|
||||
|
||||
if (!type) return { text: CARD_USAGE };
|
||||
|
||||
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||
if (ctx.channel !== "line") {
|
||||
const fallbackText = args.join(" - ");
|
||||
return { text: `[${type} card] ${fallbackText}`.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "info": {
|
||||
const [title = "Info", body = "", footer] = args;
|
||||
const bubble = createInfoCard(title, body, footer);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${body}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const [title = "Image", caption = ""] = args;
|
||||
const imageUrl = flags.url || flags.image;
|
||||
if (!imageUrl) {
|
||||
return { text: "Error: Image card requires --url <image-url>" };
|
||||
}
|
||||
const bubble = createImageCard(imageUrl, title, caption);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${caption}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "action": {
|
||||
const [title = "Actions", body = ""] = args;
|
||||
const actions = parseActions(flags.actions);
|
||||
if (actions.length === 0) {
|
||||
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
|
||||
}
|
||||
const bubble = createActionCard(title, body, actions, {
|
||||
imageUrl: flags.url || flags.image,
|
||||
});
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${body}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const [title = "List", itemsStr = ""] = args;
|
||||
const items = parseListItems(itemsStr || flags.items || "");
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
text:
|
||||
'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
|
||||
};
|
||||
}
|
||||
const bubble = createListCard(title, items);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "receipt": {
|
||||
const [title = "Receipt", itemsStr = ""] = args;
|
||||
const items = parseReceiptItems(itemsStr || flags.items || "");
|
||||
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
|
||||
const footer = flags.footer;
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
text:
|
||||
'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
|
||||
};
|
||||
}
|
||||
|
||||
const bubble = createReceiptCard({ title, items, total, footer });
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
|
||||
0,
|
||||
400,
|
||||
),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "confirm": {
|
||||
const [question = "Confirm?"] = args;
|
||||
const yesStr = flags.yes || "Yes|yes";
|
||||
const noStr = flags.no || "No|no";
|
||||
|
||||
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
|
||||
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
|
||||
|
||||
return buildLineReply({
|
||||
templateMessage: {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel || "Yes",
|
||||
confirmData: yesData || "yes",
|
||||
cancelLabel: noLabel || "No",
|
||||
cancelData: noData || "no",
|
||||
altText: question,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "buttons": {
|
||||
const [title = "Menu", text = "Choose an option"] = args;
|
||||
const actionsStr = flags.actions || "";
|
||||
const actionParts = parseActions(actionsStr);
|
||||
|
||||
if (actionParts.length === 0) {
|
||||
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
|
||||
}
|
||||
|
||||
const templateActions: Array<{
|
||||
type: "message" | "uri" | "postback";
|
||||
label: string;
|
||||
data?: string;
|
||||
uri?: string;
|
||||
}> = actionParts.map((a) => {
|
||||
const action = a.action;
|
||||
const label = action.label ?? a.label;
|
||||
if (action.type === "uri") {
|
||||
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
|
||||
}
|
||||
if (action.type === "postback") {
|
||||
return {
|
||||
type: "postback" as const,
|
||||
label,
|
||||
data: (action as { data: string }).data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "message" as const,
|
||||
label,
|
||||
data: (action as { text: string }).text,
|
||||
};
|
||||
});
|
||||
|
||||
return buildLineReply({
|
||||
templateMessage: {
|
||||
type: "buttons",
|
||||
title,
|
||||
text,
|
||||
thumbnailImageUrl: flags.url || flags.image,
|
||||
actions: templateActions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return { text: `Error creating card: ${String(err)}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
96
extensions/line/src/channel.logout.test.ts
Normal file
96
extensions/line/src/channel.logout.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
writeConfigFile: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const entry =
|
||||
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? lineConfig.accounts?.[accountId] ?? {}
|
||||
: lineConfig;
|
||||
const hasToken =
|
||||
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
|
||||
const hasSecret =
|
||||
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
|
||||
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
config: { writeConfigFile },
|
||||
channel: { line: { resolveLineAccount } },
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.logoutAccount", () => {
|
||||
beforeEach(() => {
|
||||
setLineRuntime(createRuntime().runtime);
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on default account logout", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await linePlugin.gateway.logoutAccount({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on account logout", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
primary: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await linePlugin.gateway.logoutAccount({
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
308
extensions/line/src/channel.sendPayload.test.ts
Normal file
308
extensions/line/src/channel.sendPayload.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
pushMessageLine: ReturnType<typeof vi.fn>;
|
||||
pushMessagesLine: ReturnType<typeof vi.fn>;
|
||||
pushFlexMessage: ReturnType<typeof vi.fn>;
|
||||
pushTemplateMessage: ReturnType<typeof vi.fn>;
|
||||
pushLocationMessage: ReturnType<typeof vi.fn>;
|
||||
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
|
||||
createQuickReplyItems: ReturnType<typeof vi.fn>;
|
||||
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
|
||||
sendMessageLine: ReturnType<typeof vi.fn>;
|
||||
chunkMarkdownText: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
|
||||
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
|
||||
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
|
||||
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
|
||||
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
|
||||
messageId: "m-quick",
|
||||
chatId: "c1",
|
||||
}));
|
||||
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
|
||||
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
|
||||
const chunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const resolveTextChunkLimit = vi.fn(() => 123);
|
||||
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||
const resolved = accountId ?? "default";
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const accountConfig =
|
||||
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
|
||||
return {
|
||||
accountId: resolved,
|
||||
config: { ...lineConfig, ...accountConfig },
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
resolveLineAccount,
|
||||
},
|
||||
text: {
|
||||
chunkMarkdownText,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
mocks: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
chunkMarkdownText,
|
||||
resolveLineAccount,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("linePlugin outbound.sendPayload", () => {
|
||||
it("sends flex message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Now playing:",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Now playing",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:group:1",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends template message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Choose one:",
|
||||
channelData: {
|
||||
line: {
|
||||
templateMessage: {
|
||||
type: "confirm",
|
||||
text: "Continue?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:1",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches quick replies when no text chunks are present", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:2",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
|
||||
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:2",
|
||||
[
|
||||
{
|
||||
type: "flex",
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
quickReply: { items: ["One", "Two"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default" },
|
||||
);
|
||||
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||
});
|
||||
|
||||
it("sends media before quick-reply text so buttons stay visible", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello",
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:3",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||
"line:user:3",
|
||||
"Hello",
|
||||
["One", "Two"],
|
||||
{ verbose: false, accountId: "default" },
|
||||
);
|
||||
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
||||
});
|
||||
|
||||
it("uses configured text chunk limit for payloads", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello world",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:3",
|
||||
payload,
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
|
||||
cfg,
|
||||
"line",
|
||||
"primary",
|
||||
{ fallbackLimit: 5000 },
|
||||
);
|
||||
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin config.formatAllowFrom", () => {
|
||||
it("strips line:user: prefixes without lowercasing", () => {
|
||||
const formatted = linePlugin.config.formatAllowFrom({
|
||||
allowFrom: ["line:user:UABC", "line:UDEF"],
|
||||
});
|
||||
expect(formatted).toEqual(["UABC", "UDEF"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin groups.resolveRequireMention", () => {
|
||||
it("uses account-level group settings when provided", () => {
|
||||
const { runtime } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
groups: {
|
||||
"group-1": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const requireMention = linePlugin.groups.resolveRequireMention({
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
groupId: "group-1",
|
||||
});
|
||||
|
||||
expect(requireMention).toBe(true);
|
||||
});
|
||||
});
|
||||
773
extensions/line/src/channel.ts
Normal file
773
extensions/line/src/channel.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
LineConfigSchema,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ClawdbotConfig,
|
||||
type LineConfig,
|
||||
type LineChannelData,
|
||||
type ResolvedLineAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
// LINE channel metadata
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
};
|
||||
|
||||
function parseThreadId(threadId?: string | number | null): number | undefined {
|
||||
if (threadId == null) return undefined;
|
||||
if (typeof threadId === "number") {
|
||||
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
|
||||
}
|
||||
const trimmed = threadId.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "lineUserId",
|
||||
normalizeAllowEntry: (entry) => {
|
||||
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
},
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const line = getLineRuntime().channel.line;
|
||||
const account = line.resolveLineAccount({ cfg });
|
||||
if (!account.channelAccessToken) {
|
||||
throw new Error("LINE channel access token not configured");
|
||||
}
|
||||
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
|
||||
channelAccessToken: account.channelAccessToken,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
const accounts = { ...lineConfig.accounts };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim()),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
// LINE sender IDs are case-sensitive; keep original casing.
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.line.accounts.${resolvedAccountId}.`
|
||||
: "channels.line.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: "clawdbot pairing approve line <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy =
|
||||
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
||||
const groups = account.config.groups;
|
||||
if (!groups) return false;
|
||||
const groupConfig = groups[groupId] ?? groups["*"];
|
||||
return groupConfig?.requireMention ?? false;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return false;
|
||||
// LINE user IDs are typically U followed by 32 hex characters
|
||||
// Group IDs are C followed by 32 hex characters
|
||||
// Room IDs are R followed by 32 hex characters
|
||||
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||
},
|
||||
hint: "<userId|groupId|roomId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async () => [],
|
||||
listGroups: async () => [],
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) =>
|
||||
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
name,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
validateInput: ({ accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
useEnv?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
|
||||
return "LINE requires channelAccessToken or --token-file (or --use-env).";
|
||||
}
|
||||
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
|
||||
return "LINE requires channelSecret or --secret-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
name?: string;
|
||||
useEnv?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled: true,
|
||||
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||
...(typedInput.useEnv
|
||||
? {}
|
||||
: typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.useEnv
|
||||
? {}
|
||||
: typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||
...(typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
|
||||
const quickReply = hasQuickReplies
|
||||
? createQuickReplyItems(lineData.quickReplies!)
|
||||
: undefined;
|
||||
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) return;
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(
|
||||
cfg,
|
||||
"line",
|
||||
accountId ?? undefined,
|
||||
{
|
||||
fallbackLimit: 5000,
|
||||
},
|
||||
) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
lastResult = await sendFlex(
|
||||
to,
|
||||
lineData.flexMessage.altText,
|
||||
lineData.flexMessage.contents,
|
||||
{
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i]!, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) continue;
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResult) return { channel: "line", ...lastResult };
|
||||
return { channel: "line", messageId: "empty", chatId: to };
|
||||
},
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
|
||||
// Process markdown: extract tables/code blocks, strip formatting
|
||||
const processed = processLineMessage(text);
|
||||
|
||||
// Send cleaned text first (if non-empty)
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
// If text is empty after processing, still need a result
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
|
||||
// Send flex messages for tables/code blocks
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { channel: "line", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "line", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: ({ account }) => {
|
||||
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
|
||||
if (!account.channelAccessToken?.trim()) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: "LINE channel access token not configured",
|
||||
});
|
||||
}
|
||||
if (!account.channelSecret?.trim()) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: "LINE channel secret not configured",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: snapshot.mode ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const configured = Boolean(account.channelAccessToken?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
mode: "webhook",
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) lineBotLabel = ` (${displayName})`;
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as ClawdbotConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
|
||||
if (accounts && accountId in accounts) {
|
||||
const entry = accounts[accountId];
|
||||
if (entry && typeof entry === "object") {
|
||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
||||
if (
|
||||
"channelAccessToken" in nextEntry ||
|
||||
"channelSecret" in nextEntry ||
|
||||
"tokenFile" in nextEntry ||
|
||||
"secretFile" in nextEntry
|
||||
) {
|
||||
cleared = true;
|
||||
delete nextEntry.channelAccessToken;
|
||||
delete nextEntry.channelSecret;
|
||||
delete nextEntry.tokenFile;
|
||||
delete nextEntry.secretFile;
|
||||
changed = true;
|
||||
}
|
||||
if (Object.keys(nextEntry).length === 0) {
|
||||
delete accounts[accountId];
|
||||
changed = true;
|
||||
} else {
|
||||
accounts[accountId] = nextEntry as typeof entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts) {
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete nextLine.accounts;
|
||||
changed = true;
|
||||
} else {
|
||||
nextLine.accounts = accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
"### LINE Rich Messages",
|
||||
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
||||
"",
|
||||
"**Quick Replies** (bottom button suggestions):",
|
||||
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
||||
"",
|
||||
"**Location** (map pin):",
|
||||
" [[location: Place Name | Address | latitude | longitude]]",
|
||||
"",
|
||||
"**Confirm Dialog** (yes/no prompt):",
|
||||
" [[confirm: Question text? | Yes Label | No Label]]",
|
||||
"",
|
||||
"**Button Menu** (title + text + buttons):",
|
||||
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
||||
"",
|
||||
"**Media Player Card** (music status):",
|
||||
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
||||
" - Status: 'playing' or 'paused' (optional)",
|
||||
"",
|
||||
"**Event Card** (calendar events, meetings):",
|
||||
" [[event: Event Title | Date | Time | Location | Description]]",
|
||||
" - Time, Location, Description are optional",
|
||||
"",
|
||||
"**Agenda Card** (multiple events/schedule):",
|
||||
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
||||
"",
|
||||
"**Device Control Card** (smart devices, TVs, etc.):",
|
||||
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
||||
"",
|
||||
"**Apple TV Remote** (full D-pad + transport):",
|
||||
" [[appletv_remote: Apple TV | Playing]]",
|
||||
"",
|
||||
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
||||
"",
|
||||
"When to use rich messages:",
|
||||
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
||||
"- Use [[confirm:...]] for yes/no decisions",
|
||||
"- Use [[buttons:...]] for menus with actions/links",
|
||||
"- Use [[location:...]] when sharing a place",
|
||||
"- Use [[media_player:...]] when showing what's playing",
|
||||
"- Use [[event:...]] for calendar event details",
|
||||
"- Use [[agenda:...]] for a day's schedule or event list",
|
||||
"- Use [[device:...]] for smart device status/controls",
|
||||
"- Tables/code in your response auto-convert to visual cards",
|
||||
],
|
||||
},
|
||||
};
|
||||
14
extensions/line/src/runtime.ts
Normal file
14
extensions/line/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setLineRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getLineRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
"dist/signal/**",
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/line/**",
|
||||
"dist/tui/**",
|
||||
"dist/tts/**",
|
||||
"dist/web/**",
|
||||
@@ -154,6 +155,7 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.49.3",
|
||||
"@mariozechner/pi-ai": "0.49.3",
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -34,6 +34,9 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
'@lydell/node-pty':
|
||||
specifier: 1.2.0-beta.3
|
||||
version: 1.2.0-beta.3
|
||||
@@ -317,6 +320,12 @@ importers:
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/llm-task: {}
|
||||
|
||||
extensions/lobster: {}
|
||||
@@ -1260,6 +1269,10 @@ packages:
|
||||
peerDependencies:
|
||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||
|
||||
@@ -2647,6 +2660,9 @@ packages:
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
|
||||
'@types/node@25.0.10':
|
||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||
|
||||
@@ -6721,6 +6737,14 @@ snapshots:
|
||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
optionalDependencies:
|
||||
axios: 1.13.2(debug@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
dependencies:
|
||||
lit: 3.3.2
|
||||
@@ -8298,6 +8322,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.0.10':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -28,9 +28,10 @@ const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "",
|
||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
// Keep worker counts predictable for local runs and for CI on macOS.
|
||||
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
|
||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
|
||||
@@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
textAlias: "/context",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts",
|
||||
nativeName: "tts",
|
||||
description: "Configure text-to-speech.",
|
||||
textAlias: "/tts",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "whoami",
|
||||
nativeName: "whoami",
|
||||
@@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts",
|
||||
nativeName: "tts",
|
||||
description: "Control text-to-speech (TTS).",
|
||||
textAlias: "/tts",
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "on | off | status | provider | limit | summary | audio | help",
|
||||
type: "string",
|
||||
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Provider, limit, or text",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
|
||||
@@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
_allowTextCommands,
|
||||
allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
@@ -36,6 +38,6 @@ export const handlePluginCommand: CommandHandler = async (
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: result.text },
|
||||
reply: result,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,11 +10,26 @@ import {
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
// Avoid expensive workspace scans during /context tests.
|
||||
vi.mock("./commands-context-report.js", () => ({
|
||||
buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => {
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized === "/context list") {
|
||||
return { text: "Injected workspace files:\n- AGENTS.md" };
|
||||
}
|
||||
if (normalized === "/context detail") {
|
||||
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
|
||||
}
|
||||
return { text: "/context\n- /context list\nInline shortcut" };
|
||||
},
|
||||
}));
|
||||
|
||||
let testWorkspaceDir = os.tmpdir();
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -143,6 +158,29 @@ describe("handleCommands bash alias", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands plugin commands", () => {
|
||||
it("dispatches registered plugin commands", async () => {
|
||||
clearPluginCommands();
|
||||
const result = registerPluginCommand("test-plugin", {
|
||||
name: "card",
|
||||
description: "Test card",
|
||||
handler: async () => ({ text: "from plugin" }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/card", cfg);
|
||||
const commandResult = await handleCommands(params);
|
||||
|
||||
expect(commandResult.shouldContinue).toBe(false);
|
||||
expect(commandResult.reply?.text).toBe("from plugin");
|
||||
clearPluginCommands();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands identity", () => {
|
||||
it("returns sender details for /whoami", async () => {
|
||||
const cfg = {
|
||||
|
||||
377
src/auto-reply/reply/line-directives.test.ts
Normal file
377
src/auto-reply/reply/line-directives.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLineDirectives, hasLineDirectives } from "./line-directives.js";
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("detects quick_replies directive", () => {
|
||||
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects location directive", () => {
|
||||
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects confirm directive", () => {
|
||||
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects buttons directive", () => {
|
||||
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular text", () => {
|
||||
expect(hasLineDirectives("Just regular text")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for similar but invalid patterns", () => {
|
||||
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects media_player directive", () => {
|
||||
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects event directive", () => {
|
||||
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects agenda directive", () => {
|
||||
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects device directive", () => {
|
||||
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects appletv_remote directive", () => {
|
||||
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick_replies and removes from text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
|
||||
expect(result.text).toBe("Choose one:");
|
||||
});
|
||||
|
||||
it("handles quick_replies in middle of text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
expect(result.text).toBe("Before After");
|
||||
});
|
||||
|
||||
it("merges with existing quickReplies", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("parses location with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toEqual({
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
});
|
||||
expect(result.text).toBe("Here's the location:");
|
||||
});
|
||||
|
||||
it("ignores invalid coordinates", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not override existing location", () => {
|
||||
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("parses simple confirm", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
});
|
||||
// Text is undefined when directive consumes entire text
|
||||
expect(result.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses confirm with custom data", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttons", () => {
|
||||
it("parses buttons with message actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses buttons with uri actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses buttons with postback actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("limits to 4 actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.length).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_player", () => {
|
||||
it("parses media_player with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
|
||||
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
|
||||
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
|
||||
expect(result.text).toBe("Now playing:");
|
||||
});
|
||||
|
||||
it("parses media_player with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
|
||||
});
|
||||
|
||||
it("handles paused status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
contents?: { body: { contents: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
|
||||
expect(contents).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("parses event with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
|
||||
});
|
||||
|
||||
it("parses event with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenda", () => {
|
||||
it("parses agenda with multiple events", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
|
||||
});
|
||||
|
||||
it("parses agenda with events without times", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device", () => {
|
||||
it("parses device with controls", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 TV: Playing");
|
||||
});
|
||||
|
||||
it("parses device with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: Speaker]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 Speaker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv_remote with status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toContain("Apple TV");
|
||||
});
|
||||
|
||||
it("parses appletv_remote with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined directives", () => {
|
||||
it("handles text with no directives", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Just plain text here",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Just plain text here");
|
||||
expect(getLineData(result).quickReplies).toBeUndefined();
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
expect(getLineData(result).templateMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other payload fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Hello [[quick_replies: A, B]]",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
replyToId: "msg123",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
|
||||
expect(result.replyToId).toBe("msg123");
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
336
src/auto-reply/reply/line-directives.ts
Normal file
336
src/auto-reply/reply/line-directives.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { LineChannelData } from "../../line/types.js";
|
||||
import {
|
||||
createMediaPlayerCard,
|
||||
createEventCard,
|
||||
createAgendaCard,
|
||||
createDeviceControlCard,
|
||||
createAppleTvRemoteCard,
|
||||
} from "../../line/flex-templates.js";
|
||||
|
||||
/**
|
||||
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||
*
|
||||
* Supported directives:
|
||||
* - [[quick_replies: option1, option2, option3]]
|
||||
* - [[location: title | address | latitude | longitude]]
|
||||
* - [[confirm: question | yes_label | no_label]]
|
||||
* - [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
* - [[event: title | date | time | location | description]]
|
||||
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
* - [[appletv_remote: name | status]]
|
||||
*
|
||||
* Returns the modified payload with directives removed from text and fields populated.
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
if (!text) return payload;
|
||||
|
||||
const result: ReplyPayload = { ...payload };
|
||||
const lineData: LineChannelData = {
|
||||
...(result.channelData?.line as LineChannelData | undefined),
|
||||
};
|
||||
const toSlug = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "device";
|
||||
const lineActionData = (action: string, extras?: Record<string, string>): string => {
|
||||
const base = [`line.action=${encodeURIComponent(action)}`];
|
||||
if (extras) {
|
||||
for (const [key, value] of Object.entries(extras)) {
|
||||
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
return base.join("&");
|
||||
};
|
||||
|
||||
// Parse [[quick_replies: option1, option2, option3]]
|
||||
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||
if (quickRepliesMatch) {
|
||||
const options = quickRepliesMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (options.length > 0) {
|
||||
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
|
||||
}
|
||||
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[location: title | address | latitude | longitude]]
|
||||
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||
if (locationMatch && !lineData.location) {
|
||||
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 4) {
|
||||
const [title, address, latStr, lonStr] = parts;
|
||||
const latitude = parseFloat(latStr);
|
||||
const longitude = parseFloat(lonStr);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
lineData.location = {
|
||||
title: title || "Location",
|
||||
address: address || "",
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(locationMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
|
||||
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||
if (confirmMatch && !lineData.templateMessage) {
|
||||
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [question, yesPart, noPart] = parts;
|
||||
|
||||
// Parse yes_label:yes_data format
|
||||
const [yesLabel, yesData] = yesPart.includes(":")
|
||||
? yesPart.split(":").map((s) => s.trim())
|
||||
: [yesPart, yesPart.toLowerCase()];
|
||||
|
||||
const [noLabel, noData] = noPart.includes(":")
|
||||
? noPart.split(":").map((s) => s.trim())
|
||||
: [noPart, noPart.toLowerCase()];
|
||||
|
||||
lineData.templateMessage = {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel,
|
||||
confirmData: yesData,
|
||||
cancelLabel: noLabel,
|
||||
cancelData: noData,
|
||||
altText: question,
|
||||
};
|
||||
}
|
||||
text = text.replace(confirmMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||
if (buttonsMatch && !lineData.templateMessage) {
|
||||
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [title, bodyText, actionsStr] = parts;
|
||||
|
||||
const actions = actionsStr.split(",").map((actionStr) => {
|
||||
const trimmed = actionStr.trim();
|
||||
// Find first colon delimiter, ignoring URLs without a label.
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) return -1;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) return -1;
|
||||
return index;
|
||||
})();
|
||||
|
||||
let label: string;
|
||||
let data: string;
|
||||
|
||||
if (colonIndex === -1) {
|
||||
label = trimmed;
|
||||
data = trimmed;
|
||||
} else {
|
||||
label = trimmed.slice(0, colonIndex).trim();
|
||||
data = trimmed.slice(colonIndex + 1).trim();
|
||||
}
|
||||
|
||||
// Detect action type
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
return { type: "uri" as const, label, uri: data };
|
||||
}
|
||||
if (data.includes("=")) {
|
||||
return { type: "postback" as const, label, data };
|
||||
}
|
||||
return { type: "message" as const, label, data: data || label };
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
lineData.templateMessage = {
|
||||
type: "buttons",
|
||||
title,
|
||||
text: bodyText,
|
||||
actions: actions.slice(0, 4), // LINE limit
|
||||
altText: `${title}: ${bodyText}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(buttonsMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||
const isPlaying = statusStr?.toLowerCase() === "playing";
|
||||
|
||||
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
|
||||
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||
|
||||
const deviceKey = toSlug(source || title || "media");
|
||||
const card = createMediaPlayerCard({
|
||||
title: title || "Unknown Track",
|
||||
subtitle: artist || undefined,
|
||||
source: source || undefined,
|
||||
imageUrl: validImageUrl,
|
||||
isPlaying: statusStr ? isPlaying : undefined,
|
||||
controls: {
|
||||
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
|
||||
play: { data: lineActionData("play", { "line.device": deviceKey }) },
|
||||
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
|
||||
next: { data: lineActionData("next", { "line.device": deviceKey }) },
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[event: title | date | time | location | description]]
|
||||
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||
if (eventMatch && !lineData.flexMessage) {
|
||||
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, date, time, location, description] = parts;
|
||||
|
||||
const card = createEventCard({
|
||||
title: title || "Event",
|
||||
date: date || "TBD",
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(eventMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[appletv_remote: name | status]]
|
||||
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||
if (appleTvMatch && !lineData.flexMessage) {
|
||||
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, status] = parts;
|
||||
const deviceKey = toSlug(deviceName || "apple_tv");
|
||||
|
||||
const card = createAppleTvRemoteCard({
|
||||
deviceName: deviceName || "Apple TV",
|
||||
status: status || undefined,
|
||||
actionData: {
|
||||
up: lineActionData("up", { "line.device": deviceKey }),
|
||||
down: lineActionData("down", { "line.device": deviceKey }),
|
||||
left: lineActionData("left", { "line.device": deviceKey }),
|
||||
right: lineActionData("right", { "line.device": deviceKey }),
|
||||
select: lineActionData("select", { "line.device": deviceKey }),
|
||||
menu: lineActionData("menu", { "line.device": deviceKey }),
|
||||
home: lineActionData("home", { "line.device": deviceKey }),
|
||||
play: lineActionData("play", { "line.device": deviceKey }),
|
||||
pause: lineActionData("pause", { "line.device": deviceKey }),
|
||||
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
|
||||
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
|
||||
mute: lineActionData("mute", { "line.device": deviceKey }),
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📺 ${deviceName || "Apple TV"} Remote`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(appleTvMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||
if (agendaMatch && !lineData.flexMessage) {
|
||||
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, eventsStr] = parts;
|
||||
|
||||
const events = eventsStr.split(",").map((eventStr) => {
|
||||
const trimmed = eventStr.trim();
|
||||
const colonIdx = trimmed.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
return {
|
||||
title: trimmed.slice(0, colonIdx).trim(),
|
||||
time: trimmed.slice(colonIdx + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { title: trimmed };
|
||||
});
|
||||
|
||||
const card = createAgendaCard({
|
||||
title: title || "Agenda",
|
||||
events,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📋 ${title} (${events.length} events)`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(agendaMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||
if (deviceMatch && !lineData.flexMessage) {
|
||||
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||
|
||||
const deviceKey = toSlug(deviceName || "device");
|
||||
const controls = controlsStr
|
||||
? controlsStr.split(",").map((ctrlStr) => {
|
||||
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
|
||||
const action = data || label.toLowerCase().replace(/\s+/g, "_");
|
||||
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
|
||||
})
|
||||
: [];
|
||||
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: deviceName || "Device",
|
||||
deviceType: deviceType || undefined,
|
||||
status: status || undefined,
|
||||
controls,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(deviceMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Clean up multiple whitespace/newlines
|
||||
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
result.text = text || undefined;
|
||||
if (Object.keys(lineData).length > 0) {
|
||||
result.channelData = { ...result.channelData, line: lineData };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any LINE directives
|
||||
*/
|
||||
export function hasLineDirectives(text: string): boolean {
|
||||
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
22
src/auto-reply/reply/normalize-reply.test.ts
Normal file
22
src/auto-reply/reply/normalize-reply.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
||||
describe("normalizeReplyPayload", () => {
|
||||
it("keeps channelData-only replies", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeReplyPayload(payload);
|
||||
|
||||
expect(normalized).not.toBeNull();
|
||||
expect(normalized?.text).toBeUndefined();
|
||||
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
@@ -21,13 +22,16 @@ export function normalizeReplyPayload(
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||
const hasChannelData = Boolean(
|
||||
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
||||
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && isSilentReplyText(text, silentToken)) {
|
||||
if (!hasMedia) return null;
|
||||
if (!hasMedia && !hasChannelData) return null;
|
||||
text = "";
|
||||
}
|
||||
if (text && !trimmed) {
|
||||
@@ -39,14 +43,21 @@ export function normalizeReplyPayload(
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text = sanitizeUserFacingText(text);
|
||||
}
|
||||
if (!text?.trim() && !hasMedia) return null;
|
||||
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
|
||||
|
||||
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||
if (text && hasLineDirectives(text)) {
|
||||
enrichedPayload = parseLineDirectives(enrichedPayload);
|
||||
text = enrichedPayload.text;
|
||||
}
|
||||
|
||||
// Resolve template variables in responsePrefix if context is provided
|
||||
const effectivePrefix = opts.responsePrefixContext
|
||||
@@ -62,5 +73,5 @@ export function normalizeReplyPayload(
|
||||
text = `${effectivePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
return { ...enrichedPayload, text };
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
||||
payload.audioAsVoice,
|
||||
payload.audioAsVoice ||
|
||||
payload.channelData,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../utils/usage-format.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||
import { listPluginCommands } from "../plugins/commands.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||
@@ -473,5 +474,14 @@ export function buildCommandsMessage(
|
||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||
}
|
||||
const pluginCommands = listPluginCommands();
|
||||
if (pluginCommands.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Plugin commands:");
|
||||
for (const command of pluginCommands) {
|
||||
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
|
||||
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -52,4 +52,6 @@ export type ReplyPayload = {
|
||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||
audioAsVoice?: boolean;
|
||||
isError?: boolean;
|
||||
/** Channel-specific payload data (per-channel envelope). */
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
@@ -81,6 +82,10 @@ export type ChannelOutboundContext = {
|
||||
deps?: OutboundSendDeps;
|
||||
};
|
||||
|
||||
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||
payload: ReplyPayload;
|
||||
};
|
||||
|
||||
export type ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||
@@ -94,6 +99,7 @@ export type ChannelOutboundAdapter = {
|
||||
accountId?: string | null;
|
||||
mode?: ChannelOutboundTargetMode;
|
||||
}) => { ok: true; to: string } | { ok: false; error: Error };
|
||||
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
||||
|
||||
@@ -134,6 +134,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -230,8 +230,6 @@ export function createGatewayHttpServer(opts: {
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
if (await handleHooksRequest(req, res)) return;
|
||||
if (await handleSlackHttpRequest(req, res)) return;
|
||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||
if (
|
||||
await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
@@ -239,6 +237,8 @@ export function createGatewayHttpServer(opts: {
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (await handleSlackHttpRequest(req, res)) return;
|
||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||
if (openResponsesEnabled) {
|
||||
if (
|
||||
await handleOpenResponsesHttpRequest(req, res, {
|
||||
|
||||
@@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics,
|
||||
|
||||
@@ -40,6 +40,7 @@ const registryState = vi.hoisted(() => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
@@ -81,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -49,6 +49,7 @@ const registryState = vi.hoisted(() => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
@@ -78,6 +79,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -21,6 +21,7 @@ const registryState = vi.hoisted(() => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
@@ -47,6 +48,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
@@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
...merged,
|
||||
gatewayHandlers: merged.gatewayHandlers ?? {},
|
||||
httpHandlers: merged.httpHandlers ?? [],
|
||||
httpRoutes: merged.httpRoutes ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,6 +56,35 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles registered http routes before generic handlers", async () => {
|
||||
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
|
||||
res.statusCode = 200;
|
||||
});
|
||||
const fallback = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "route",
|
||||
path: "/demo",
|
||||
handler: routeHandler,
|
||||
source: "route",
|
||||
},
|
||||
],
|
||||
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
|
||||
const { res } = makeResponse();
|
||||
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(routeHandler).toHaveBeenCalledTimes(1);
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs and responds with 500 when a handler throws", async () => {
|
||||
const log = { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
|
||||
@@ -16,8 +16,30 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
}): PluginHttpRequestHandler {
|
||||
const { registry, log } = params;
|
||||
return async (req, res) => {
|
||||
if (registry.httpHandlers.length === 0) return false;
|
||||
for (const entry of registry.httpHandlers) {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
const handlers = registry.httpHandlers ?? [];
|
||||
if (routes.length === 0 && handlers.length === 0) return false;
|
||||
|
||||
if (routes.length > 0) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const route = routes.find((entry) => entry.path === url.pathname);
|
||||
if (route) {
|
||||
try {
|
||||
await route.handler(req, res);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of handlers) {
|
||||
try {
|
||||
const handled = await entry.handler(req, res);
|
||||
if (handled) return true;
|
||||
|
||||
@@ -138,6 +138,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
|
||||
import { testState } from "./test-helpers.mocks.js";
|
||||
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
@@ -70,6 +72,58 @@ describe("POST /tools/invoke", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("routes tools invoke before plugin HTTP handlers", async () => {
|
||||
const pluginHandler = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
res.statusCode = 418;
|
||||
res.end("plugin");
|
||||
return true;
|
||||
});
|
||||
const registry = createTestRegistry();
|
||||
registry.httpHandlers = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
source: "test",
|
||||
handler: pluginHandler as unknown as (
|
||||
req: import("node:http").IncomingMessage,
|
||||
res: import("node:http").ServerResponse,
|
||||
) => Promise<boolean>,
|
||||
},
|
||||
];
|
||||
setTestPluginRegistry(registry);
|
||||
|
||||
testState.agentsConfig = {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
tools: {
|
||||
allow: ["sessions_list"],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tool: "sessions_list",
|
||||
action: "json",
|
||||
args: {},
|
||||
sessionKey: "main",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginHandler).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await server.close();
|
||||
resetTestPluginRegistry();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unauthorized when auth mode is token and header is missing", async () => {
|
||||
testState.agentsConfig = {
|
||||
list: [
|
||||
|
||||
@@ -311,6 +311,28 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
||||
});
|
||||
|
||||
it("passes normalized payload to onError", async () => {
|
||||
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const onError = vi.fn();
|
||||
const cfg: ClawdbotConfig = {};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
|
||||
deps: { sendWhatsApp },
|
||||
bestEffort: true,
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors delivered output when mirror options are provided", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const cfg: ClawdbotConfig = {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resolveMirroredTranscriptText,
|
||||
} from "../../config/sessions.js";
|
||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
import { normalizeOutboundPayloads } from "./payloads.js";
|
||||
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
||||
import type { OutboundChannel } from "./targets.js";
|
||||
|
||||
export type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
@@ -69,6 +69,7 @@ type ChannelHandler = {
|
||||
chunker: Chunker | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
|
||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
||||
};
|
||||
@@ -132,6 +133,21 @@ function createPluginHandler(params: {
|
||||
chunker,
|
||||
chunkerMode,
|
||||
textChunkLimit: outbound.textChunkLimit,
|
||||
sendPayload: outbound.sendPayload
|
||||
? async (payload) =>
|
||||
outbound.sendPayload!({
|
||||
cfg: params.cfg,
|
||||
to: params.to,
|
||||
text: payload.text ?? "",
|
||||
mediaUrl: payload.mediaUrl,
|
||||
accountId: params.accountId,
|
||||
replyToId: params.replyToId,
|
||||
threadId: params.threadId,
|
||||
gifPlayback: params.gifPlayback,
|
||||
deps: params.deps,
|
||||
payload,
|
||||
})
|
||||
: undefined,
|
||||
sendText: async (text) =>
|
||||
sendText({
|
||||
cfg: params.cfg,
|
||||
@@ -294,24 +310,33 @@ export async function deliverOutboundPayloads(params: {
|
||||
})),
|
||||
};
|
||||
};
|
||||
const normalizedPayloads = normalizeOutboundPayloads(payloads);
|
||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
|
||||
for (const payload of normalizedPayloads) {
|
||||
const payloadSummary: NormalizedOutboundPayload = {
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
channelData: payload.channelData,
|
||||
};
|
||||
try {
|
||||
throwIfAborted(abortSignal);
|
||||
params.onPayload?.(payload);
|
||||
if (payload.mediaUrls.length === 0) {
|
||||
params.onPayload?.(payloadSummary);
|
||||
if (handler.sendPayload && payload.channelData) {
|
||||
results.push(await handler.sendPayload(payload));
|
||||
continue;
|
||||
}
|
||||
if (payloadSummary.mediaUrls.length === 0) {
|
||||
if (isSignalChannel) {
|
||||
await sendSignalTextChunks(payload.text);
|
||||
await sendSignalTextChunks(payloadSummary.text);
|
||||
} else {
|
||||
await sendTextChunks(payload.text);
|
||||
await sendTextChunks(payloadSummary.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const url of payload.mediaUrls) {
|
||||
for (const url of payloadSummary.mediaUrls) {
|
||||
throwIfAborted(abortSignal);
|
||||
const caption = first ? payload.text : "";
|
||||
const caption = first ? payloadSummary.text : "";
|
||||
first = false;
|
||||
if (isSignalChannel) {
|
||||
results.push(await sendSignalMedia(caption, url));
|
||||
@@ -321,7 +346,7 @@ export async function deliverOutboundPayloads(params: {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!params.bestEffort) throw err;
|
||||
params.onError?.(err, payload);
|
||||
params.onError?.(err, payloadSummary);
|
||||
}
|
||||
}
|
||||
if (params.mirror && results.length > 0) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
|
||||
import {
|
||||
formatOutboundPayloadLog,
|
||||
normalizeOutboundPayloads,
|
||||
normalizeOutboundPayloadsForJson,
|
||||
} from "./payloads.js";
|
||||
|
||||
describe("normalizeOutboundPayloadsForJson", () => {
|
||||
it("normalizes payloads with mediaUrl and mediaUrls", () => {
|
||||
@@ -11,16 +15,18 @@ describe("normalizeOutboundPayloadsForJson", () => {
|
||||
{ text: "multi", mediaUrls: ["https://x.test/1.png"] },
|
||||
]),
|
||||
).toEqual([
|
||||
{ text: "hi", mediaUrl: null, mediaUrls: undefined },
|
||||
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
|
||||
{
|
||||
text: "photo",
|
||||
mediaUrl: "https://x.test/a.jpg",
|
||||
mediaUrls: ["https://x.test/a.jpg"],
|
||||
channelData: undefined,
|
||||
},
|
||||
{
|
||||
text: "multi",
|
||||
mediaUrl: null,
|
||||
mediaUrls: ["https://x.test/1.png"],
|
||||
channelData: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -37,11 +43,20 @@ describe("normalizeOutboundPayloadsForJson", () => {
|
||||
text: "",
|
||||
mediaUrl: null,
|
||||
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
|
||||
channelData: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOutboundPayloads", () => {
|
||||
it("keeps channelData-only payloads", () => {
|
||||
const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } };
|
||||
const normalized = normalizeOutboundPayloads([{ channelData }]);
|
||||
expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOutboundPayloadLog", () => {
|
||||
it("trims trailing text and appends media lines", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
export type NormalizedOutboundPayload = {
|
||||
text: string;
|
||||
mediaUrls: string[];
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OutboundPayloadJson = {
|
||||
text: string;
|
||||
mediaUrl: string | null;
|
||||
mediaUrls?: string[];
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function mergeMediaUrls(...lists: Array<Array<string | undefined> | undefined>): string[] {
|
||||
@@ -58,11 +60,23 @@ export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): Rep
|
||||
|
||||
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
|
||||
return normalizeReplyPayloadsForDelivery(payloads)
|
||||
.map((payload) => ({
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
}))
|
||||
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
|
||||
.map((payload) => {
|
||||
const channelData = payload.channelData;
|
||||
const normalized: NormalizedOutboundPayload = {
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
};
|
||||
if (channelData && Object.keys(channelData).length > 0) {
|
||||
normalized.channelData = channelData;
|
||||
}
|
||||
return normalized;
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrls.length > 0 ||
|
||||
Boolean(payload.channelData && Object.keys(payload.channelData).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
|
||||
@@ -70,6 +84,7 @@ export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): Outb
|
||||
text: payload.text ?? "",
|
||||
mediaUrl: payload.mediaUrl ?? null,
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
channelData: payload.channelData,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
199
src/line/accounts.test.ts
Normal file
199
src/line/accounts.test.ts
Normal 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
179
src/line/accounts.ts
Normal 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;
|
||||
}
|
||||
202
src/line/auto-reply-delivery.test.ts
Normal file
202
src/line/auto-reply-delivery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
180
src/line/auto-reply-delivery.ts
Normal file
180
src/line/auto-reply-delivery.ts
Normal 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
48
src/line/bot-access.ts
Normal 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);
|
||||
};
|
||||
173
src/line/bot-handlers.test.ts
Normal file
173
src/line/bot-handlers.test.ts
Normal 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
337
src/line/bot-handlers.ts
Normal 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)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/line/bot-message-context.test.ts
Normal file
82
src/line/bot-message-context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
465
src/line/bot-message-context.ts
Normal file
465
src/line/bot-message-context.ts
Normal 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
82
src/line/bot.ts
Normal 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
53
src/line/config-schema.ts
Normal 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
120
src/line/download.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
499
src/line/flex-templates.test.ts
Normal file
499
src/line/flex-templates.test.ts
Normal 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
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
45
src/line/http-registry.ts
Normal 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
155
src/line/index.ts
Normal 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";
|
||||
449
src/line/markdown-to-line.test.ts
Normal file
449
src/line/markdown-to-line.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
433
src/line/markdown-to-line.ts
Normal file
433
src/line/markdown-to-line.ts
Normal 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
376
src/line/monitor.ts
Normal 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
51
src/line/probe.test.ts
Normal 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
43
src/line/probe.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
115
src/line/reply-chunks.test.ts
Normal file
115
src/line/reply-chunks.test.ts
Normal 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
101
src/line/reply-chunks.ts
Normal 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
247
src/line/rich-menu.test.ts
Normal 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
463
src/line/rich-menu.ts
Normal 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
95
src/line/send.test.ts
Normal 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
629
src/line/send.ts
Normal 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;
|
||||
}
|
||||
391
src/line/template-messages.test.ts
Normal file
391
src/line/template-messages.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
401
src/line/template-messages.ts
Normal file
401
src/line/template-messages.ts
Normal 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
150
src/line/types.ts
Normal 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
73
src/line/webhook.test.ts
Normal 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
102
src/line/webhook.ts
Normal 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 };
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export type {
|
||||
ClawdbotPluginServiceContext,
|
||||
} from "../plugins/types.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export type { ClawdbotConfig } from "../config/config.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
@@ -324,5 +326,35 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w
|
||||
// Channel: BlueBubbles
|
||||
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
|
||||
|
||||
// Channel: LINE
|
||||
export {
|
||||
listLineAccountIds,
|
||||
normalizeAccountId as normalizeLineAccountId,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "../line/accounts.js";
|
||||
export { LineConfigSchema } from "../line/config-schema.js";
|
||||
export type {
|
||||
LineConfig,
|
||||
LineAccountConfig,
|
||||
ResolvedLineAccount,
|
||||
LineChannelData,
|
||||
} from "../line/types.js";
|
||||
export {
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createImageCard,
|
||||
createActionCard,
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "../line/flex-templates.js";
|
||||
export {
|
||||
processLineMessage,
|
||||
hasMarkdownToConvert,
|
||||
stripMarkdown,
|
||||
} from "../line/markdown-to-line.js";
|
||||
export type { ProcessedLineMessage } from "../line/markdown-to-line.js";
|
||||
|
||||
// Media utilities
|
||||
export { loadWebMedia, type WebMediaResult } from "../web/media.js";
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
*/
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
|
||||
import type {
|
||||
ClawdbotPluginCommandDefinition,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
} from "./types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
|
||||
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
|
||||
@@ -218,7 +222,7 @@ export async function executePluginCommand(params: {
|
||||
isAuthorizedSender: boolean;
|
||||
commandBody: string;
|
||||
config: ClawdbotConfig;
|
||||
}): Promise<{ text: string }> {
|
||||
}): Promise<PluginCommandResult> {
|
||||
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
|
||||
|
||||
// Check authorization
|
||||
@@ -249,7 +253,7 @@ export async function executePluginCommand(params: {
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
|
||||
);
|
||||
return { text: result.text };
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
|
||||
|
||||
12
src/plugins/http-path.ts
Normal file
12
src/plugins/http-path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function normalizePluginHttpPath(
|
||||
path?: string | null,
|
||||
fallback?: string | null,
|
||||
): string | null {
|
||||
const trimmed = path?.trim();
|
||||
if (!trimmed) {
|
||||
const fallbackTrimmed = fallback?.trim();
|
||||
if (!fallbackTrimmed) return null;
|
||||
return fallbackTrimmed.startsWith("/") ? fallbackTrimmed : `/${fallbackTrimmed}`;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
53
src/plugins/http-registry.ts
Normal file
53
src/plugins/http-registry.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js";
|
||||
import { requireActivePluginRegistry } from "./runtime.js";
|
||||
import { normalizePluginHttpPath } from "./http-path.js";
|
||||
|
||||
export type PluginHttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export function registerPluginHttpRoute(params: {
|
||||
path?: string | null;
|
||||
fallbackPath?: string | null;
|
||||
handler: PluginHttpRouteHandler;
|
||||
pluginId?: string;
|
||||
source?: string;
|
||||
accountId?: string;
|
||||
log?: (message: string) => void;
|
||||
registry?: PluginRegistry;
|
||||
}): () => void {
|
||||
const registry = params.registry ?? requireActivePluginRegistry();
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
registry.httpRoutes = routes;
|
||||
|
||||
const normalizedPath = normalizePluginHttpPath(params.path, params.fallbackPath);
|
||||
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
|
||||
if (!normalizedPath) {
|
||||
params.log?.(`plugin: webhook path missing${suffix}`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (routes.some((entry) => entry.path === normalizedPath)) {
|
||||
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
|
||||
params.log?.(`plugin: webhook path ${normalizedPath} already registered${suffix}${pluginHint}`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const entry: PluginHttpRouteRegistration = {
|
||||
path: normalizedPath,
|
||||
handler: params.handler,
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
};
|
||||
routes.push(entry);
|
||||
|
||||
return () => {
|
||||
const index = routes.indexOf(entry);
|
||||
if (index >= 0) {
|
||||
routes.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -350,6 +350,33 @@ describe("loadClawdbotPlugins", () => {
|
||||
expect(httpPlugin?.httpHandlers).toBe(1);
|
||||
});
|
||||
|
||||
it("registers http routes", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
id: "http-route-demo",
|
||||
body: `export default { id: "http-route-demo", register(api) {
|
||||
api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } });
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
cache: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["http-route-demo"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.path).toBe("/demo");
|
||||
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
|
||||
expect(httpPlugin?.httpHandlers).toBe(1);
|
||||
});
|
||||
|
||||
it("respects explicit disable in config", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginCommandDefinition,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ClawdbotPluginHttpRouteHandler,
|
||||
ClawdbotPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
ClawdbotPluginService,
|
||||
@@ -31,6 +32,7 @@ import { registerPluginCommand } from "./commands.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import path from "node:path";
|
||||
import { normalizePluginHttpPath } from "./http-path.js";
|
||||
|
||||
export type PluginToolRegistration = {
|
||||
pluginId: string;
|
||||
@@ -53,6 +55,13 @@ export type PluginHttpRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginHttpRouteRegistration = {
|
||||
pluginId?: string;
|
||||
path: string;
|
||||
handler: ClawdbotPluginHttpRouteHandler;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type PluginChannelRegistration = {
|
||||
pluginId: string;
|
||||
plugin: ChannelPlugin;
|
||||
@@ -121,6 +130,7 @@ export type PluginRegistry = {
|
||||
providers: PluginProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
httpRoutes: PluginHttpRouteRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
@@ -143,6 +153,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
@@ -280,6 +291,38 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerHttpRoute = (
|
||||
record: PluginRecord,
|
||||
params: { path: string; handler: ClawdbotPluginHttpRouteHandler },
|
||||
) => {
|
||||
const normalizedPath = normalizePluginHttpPath(params.path);
|
||||
if (!normalizedPath) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "http route registration missing path",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `http route already registered: ${normalizedPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.httpHandlers += 1;
|
||||
registry.httpRoutes.push({
|
||||
pluginId: record.id,
|
||||
path: normalizedPath,
|
||||
handler: params.handler,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerChannel = (
|
||||
record: PluginRecord,
|
||||
registration: ClawdbotPluginChannelRegistration | ChannelPlugin,
|
||||
@@ -439,6 +482,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerHook: (events, handler, opts) =>
|
||||
registerHook(record, events, handler, opts, params.config),
|
||||
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
|
||||
registerHttpRoute: (params) => registerHttpRoute(record, params),
|
||||
registerChannel: (registration) => registerChannel(record, registration),
|
||||
registerProvider: (provider) => registerProvider(record, provider),
|
||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||
|
||||
@@ -9,6 +9,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
|
||||
@@ -125,6 +125,25 @@ import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||
import { registerMemoryCli } from "../../cli/memory-cli.js";
|
||||
import { formatNativeDependencyHint } from "./native-deps.js";
|
||||
import { textToSpeechTelephony } from "../../tts/tts.js";
|
||||
import {
|
||||
listLineAccountIds,
|
||||
normalizeAccountId as normalizeLineAccountId,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "../../line/accounts.js";
|
||||
import { probeLineBot } from "../../line/probe.js";
|
||||
import {
|
||||
createQuickReplyItems,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
sendMessageLine,
|
||||
} from "../../line/send.js";
|
||||
import { monitorLineProvider } from "../../line/monitor.js";
|
||||
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
|
||||
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
|
||||
@@ -299,6 +318,23 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
handleWhatsAppAction,
|
||||
createLoginTool: createWhatsAppLoginTool,
|
||||
},
|
||||
line: {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
normalizeAccountId: normalizeLineAccountId,
|
||||
probeLineBot,
|
||||
sendMessageLine,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
monitorLineProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose,
|
||||
|
||||
@@ -148,6 +148,26 @@ type HandleWhatsAppAction =
|
||||
type CreateWhatsAppLoginTool =
|
||||
typeof import("../../channels/plugins/agent-tools/whatsapp-login.js").createWhatsAppLoginTool;
|
||||
|
||||
// LINE channel types
|
||||
type ListLineAccountIds = typeof import("../../line/accounts.js").listLineAccountIds;
|
||||
type ResolveDefaultLineAccountId =
|
||||
typeof import("../../line/accounts.js").resolveDefaultLineAccountId;
|
||||
type ResolveLineAccount = typeof import("../../line/accounts.js").resolveLineAccount;
|
||||
type NormalizeLineAccountId = typeof import("../../line/accounts.js").normalizeAccountId;
|
||||
type ProbeLineBot = typeof import("../../line/probe.js").probeLineBot;
|
||||
type SendMessageLine = typeof import("../../line/send.js").sendMessageLine;
|
||||
type PushMessageLine = typeof import("../../line/send.js").pushMessageLine;
|
||||
type PushMessagesLine = typeof import("../../line/send.js").pushMessagesLine;
|
||||
type PushFlexMessage = typeof import("../../line/send.js").pushFlexMessage;
|
||||
type PushTemplateMessage = typeof import("../../line/send.js").pushTemplateMessage;
|
||||
type PushLocationMessage = typeof import("../../line/send.js").pushLocationMessage;
|
||||
type PushTextMessageWithQuickReplies =
|
||||
typeof import("../../line/send.js").pushTextMessageWithQuickReplies;
|
||||
type CreateQuickReplyItems = typeof import("../../line/send.js").createQuickReplyItems;
|
||||
type BuildTemplateMessageFromPayload =
|
||||
typeof import("../../line/template-messages.js").buildTemplateMessageFromPayload;
|
||||
type MonitorLineProvider = typeof import("../../line/monitor.js").monitorLineProvider;
|
||||
|
||||
export type RuntimeLogger = {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
@@ -310,6 +330,23 @@ export type PluginRuntime = {
|
||||
handleWhatsAppAction: HandleWhatsAppAction;
|
||||
createLoginTool: CreateWhatsAppLoginTool;
|
||||
};
|
||||
line: {
|
||||
listLineAccountIds: ListLineAccountIds;
|
||||
resolveDefaultLineAccountId: ResolveDefaultLineAccountId;
|
||||
resolveLineAccount: ResolveLineAccount;
|
||||
normalizeAccountId: NormalizeLineAccountId;
|
||||
probeLineBot: ProbeLineBot;
|
||||
sendMessageLine: SendMessageLine;
|
||||
pushMessageLine: PushMessageLine;
|
||||
pushMessagesLine: PushMessagesLine;
|
||||
pushFlexMessage: PushFlexMessage;
|
||||
pushTemplateMessage: PushTemplateMessage;
|
||||
pushLocationMessage: PushLocationMessage;
|
||||
pushTextMessageWithQuickReplies: PushTextMessageWithQuickReplies;
|
||||
createQuickReplyItems: CreateQuickReplyItems;
|
||||
buildTemplateMessageFromPayload: BuildTemplateMessageFromPayload;
|
||||
monitorLineProvider: MonitorLineProvider;
|
||||
};
|
||||
};
|
||||
logging: {
|
||||
shouldLogVerbose: ShouldLogVerbose;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
@@ -154,10 +155,7 @@ export type PluginCommandContext = {
|
||||
/**
|
||||
* Result returned by a plugin command handler.
|
||||
*/
|
||||
export type PluginCommandResult = {
|
||||
/** Text response to send back to the user */
|
||||
text: string;
|
||||
};
|
||||
export type PluginCommandResult = ReplyPayload;
|
||||
|
||||
/**
|
||||
* Handler function for plugin commands.
|
||||
@@ -187,6 +185,11 @@ export type ClawdbotPluginHttpHandler = (
|
||||
res: ServerResponse,
|
||||
) => Promise<boolean> | boolean;
|
||||
|
||||
export type ClawdbotPluginHttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export type ClawdbotPluginCliContext = {
|
||||
program: Command;
|
||||
config: ClawdbotConfig;
|
||||
@@ -249,6 +252,7 @@ export type ClawdbotPluginApi = {
|
||||
opts?: ClawdbotPluginHookOptions,
|
||||
) => void;
|
||||
registerHttpHandler: (handler: ClawdbotPluginHttpHandler) => void;
|
||||
registerHttpRoute: (params: { path: string; handler: ClawdbotPluginHttpRouteHandler }) => void;
|
||||
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
|
||||
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
|
||||
@@ -12,6 +12,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
|
||||
Reference in New Issue
Block a user