From c96ffa7186a43cda7aa5e2b865a194f94c24d42e Mon Sep 17 00:00:00 2001 From: plum-dawg Date: Sun, 25 Jan 2026 07:22:36 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + extensions/line/clawdbot.plugin.json | 11 + extensions/line/index.ts | 20 + extensions/line/package.json | 29 + extensions/line/src/card-command.ts | 338 ++++ extensions/line/src/channel.logout.test.ts | 96 ++ .../line/src/channel.sendPayload.test.ts | 308 ++++ extensions/line/src/channel.ts | 773 +++++++++ extensions/line/src/runtime.ts | 14 + package.json | 2 + pnpm-lock.yaml | 28 + scripts/test-parallel.mjs | 5 +- src/auto-reply/commands-registry.data.ts | 28 +- src/auto-reply/reply/commands-plugin.ts | 6 +- src/auto-reply/reply/commands.test.ts | 38 + src/auto-reply/reply/line-directives.test.ts | 377 +++++ src/auto-reply/reply/line-directives.ts | 336 ++++ src/auto-reply/reply/normalize-reply.test.ts | 22 + src/auto-reply/reply/normalize-reply.ts | 21 +- src/auto-reply/reply/reply-payloads.ts | 3 +- src/auto-reply/reply/route-reply.test.ts | 1 + src/auto-reply/status.ts | 10 + src/auto-reply/types.ts | 2 + src/channels/plugins/load.test.ts | 1 + src/channels/plugins/types.adapters.ts | 6 + src/config/channel-capabilities.test.ts | 1 + src/gateway/server-http.ts | 4 +- src/gateway/server-plugins.test.ts | 1 + ...r.agent.gateway-server-agent-a.e2e.test.ts | 2 + ...r.agent.gateway-server-agent-b.e2e.test.ts | 2 + src/gateway/server.channels.e2e.test.ts | 2 + .../server.models-voicewake-misc.e2e.test.ts | 1 + src/gateway/server/__tests__/test-utils.ts | 2 + src/gateway/server/plugins-http.test.ts | 29 + src/gateway/server/plugins-http.ts | 26 +- src/gateway/test-helpers.mocks.ts | 1 + src/gateway/tools-invoke-http.test.ts | 58 +- src/infra/outbound/deliver.test.ts | 22 + src/infra/outbound/deliver.ts | 43 +- src/infra/outbound/payloads.test.ts | 19 +- src/infra/outbound/payloads.ts | 25 +- src/line/accounts.test.ts | 199 +++ src/line/accounts.ts | 179 ++ src/line/auto-reply-delivery.test.ts | 202 +++ src/line/auto-reply-delivery.ts | 180 ++ src/line/bot-access.ts | 48 + src/line/bot-handlers.test.ts | 173 ++ src/line/bot-handlers.ts | 337 ++++ src/line/bot-message-context.test.ts | 82 + src/line/bot-message-context.ts | 465 +++++ src/line/bot.ts | 82 + src/line/config-schema.ts | 53 + src/line/download.ts | 120 ++ src/line/flex-templates.test.ts | 499 ++++++ src/line/flex-templates.ts | 1507 +++++++++++++++++ src/line/http-registry.ts | 45 + src/line/index.ts | 155 ++ src/line/markdown-to-line.test.ts | 449 +++++ src/line/markdown-to-line.ts | 433 +++++ src/line/monitor.ts | 376 ++++ src/line/probe.test.ts | 51 + src/line/probe.ts | 43 + src/line/reply-chunks.test.ts | 115 ++ src/line/reply-chunks.ts | 101 ++ src/line/rich-menu.test.ts | 247 +++ src/line/rich-menu.ts | 463 +++++ src/line/send.test.ts | 95 ++ src/line/send.ts | 629 +++++++ src/line/template-messages.test.ts | 391 +++++ src/line/template-messages.ts | 401 +++++ src/line/types.ts | 150 ++ src/line/webhook.test.ts | 73 + src/line/webhook.ts | 102 ++ src/plugin-sdk/index.ts | 32 + src/plugins/commands.ts | 10 +- src/plugins/http-path.ts | 12 + src/plugins/http-registry.ts | 53 + src/plugins/loader.test.ts | 27 + src/plugins/registry.ts | 44 + src/plugins/runtime.ts | 1 + src/plugins/runtime/index.ts | 36 + src/plugins/runtime/types.ts | 37 + src/plugins/types.ts | 12 +- src/test-utils/channel-plugins.ts | 1 + src/utils/message-channel.test.ts | 1 + 85 files changed, 11365 insertions(+), 60 deletions(-) create mode 100644 extensions/line/clawdbot.plugin.json create mode 100644 extensions/line/index.ts create mode 100644 extensions/line/package.json create mode 100644 extensions/line/src/card-command.ts create mode 100644 extensions/line/src/channel.logout.test.ts create mode 100644 extensions/line/src/channel.sendPayload.test.ts create mode 100644 extensions/line/src/channel.ts create mode 100644 extensions/line/src/runtime.ts create mode 100644 src/auto-reply/reply/line-directives.test.ts create mode 100644 src/auto-reply/reply/line-directives.ts create mode 100644 src/auto-reply/reply/normalize-reply.test.ts create mode 100644 src/line/accounts.test.ts create mode 100644 src/line/accounts.ts create mode 100644 src/line/auto-reply-delivery.test.ts create mode 100644 src/line/auto-reply-delivery.ts create mode 100644 src/line/bot-access.ts create mode 100644 src/line/bot-handlers.test.ts create mode 100644 src/line/bot-handlers.ts create mode 100644 src/line/bot-message-context.test.ts create mode 100644 src/line/bot-message-context.ts create mode 100644 src/line/bot.ts create mode 100644 src/line/config-schema.ts create mode 100644 src/line/download.ts create mode 100644 src/line/flex-templates.test.ts create mode 100644 src/line/flex-templates.ts create mode 100644 src/line/http-registry.ts create mode 100644 src/line/index.ts create mode 100644 src/line/markdown-to-line.test.ts create mode 100644 src/line/markdown-to-line.ts create mode 100644 src/line/monitor.ts create mode 100644 src/line/probe.test.ts create mode 100644 src/line/probe.ts create mode 100644 src/line/reply-chunks.test.ts create mode 100644 src/line/reply-chunks.ts create mode 100644 src/line/rich-menu.test.ts create mode 100644 src/line/rich-menu.ts create mode 100644 src/line/send.test.ts create mode 100644 src/line/send.ts create mode 100644 src/line/template-messages.test.ts create mode 100644 src/line/template-messages.ts create mode 100644 src/line/types.ts create mode 100644 src/line/webhook.test.ts create mode 100644 src/line/webhook.ts create mode 100644 src/plugins/http-path.ts create mode 100644 src/plugins/http-registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 656abc4cb..7f0b9f221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/line/clawdbot.plugin.json b/extensions/line/clawdbot.plugin.json new file mode 100644 index 000000000..49f2bad10 --- /dev/null +++ b/extensions/line/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "line", + "channels": [ + "line" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/line/index.ts b/extensions/line/index.ts new file mode 100644 index 000000000..698c2d3a2 --- /dev/null +++ b/extensions/line/index.ts @@ -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; diff --git a/extensions/line/package.json b/extensions/line/package.json new file mode 100644 index 000000000..b58b2eb4d --- /dev/null +++ b/extensions/line/package.json @@ -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:*" + } +} diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts new file mode 100644 index 000000000..762faa012 --- /dev/null +++ b/extensions/line/src/card-command.ts @@ -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 "title" "body" [options] + +Types: + info "Title" "Body" ["Footer"] + image "Title" "Caption" --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; +} { + const result: { type: string; args: string[]; flags: Record } = { + 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 " }; + } + 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)}` }; + } + }, + }); +} diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts new file mode 100644 index 000000000..8523feaae --- /dev/null +++ b/extensions/line/src/channel.logout.test.ts @@ -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; + resolveLineAccount: ReturnType; +}; + +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>; + }; + 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({}); + }); +}); diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts new file mode 100644 index 000000000..1b949bb93 --- /dev/null +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -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; + pushMessagesLine: ReturnType; + pushFlexMessage: ReturnType; + pushTemplateMessage: ReturnType; + pushLocationMessage: ReturnType; + pushTextMessageWithQuickReplies: ReturnType; + createQuickReplyItems: ReturnType; + buildTemplateMessageFromPayload: ReturnType; + sendMessageLine: ReturnType; + chunkMarkdownText: ReturnType; + resolveLineAccount: ReturnType; + resolveTextChunkLimit: ReturnType; +}; + +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>; + }; + 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); + }); +}); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts new file mode 100644 index 000000000..c100f0a31 --- /dev/null +++ b/extensions/line/src/channel.ts @@ -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 = { + 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 ", + 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: "", + }, + }, + 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>) => { + 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> = []; + 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; + 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).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", + ], + }, +}; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts new file mode 100644 index 000000000..5706349c6 --- /dev/null +++ b/extensions/line/src/runtime.ts @@ -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; +} diff --git a/package.json b/package.json index bf6d003a5..3e908b3e1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b36478256..f8adb028b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index a60ae7847..242b444ff 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -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", diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 87d06b9d0..12fec300b 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 86a99e6bc..3b21a6aa0 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; */ export const handlePluginCommand: CommandHandler = async ( params, - _allowTextCommands, + allowTextCommands, ): Promise => { 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, }; }; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index d27b8e2a8..7078c15dc 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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 = { diff --git a/src/auto-reply/reply/line-directives.test.ts b/src/auto-reply/reply/line-directives.test.ts new file mode 100644 index 000000000..bf60232b8 --- /dev/null +++ b/src/auto-reply/reply/line-directives.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; + +const getLineData = (result: ReturnType) => + (result.channelData?.line as Record | 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>; + }; + 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>; + }; + 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>; + }; + 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"]); + }); + }); +}); diff --git a/src/auto-reply/reply/line-directives.ts b/src/auto-reply/reply/line-directives.ts new file mode 100644 index 000000000..a28faeef7 --- /dev/null +++ b/src/auto-reply/reply/line-directives.ts @@ -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 => { + 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, + ); +} diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts new file mode 100644 index 000000000..30fb5e3f5 --- /dev/null +++ b/src/auto-reply/reply/normalize-reply.test.ts @@ -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); + }); +}); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index f3060476b..7968088bd 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -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 }; } diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index edfdc1cbc..ecb28cf00 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -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, ); } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0e6e0a721..63cd59d3e 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics: [], diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index b6f0f44db..733205c8c 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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"); } diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 250c14091..1aa0fe067 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -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; }; diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts index 7281c43da..d2cea25e1 100644 --- a/src/channels/plugins/load.test.ts +++ b/src/channels/plugins/load.test.ts @@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics: [], diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 982975d44..5b293415d 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; sendPoll?: (ctx: ChannelPollContext) => Promise; diff --git a/src/config/channel-capabilities.test.ts b/src/config/channel-capabilities.test.ts index 1fbe3c2e6..bef3ba183 100644 --- a/src/config/channel-capabilities.test.ts +++ b/src/config/channel-capabilities.test.ts @@ -134,6 +134,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics: [], diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f8a2dbb15..3a122ebc1 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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, { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index b4cf95030..0097cd1b5 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics, diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 3a040b690..9be071e57 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -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: [], diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index 190caba61..ca695c472 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -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: [], diff --git a/src/gateway/server.channels.e2e.test.ts b/src/gateway/server.channels.e2e.test.ts index 6a121c416..c65b87c10 100644 --- a/src/gateway/server.channels.e2e.test.ts +++ b/src/gateway/server.channels.e2e.test.ts @@ -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: [], diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index 05ce14123..aca220dc2 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics: [], diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index 697c9b73b..bfc6a6871 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial = {}): Plu providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], commands: [], @@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial = {}): Plu ...merged, gatewayHandlers: merged.gatewayHandlers ?? {}, httpHandlers: merged.httpHandlers ?? [], + httpRoutes: merged.httpRoutes ?? [], }; }; diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index e4d54a68b..0308ebe31 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -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 diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 948a41a17..f8a7f85fd 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -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; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 2a402201a..c740bba66 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -138,6 +138,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], commands: [], diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index c9db031e5..f23220d9d 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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, + }, + ]; + 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: [ diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 2e939dfda..d259366b4 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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 = { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 6df384b52..cd0ccd5ff 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -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; sendText: (text: string) => Promise; sendMedia: (caption: string, mediaUrl: string) => Promise; }; @@ -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) { diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 24d2b7622..9165abed9 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -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( diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index b3558b356..94eabb2bc 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -5,12 +5,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; export type NormalizedOutboundPayload = { text: string; mediaUrls: string[]; + channelData?: Record; }; export type OutboundPayloadJson = { text: string; mediaUrl: string | null; mediaUrls?: string[]; + channelData?: Record; }; function mergeMediaUrls(...lists: Array | 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, })); } diff --git a/src/line/accounts.test.ts b/src/line/accounts.test.ts new file mode 100644 index 000000000..e0ea3dba2 --- /dev/null +++ b/src/line/accounts.test.ts @@ -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"); + }); + }); +}); diff --git a/src/line/accounts.ts b/src/line/accounts.ts new file mode 100644 index 000000000..9542bbf06 --- /dev/null +++ b/src/line/accounts.ts @@ -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(); + + // 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; +} diff --git a/src/line/auto-reply-delivery.test.ts b/src/line/auto-reply-delivery.test.ts new file mode 100644 index 000000000..48a7bf724 --- /dev/null +++ b/src/line/auto-reply-delivery.test.ts @@ -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); + }); +}); diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts new file mode 100644 index 000000000..ad4573ca1 --- /dev/null +++ b/src/line/auto-reply-delivery.ts @@ -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; + pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise; + pushTextMessageWithQuickReplies: ( + to: string, + text: string, + quickReplies: string[], + opts?: { accountId?: string }, + ) => Promise; + createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage; + createQuickReplyItems: (labels: string[]) => messagingApi.QuickReply; + pushMessagesLine: ( + to: string, + messages: messagingApi.Message[], + opts?: { accountId?: string }, + ) => Promise; + 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 => { + 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 => { + 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 }; +} diff --git a/src/line/bot-access.ts b/src/line/bot-access.ts new file mode 100644 index 000000000..2df9502fe --- /dev/null +++ b/src/line/bot-access.ts @@ -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): 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; + storeAllowFrom?: string[]; +}): NormalizedAllowFrom => { + const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]; + return normalizeAllowFrom(combined); +}; + +export const firstDefined = (...values: Array) => { + 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); +}; diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts new file mode 100644 index 000000000..00f0082ed --- /dev/null +++ b/src/line/bot-handlers.test.ts @@ -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(); + }); +}); diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts new file mode 100644 index 000000000..28e91b984 --- /dev/null +++ b/src/line/bot-handlers.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + const userId = event.source.type === "user" ? event.source.userId : undefined; + logVerbose(`line: user ${userId ?? "unknown"} unfollowed`); +} + +async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise { + 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 { + 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 { + 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 { + 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)}`)); + } + } +} diff --git a/src/line/bot-message-context.test.ts b/src/line/bot-message-context.test.ts new file mode 100644 index 000000000..357588263 --- /dev/null +++ b/src/line/bot-message-context.test.ts @@ -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"); + }); +}); diff --git a/src/line/bot-message-context.ts b/src/line/bot-message-context.ts new file mode 100644 index 000000000..2fa508982 --- /dev/null +++ b/src/line/bot-message-context.ts @@ -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 = { + "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 ""; + case "video": + return ""; + case "audio": + return ""; + case "file": + return ""; + 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 = `${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 | 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>>; +export type LinePostbackContext = NonNullable>>; +export type LineInboundContext = LineMessageContext | LinePostbackContext; diff --git a/src/line/bot.ts b/src/line/bot.ts new file mode 100644 index 000000000..c963d2435 --- /dev/null +++ b/src/line/bot.ts @@ -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; +} + +export interface LineBot { + handleWebhook: (body: WebhookRequestBody) => Promise; + 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 => { + 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 }; +} diff --git a/src/line/config-schema.ts b/src/line/config-schema.ts new file mode 100644 index 000000000..7e7a2be03 --- /dev/null +++ b/src/line/config-schema.ts @@ -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; diff --git a/src/line/download.ts b/src/line/download.ts new file mode 100644 index 000000000..e48cb0e71 --- /dev/null +++ b/src/line/download.ts @@ -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 { + 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) { + 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"; + } +} diff --git a/src/line/flex-templates.test.ts b/src/line/flex-templates.test.ts new file mode 100644 index 000000000..cfaa2297e --- /dev/null +++ b/src/line/flex-templates.test.ts @@ -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); + }); +}); diff --git a/src/line/flex-templates.ts b/src/line/flex-templates.ts new file mode 100644 index 000000000..e0fe7e693 --- /dev/null +++ b/src/line/flex-templates.ts @@ -0,0 +1,1507 @@ +import type { messagingApi } from "@line/bot-sdk"; + +// Re-export types for convenience +type FlexContainer = messagingApi.FlexContainer; +type FlexBubble = messagingApi.FlexBubble; +type FlexCarousel = messagingApi.FlexCarousel; +type FlexBox = messagingApi.FlexBox; +type FlexText = messagingApi.FlexText; +type FlexImage = messagingApi.FlexImage; +type FlexButton = messagingApi.FlexButton; +type FlexComponent = messagingApi.FlexComponent; +type Action = messagingApi.Action; + +export interface ListItem { + title: string; + subtitle?: string; + action?: Action; +} + +export interface CardAction { + label: string; + action: Action; +} + +/** + * Create an info card with title, body, and optional footer + * + * Editorial design: Clean hierarchy with accent bar, generous spacing, + * and subtle background zones for visual separation. + */ +export function createInfoCard(title: string, body: string, footer?: string): FlexBubble { + const bubble: FlexBubble = { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: [ + // Title with accent bar + { + type: "box", + layout: "horizontal", + contents: [ + { + type: "box", + layout: "vertical", + contents: [], + width: "4px", + backgroundColor: "#06C755", + cornerRadius: "2px", + } as FlexBox, + { + type: "text", + text: title, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + flex: 1, + margin: "lg", + } as FlexText, + ], + } as FlexBox, + // Body text in subtle container + { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: body, + size: "md", + color: "#444444", + wrap: true, + lineSpacing: "6px", + } as FlexText, + ], + margin: "xl", + paddingAll: "lg", + backgroundColor: "#F8F9FA", + cornerRadius: "lg", + } as FlexBox, + ], + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; + + if (footer) { + bubble.footer = { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: footer, + size: "xs", + color: "#AAAAAA", + wrap: true, + align: "center", + } as FlexText, + ], + paddingAll: "lg", + backgroundColor: "#FAFAFA", + }; + } + + return bubble; +} + +/** + * Create a list card with title and multiple items + * + * Editorial design: Numbered/bulleted list with clear visual hierarchy, + * accent dots for each item, and generous spacing. + */ +export function createListCard(title: string, items: ListItem[]): FlexBubble { + const itemContents: FlexComponent[] = items.slice(0, 8).map((item, index) => { + const itemContents: FlexComponent[] = [ + { + type: "text", + text: item.title, + size: "md", + weight: "bold", + color: "#1a1a1a", + wrap: true, + } as FlexText, + ]; + + if (item.subtitle) { + itemContents.push({ + type: "text", + text: item.subtitle, + size: "sm", + color: "#888888", + wrap: true, + margin: "xs", + } as FlexText); + } + + const itemBox: FlexBox = { + type: "box", + layout: "horizontal", + contents: [ + // Accent dot + { + type: "box", + layout: "vertical", + contents: [ + { + type: "box", + layout: "vertical", + contents: [], + width: "8px", + height: "8px", + backgroundColor: index === 0 ? "#06C755" : "#DDDDDD", + cornerRadius: "4px", + } as FlexBox, + ], + width: "20px", + alignItems: "center", + paddingTop: "sm", + } as FlexBox, + // Item content + { + type: "box", + layout: "vertical", + contents: itemContents, + flex: 1, + } as FlexBox, + ], + margin: index > 0 ? "lg" : undefined, + }; + + if (item.action) { + itemBox.action = item.action; + } + + return itemBox; + }); + + return { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + } as FlexText, + { + type: "separator", + margin: "lg", + color: "#EEEEEE", + }, + { + type: "box", + layout: "vertical", + contents: itemContents, + margin: "lg", + } as FlexBox, + ], + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; +} + +/** + * Create an image card with image, title, and optional body text + */ +export function createImageCard( + imageUrl: string, + title: string, + body?: string, + options?: { + aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1"; + aspectMode?: "cover" | "fit"; + action?: Action; + }, +): FlexBubble { + const bubble: FlexBubble = { + type: "bubble", + hero: { + type: "image", + url: imageUrl, + size: "full", + aspectRatio: options?.aspectRatio ?? "20:13", + aspectMode: options?.aspectMode ?? "cover", + action: options?.action, + } as FlexImage, + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + wrap: true, + } as FlexText, + ], + paddingAll: "lg", + }, + }; + + if (body && bubble.body) { + (bubble.body as FlexBox).contents.push({ + type: "text", + text: body, + size: "md", + wrap: true, + margin: "md", + color: "#666666", + } as FlexText); + } + + return bubble; +} + +/** + * Create an action card with title, body, and action buttons + */ +export function createActionCard( + title: string, + body: string, + actions: CardAction[], + options?: { + imageUrl?: string; + aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1"; + }, +): FlexBubble { + const bubble: FlexBubble = { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + wrap: true, + } as FlexText, + { + type: "text", + text: body, + size: "md", + wrap: true, + margin: "md", + color: "#666666", + } as FlexText, + ], + paddingAll: "lg", + }, + footer: { + type: "box", + layout: "vertical", + contents: actions.slice(0, 4).map( + (action, index) => + ({ + type: "button", + action: action.action, + style: index === 0 ? "primary" : "secondary", + margin: index > 0 ? "sm" : undefined, + }) as FlexButton, + ), + paddingAll: "md", + }, + }; + + if (options?.imageUrl) { + bubble.hero = { + type: "image", + url: options.imageUrl, + size: "full", + aspectRatio: options.aspectRatio ?? "20:13", + aspectMode: "cover", + } as FlexImage; + } + + return bubble; +} + +/** + * Create a carousel container from multiple bubbles + * LINE allows max 12 bubbles in a carousel + */ +export function createCarousel(bubbles: FlexBubble[]): FlexCarousel { + return { + type: "carousel", + contents: bubbles.slice(0, 12), + }; +} + +/** + * Create a notification bubble (for alerts, status updates) + * + * Editorial design: Bold status indicator with accent color, + * clear typography, optional icon for context. + */ +export function createNotificationBubble( + text: string, + options?: { + icon?: string; + type?: "info" | "success" | "warning" | "error"; + title?: string; + }, +): FlexBubble { + // Color based on notification type + const colors = { + info: { accent: "#3B82F6", bg: "#EFF6FF" }, + success: { accent: "#06C755", bg: "#F0FDF4" }, + warning: { accent: "#F59E0B", bg: "#FFFBEB" }, + error: { accent: "#EF4444", bg: "#FEF2F2" }, + }; + const typeColors = colors[options?.type ?? "info"]; + + const contents: FlexComponent[] = []; + + // Accent bar + contents.push({ + type: "box", + layout: "vertical", + contents: [], + width: "4px", + backgroundColor: typeColors.accent, + cornerRadius: "2px", + } as FlexBox); + + // Content section + const textContents: FlexComponent[] = []; + + if (options?.title) { + textContents.push({ + type: "text", + text: options.title, + size: "md", + weight: "bold", + color: "#111111", + wrap: true, + } as FlexText); + } + + textContents.push({ + type: "text", + text, + size: options?.title ? "sm" : "md", + color: options?.title ? "#666666" : "#333333", + wrap: true, + margin: options?.title ? "sm" : undefined, + } as FlexText); + + contents.push({ + type: "box", + layout: "vertical", + contents: textContents, + flex: 1, + paddingStart: "lg", + } as FlexBox); + + return { + type: "bubble", + body: { + type: "box", + layout: "horizontal", + contents, + paddingAll: "xl", + backgroundColor: typeColors.bg, + }, + }; +} + +/** + * Create a receipt/summary card (for orders, transactions, data tables) + * + * Editorial design: Clean table layout with alternating row backgrounds, + * prominent total section, and clear visual hierarchy. + */ +export function createReceiptCard(params: { + title: string; + subtitle?: string; + items: Array<{ name: string; value: string; highlight?: boolean }>; + total?: { label: string; value: string }; + footer?: string; +}): FlexBubble { + const { title, subtitle, items, total, footer } = params; + + const itemRows: FlexComponent[] = items.slice(0, 12).map( + (item, index) => + ({ + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: item.name, + size: "sm", + color: item.highlight ? "#111111" : "#666666", + weight: item.highlight ? "bold" : "regular", + flex: 3, + wrap: true, + } as FlexText, + { + type: "text", + text: item.value, + size: "sm", + color: item.highlight ? "#06C755" : "#333333", + weight: item.highlight ? "bold" : "regular", + flex: 2, + align: "end", + wrap: true, + } as FlexText, + ], + paddingAll: "md", + backgroundColor: index % 2 === 0 ? "#FFFFFF" : "#FAFAFA", + }) as FlexBox, + ); + + // Header section + const headerContents: FlexComponent[] = [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + } as FlexText, + ]; + + if (subtitle) { + headerContents.push({ + type: "text", + text: subtitle, + size: "sm", + color: "#888888", + margin: "sm", + wrap: true, + } as FlexText); + } + + const bodyContents: FlexComponent[] = [ + { + type: "box", + layout: "vertical", + contents: headerContents, + paddingBottom: "lg", + } as FlexBox, + { + type: "separator", + color: "#EEEEEE", + }, + { + type: "box", + layout: "vertical", + contents: itemRows, + margin: "md", + cornerRadius: "md", + borderWidth: "light", + borderColor: "#EEEEEE", + } as FlexBox, + ]; + + // Total section with emphasis + if (total) { + bodyContents.push({ + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: total.label, + size: "lg", + weight: "bold", + color: "#111111", + flex: 2, + } as FlexText, + { + type: "text", + text: total.value, + size: "xl", + weight: "bold", + color: "#06C755", + flex: 2, + align: "end", + } as FlexText, + ], + margin: "xl", + paddingAll: "lg", + backgroundColor: "#F0FDF4", + cornerRadius: "lg", + } as FlexBox); + } + + const bubble: FlexBubble = { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: bodyContents, + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; + + if (footer) { + bubble.footer = { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: footer, + size: "xs", + color: "#AAAAAA", + wrap: true, + align: "center", + } as FlexText, + ], + paddingAll: "lg", + backgroundColor: "#FAFAFA", + }; + } + + return bubble; +} + +/** + * Create a calendar event card (for meetings, appointments, reminders) + * + * Editorial design: Date as hero, strong typographic hierarchy, + * color-blocked zones, full text wrapping for readability. + */ +export function createEventCard(params: { + title: string; + date: string; + time?: string; + location?: string; + description?: string; + calendar?: string; + isAllDay?: boolean; + action?: Action; +}): FlexBubble { + const { title, date, time, location, description, calendar, isAllDay, action } = params; + + // Hero date block - the most important information + const dateBlock: FlexBox = { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: date.toUpperCase(), + size: "sm", + weight: "bold", + color: "#06C755", + wrap: true, + } as FlexText, + { + type: "text", + text: isAllDay ? "ALL DAY" : (time ?? ""), + size: "xxl", + weight: "bold", + color: "#111111", + wrap: true, + margin: "xs", + } as FlexText, + ], + paddingBottom: "lg", + borderWidth: "none", + }; + + // If no time and not all day, hide the time display + if (!time && !isAllDay) { + dateBlock.contents = [ + { + type: "text", + text: date, + size: "xl", + weight: "bold", + color: "#111111", + wrap: true, + } as FlexText, + ]; + } + + // Event title with accent bar + const titleBlock: FlexBox = { + type: "box", + layout: "horizontal", + contents: [ + { + type: "box", + layout: "vertical", + contents: [], + width: "4px", + backgroundColor: "#06C755", + cornerRadius: "2px", + } as FlexBox, + { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: title, + size: "lg", + weight: "bold", + color: "#1a1a1a", + wrap: true, + } as FlexText, + ...(calendar + ? [ + { + type: "text", + text: calendar, + size: "xs", + color: "#888888", + margin: "sm", + wrap: true, + } as FlexText, + ] + : []), + ], + flex: 1, + paddingStart: "lg", + } as FlexBox, + ], + paddingTop: "lg", + paddingBottom: "lg", + borderWidth: "light", + borderColor: "#EEEEEE", + }; + + const bodyContents: FlexComponent[] = [dateBlock, titleBlock]; + + // Details section (location + description) in subtle background + const hasDetails = location || description; + if (hasDetails) { + const detailItems: FlexComponent[] = []; + + if (location) { + detailItems.push({ + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: "📍", + size: "sm", + flex: 0, + } as FlexText, + { + type: "text", + text: location, + size: "sm", + color: "#444444", + margin: "md", + flex: 1, + wrap: true, + } as FlexText, + ], + alignItems: "flex-start", + } as FlexBox); + } + + if (description) { + detailItems.push({ + type: "text", + text: description, + size: "sm", + color: "#666666", + wrap: true, + margin: location ? "lg" : "none", + } as FlexText); + } + + bodyContents.push({ + type: "box", + layout: "vertical", + contents: detailItems, + margin: "lg", + paddingAll: "lg", + backgroundColor: "#F8F9FA", + cornerRadius: "lg", + } as FlexBox); + } + + return { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: bodyContents, + paddingAll: "xl", + backgroundColor: "#FFFFFF", + action, + }, + }; +} + +/** + * Create a calendar agenda card showing multiple events + * + * Editorial timeline design: Time-focused left column with event details + * on the right. Visual accent bars indicate event priority/recency. + */ +export function createAgendaCard(params: { + title: string; + subtitle?: string; + events: Array<{ + title: string; + time?: string; + location?: string; + calendar?: string; + isNow?: boolean; + }>; + footer?: string; +}): FlexBubble { + const { title, subtitle, events, footer } = params; + + // Header with title and optional subtitle + const headerContents: FlexComponent[] = [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + } as FlexText, + ]; + + if (subtitle) { + headerContents.push({ + type: "text", + text: subtitle, + size: "sm", + color: "#888888", + margin: "sm", + wrap: true, + } as FlexText); + } + + // Event timeline items + const eventItems: FlexComponent[] = events.slice(0, 6).map((event, index) => { + const isActive = event.isNow || index === 0; + const accentColor = isActive ? "#06C755" : "#E5E5E5"; + + // Time column (fixed width) + const timeColumn: FlexBox = { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: event.time ?? "—", + size: "sm", + weight: isActive ? "bold" : "regular", + color: isActive ? "#06C755" : "#666666", + align: "end", + wrap: true, + } as FlexText, + ], + width: "65px", + justifyContent: "flex-start", + }; + + // Accent dot + const dotColumn: FlexBox = { + type: "box", + layout: "vertical", + contents: [ + { + type: "box", + layout: "vertical", + contents: [], + width: "10px", + height: "10px", + backgroundColor: accentColor, + cornerRadius: "5px", + } as FlexBox, + ], + width: "24px", + alignItems: "center", + justifyContent: "flex-start", + paddingTop: "xs", + }; + + // Event details column + const detailContents: FlexComponent[] = [ + { + type: "text", + text: event.title, + size: "md", + weight: "bold", + color: "#1a1a1a", + wrap: true, + } as FlexText, + ]; + + // Secondary info line + const secondaryParts: string[] = []; + if (event.location) secondaryParts.push(event.location); + if (event.calendar) secondaryParts.push(event.calendar); + + if (secondaryParts.length > 0) { + detailContents.push({ + type: "text", + text: secondaryParts.join(" · "), + size: "xs", + color: "#888888", + wrap: true, + margin: "xs", + } as FlexText); + } + + const detailColumn: FlexBox = { + type: "box", + layout: "vertical", + contents: detailContents, + flex: 1, + }; + + return { + type: "box", + layout: "horizontal", + contents: [timeColumn, dotColumn, detailColumn], + margin: index > 0 ? "xl" : undefined, + alignItems: "flex-start", + } as FlexBox; + }); + + const bodyContents: FlexComponent[] = [ + { + type: "box", + layout: "vertical", + contents: headerContents, + paddingBottom: "lg", + } as FlexBox, + { + type: "separator", + color: "#EEEEEE", + }, + { + type: "box", + layout: "vertical", + contents: eventItems, + paddingTop: "xl", + } as FlexBox, + ]; + + const bubble: FlexBubble = { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: bodyContents, + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; + + if (footer) { + bubble.footer = { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: footer, + size: "xs", + color: "#AAAAAA", + align: "center", + wrap: true, + } as FlexText, + ], + paddingAll: "lg", + backgroundColor: "#FAFAFA", + }; + } + + return bubble; +} + +/** + * Create a media player card for Sonos, Spotify, Apple Music, etc. + * + * Editorial design: Album art hero with gradient overlay for text, + * prominent now-playing indicator, refined playback controls. + */ +export function createMediaPlayerCard(params: { + title: string; + subtitle?: string; + source?: string; + imageUrl?: string; + isPlaying?: boolean; + progress?: string; + controls?: { + previous?: { data: string }; + play?: { data: string }; + pause?: { data: string }; + next?: { data: string }; + }; + extraActions?: Array<{ label: string; data: string }>; +}): FlexBubble { + const { title, subtitle, source, imageUrl, isPlaying, progress, controls, extraActions } = params; + + // Track info section + const trackInfo: FlexComponent[] = [ + { + type: "text", + text: title, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + } as FlexText, + ]; + + if (subtitle) { + trackInfo.push({ + type: "text", + text: subtitle, + size: "md", + color: "#666666", + wrap: true, + margin: "sm", + } as FlexText); + } + + // Status row with source and playing indicator + const statusItems: FlexComponent[] = []; + + if (isPlaying !== undefined) { + statusItems.push({ + type: "box", + layout: "horizontal", + contents: [ + { + type: "box", + layout: "vertical", + contents: [], + width: "8px", + height: "8px", + backgroundColor: isPlaying ? "#06C755" : "#CCCCCC", + cornerRadius: "4px", + } as FlexBox, + { + type: "text", + text: isPlaying ? "Now Playing" : "Paused", + size: "xs", + color: isPlaying ? "#06C755" : "#888888", + weight: "bold", + margin: "sm", + } as FlexText, + ], + alignItems: "center", + } as FlexBox); + } + + if (source) { + statusItems.push({ + type: "text", + text: source, + size: "xs", + color: "#AAAAAA", + margin: statusItems.length > 0 ? "lg" : undefined, + } as FlexText); + } + + if (progress) { + statusItems.push({ + type: "text", + text: progress, + size: "xs", + color: "#888888", + align: "end", + flex: 1, + } as FlexText); + } + + const bodyContents: FlexComponent[] = [ + { + type: "box", + layout: "vertical", + contents: trackInfo, + } as FlexBox, + ]; + + if (statusItems.length > 0) { + bodyContents.push({ + type: "box", + layout: "horizontal", + contents: statusItems, + margin: "lg", + alignItems: "center", + } as FlexBox); + } + + const bubble: FlexBubble = { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: bodyContents, + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; + + // Album art hero + if (imageUrl) { + bubble.hero = { + type: "image", + url: imageUrl, + size: "full", + aspectRatio: "1:1", + aspectMode: "cover", + } as FlexImage; + } + + // Control buttons in footer + if (controls || extraActions?.length) { + const footerContents: FlexComponent[] = []; + + // Main playback controls with refined styling + if (controls) { + const controlButtons: FlexComponent[] = []; + + if (controls.previous) { + controlButtons.push({ + type: "button", + action: { + type: "postback", + label: "⏮", + data: controls.previous.data, + }, + style: "secondary", + flex: 1, + height: "sm", + } as FlexButton); + } + + if (controls.play) { + controlButtons.push({ + type: "button", + action: { + type: "postback", + label: "▶", + data: controls.play.data, + }, + style: isPlaying ? "secondary" : "primary", + flex: 1, + height: "sm", + margin: controls.previous ? "md" : undefined, + } as FlexButton); + } + + if (controls.pause) { + controlButtons.push({ + type: "button", + action: { + type: "postback", + label: "⏸", + data: controls.pause.data, + }, + style: isPlaying ? "primary" : "secondary", + flex: 1, + height: "sm", + margin: controlButtons.length > 0 ? "md" : undefined, + } as FlexButton); + } + + if (controls.next) { + controlButtons.push({ + type: "button", + action: { + type: "postback", + label: "⏭", + data: controls.next.data, + }, + style: "secondary", + flex: 1, + height: "sm", + margin: controlButtons.length > 0 ? "md" : undefined, + } as FlexButton); + } + + if (controlButtons.length > 0) { + footerContents.push({ + type: "box", + layout: "horizontal", + contents: controlButtons, + } as FlexBox); + } + } + + // Extra actions + if (extraActions?.length) { + footerContents.push({ + type: "box", + layout: "horizontal", + contents: extraActions.slice(0, 2).map( + (action, index) => + ({ + type: "button", + action: { + type: "postback", + label: action.label.slice(0, 15), + data: action.data, + }, + style: "secondary", + flex: 1, + height: "sm", + margin: index > 0 ? "md" : undefined, + }) as FlexButton, + ), + margin: "md", + } as FlexBox); + } + + if (footerContents.length > 0) { + bubble.footer = { + type: "box", + layout: "vertical", + contents: footerContents, + paddingAll: "lg", + backgroundColor: "#FAFAFA", + }; + } + } + + return bubble; +} + +/** + * Create an Apple TV remote card with a D-pad and control rows. + */ +export function createAppleTvRemoteCard(params: { + deviceName: string; + status?: string; + actionData: { + up: string; + down: string; + left: string; + right: string; + select: string; + menu: string; + home: string; + play: string; + pause: string; + volumeUp: string; + volumeDown: string; + mute: string; + }; +}): FlexBubble { + const { deviceName, status, actionData } = params; + + const headerContents: FlexComponent[] = [ + { + type: "text", + text: deviceName, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + } as FlexText, + ]; + + if (status) { + headerContents.push({ + type: "text", + text: status, + size: "sm", + color: "#666666", + wrap: true, + margin: "sm", + } as FlexText); + } + + const makeButton = ( + label: string, + data: string, + style: "primary" | "secondary" = "secondary", + ): FlexButton => ({ + type: "button", + action: { + type: "postback", + label, + data, + }, + style, + height: "sm", + flex: 1, + }); + + const dpadRows: FlexComponent[] = [ + { + type: "box", + layout: "horizontal", + contents: [{ type: "filler" }, makeButton("↑", actionData.up), { type: "filler" }], + } as FlexBox, + { + type: "box", + layout: "horizontal", + contents: [ + makeButton("←", actionData.left), + makeButton("OK", actionData.select, "primary"), + makeButton("→", actionData.right), + ], + margin: "md", + } as FlexBox, + { + type: "box", + layout: "horizontal", + contents: [{ type: "filler" }, makeButton("↓", actionData.down), { type: "filler" }], + margin: "md", + } as FlexBox, + ]; + + const menuRow: FlexComponent = { + type: "box", + layout: "horizontal", + contents: [makeButton("Menu", actionData.menu), makeButton("Home", actionData.home)], + margin: "lg", + } as FlexBox; + + const playbackRow: FlexComponent = { + type: "box", + layout: "horizontal", + contents: [makeButton("Play", actionData.play), makeButton("Pause", actionData.pause)], + margin: "md", + } as FlexBox; + + const volumeRow: FlexComponent = { + type: "box", + layout: "horizontal", + contents: [ + makeButton("Vol +", actionData.volumeUp), + makeButton("Mute", actionData.mute), + makeButton("Vol -", actionData.volumeDown), + ], + margin: "md", + } as FlexBox; + + return { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "box", + layout: "vertical", + contents: headerContents, + } as FlexBox, + { + type: "separator", + margin: "lg", + color: "#EEEEEE", + }, + ...dpadRows, + menuRow, + playbackRow, + volumeRow, + ], + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; +} + +/** + * Create a device control card for Apple TV, smart home devices, etc. + * + * Editorial design: Device-focused header with status indicator, + * clean control grid with clear visual hierarchy. + */ +export function createDeviceControlCard(params: { + deviceName: string; + deviceType?: string; + status?: string; + isOnline?: boolean; + imageUrl?: string; + controls: Array<{ + label: string; + icon?: string; + data: string; + style?: "primary" | "secondary"; + }>; +}): FlexBubble { + const { deviceName, deviceType, status, isOnline, imageUrl, controls } = params; + + // Device header with status indicator + const headerContents: FlexComponent[] = [ + { + type: "box", + layout: "horizontal", + contents: [ + // Status dot + { + type: "box", + layout: "vertical", + contents: [], + width: "10px", + height: "10px", + backgroundColor: isOnline !== false ? "#06C755" : "#FF5555", + cornerRadius: "5px", + } as FlexBox, + { + type: "text", + text: deviceName, + weight: "bold", + size: "xl", + color: "#111111", + wrap: true, + flex: 1, + margin: "md", + } as FlexText, + ], + alignItems: "center", + } as FlexBox, + ]; + + if (deviceType) { + headerContents.push({ + type: "text", + text: deviceType, + size: "sm", + color: "#888888", + margin: "sm", + } as FlexText); + } + + if (status) { + headerContents.push({ + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: status, + size: "sm", + color: "#444444", + wrap: true, + } as FlexText, + ], + margin: "lg", + paddingAll: "md", + backgroundColor: "#F8F9FA", + cornerRadius: "md", + } as FlexBox); + } + + const bubble: FlexBubble = { + type: "bubble", + size: "mega", + body: { + type: "box", + layout: "vertical", + contents: headerContents, + paddingAll: "xl", + backgroundColor: "#FFFFFF", + }, + }; + + if (imageUrl) { + bubble.hero = { + type: "image", + url: imageUrl, + size: "full", + aspectRatio: "16:9", + aspectMode: "cover", + } as FlexImage; + } + + // Control buttons in refined grid layout (2 per row) + if (controls.length > 0) { + const rows: FlexComponent[] = []; + const limitedControls = controls.slice(0, 6); + + for (let i = 0; i < limitedControls.length; i += 2) { + const rowButtons: FlexComponent[] = []; + + for (let j = i; j < Math.min(i + 2, limitedControls.length); j++) { + const ctrl = limitedControls[j]; + const buttonLabel = ctrl.icon ? `${ctrl.icon} ${ctrl.label}` : ctrl.label; + + rowButtons.push({ + type: "button", + action: { + type: "postback", + label: buttonLabel.slice(0, 18), + data: ctrl.data, + }, + style: ctrl.style ?? "secondary", + flex: 1, + height: "sm", + margin: j > i ? "md" : undefined, + } as FlexButton); + } + + // If odd number of controls in last row, add spacer + if (rowButtons.length === 1) { + rowButtons.push({ + type: "filler", + }); + } + + rows.push({ + type: "box", + layout: "horizontal", + contents: rowButtons, + margin: i > 0 ? "md" : undefined, + } as FlexBox); + } + + bubble.footer = { + type: "box", + layout: "vertical", + contents: rows, + paddingAll: "lg", + backgroundColor: "#FAFAFA", + }; + } + + return bubble; +} + +/** + * Wrap a FlexContainer in a FlexMessage + */ +export function toFlexMessage(altText: string, contents: FlexContainer): messagingApi.FlexMessage { + return { + type: "flex", + altText, + contents, + }; +} + +// Re-export the types for consumers +export type { + FlexContainer, + FlexBubble, + FlexCarousel, + FlexBox, + FlexText, + FlexImage, + FlexButton, + FlexComponent, + Action, +}; diff --git a/src/line/http-registry.ts b/src/line/http-registry.ts new file mode 100644 index 000000000..1d971e752 --- /dev/null +++ b/src/line/http-registry.ts @@ -0,0 +1,45 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export type LineHttpRequestHandler = ( + req: IncomingMessage, + res: ServerResponse, +) => Promise | void; + +type RegisterLineHttpHandlerArgs = { + path?: string | null; + handler: LineHttpRequestHandler; + log?: (message: string) => void; + accountId?: string; +}; + +const lineHttpRoutes = new Map(); + +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 { + const url = new URL(req.url ?? "/", "http://localhost"); + const handler = lineHttpRoutes.get(url.pathname); + if (!handler) return false; + await handler(req, res); + return true; +} diff --git a/src/line/index.ts b/src/line/index.ts new file mode 100644 index 000000000..f812ee58d --- /dev/null +++ b/src/line/index.ts @@ -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"; diff --git a/src/line/markdown-to-line.test.ts b/src/line/markdown-to-line.test.ts new file mode 100644 index 000000000..99c37a4f4 --- /dev/null +++ b/src/line/markdown-to-line.test.ts @@ -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); + }); +}); diff --git a/src/line/markdown-to-line.ts b/src/line/markdown-to-line.ts new file mode 100644 index 000000000..21253c36a --- /dev/null +++ b/src/line/markdown-to-line.ts @@ -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(/(? 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; +} diff --git a/src/line/monitor.ts b/src/line/monitor.ts new file mode 100644 index 000000000..9b40e4460 --- /dev/null +++ b/src/line/monitor.ts @@ -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; + 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 { + 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 { + 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); + }, + }; +} diff --git a/src/line/probe.test.ts b/src/line/probe.test.ts new file mode 100644 index 000000000..732295e39 --- /dev/null +++ b/src/line/probe.test.ts @@ -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"); + }); +}); diff --git a/src/line/probe.ts b/src/line/probe.ts new file mode 100644 index 000000000..d538d4271 --- /dev/null +++ b/src/line/probe.ts @@ -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 { + 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(promise: Promise, timeoutMs: number): Promise { + if (!timeoutMs || timeoutMs <= 0) return promise; + let timer: NodeJS.Timeout | null = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} diff --git a/src/line/reply-chunks.test.ts b/src/line/reply-chunks.test.ts new file mode 100644 index 000000000..60e03e688 --- /dev/null +++ b/src/line/reply-chunks.test.ts @@ -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(); + }); +}); diff --git a/src/line/reply-chunks.ts b/src/line/reply-chunks.ts new file mode 100644 index 000000000..e4d5c4b9d --- /dev/null +++ b/src/line/reply-chunks.ts @@ -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; + pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise; + pushTextMessageWithQuickReplies: ( + to: string, + text: string, + quickReplies: string[], + opts?: { accountId?: string }, + ) => Promise; + 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 }; +} diff --git a/src/line/rich-menu.test.ts b/src/line/rich-menu.test.ts new file mode 100644 index 000000000..96b069f34 --- /dev/null +++ b/src/line/rich-menu.test.ts @@ -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, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ]; + + 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, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ]; + + 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, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ]; + + 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"); + }); +}); diff --git a/src/line/rich-menu.ts b/src/line/rich-menu.ts new file mode 100644 index 000000000..6149405a9 --- /dev/null +++ b/src/line/rich-menu.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/src/line/send.test.ts b/src/line/send.test.ts new file mode 100644 index 000000000..add3669f7 --- /dev/null +++ b/src/line/send.test.ts @@ -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"); + }); +}); diff --git a/src/line/send.ts b/src/line/send.ts new file mode 100644 index 000000000..68be26a29 --- /dev/null +++ b/src/line/send.ts @@ -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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const profile = await getUserProfile(userId, opts); + return profile?.displayName ?? userId; +} diff --git a/src/line/template-messages.test.ts b/src/line/template-messages.test.ts new file mode 100644 index 000000000..dc43b321b --- /dev/null +++ b/src/line/template-messages.test.ts @@ -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); + }); +}); diff --git a/src/line/template-messages.ts b/src/line/template-messages.ts new file mode 100644 index 000000000..686dc8337 --- /dev/null +++ b/src/line/template-messages.ts @@ -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, +}; diff --git a/src/line/types.ts b/src/line/types.ts new file mode 100644 index 000000000..252fcb949 --- /dev/null +++ b/src/line/types.ts @@ -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; + groupAllowFrom?: Array; + dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; + mediaMaxMb?: number; + webhookPath?: string; + accounts?: Record; + groups?: Record; +} + +export interface LineAccountConfig { + enabled?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + name?: string; + allowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; + mediaMaxMb?: number; + webhookPath?: string; + groups?: Record; +} + +export interface LineGroupConfig { + enabled?: boolean; + allowFrom?: Array; + 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; +}; diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts new file mode 100644 index 000000000..af30040b4 --- /dev/null +++ b/src/line/webhook.test.ts @@ -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(); + }); +}); diff --git a/src/line/webhook.ts b/src/line/webhook.ts new file mode 100644 index 000000000..5f5e12441 --- /dev/null +++ b/src/line/webhook.ts @@ -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; + 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 => { + 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; + 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 }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index cb4e95a82..3e213746f 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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"; diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 27e424303..bb4e4b386 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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 { 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}`); diff --git a/src/plugins/http-path.ts b/src/plugins/http-path.ts new file mode 100644 index 000000000..341b91dcd --- /dev/null +++ b/src/plugins/http-path.ts @@ -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}`; +} diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts new file mode 100644 index 000000000..ae84fc91c --- /dev/null +++ b/src/plugins/http-registry.ts @@ -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; + +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); + } + }; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index f4c581486..4417a13aa 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 048e490f3..8e9abdda5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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), diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 0da06ae63..16cb8d5c7 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -9,6 +9,7 @@ const createEmptyRegistry = (): PluginRegistry => ({ providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], commands: [], diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 2edf22513..685dcb38b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index b9589f4b7..b7aecaf1a 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index c5363d72e..1ce9731ea 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; +export type ClawdbotPluginHttpRouteHandler = ( + req: IncomingMessage, + res: ServerResponse, +) => Promise | 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; diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 6bac4cfc2..3e6bf2112 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -17,6 +17,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], commands: [], diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index f61ee8fd3..5651b97a3 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -12,6 +12,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => providers: [], gatewayHandlers: {}, httpHandlers: [], + httpRoutes: [], cliRegistrars: [], services: [], diagnostics: [],