Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
11
extensions/line/README.md
Normal file
11
extensions/line/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw LINE
|
||||
|
||||
Official OpenClaw channel plugin for LINE Bot API chats.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/line
|
||||
```
|
||||
|
||||
Configure LINE channel credentials in OpenClaw, then connect the bot to the chats where agents should receive and send messages.
|
||||
12
extensions/line/api.ts
Normal file
12
extensions/line/api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ResolvedLineAccount } from "./runtime-api.js";
|
||||
export { linePlugin } from "./src/channel.js";
|
||||
export { lineSetupPlugin } from "./src/channel.setup.js";
|
||||
2
extensions/line/channel-plugin-api.ts
Normal file
2
extensions/line/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export { linePlugin } from "./src/channel.js";
|
||||
6
extensions/line/contract-api.ts
Normal file
6
extensions/line/contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "./src/accounts.js";
|
||||
54
extensions/line/index.ts
Normal file
54
extensions/line/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Line plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
defineBundledChannelEntry,
|
||||
type OpenClawPluginCommandDefinition,
|
||||
type OpenClawPluginApi,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
|
||||
type RegisteredLineCardCommand = OpenClawPluginCommandDefinition;
|
||||
|
||||
function createLineCardCommandLoader(api: OpenClawPluginApi) {
|
||||
return createLazyRuntimeModule<RegisteredLineCardCommand>(async () => {
|
||||
let registered: RegisteredLineCardCommand | null = null;
|
||||
const { registerLineCardCommand } = await import("./src/card-command.js");
|
||||
registerLineCardCommand({
|
||||
...api,
|
||||
registerCommand(command: RegisteredLineCardCommand) {
|
||||
registered = command;
|
||||
},
|
||||
});
|
||||
if (!registered) {
|
||||
throw new Error("LINE card command registration unavailable");
|
||||
}
|
||||
return registered;
|
||||
});
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "line",
|
||||
name: "LINE",
|
||||
description: "LINE Messaging API channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "linePlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setLineRuntime",
|
||||
},
|
||||
registerFull(api) {
|
||||
const loadLineCardCommand = createLineCardCommandLoader(api);
|
||||
api.registerCommand({
|
||||
name: "card",
|
||||
description: "Send a rich card message (LINE).",
|
||||
acceptsArgs: true,
|
||||
requireAuth: false,
|
||||
async handler(ctx) {
|
||||
const command = await loadLineCardCommand();
|
||||
return await command.handler(ctx);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
60
extensions/line/npm-shrinkwrap.json
generated
Normal file
60
extensions/line/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@line/bot-sdk": "11.1.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@line/bot-sdk": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@line/bot-sdk/-/bot-sdk-11.1.0.tgz",
|
||||
"integrity": "sha512-i8EQziuuvNitMrqSHQfzmjiyz9CBA7KjhmAJhCWhZzbI0kElfbaOOVhxEYxJGul5Aar9dv6HrHUgPeDLNIghEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/line/openclaw.plugin.json
Normal file
18
extensions/line/openclaw.plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "line",
|
||||
"name": "LINE",
|
||||
"description": "OpenClaw LINE channel plugin for LINE Bot API chats.",
|
||||
"icon": "https://cdn.simpleicons.org/line",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["line"],
|
||||
"channelEnvVars": {
|
||||
"line": ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
59
extensions/line/package.json
Normal file
59
extensions/line/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw LINE channel plugin for LINE Bot API chats.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@line/bot-sdk": "11.1.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "line",
|
||||
"label": "LINE",
|
||||
"selectionLabel": "LINE (Messaging API)",
|
||||
"detailLabel": "LINE Bot",
|
||||
"docsPath": "/channels/line",
|
||||
"docsLabel": "line",
|
||||
"blurb": "LINE Messaging API webhook bot.",
|
||||
"systemImage": "message",
|
||||
"order": 75,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/line",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
182
extensions/line/runtime-api.ts
Normal file
182
extensions/line/runtime-api.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Private runtime barrel for the bundled LINE extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
ChannelGatewayContext,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export { setLineRuntime } from "./src/runtime.js";
|
||||
export { firstDefined, normalizeAllowFrom } from "./src/bot-access.js";
|
||||
export { downloadLineMedia } from "./src/download.js";
|
||||
export { probeLineBot } from "./src/probe.js";
|
||||
export { buildTemplateMessageFromPayload } from "./src/template-messages.js";
|
||||
export {
|
||||
createQuickReplyItems,
|
||||
pushFlexMessage,
|
||||
pushLocationMessage,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushTemplateMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
sendMessageLine,
|
||||
} from "./src/send.js";
|
||||
export { monitorLineProvider } from "./src/monitor.js";
|
||||
export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js";
|
||||
export {
|
||||
listLineAccountIds,
|
||||
normalizeAccountId,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "./src/accounts.js";
|
||||
export { type NormalizedAllowFrom } from "./src/bot-access.js";
|
||||
export { resolveLineChannelAccessToken } from "./src/channel-access-token.js";
|
||||
export {
|
||||
LineChannelConfigSchema,
|
||||
LineConfigSchema,
|
||||
type LineConfigSchemaType,
|
||||
} from "./src/config-schema.js";
|
||||
export {
|
||||
resolveExactLineGroupConfigKey,
|
||||
resolveLineGroupConfigEntry,
|
||||
resolveLineGroupLookupIds,
|
||||
resolveLineGroupsConfig,
|
||||
} from "./src/group-keys.js";
|
||||
export {
|
||||
type CodeBlock,
|
||||
convertCodeBlockToFlexBubble,
|
||||
convertLinksToFlexBubble,
|
||||
convertTableToFlexBubble,
|
||||
extractCodeBlocks,
|
||||
extractLinks,
|
||||
extractMarkdownTables,
|
||||
hasMarkdownToConvert,
|
||||
type MarkdownLink,
|
||||
type MarkdownTable,
|
||||
type ProcessedLineMessage,
|
||||
processLineMessage,
|
||||
stripMarkdown,
|
||||
} from "./src/markdown-to-line.js";
|
||||
export {
|
||||
createAudioMessage,
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
createTextMessageWithQuickReplies,
|
||||
createVideoMessage,
|
||||
getUserDisplayName,
|
||||
getUserProfile,
|
||||
pushImageMessage,
|
||||
replyMessageLine,
|
||||
showLoadingAnimation,
|
||||
} from "./src/send.js";
|
||||
export { validateLineSignature } from "./src/signature.js";
|
||||
export {
|
||||
type ButtonsTemplate,
|
||||
type CarouselColumn,
|
||||
type CarouselTemplate,
|
||||
type ConfirmTemplate,
|
||||
createButtonMenu,
|
||||
createButtonTemplate,
|
||||
createCarouselColumn,
|
||||
createConfirmTemplate,
|
||||
createImageCarousel,
|
||||
createImageCarouselColumn,
|
||||
createLinkMenu,
|
||||
createProductCarousel,
|
||||
createTemplateCarousel,
|
||||
createYesNoConfirm,
|
||||
type ImageCarouselColumn,
|
||||
type ImageCarouselTemplate,
|
||||
type TemplateMessage,
|
||||
} from "./src/template-messages.js";
|
||||
export type {
|
||||
LineChannelData,
|
||||
LineConfig,
|
||||
LineProbeResult,
|
||||
ResolvedLineAccount,
|
||||
} from "./src/types.js";
|
||||
export { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./src/webhook-node.js";
|
||||
export {
|
||||
createLineWebhookMiddleware,
|
||||
type LineWebhookOptions,
|
||||
startLineWebhook,
|
||||
type StartLineWebhookOptions,
|
||||
} from "./src/webhook.js";
|
||||
export { parseLineWebhookBody } from "./src/webhook-utils.js";
|
||||
export { datetimePickerAction, messageAction, postbackAction, uriAction } from "./src/actions.js";
|
||||
export type { Action } from "./src/actions.js";
|
||||
export {
|
||||
createActionCard,
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createCarousel,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createMediaPlayerCard,
|
||||
createNotificationBubble,
|
||||
createReceiptCard,
|
||||
toFlexMessage,
|
||||
} from "./src/flex-templates.js";
|
||||
export type {
|
||||
CardAction,
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexCarousel,
|
||||
FlexComponent,
|
||||
FlexContainer,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
ListItem,
|
||||
} from "./src/flex-templates.js";
|
||||
export {
|
||||
cancelDefaultRichMenu,
|
||||
createDefaultMenuConfig,
|
||||
createGridLayout,
|
||||
createRichMenu,
|
||||
createRichMenuAlias,
|
||||
deleteRichMenu,
|
||||
deleteRichMenuAlias,
|
||||
getDefaultRichMenuId,
|
||||
getRichMenu,
|
||||
getRichMenuIdOfUser,
|
||||
getRichMenuList,
|
||||
linkRichMenuToUser,
|
||||
linkRichMenuToUsers,
|
||||
setDefaultRichMenu,
|
||||
unlinkRichMenuFromUser,
|
||||
unlinkRichMenuFromUsers,
|
||||
uploadRichMenuImage,
|
||||
} from "./src/rich-menu.js";
|
||||
export type {
|
||||
CreateRichMenuParams,
|
||||
RichMenuArea,
|
||||
RichMenuAreaRequest,
|
||||
RichMenuRequest,
|
||||
RichMenuResponse,
|
||||
RichMenuSize,
|
||||
} from "./src/rich-menu.js";
|
||||
4
extensions/line/secret-contract-api.ts
Normal file
4
extensions/line/secret-contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Line does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
3
extensions/line/setup-api.ts
Normal file
3
extensions/line/setup-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export { lineSetupAdapter } from "./src/setup-core.js";
|
||||
export { lineSetupWizard } from "./src/setup-surface.js";
|
||||
10
extensions/line/setup-entry.ts
Normal file
10
extensions/line/setup-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Line plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "lineSetupPlugin",
|
||||
},
|
||||
});
|
||||
17
extensions/line/src/account-helpers.ts
Normal file
17
extensions/line/src/account-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Line helper module supports account helpers behavior.
|
||||
type LineCredentialAccount = {
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
};
|
||||
|
||||
export function hasLineCredentials(account: LineCredentialAccount): boolean {
|
||||
return Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim());
|
||||
}
|
||||
|
||||
export function parseLineAllowFromId(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^line:(?:user:)?/i, "");
|
||||
if (!/^U[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
443
extensions/line/src/accounts.test.ts
Normal file
443
extensions/line/src/accounts.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
// Line tests cover accounts plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
resolveDefaultLineAccountId,
|
||||
normalizeAccountId,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("LINE accounts", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const createSecretFile = (fileName: string, contents: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, fileName);
|
||||
fs.writeFileSync(filePath, contents, "utf8");
|
||||
return filePath;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("LINE_CHANNEL_ACCESS_TOKEN", "");
|
||||
vi.stubEnv("LINE_CHANNEL_SECRET", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveLineAccount", () => {
|
||||
it("resolves account from config", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "test-token",
|
||||
channelSecret: "test-secret",
|
||||
name: "Test Bot",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("test-token");
|
||||
expect(account.channelSecret).toBe("test-secret");
|
||||
expect(account.name).toBe("Test Bot");
|
||||
expect(account.tokenSource).toBe("config");
|
||||
});
|
||||
|
||||
it("resolves account from environment variables", () => {
|
||||
vi.stubEnv("LINE_CHANNEL_ACCESS_TOKEN", "env-token");
|
||||
vi.stubEnv("LINE_CHANNEL_SECRET", "env-secret");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("env-token");
|
||||
expect(account.channelSecret).toBe("env-secret");
|
||||
expect(account.tokenSource).toBe("env");
|
||||
});
|
||||
|
||||
it("resolves named account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
business: {
|
||||
enabled: true,
|
||||
channelAccessToken: "business-token",
|
||||
channelSecret: "business-secret",
|
||||
name: "Business Bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "business" });
|
||||
|
||||
expect(account.accountId).toBe("business");
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("business-token");
|
||||
expect(account.channelSecret).toBe("business-secret");
|
||||
expect(account.name).toBe("Business Bot");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "business",
|
||||
accounts: {
|
||||
business: {
|
||||
enabled: true,
|
||||
channelAccessToken: "business-token",
|
||||
channelSecret: "business-secret",
|
||||
name: "Business Bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.accountId).toBe("business");
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("business-token");
|
||||
expect(account.channelSecret).toBe("business-secret");
|
||||
expect(account.name).toBe("Business Bot");
|
||||
});
|
||||
|
||||
it("returns empty token when not configured", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("");
|
||||
expect(account.channelSecret).toBe("");
|
||||
expect(account.tokenSource).toBe("none");
|
||||
});
|
||||
|
||||
it("resolves default account credentials from files", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: createSecretFile("token.txt", "file-token\n"),
|
||||
secretFile: createSecretFile("secret.txt", "file-secret\n"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("file-token");
|
||||
expect(account.channelSecret).toBe("file-secret");
|
||||
expect(account.tokenSource).toBe("file");
|
||||
});
|
||||
|
||||
it("resolves named account credentials from account-level files", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
business: {
|
||||
tokenFile: createSecretFile("business-token.txt", "business-file-token\n"),
|
||||
secretFile: createSecretFile("business-secret.txt", "business-file-secret\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "business" });
|
||||
|
||||
expect(account.channelAccessToken).toBe("business-file-token");
|
||||
expect(account.channelSecret).toBe("business-file-secret");
|
||||
expect(account.tokenSource).toBe("file");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked token and secret files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
|
||||
tempDirs.push(dir);
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const tokenLink = path.join(dir, "token-link.txt");
|
||||
const secretFile = path.join(dir, "secret.txt");
|
||||
const secretLink = path.join(dir, "secret-link.txt");
|
||||
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
|
||||
fs.writeFileSync(secretFile, "file-secret\n", "utf8");
|
||||
fs.symlinkSync(tokenFile, tokenLink);
|
||||
fs.symlinkSync(secretFile, secretLink);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: tokenLink,
|
||||
secretFile: secretLink,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => resolveLineAccount({ cfg })).toThrow(
|
||||
/LINE credential file.*must not be a symlink/,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves default account credentials from accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
default: {
|
||||
channelAccessToken: "default-token",
|
||||
channelSecret: "default-secret",
|
||||
name: "Default Bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("default-token");
|
||||
expect(account.channelSecret).toBe("default-secret");
|
||||
expect(account.name).toBe("Default Bot");
|
||||
expect(account.tokenSource).toBe("config");
|
||||
});
|
||||
|
||||
it("prefers accounts.default credentials over top-level base credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "base-token",
|
||||
channelSecret: "base-secret",
|
||||
accounts: {
|
||||
default: {
|
||||
channelAccessToken: "override-token",
|
||||
channelSecret: "override-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.channelAccessToken).toBe("override-token");
|
||||
expect(account.channelSecret).toBe("override-secret");
|
||||
});
|
||||
|
||||
it("treats named accounts without explicit enabled as enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
twgreen: {
|
||||
channelAccessToken: "twgreen-token",
|
||||
channelSecret: "twgreen-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "twgreen" });
|
||||
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.channelAccessToken).toBe("twgreen-token");
|
||||
expect(account.channelSecret).toBe("twgreen-secret");
|
||||
});
|
||||
|
||||
it("disables a named account when channels.line.enabled is false", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: false,
|
||||
accounts: {
|
||||
twgreen: {
|
||||
enabled: true,
|
||||
channelAccessToken: "twgreen-token",
|
||||
channelSecret: "twgreen-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "twgreen" });
|
||||
|
||||
expect(account.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("disables accounts.default when channels.line.enabled is false", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: false,
|
||||
accounts: {
|
||||
default: {
|
||||
channelAccessToken: "default-token",
|
||||
channelSecret: "default-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("respects explicit enabled:false on a named account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
twgreen: {
|
||||
enabled: false,
|
||||
channelAccessToken: "twgreen-token",
|
||||
channelSecret: "twgreen-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg, accountId: "twgreen" });
|
||||
|
||||
expect(account.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers accounts.default name over top-level channels.line.name", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
name: "Top-level Bot",
|
||||
accounts: {
|
||||
default: {
|
||||
channelAccessToken: "default-token",
|
||||
channelSecret: "default-secret",
|
||||
name: "Default Account Bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveLineAccount({ cfg });
|
||||
|
||||
expect(account.name).toBe("Default Account Bot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultLineAccountId", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "prefers channels.line.defaultAccount when configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "business",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
support: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "normalizes channels.line.defaultAccount before lookup",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "Business Ops",
|
||||
accounts: {
|
||||
"business-ops": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business-ops",
|
||||
},
|
||||
{
|
||||
name: "returns first named account when default not configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "falls back when channels.line.defaultAccount is missing",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: "business",
|
||||
},
|
||||
{
|
||||
name: "prefers the default account when base credentials are configured",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
channelAccessToken: "base-token",
|
||||
accounts: {
|
||||
business: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expected: DEFAULT_ACCOUNT_ID,
|
||||
},
|
||||
])("$name", ({ cfg, expected }) => {
|
||||
expect(resolveDefaultLineAccountId(cfg)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAccountId", () => {
|
||||
it("trims and lowercases account ids", () => {
|
||||
expect(normalizeAccountId(" Business ")).toBe("business");
|
||||
});
|
||||
});
|
||||
});
|
||||
187
extensions/line/src/accounts.ts
Normal file
187
extensions/line/src/accounts.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// Line plugin module implements accounts behavior.
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId as normalizeSharedAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
||||
import type {
|
||||
LineAccountConfig,
|
||||
LineConfig,
|
||||
LineTokenSource,
|
||||
ResolvedLineAccount,
|
||||
} from "./types.js";
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
function readFileIfExists(filePath: string | undefined): string | undefined {
|
||||
return tryReadSecretFileSync(filePath, "LINE credential file", { rejectSymlink: true });
|
||||
}
|
||||
|
||||
function resolveToken(params: {
|
||||
accountId: string;
|
||||
baseConfig?: LineConfig;
|
||||
accountConfig?: LineAccountConfig;
|
||||
}): { token: string; tokenSource: LineTokenSource } {
|
||||
const { accountId, baseConfig, accountConfig } = params;
|
||||
|
||||
if (accountConfig?.channelAccessToken?.trim()) {
|
||||
return { token: accountConfig.channelAccessToken.trim(), tokenSource: "config" };
|
||||
}
|
||||
|
||||
const accountFileToken = readFileIfExists(accountConfig?.tokenFile);
|
||||
if (accountFileToken) {
|
||||
return { token: accountFileToken, tokenSource: "file" };
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (baseConfig?.channelAccessToken?.trim()) {
|
||||
return { token: baseConfig.channelAccessToken.trim(), tokenSource: "config" };
|
||||
}
|
||||
|
||||
const baseFileToken = readFileIfExists(baseConfig?.tokenFile);
|
||||
if (baseFileToken) {
|
||||
return { token: baseFileToken, tokenSource: "file" };
|
||||
}
|
||||
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return { token: envToken, tokenSource: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { token: "", tokenSource: "none" };
|
||||
}
|
||||
|
||||
function resolveSecret(params: {
|
||||
accountId: string;
|
||||
baseConfig?: LineConfig;
|
||||
accountConfig?: LineAccountConfig;
|
||||
}): string {
|
||||
const { accountId, baseConfig, accountConfig } = params;
|
||||
|
||||
if (accountConfig?.channelSecret?.trim()) {
|
||||
return accountConfig.channelSecret.trim();
|
||||
}
|
||||
|
||||
const accountFileSecret = readFileIfExists(accountConfig?.secretFile);
|
||||
if (accountFileSecret) {
|
||||
return accountFileSecret;
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (baseConfig?.channelSecret?.trim()) {
|
||||
return baseConfig.channelSecret.trim();
|
||||
}
|
||||
|
||||
const baseFileSecret = readFileIfExists(baseConfig?.secretFile);
|
||||
if (baseFileSecret) {
|
||||
return baseFileSecret;
|
||||
}
|
||||
|
||||
const envSecret = process.env.LINE_CHANNEL_SECRET?.trim();
|
||||
if (envSecret) {
|
||||
return envSecret;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveLineAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedLineAccount {
|
||||
const cfg = params.cfg;
|
||||
const accountId = normalizeSharedAccountId(params.accountId ?? resolveDefaultLineAccountId(cfg));
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
const accounts = lineConfig?.accounts;
|
||||
const accountConfig = resolveAccountEntry(accounts, accountId);
|
||||
|
||||
const { token, tokenSource } = resolveToken({
|
||||
accountId,
|
||||
baseConfig: lineConfig,
|
||||
accountConfig,
|
||||
});
|
||||
|
||||
const secret = resolveSecret({
|
||||
accountId,
|
||||
baseConfig: lineConfig,
|
||||
accountConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
accounts: _ignoredAccounts,
|
||||
defaultAccount: _ignoredDefaultAccount,
|
||||
...lineBase
|
||||
} = (lineConfig ?? {}) as LineConfig & {
|
||||
accounts?: unknown;
|
||||
defaultAccount?: unknown;
|
||||
};
|
||||
const mergedConfig: LineConfig & LineAccountConfig = {
|
||||
...lineBase,
|
||||
...accountConfig,
|
||||
};
|
||||
|
||||
const baseEnabled = lineConfig?.enabled !== false;
|
||||
const accountEnabled = accountConfig?.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const name =
|
||||
accountConfig?.name ?? (accountId === DEFAULT_ACCOUNT_ID ? lineConfig?.name : undefined);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name,
|
||||
enabled,
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
tokenSource,
|
||||
config: mergedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function listLineAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
const accounts = lineConfig?.accounts;
|
||||
const ids = new Set<string>();
|
||||
|
||||
if (
|
||||
lineConfig?.channelAccessToken?.trim() ||
|
||||
lineConfig?.tokenFile ||
|
||||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim()
|
||||
) {
|
||||
ids.add(DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
if (accounts) {
|
||||
for (const id of Object.keys(accounts)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string {
|
||||
const preferred = normalizeOptionalAccountId(
|
||||
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
|
||||
);
|
||||
if (
|
||||
preferred &&
|
||||
listLineAccountIds(cfg).some((accountId) => normalizeSharedAccountId(accountId) === preferred)
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
const ids = listLineAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function normalizeAccountId(accountId: string | undefined): string {
|
||||
return normalizeSharedAccountId(accountId);
|
||||
}
|
||||
73
extensions/line/src/actions.ts
Normal file
73
extensions/line/src/actions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Line plugin module implements actions behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
export type Action = messagingApi.Action;
|
||||
export const LINE_ACTION_LABEL_LIMIT = 20;
|
||||
export const LINE_ACTION_DATA_LIMIT = 300;
|
||||
|
||||
export function truncateLineActionLabel(label: string, limit = LINE_ACTION_LABEL_LIMIT): string {
|
||||
return truncateUtf16Safe(label, limit);
|
||||
}
|
||||
|
||||
export function truncateLineActionData(data: string): string {
|
||||
return truncateUtf16Safe(data, LINE_ACTION_DATA_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message action (sends text when tapped)
|
||||
*/
|
||||
export function messageAction(label: string, text?: string): Action {
|
||||
return {
|
||||
type: "message",
|
||||
label: truncateLineActionLabel(label),
|
||||
text: text ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI action (opens a URL when tapped)
|
||||
*/
|
||||
export function uriAction(label: string, uri: string): Action {
|
||||
return {
|
||||
type: "uri",
|
||||
label: truncateLineActionLabel(label),
|
||||
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: truncateLineActionLabel(label),
|
||||
data: truncateLineActionData(data),
|
||||
displayText: displayText === undefined ? undefined : truncateLineActionData(displayText),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: truncateLineActionLabel(label),
|
||||
data: truncateLineActionData(data),
|
||||
mode,
|
||||
initial: options?.initial,
|
||||
max: options?.max,
|
||||
min: options?.min,
|
||||
};
|
||||
}
|
||||
279
extensions/line/src/auto-reply-delivery.test.ts
Normal file
279
extensions/line/src/auto-reply-delivery.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// Line tests cover auto reply delivery plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LineAutoReplyDeps } from "./auto-reply-delivery.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
import { createLineSendReceipt } from "./send-receipt.js";
|
||||
|
||||
const createFlexMessage = (altText: string, contents: unknown) => ({
|
||||
type: "flex" as const,
|
||||
altText,
|
||||
contents,
|
||||
});
|
||||
|
||||
const createImageMessage = (url: string) => ({
|
||||
type: "image" as const,
|
||||
originalContentUrl: url,
|
||||
previewImageUrl: url,
|
||||
});
|
||||
|
||||
const createLocationMessage = (location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) => ({
|
||||
type: "location" as const,
|
||||
...location,
|
||||
});
|
||||
|
||||
describe("deliverLineAutoReply", () => {
|
||||
const LINE_TEST_CFG = { channels: { line: { accounts: { acc: {} } } } };
|
||||
const baseDeliveryParams = {
|
||||
cfg: LINE_TEST_CFG,
|
||||
to: "line:user:1",
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
accountId: "acc",
|
||||
textLimit: 5000,
|
||||
};
|
||||
|
||||
function createDeps(overrides?: Partial<LineAutoReplyDeps>) {
|
||||
const replyMessageLine = vi.fn(async () => ({}));
|
||||
const pushMessageLine = vi.fn(async () => ({}));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
}));
|
||||
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||
const pushMessagesLine = vi.fn(async () => ({
|
||||
messageId: "push",
|
||||
chatId: "u1",
|
||||
receipt: createLineSendReceipt({ messageId: "push", chatId: "u1", kind: "text" }),
|
||||
}));
|
||||
|
||||
const deps: LineAutoReplyDeps = {
|
||||
buildTemplateMessageFromPayload: () => null,
|
||||
processLineMessage: (text) => ({ text, flexMessages: [] }),
|
||||
chunkMarkdownText: (text) => [text],
|
||||
sendLineReplyChunks,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
createQuickReplyItems: createQuickReplyItems as LineAutoReplyDeps["createQuickReplyItems"],
|
||||
pushMessagesLine,
|
||||
createFlexMessage: createFlexMessage as LineAutoReplyDeps["createFlexMessage"],
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
pushMessagesLine,
|
||||
};
|
||||
}
|
||||
|
||||
it("uses reply token for text before sending rich messages", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
};
|
||||
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps();
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "hello", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "acc",
|
||||
});
|
||||
expect(pushMessagesLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(createQuickReplyItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("truncates flex altText on a surrogate boundary", async () => {
|
||||
// The emoji's surrogate pair straddles LINE's 400-char altText cap; a raw
|
||||
// slice used to send a lone high surrogate to the LINE API.
|
||||
const lineData = {
|
||||
flexMessage: { altText: `${"a".repeat(399)}😀 overflow`, contents: { type: "bubble" } },
|
||||
};
|
||||
const createFlexMessageSpy = vi.fn(createFlexMessage);
|
||||
const { deps } = createDeps({
|
||||
createFlexMessage: createFlexMessageSpy as LineAutoReplyDeps["createFlexMessage"],
|
||||
});
|
||||
|
||||
await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "hello", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
const sentAltText = createFlexMessageSpy.mock.calls[0]?.[0] ?? "";
|
||||
expect(sentAltText.length).toBeLessThanOrEqual(400);
|
||||
expect(
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(sentAltText),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses reply token for rich-only payloads", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
quickReplies: ["A"],
|
||||
};
|
||||
const { deps, replyMessageLine, pushMessagesLine, createQuickReplyItems } = createDeps({
|
||||
processLineMessage: () => ({ text: "", flexMessages: [] }),
|
||||
chunkMarkdownText: () => [],
|
||||
sendLineReplyChunks: vi.fn(async () => ({ replyTokenUsed: false })),
|
||||
});
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{
|
||||
...createFlexMessage("Card", { type: "bubble" }),
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(pushMessagesLine).not.toHaveBeenCalled();
|
||||
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
|
||||
});
|
||||
|
||||
it("uses fallback text for quick-reply-only payloads", async () => {
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
quickReply: { items: ["A", "B"] },
|
||||
}));
|
||||
const lineData = {
|
||||
quickReplies: ["A", "B"],
|
||||
};
|
||||
const { deps, replyMessageLine, pushMessagesLine } = createDeps({
|
||||
createTextMessageWithQuickReplies:
|
||||
createTextMessageWithQuickReplies as LineAutoReplyDeps["createTextMessageWithQuickReplies"],
|
||||
});
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
text: "Options:\n- A\n- B",
|
||||
quickReply: { items: ["A", "B"] },
|
||||
},
|
||||
],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(pushMessagesLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends rich messages before quick-reply text so quick replies remain visible", async () => {
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
quickReply: { items: ["A"] },
|
||||
}));
|
||||
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
quickReplies: ["A"],
|
||||
};
|
||||
const { deps, pushMessagesLine, replyMessageLine } = createDeps({
|
||||
createTextMessageWithQuickReplies:
|
||||
createTextMessageWithQuickReplies as LineAutoReplyDeps["createTextMessageWithQuickReplies"],
|
||||
});
|
||||
|
||||
await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { text: "hello", channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
text: "hello",
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
const pushOrder = pushMessagesLine.mock.invocationCallOrder[0];
|
||||
const replyOrder = replyMessageLine.mock.invocationCallOrder[0];
|
||||
expect(pushOrder).toBeLessThan(replyOrder);
|
||||
});
|
||||
|
||||
it("falls back to push when reply token delivery fails", async () => {
|
||||
const lineData = {
|
||||
flexMessage: { altText: "Card", contents: { type: "bubble" } },
|
||||
};
|
||||
const failingReplyMessageLine = vi.fn(async () => {
|
||||
throw new Error("reply failed");
|
||||
});
|
||||
const { deps, pushMessagesLine } = createDeps({
|
||||
processLineMessage: () => ({ text: "", flexMessages: [] }),
|
||||
chunkMarkdownText: () => [],
|
||||
replyMessageLine: failingReplyMessageLine as LineAutoReplyDeps["replyMessageLine"],
|
||||
});
|
||||
|
||||
const result = await deliverLineAutoReply({
|
||||
...baseDeliveryParams,
|
||||
payload: { channelData: { line: lineData } },
|
||||
lineData,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(failingReplyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
});
|
||||
});
|
||||
204
extensions/line/src/auto-reply-delivery.ts
Normal file
204
extensions/line/src/auto-reply-delivery.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Line plugin module implements auto reply delivery behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { FlexContainer } from "./flex-templates.js";
|
||||
import type { ProcessedLineMessage } from "./markdown-to-line.js";
|
||||
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
|
||||
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
|
||||
import type { LineChannelData, LineTemplateMessagePayload } from "./types.js";
|
||||
|
||||
export type LineAutoReplyDeps = {
|
||||
buildTemplateMessageFromPayload: (
|
||||
payload: LineTemplateMessagePayload,
|
||||
) => messagingApi.TemplateMessage | null;
|
||||
processLineMessage: (text: string) => ProcessedLineMessage;
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
sendLineReplyChunks: (params: SendLineReplyChunksParams) => Promise<{ replyTokenUsed: boolean }>;
|
||||
createQuickReplyItems: (labels: string[]) => messagingApi.QuickReply;
|
||||
pushMessagesLine: (
|
||||
to: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createFlexMessage: (altText: string, contents: FlexContainer) => messagingApi.FlexMessage;
|
||||
createImageMessage: (
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
) => messagingApi.ImageMessage;
|
||||
createLocationMessage: (location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) => messagingApi.LocationMessage;
|
||||
} & Pick<
|
||||
SendLineReplyChunksParams,
|
||||
| "replyMessageLine"
|
||||
| "pushMessageLine"
|
||||
| "pushTextMessageWithQuickReplies"
|
||||
| "createTextMessageWithQuickReplies"
|
||||
| "onReplyError"
|
||||
>;
|
||||
|
||||
export async function deliverLineAutoReply(params: {
|
||||
payload: ReplyPayload;
|
||||
lineData: LineChannelData;
|
||||
to: string;
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed: boolean;
|
||||
accountId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
textLimit: number;
|
||||
deps: LineAutoReplyDeps;
|
||||
}): Promise<{ replyTokenUsed: boolean }> {
|
||||
const { payload, lineData, replyToken, accountId, to, textLimit, deps } = params;
|
||||
let replyTokenUsed = params.replyTokenUsed;
|
||||
|
||||
const pushLineMessages = async (messages: messagingApi.Message[]): Promise<void> => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendLineMessages = async (
|
||||
messages: messagingApi.Message[],
|
||||
allowReplyToken: boolean,
|
||||
): Promise<void> => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = messages;
|
||||
if (allowReplyToken && replyToken && !replyTokenUsed) {
|
||||
const replyBatch = remaining.slice(0, 5);
|
||||
try {
|
||||
await deps.replyMessageLine(replyToken, replyBatch, {
|
||||
cfg: params.cfg,
|
||||
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(
|
||||
truncateUtf16Safe(lineData.flexMessage.altText, 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(truncateUtf16Safe(flexMsg.altText, 400), flexMsg.contents),
|
||||
);
|
||||
}
|
||||
|
||||
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
|
||||
|
||||
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
|
||||
const mediaMessages = mediaUrls
|
||||
.map((url) => url?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
.map((url) => deps.createImageMessage(url));
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0;
|
||||
if (hasQuickReplies && hasRichOrMedia) {
|
||||
try {
|
||||
await sendLineMessages([...richMessages, ...mediaMessages], false);
|
||||
} catch (err) {
|
||||
deps.onReplyError?.(err);
|
||||
}
|
||||
}
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
|
||||
to,
|
||||
chunks,
|
||||
quickReplies: lineData.quickReplies,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
cfg: params.cfg,
|
||||
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 { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
|
||||
to,
|
||||
chunks: [buildLineQuickReplyFallbackText(lineData.quickReplies)],
|
||||
quickReplies: lineData.quickReplies,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
replyMessageLine: deps.replyMessageLine,
|
||||
pushMessageLine: deps.pushMessageLine,
|
||||
pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
|
||||
onReplyError: deps.onReplyError,
|
||||
});
|
||||
replyTokenUsed = nextReplyTokenUsed;
|
||||
} else {
|
||||
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 };
|
||||
}
|
||||
66
extensions/line/src/bindings.ts
Normal file
66
extensions/line/src/bindings.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Line plugin module implements bindings behavior.
|
||||
function normalizeLineConversationId(raw?: string | null): string | null {
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const prefixed = trimmed.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1];
|
||||
return (prefixed ?? trimmed).trim() || null;
|
||||
}
|
||||
|
||||
function resolveLineCommandConversation(params: {
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
}) {
|
||||
const conversationId =
|
||||
normalizeLineConversationId(params.originatingTo) ??
|
||||
normalizeLineConversationId(params.commandTo) ??
|
||||
normalizeLineConversationId(params.fallbackTo);
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
function resolveLineInboundConversation(params: { to?: string; conversationId?: string }) {
|
||||
const conversationId =
|
||||
normalizeLineConversationId(params.conversationId) ?? normalizeLineConversationId(params.to);
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
export const lineBindingsAdapter = {
|
||||
compileConfiguredBinding: ({ conversationId }: { conversationId?: string }) => {
|
||||
const normalized = normalizeLineConversationId(conversationId);
|
||||
return normalized ? { conversationId: normalized } : null;
|
||||
},
|
||||
matchInboundConversation: ({
|
||||
compiledBinding,
|
||||
conversationId,
|
||||
}: {
|
||||
compiledBinding: { conversationId: string };
|
||||
conversationId?: string;
|
||||
}) => {
|
||||
const normalizedIncoming = normalizeLineConversationId(conversationId);
|
||||
if (!normalizedIncoming || compiledBinding.conversationId !== normalizedIncoming) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: normalizedIncoming,
|
||||
matchPriority: 2,
|
||||
};
|
||||
},
|
||||
resolveCommandConversation: ({
|
||||
originatingTo,
|
||||
commandTo,
|
||||
fallbackTo,
|
||||
}: {
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
}) =>
|
||||
resolveLineCommandConversation({
|
||||
originatingTo,
|
||||
commandTo,
|
||||
fallbackTo,
|
||||
}),
|
||||
resolveInboundConversation: ({ to, conversationId }: { to?: string; conversationId?: string }) =>
|
||||
resolveLineInboundConversation({ to, conversationId }),
|
||||
};
|
||||
31
extensions/line/src/bot-access.ts
Normal file
31
extensions/line/src/bot-access.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Line plugin module implements bot access behavior.
|
||||
import { firstDefined } from "openclaw/plugin-sdk/allow-from";
|
||||
|
||||
export type NormalizedAllowFrom = {
|
||||
entries: string[];
|
||||
hasWildcard: boolean;
|
||||
hasEntries: boolean;
|
||||
};
|
||||
|
||||
export function normalizeLineAllowEntry(value: string | number): string {
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed.replace(/^line:(?:user:)?/i, "");
|
||||
}
|
||||
|
||||
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||
const entries = (list ?? []).map((value) => normalizeLineAllowEntry(value)).filter(Boolean);
|
||||
const hasWildcard = entries.includes("*");
|
||||
return {
|
||||
entries,
|
||||
hasWildcard,
|
||||
hasEntries: entries.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export { firstDefined };
|
||||
1166
extensions/line/src/bot-handlers.test.ts
Normal file
1166
extensions/line/src/bot-handlers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
645
extensions/line/src/bot-handlers.ts
Normal file
645
extensions/line/src/bot-handlers.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
// Line plugin module implements bot handlers behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
resolvePairingIdLabel,
|
||||
upsertChannelPairingRequest,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { firstDefined, normalizeLineAllowEntry } from "./bot-access.js";
|
||||
import {
|
||||
buildLineMessageContext,
|
||||
buildLinePostbackContext,
|
||||
getLineSourceInfo,
|
||||
type LineInboundContext,
|
||||
} from "./bot-message-context.js";
|
||||
import { downloadLineMedia } from "./download.js";
|
||||
import { resolveLineGroupConfigEntry } from "./group-keys.js";
|
||||
import { pushMessageLine, replyMessageLine } from "./send.js";
|
||||
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type FollowEvent = webhook.FollowEvent;
|
||||
type JoinEvent = webhook.JoinEvent;
|
||||
type LeaveEvent = webhook.LeaveEvent;
|
||||
type MessageEvent = webhook.MessageEvent;
|
||||
type PostbackEvent = webhook.PostbackEvent;
|
||||
type UnfollowEvent = webhook.UnfollowEvent;
|
||||
type WebhookEvent = webhook.Event;
|
||||
|
||||
interface MediaRef {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"file",
|
||||
]);
|
||||
|
||||
function isDownloadableLineMessageType(
|
||||
messageType: MessageEvent["message"]["type"],
|
||||
): messageType is "image" | "video" | "audio" | "file" {
|
||||
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
|
||||
}
|
||||
|
||||
export interface LineHandlerContext {
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
runtime: RuntimeEnv;
|
||||
mediaMaxBytes: number;
|
||||
processMessage: (ctx: LineInboundContext) => Promise<void>;
|
||||
replayCache?: LineWebhookReplayCache;
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
||||
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
|
||||
export type LineWebhookReplayCache = ClaimableDedupe;
|
||||
|
||||
function normalizeLineIngressEntry(value: string): string | null {
|
||||
return normalizeLineAllowEntry(value) || null;
|
||||
}
|
||||
|
||||
export class LineRetryableWebhookError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "LineRetryableWebhookError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineWebhookReplayCache(): LineWebhookReplayCache {
|
||||
return createClaimableDedupe({
|
||||
ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
|
||||
memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function buildLineWebhookReplayKey(
|
||||
event: WebhookEvent,
|
||||
accountId: string,
|
||||
): { key: string; eventId: string } | null {
|
||||
if (event.type === "message") {
|
||||
const messageId = event.message?.id?.trim();
|
||||
if (messageId) {
|
||||
return {
|
||||
key: `${accountId}|message:${messageId}`,
|
||||
eventId: `message:${messageId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = (
|
||||
event as {
|
||||
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
|
||||
}
|
||||
).source;
|
||||
const sourceId =
|
||||
source?.type === "group"
|
||||
? `group:${source.groupId ?? ""}`
|
||||
: source?.type === "room"
|
||||
? `room:${source.roomId ?? ""}`
|
||||
: `user:${source?.userId ?? ""}`;
|
||||
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
|
||||
}
|
||||
|
||||
type LineReplayCandidate = {
|
||||
key: string;
|
||||
eventId: string;
|
||||
cache: LineWebhookReplayCache;
|
||||
};
|
||||
|
||||
function getLineReplayCandidate(
|
||||
event: WebhookEvent,
|
||||
context: LineHandlerContext,
|
||||
): LineReplayCandidate | null {
|
||||
const replay = buildLineWebhookReplayKey(event, context.account.accountId);
|
||||
const cache = context.replayCache;
|
||||
if (!replay || !cache) {
|
||||
return null;
|
||||
}
|
||||
return { key: replay.key, eventId: replay.eventId, cache };
|
||||
}
|
||||
|
||||
async function claimLineReplayEvent(
|
||||
candidate: LineReplayCandidate,
|
||||
): Promise<{ skip: true; inFlightResult?: Promise<void> } | { skip: false }> {
|
||||
const claim = await candidate.cache.claim(candidate.key);
|
||||
if (claim.kind === "claimed") {
|
||||
return { skip: false };
|
||||
}
|
||||
if (claim.kind === "inflight") {
|
||||
logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
|
||||
return { skip: true, inFlightResult: claim.pending.then(() => undefined) };
|
||||
}
|
||||
logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
|
||||
return { skip: true };
|
||||
}
|
||||
|
||||
function resolveLineGroupConfig(params: {
|
||||
config: ResolvedLineAccount["config"];
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
}): LineGroupConfig | undefined {
|
||||
return resolveLineGroupConfigEntry(params.config.groups, {
|
||||
groupId: params.groupId,
|
||||
roomId: params.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendLinePairingReply(params: {
|
||||
senderId: string;
|
||||
replyToken?: string;
|
||||
context: LineHandlerContext;
|
||||
}): Promise<void> {
|
||||
const { senderId, replyToken, context } = params;
|
||||
const idLabel = (() => {
|
||||
try {
|
||||
return resolvePairingIdLabel("line");
|
||||
} catch {
|
||||
return "lineUserId";
|
||||
}
|
||||
})();
|
||||
await createChannelPairingChallengeIssuer({
|
||||
channel: "line",
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "line",
|
||||
id,
|
||||
accountId: context.account.accountId,
|
||||
meta,
|
||||
}),
|
||||
})({
|
||||
senderId,
|
||||
senderIdLine: `Your ${idLabel}: ${senderId}`,
|
||||
onCreated: () => {
|
||||
logVerbose(`line pairing request sender=${senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(replyToken, [{ type: "text", text }], {
|
||||
cfg: context.cfg,
|
||||
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, {
|
||||
cfg: context.cfg,
|
||||
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,
|
||||
) {
|
||||
const { cfg, account } = context;
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
|
||||
const senderId = userId ?? "";
|
||||
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
||||
const rawText = resolveEventRawText(event);
|
||||
const requireMention = isGroup ? groupConfig?.requireMention !== false : false;
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const { groupPolicy: runtimeGroupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.line !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy: resolveDefaultGroupPolicy(cfg),
|
||||
});
|
||||
const groupPolicy: GroupPolicy =
|
||||
runtimeGroupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: groupConfig?.allowFrom !== undefined
|
||||
? "allowlist"
|
||||
: runtimeGroupPolicy;
|
||||
const groupAllowFrom = normalizeStringEntries(
|
||||
firstDefined(
|
||||
groupConfig?.allowFrom,
|
||||
account.config.groupAllowFrom,
|
||||
account.config.allowFrom?.length ? account.config.allowFrom : undefined,
|
||||
),
|
||||
);
|
||||
const mentionFacts = (() => {
|
||||
if (!isGroup || event.type !== "message") {
|
||||
return { canDetectMention: false, wasMentioned: false, hasAnyMention: false };
|
||||
}
|
||||
const peerId = groupId ?? roomId ?? userId ?? "unknown";
|
||||
const { agentId } = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "line",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "group", id: peerId },
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
const wasMentionedByNative = isLineBotMentioned(event.message);
|
||||
const wasMentionedByPattern =
|
||||
event.message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
|
||||
return {
|
||||
canDetectMention: event.message.type === "text",
|
||||
wasMentioned: wasMentionedByNative || wasMentionedByPattern,
|
||||
hasAnyMention: hasAnyLineMention(event.message),
|
||||
};
|
||||
})();
|
||||
const access = await resolveStableChannelMessageIngress({
|
||||
channelId: "line",
|
||||
accountId: account.accountId,
|
||||
identity: {
|
||||
key: "line-user-id",
|
||||
normalize: normalizeLineIngressEntry,
|
||||
sensitivity: "pii",
|
||||
entryIdPrefix: "line-entry",
|
||||
},
|
||||
cfg,
|
||||
readStoreAllowFrom: async () =>
|
||||
await readChannelAllowFromStore("line", undefined, account.accountId),
|
||||
subject: { stableId: senderId },
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: (groupId ?? roomId ?? senderId) || "unknown",
|
||||
},
|
||||
...(isGroup && groupConfig?.enabled === false
|
||||
? { route: { id: "line:group-config", enabled: false } }
|
||||
: {}),
|
||||
mentionFacts:
|
||||
isGroup && event.type === "message"
|
||||
? {
|
||||
canDetectMention: mentionFacts.canDetectMention,
|
||||
wasMentioned: mentionFacts.wasMentioned,
|
||||
hasAnyMention: mentionFacts.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
}
|
||||
: undefined,
|
||||
event: { kind: event.type === "postback" ? "postback" : "message" },
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
activation: {
|
||||
requireMention: isGroup && event.type === "message" && requireMention,
|
||||
allowTextCommands: true,
|
||||
},
|
||||
},
|
||||
allowFrom: normalizeStringEntries(account.config.allowFrom),
|
||||
groupAllowFrom,
|
||||
command: {
|
||||
hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg),
|
||||
groupOwnerAllowFrom: "none",
|
||||
},
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "line",
|
||||
accountId: account.accountId,
|
||||
log: (message) => logVerbose(message),
|
||||
});
|
||||
|
||||
if (
|
||||
access.senderAccess.decision === "allow" &&
|
||||
(access.ingress.admission === "dispatch" ||
|
||||
access.ingress.admission === "observe" ||
|
||||
access.ingress.admission === "skip")
|
||||
) {
|
||||
return access;
|
||||
}
|
||||
|
||||
if (access.senderAccess.decision === "allow") {
|
||||
logVerbose(`Blocked line event (${access.ingress.reasonCode})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfig?.enabled === false) {
|
||||
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
|
||||
return null;
|
||||
}
|
||||
if (groupConfig?.allowFrom !== undefined) {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
|
||||
return null;
|
||||
}
|
||||
if (access.senderAccess.reasonCode !== "group_policy_allowed") {
|
||||
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (access.senderAccess.reasonCode === "group_policy_disabled") {
|
||||
logVerbose("Blocked line group message (groupPolicy: disabled)");
|
||||
} else if (!senderId && groupPolicy === "allowlist") {
|
||||
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
|
||||
} else if (access.senderAccess.reasonCode === "group_policy_empty_allowlist") {
|
||||
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
} else {
|
||||
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (access.senderAccess.reasonCode === "dm_policy_disabled") {
|
||||
logVerbose("Blocked line sender (dmPolicy: disabled)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (access.senderAccess.decision === "pairing") {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
|
||||
return null;
|
||||
}
|
||||
await sendLinePairingReply({
|
||||
senderId,
|
||||
replyToken: "replyToken" in event ? event.replyToken : undefined,
|
||||
context,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${
|
||||
account.config.dmPolicy ?? "pairing"
|
||||
})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLineMentionees(
|
||||
message: MessageEvent["message"],
|
||||
): Array<{ type?: string; isSelf?: boolean }> {
|
||||
if (message.type !== "text") {
|
||||
return [];
|
||||
}
|
||||
const mentionees = (
|
||||
message as Record<string, unknown> & {
|
||||
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
|
||||
}
|
||||
).mention?.mentionees;
|
||||
return Array.isArray(mentionees) ? mentionees : [];
|
||||
}
|
||||
|
||||
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
|
||||
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
|
||||
}
|
||||
|
||||
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
|
||||
return getLineMentionees(message).length > 0;
|
||||
}
|
||||
|
||||
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
||||
if (event.type === "message") {
|
||||
const msg = event.message;
|
||||
if (msg.type === "text") {
|
||||
return msg.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (event.type === "postback") {
|
||||
return event.postback?.data?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
|
||||
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
|
||||
const message = event.message;
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
|
||||
if (isGroup && decision.activationAccess.shouldSkip) {
|
||||
const rawText = message.type === "text" ? message.text : "";
|
||||
const sourceInfo = getLineSourceInfo(event.source);
|
||||
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
|
||||
const historyKey = groupId ?? roomId;
|
||||
const senderId = sourceInfo.userId ?? "unknown";
|
||||
if (historyKey && context.groupHistories) {
|
||||
createChannelHistoryWindow({ historyMap: context.groupHistories }).record({
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
entry: {
|
||||
sender: `user:${senderId}`,
|
||||
body: rawText || `<${message.type}>`,
|
||||
timestamp: event.timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allMedia: MediaRef[] = [];
|
||||
let mediaUnavailable = false;
|
||||
|
||||
if (isDownloadableLineMessageType(message.type)) {
|
||||
try {
|
||||
const originalFilename =
|
||||
message.type === "file" ? normalizeOptionalString(message.fileName) : undefined;
|
||||
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, {
|
||||
originalFilename,
|
||||
});
|
||||
allMedia.push({
|
||||
path: media.path,
|
||||
contentType: media.contentType,
|
||||
});
|
||||
} catch (err) {
|
||||
mediaUnavailable = true;
|
||||
const errMsg = String(err);
|
||||
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
|
||||
logVerbose(`line: media exceeds size limit for message ${message.id}`);
|
||||
} else {
|
||||
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageContext = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia,
|
||||
mediaUnavailable,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: decision.commandAccess.authorized,
|
||||
groupHistories: context.groupHistories,
|
||||
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
if (!messageContext) {
|
||||
logVerbose("line: skipping empty message");
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessage(messageContext);
|
||||
|
||||
if (isGroup && context.groupHistories) {
|
||||
const historyKey = groupId ?? roomId;
|
||||
if (historyKey && context.groupHistories.has(historyKey)) {
|
||||
createChannelHistoryWindow({ historyMap: context.groupHistories }).clear({
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const { userId } = getLineSourceInfo(event.source);
|
||||
logVerbose(`line: user ${userId ?? "unknown"} followed`);
|
||||
}
|
||||
|
||||
async function handleUnfollowEvent(
|
||||
event: UnfollowEvent,
|
||||
_context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
const { userId } = getLineSourceInfo(event.source);
|
||||
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
|
||||
}
|
||||
|
||||
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const { groupId, roomId } = getLineSourceInfo(event.source);
|
||||
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
||||
}
|
||||
|
||||
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
|
||||
const { groupId, roomId } = getLineSourceInfo(event.source);
|
||||
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
||||
}
|
||||
|
||||
async function handlePostbackEvent(
|
||||
event: PostbackEvent,
|
||||
context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
const data = event.postback.data;
|
||||
logVerbose(`line: received postback: ${data}`);
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postbackContext = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg: context.cfg,
|
||||
account: context.account,
|
||||
commandAuthorized: decision.commandAccess.authorized,
|
||||
});
|
||||
if (!postbackContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.processMessage(postbackContext);
|
||||
}
|
||||
|
||||
export async function handleLineWebhookEvents(
|
||||
events: WebhookEvent[],
|
||||
context: LineHandlerContext,
|
||||
): Promise<void> {
|
||||
let firstError: unknown;
|
||||
for (const event of events) {
|
||||
const replayCandidate = getLineReplayCandidate(event, context);
|
||||
const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
|
||||
if (replaySkip?.skip) {
|
||||
if (replaySkip.inFlightResult) {
|
||||
try {
|
||||
await replaySkip.inFlightResult;
|
||||
} catch (err) {
|
||||
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
|
||||
firstError ??= err;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
await handleMessageEvent(event, context);
|
||||
break;
|
||||
case "follow":
|
||||
await handleFollowEvent(event, context);
|
||||
break;
|
||||
case "unfollow":
|
||||
await handleUnfollowEvent(event, context);
|
||||
break;
|
||||
case "join":
|
||||
await handleJoinEvent(event, context);
|
||||
break;
|
||||
case "leave":
|
||||
await handleLeaveEvent(event, context);
|
||||
break;
|
||||
case "postback":
|
||||
await handlePostbackEvent(event, context);
|
||||
break;
|
||||
default:
|
||||
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
|
||||
}
|
||||
if (replayCandidate) {
|
||||
await replayCandidate.cache.commit(replayCandidate.key);
|
||||
}
|
||||
} catch (err) {
|
||||
if (replayCandidate) {
|
||||
if (err instanceof LineRetryableWebhookError) {
|
||||
replayCandidate.cache.release(replayCandidate.key, { error: err });
|
||||
} else {
|
||||
await replayCandidate.cache.commit(replayCandidate.key);
|
||||
}
|
||||
}
|
||||
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
|
||||
firstError ??= err;
|
||||
}
|
||||
}
|
||||
if (firstError) {
|
||||
throw toLintErrorObject(firstError, "Non-Error thrown");
|
||||
}
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
448
extensions/line/src/bot-message-context.test.ts
Normal file
448
extensions/line/src/bot-message-context.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
// Line tests cover bot message context plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { testing as sessionBindingTesting } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
createTestRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { lineBindingsAdapter } from "./bindings.js";
|
||||
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type MessageEvent = webhook.MessageEvent;
|
||||
type PostbackEvent = webhook.PostbackEvent;
|
||||
|
||||
const lineBindingsPlugin = {
|
||||
id: "line",
|
||||
bindings: lineBindingsAdapter,
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
supportsCurrentConversationBinding: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("buildLineMessageContext", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
let cfg: OpenClawConfig;
|
||||
const account: ResolvedLineAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
const createMessageEvent = (
|
||||
source: MessageEvent["source"],
|
||||
overrides?: Partial<MessageEvent>,
|
||||
): MessageEvent =>
|
||||
({
|
||||
type: "message",
|
||||
message: { id: "1", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
mode: "active",
|
||||
webhookEventId: "evt-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
...overrides,
|
||||
}) as MessageEvent;
|
||||
|
||||
const createPostbackEvent = (
|
||||
source: PostbackEvent["source"],
|
||||
overrides?: Partial<PostbackEvent>,
|
||||
): PostbackEvent =>
|
||||
({
|
||||
type: "postback",
|
||||
postback: { data: "action=select" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
mode: "active",
|
||||
webhookEventId: "evt-2",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
...overrides,
|
||||
}) as PostbackEvent;
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: lineBindingsPlugin.id,
|
||||
plugin: lineBindingsPlugin,
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
cfg = { session: { store: storePath } };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes group message replies to the group id", async () => {
|
||||
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-1");
|
||||
expect(context?.ctxPayload.To).toBe("line:group:group-1");
|
||||
});
|
||||
|
||||
it("replaces a failed media placeholder with an unavailable notice", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-image" }, {
|
||||
message: {
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
contentProvider: { type: "line" },
|
||||
},
|
||||
} as Partial<MessageEvent>);
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
mediaUnavailable: true,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.RawBody).toBe("<media:image>");
|
||||
expect(context?.ctxPayload.CommandBody).toBe("<media:image>");
|
||||
expect(context?.ctxPayload.BodyForAgent).toBe("[line attachment unavailable]");
|
||||
expect(context?.ctxPayload.MediaPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes group postback replies to the group id", async () => {
|
||||
const event = createPostbackEvent({ type: "group", groupId: "group-2", userId: "user-2" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
|
||||
expect(context?.ctxPayload.To).toBe("line:group:group-2");
|
||||
});
|
||||
|
||||
it("routes room postback replies to the room id", async () => {
|
||||
const event = createPostbackEvent({ type: "room", roomId: "room-1", userId: "user-3" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1");
|
||||
expect(context?.ctxPayload.To).toBe("line:room:room-1");
|
||||
});
|
||||
|
||||
it("resolves prefixed-only group config through the inbound message context", async () => {
|
||||
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
groups: {
|
||||
"group:group-1": {
|
||||
systemPrompt: "Use the prefixed group config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed group config");
|
||||
});
|
||||
|
||||
it("resolves prefixed-only room config through the inbound message context", async () => {
|
||||
const event = createMessageEvent({ type: "room", roomId: "room-1", userId: "user-1" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
groups: {
|
||||
"room:room-1": {
|
||||
systemPrompt: "Use the prefixed room config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed room config");
|
||||
});
|
||||
|
||||
it("keeps non-text message contexts fail-closed for command auth", async () => {
|
||||
const event = createMessageEvent(
|
||||
{ type: "user", userId: "user-audio" },
|
||||
{
|
||||
message: { id: "audio-1", type: "audio", duration: 1000 } as MessageEvent["message"],
|
||||
},
|
||||
);
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized=true when authorized", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-auth" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized=false when not authorized", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-noauth" });
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
|
||||
const event = createMessageEvent({ type: "user", userId: "user-1" });
|
||||
const directCfg: OpenClawConfig = {
|
||||
session: { store: storePath, dmScope: "per-channel-peer" },
|
||||
};
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg: directCfg,
|
||||
account: {
|
||||
...account,
|
||||
config: { allowFrom: ["user-1"] },
|
||||
},
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.route.sessionKey).toBe("agent:main:line:direct:user-1");
|
||||
const updateLastRoute = context?.turn.record.updateLastRoute;
|
||||
expect(updateLastRoute?.sessionKey).toBe(context?.route.sessionKey);
|
||||
expect(updateLastRoute?.sessionKey).not.toBe("agent:main:main");
|
||||
expect(updateLastRoute?.channel).toBe("line");
|
||||
expect(updateLastRoute?.to).toBe("user-1");
|
||||
expect(updateLastRoute?.mainDmOwnerPin).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets CommandAuthorized on postback context", async () => {
|
||||
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
|
||||
|
||||
const context = await buildLinePostbackContext({
|
||||
event,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("group peer binding matches raw groupId without prefix (#21907)", async () => {
|
||||
const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret
|
||||
const bindingCfg: OpenClawConfig = {
|
||||
session: { store: storePath },
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "line-group-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "line-group-agent",
|
||||
match: { channel: "line", peer: { kind: "group", id: groupId } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "msg-1", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId, userId: "user-1" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-1",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg: bindingCfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(context?.route.agentId).toBe("line-group-agent");
|
||||
expect(context?.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
it("room peer binding matches raw roomId without prefix (#21907)", async () => {
|
||||
const roomId = "Rr1234567890abcdef";
|
||||
const bindingCfg: OpenClawConfig = {
|
||||
session: { store: storePath },
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "line-room-agent" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "line-room-agent",
|
||||
match: { channel: "line", peer: { kind: "group", id: roomId } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "msg-2", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "room", roomId, userId: "user-2" },
|
||||
mode: "active",
|
||||
webhookEventId: "evt-2",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg: bindingCfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(context?.route.agentId).toBe("line-room-agent");
|
||||
expect(context?.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", () => {
|
||||
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
||||
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
expect(compiled).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(
|
||||
lineBindingsAdapter.matchInboundConversation({
|
||||
compiledBinding: compiled!,
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
matchPriority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes canonical LINE targets through the plugin bindings surface", () => {
|
||||
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
||||
conversationId: "line:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
expect(compiled).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(
|
||||
lineBindingsAdapter.resolveCommandConversation({
|
||||
originatingTo: "line:U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(
|
||||
lineBindingsAdapter.matchInboundConversation({
|
||||
compiledBinding: compiled!,
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
matchPriority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes LINE conversations through active ACP session bindings", async () => {
|
||||
const userId = "U1234567890abcdef1234567890abcdef";
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:line:default:test123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
conversationId: userId,
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
const event = createMessageEvent({ type: "user", userId });
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context?.route.agentId).toBe("codex");
|
||||
expect(context?.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123");
|
||||
expect(context?.route.matchedBy).toBe("binding.channel");
|
||||
});
|
||||
});
|
||||
608
extensions/line/src/bot-message-context.ts
Normal file
608
extensions/line/src/bot-message-context.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
// Line plugin module implements bot message context behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import {
|
||||
formatInboundMediaUnavailableText,
|
||||
formatInboundEnvelope,
|
||||
formatLocationText,
|
||||
resolveInboundSessionEnvelopeContext,
|
||||
toLocationContext,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { createChannelHistoryWindow, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import { resolveLineGroupConfigEntry } from "./group-keys.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type EventSource = webhook.Source | undefined;
|
||||
type MessageEvent = webhook.MessageEvent;
|
||||
type PostbackEvent = webhook.PostbackEvent;
|
||||
type StickerEventMessage = webhook.StickerMessageContent;
|
||||
|
||||
interface MediaRef {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
interface BuildLineMessageContextParams {
|
||||
event: MessageEvent;
|
||||
allMedia: MediaRef[];
|
||||
mediaUnavailable?: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
commandAuthorized: boolean;
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
type LineSourceInfo = {
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export function getLineSourceInfo(source: EventSource): LineSourceInfo {
|
||||
if (!source) {
|
||||
return { userId: undefined, groupId: undefined, roomId: undefined, isGroup: false };
|
||||
}
|
||||
const userId =
|
||||
source.type === "user"
|
||||
? source.userId
|
||||
: source.type === "group"
|
||||
? source.userId
|
||||
: source.type === "room"
|
||||
? source.userId
|
||||
: undefined;
|
||||
const groupId = source.type === "group" ? source.groupId : undefined;
|
||||
const roomId = source.type === "room" ? source.roomId : undefined;
|
||||
const isGroup = source.type === "group" || source.type === "room";
|
||||
|
||||
return { userId, groupId, roomId, isGroup };
|
||||
}
|
||||
|
||||
function buildPeerId(source: EventSource): string {
|
||||
if (!source) {
|
||||
return "unknown";
|
||||
}
|
||||
const groupKey =
|
||||
normalizeOptionalString(source.type === "group" ? source.groupId : undefined) ??
|
||||
normalizeOptionalString(source.type === "room" ? source.roomId : undefined);
|
||||
if (groupKey) {
|
||||
return groupKey;
|
||||
}
|
||||
if (source.type === "user" && source.userId) {
|
||||
return source.userId;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function resolveLineInboundRoute(params: {
|
||||
source: EventSource;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
}): Promise<{
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
peerId: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
}> {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
|
||||
const peerId = buildPeerId(params.source);
|
||||
let route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
const configuredRoute = resolveConfiguredBindingRoute({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
},
|
||||
});
|
||||
let configuredBinding = configuredRoute.bindingResolution;
|
||||
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
},
|
||||
});
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord) {
|
||||
configuredBinding = null;
|
||||
logVerbose(
|
||||
runtimeRoute.boundSessionKey
|
||||
? `line: routed via bound conversation ${peerId} -> ${runtimeRoute.boundSessionKey}`
|
||||
: `line: plugin-bound conversation ${peerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: params.cfg,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`line: configured ACP binding unavailable for ${peerId} -> ${configuredBindingSessionKey}: ${ensured.error}`,
|
||||
);
|
||||
throw new Error(`Configured ACP binding unavailable: ${ensured.error}`);
|
||||
}
|
||||
logVerbose(
|
||||
`line: using configured ACP binding for ${peerId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { userId, groupId, roomId, isGroup, peerId, route };
|
||||
}
|
||||
|
||||
const STICKER_PACKAGES: Record<string, string> = {
|
||||
"1": "Moon & James",
|
||||
"2": "Cony & Brown",
|
||||
"3": "Brown & Friends",
|
||||
"4": "Moon Special",
|
||||
"789": "LINE Characters",
|
||||
"6136": "Cony's Happy Life",
|
||||
"6325": "Brown's Life",
|
||||
"6359": "Choco",
|
||||
"6362": "Sally",
|
||||
"6370": "Edward",
|
||||
"11537": "Cony",
|
||||
"11538": "Brown",
|
||||
"11539": "Moon",
|
||||
};
|
||||
|
||||
function describeStickerKeywords(sticker: StickerEventMessage): string {
|
||||
const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
|
||||
if (keywords && keywords.length > 0) {
|
||||
return keywords.slice(0, 3).join(", ");
|
||||
}
|
||||
|
||||
const stickerText = (sticker as StickerEventMessage & { text?: string }).text;
|
||||
if (stickerText) {
|
||||
return stickerText;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractMessageText(message: MessageEvent["message"]): string {
|
||||
if (message.type === "text") {
|
||||
return message.text;
|
||||
}
|
||||
if (message.type === "location") {
|
||||
const loc = message;
|
||||
return (
|
||||
formatLocationText({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
name: loc.title,
|
||||
address: loc.address,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
if (message.type === "sticker") {
|
||||
const sticker = message;
|
||||
const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
|
||||
const keywords = describeStickerKeywords(sticker);
|
||||
|
||||
if (keywords) {
|
||||
return `[Sent a ${packageName} sticker: ${keywords}]`;
|
||||
}
|
||||
return `[Sent a ${packageName} sticker]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractMediaPlaceholder(message: MessageEvent["message"]): string {
|
||||
switch (message.type) {
|
||||
case "image":
|
||||
return "<media:image>";
|
||||
case "video":
|
||||
return "<media:video>";
|
||||
case "audio":
|
||||
return "<media:audio>";
|
||||
case "file":
|
||||
return "<media:document>";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
type LineRouteInfo = ReturnType<typeof resolveAgentRoute>;
|
||||
type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string };
|
||||
|
||||
function resolveLineConversationLabel(params: {
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
senderLabel: string;
|
||||
}): string {
|
||||
return params.isGroup
|
||||
? params.groupId
|
||||
? `group:${params.groupId}`
|
||||
: params.roomId
|
||||
? `room:${params.roomId}`
|
||||
: "unknown-group"
|
||||
: params.senderLabel;
|
||||
}
|
||||
|
||||
function resolveLineAddresses(params: {
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
userId?: string;
|
||||
peerId: string;
|
||||
}): { fromAddress: string; toAddress: string; originatingTo: string } {
|
||||
const fromAddress = params.isGroup
|
||||
? params.groupId
|
||||
? `line:group:${params.groupId}`
|
||||
: params.roomId
|
||||
? `line:room:${params.roomId}`
|
||||
: `line:${params.peerId}`
|
||||
: `line:${params.userId ?? params.peerId}`;
|
||||
const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
||||
const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
|
||||
return { fromAddress, toAddress, originatingTo };
|
||||
}
|
||||
|
||||
async function finalizeLineInboundContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
event: MessageEvent | PostbackEvent;
|
||||
route: LineRouteInfo;
|
||||
source: LineSourceInfoWithPeerId;
|
||||
rawBody: string;
|
||||
agentBody?: string;
|
||||
timestamp: number;
|
||||
messageSid: string;
|
||||
commandAuthorized: boolean;
|
||||
media: {
|
||||
firstPath: string | undefined;
|
||||
firstContentType?: string;
|
||||
paths?: string[];
|
||||
types?: string[];
|
||||
};
|
||||
locationContext?: ReturnType<typeof toLocationContext>;
|
||||
verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
|
||||
inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
|
||||
}) {
|
||||
const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
|
||||
isGroup: params.source.isGroup,
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
userId: params.source.userId,
|
||||
peerId: params.source.peerId,
|
||||
});
|
||||
|
||||
const senderId = params.source.userId ?? "unknown";
|
||||
const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
|
||||
const conversationLabel = resolveLineConversationLabel({
|
||||
isGroup: params.source.isGroup,
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
senderLabel,
|
||||
});
|
||||
|
||||
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
||||
cfg: params.cfg,
|
||||
agentId: params.route.agentId,
|
||||
sessionKey: params.route.sessionKey,
|
||||
});
|
||||
|
||||
const agentBody = params.agentBody ?? params.rawBody;
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "LINE",
|
||||
from: conversationLabel,
|
||||
timestamp: params.timestamp,
|
||||
body: agentBody,
|
||||
chatType: params.source.isGroup ? "group" : "direct",
|
||||
sender: {
|
||||
id: senderId,
|
||||
},
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: agentBody,
|
||||
RawBody: params.rawBody,
|
||||
CommandBody: params.rawBody,
|
||||
From: fromAddress,
|
||||
To: toAddress,
|
||||
SessionKey: params.route.sessionKey,
|
||||
AccountId: params.route.accountId,
|
||||
ChatType: params.source.isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: params.source.isGroup
|
||||
? (params.source.groupId ?? params.source.roomId)
|
||||
: undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "line",
|
||||
Surface: "line",
|
||||
MessageSid: params.messageSid,
|
||||
Timestamp: params.timestamp,
|
||||
MediaPath: params.media.firstPath,
|
||||
MediaType: params.media.firstContentType,
|
||||
MediaUrl: params.media.firstPath,
|
||||
MediaPaths: params.media.paths,
|
||||
MediaUrls: params.media.paths,
|
||||
MediaTypes: params.media.types,
|
||||
...params.locationContext,
|
||||
CommandAuthorized: params.commandAuthorized,
|
||||
OriginatingChannel: "line" as const,
|
||||
OriginatingTo: originatingTo,
|
||||
GroupSystemPrompt: params.source.isGroup
|
||||
? normalizeOptionalString(
|
||||
resolveLineGroupConfigEntry(params.account.config.groups, {
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
})?.systemPrompt,
|
||||
)
|
||||
: undefined,
|
||||
InboundHistory: params.inboundHistory,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !params.source.isGroup
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
allowFrom: params.account.config.allowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
|
||||
route: params.route,
|
||||
sessionKey: params.route.sessionKey,
|
||||
});
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
||||
const mediaInfo =
|
||||
params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1
|
||||
? ` mediaCount=${params.verboseLog.mediaCount}`
|
||||
: "";
|
||||
const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
|
||||
logVerbose(
|
||||
`${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
replyToken: (params.event as { replyToken: string }).replyToken,
|
||||
turn: {
|
||||
storePath,
|
||||
record: {
|
||||
updateLastRoute: !params.source.isGroup
|
||||
? {
|
||||
sessionKey: inboundLastRouteSessionKey,
|
||||
channel: "line",
|
||||
to: params.source.userId ?? params.source.peerId,
|
||||
accountId: params.route.accountId,
|
||||
mainDmOwnerPin:
|
||||
inboundLastRouteSessionKey === params.route.mainSessionKey &&
|
||||
pinnedMainDmOwner &&
|
||||
params.source.userId
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: params.source.userId,
|
||||
onSkip: ({
|
||||
ownerRecipient,
|
||||
senderRecipient,
|
||||
}: {
|
||||
ownerRecipient: string;
|
||||
senderRecipient: string;
|
||||
}) => {
|
||||
logVerbose(
|
||||
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err: unknown) => {
|
||||
logVerbose(`line: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
|
||||
const {
|
||||
event,
|
||||
allMedia,
|
||||
mediaUnavailable,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
} = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
});
|
||||
|
||||
const message = event.message;
|
||||
const messageId = message.id;
|
||||
const timestamp = event.timestamp;
|
||||
|
||||
const textContent = extractMessageText(message);
|
||||
const placeholder = extractMediaPlaceholder(message);
|
||||
|
||||
let rawBody = textContent || placeholder;
|
||||
if (!rawBody && allMedia.length > 0) {
|
||||
rawBody = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
||||
}
|
||||
const agentBody = mediaUnavailable
|
||||
? formatInboundMediaUnavailableText({
|
||||
body: rawBody,
|
||||
mediaPlaceholder: placeholder,
|
||||
notice: "[line attachment unavailable]",
|
||||
})
|
||||
: rawBody;
|
||||
|
||||
if (!agentBody && allMedia.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let locationContext: ReturnType<typeof toLocationContext> | undefined;
|
||||
if (message.type === "location") {
|
||||
const loc = message;
|
||||
locationContext = toLocationContext({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
name: loc.title,
|
||||
address: loc.address,
|
||||
});
|
||||
}
|
||||
|
||||
const historyKey = isGroup ? peerId : undefined;
|
||||
const inboundHistory =
|
||||
historyKey && groupHistories && (historyLimit ?? 0) > 0
|
||||
? createChannelHistoryWindow({ historyMap: groupHistories }).buildInboundHistory({
|
||||
historyKey,
|
||||
limit: historyLimit ?? 0,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const finalized = await finalizeLineInboundContext({
|
||||
cfg,
|
||||
account,
|
||||
event,
|
||||
route,
|
||||
source: { userId, groupId, roomId, isGroup, peerId },
|
||||
rawBody,
|
||||
agentBody,
|
||||
timestamp,
|
||||
messageSid: messageId,
|
||||
commandAuthorized,
|
||||
media: {
|
||||
firstPath: allMedia[0]?.path,
|
||||
firstContentType: allMedia[0]?.contentType,
|
||||
paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
types:
|
||||
allMedia.length > 0
|
||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
},
|
||||
locationContext,
|
||||
verboseLog: { kind: "inbound", mediaCount: allMedia.length },
|
||||
inboundHistory,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload: finalized.ctxPayload,
|
||||
turn: finalized.turn,
|
||||
event,
|
||||
userId,
|
||||
groupId,
|
||||
roomId,
|
||||
isGroup,
|
||||
route,
|
||||
replyToken: event.replyToken,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildLinePostbackContext(params: {
|
||||
event: PostbackEvent;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
commandAuthorized: boolean;
|
||||
}) {
|
||||
const { event, cfg, account, commandAuthorized } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
});
|
||||
|
||||
const timestamp = event.timestamp;
|
||||
const rawData = event.postback?.data?.trim() ?? "";
|
||||
if (!rawData) {
|
||||
return null;
|
||||
}
|
||||
let rawBody = rawData;
|
||||
if (rawData.includes("line.action=")) {
|
||||
const searchParams = new URLSearchParams(rawData);
|
||||
const action = searchParams.get("line.action") ?? "";
|
||||
const device = searchParams.get("line.device");
|
||||
rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
|
||||
}
|
||||
|
||||
const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
|
||||
const finalized = await finalizeLineInboundContext({
|
||||
cfg,
|
||||
account,
|
||||
event,
|
||||
route,
|
||||
source: { userId, groupId, roomId, isGroup, peerId },
|
||||
rawBody,
|
||||
timestamp,
|
||||
messageSid,
|
||||
commandAuthorized,
|
||||
media: {
|
||||
firstPath: "",
|
||||
firstContentType: undefined,
|
||||
paths: undefined,
|
||||
types: undefined,
|
||||
},
|
||||
verboseLog: { kind: "postback" },
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload: finalized.ctxPayload,
|
||||
turn: finalized.turn,
|
||||
event,
|
||||
userId,
|
||||
groupId,
|
||||
roomId,
|
||||
isGroup,
|
||||
route,
|
||||
replyToken: event.replyToken,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
|
||||
type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
|
||||
export type LineInboundContext = LineMessageContext | LinePostbackContext;
|
||||
71
extensions/line/src/bot.ts
Normal file
71
extensions/line/src/bot.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Line plugin module implements bot behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import {
|
||||
createNonExitingRuntime,
|
||||
logVerbose,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { createLineWebhookReplayCache, handleLineWebhookEvents } from "./bot-handlers.js";
|
||||
import type { LineInboundContext } from "./bot-message-context.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
interface LineBotOptions {
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
accountId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
config?: OpenClawConfig;
|
||||
mediaMaxMb?: number;
|
||||
onMessage?: (ctx: LineInboundContext) => Promise<void>;
|
||||
}
|
||||
|
||||
interface LineBot {
|
||||
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
|
||||
account: ResolvedLineAccount;
|
||||
}
|
||||
|
||||
export function createLineBot(opts: LineBotOptions): LineBot {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
const cfg = opts.config ?? getRuntimeConfig();
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? account.config.mediaMaxMb ?? 10) * 1024 * 1024;
|
||||
|
||||
const processMessage =
|
||||
opts.onMessage ??
|
||||
(async () => {
|
||||
logVerbose("line: no message handler configured");
|
||||
});
|
||||
const replayCache = createLineWebhookReplayCache();
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const handleWebhook = async (body: webhook.CallbackRequest): Promise<void> => {
|
||||
if (!body.events || body.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleLineWebhookEvents(body.events, {
|
||||
cfg,
|
||||
account,
|
||||
runtime,
|
||||
mediaMaxBytes,
|
||||
processMessage,
|
||||
replayCache,
|
||||
groupHistories,
|
||||
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleWebhook,
|
||||
account,
|
||||
};
|
||||
}
|
||||
349
extensions/line/src/card-command.ts
Normal file
349
extensions/line/src/card-command.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// Line plugin module implements card command behavior.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { messageAction, postbackAction, uriAction } from "./actions.js";
|
||||
import {
|
||||
createActionCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "./flex-templates.js";
|
||||
import type { LineChannelData } from "./types.js";
|
||||
|
||||
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||
|
||||
Types:
|
||||
info "Title" "Body" ["Footer"]
|
||||
image "Title" "Caption" --url <image-url>
|
||||
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
|
||||
list "Title" "Item1|Desc1,Item2|Desc2"
|
||||
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
|
||||
confirm "Question?" --yes "Yes|data" --no "No|data"
|
||||
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
|
||||
|
||||
Examples:
|
||||
/card info "Welcome" "Thanks for joining!"
|
||||
/card image "Product" "Check it out" --url https://example.com/img.jpg
|
||||
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
|
||||
|
||||
function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
||||
return {
|
||||
channelData: {
|
||||
line: lineData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse action string format: "Label|data,Label2|data2"
|
||||
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||
*/
|
||||
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||
if (!actionsStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: CardAction[] = [];
|
||||
|
||||
for (const part of actionsStr.split(",")) {
|
||||
const [label, data] = part
|
||||
.trim()
|
||||
.split("|")
|
||||
.map((s) => s.trim());
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionData = data || label;
|
||||
|
||||
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
|
||||
results.push({
|
||||
label,
|
||||
action: uriAction(label, actionData),
|
||||
});
|
||||
} else if (actionData.includes("=")) {
|
||||
results.push({
|
||||
label,
|
||||
action: postbackAction(label, actionData, label),
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
label,
|
||||
action: messageAction(label, 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(argsStrInput: string): {
|
||||
type: string;
|
||||
args: string[];
|
||||
flags: Record<string, string>;
|
||||
} {
|
||||
let argsStr = argsStrInput;
|
||||
const result: { type: string; args: string[]; flags: Record<string, string> } = {
|
||||
type: "",
|
||||
args: [],
|
||||
flags: {},
|
||||
};
|
||||
|
||||
// Extract type (first word)
|
||||
const typeMatch = argsStr.match(/^(\w+)/);
|
||||
if (typeMatch) {
|
||||
result.type = normalizeLowercaseStringOrEmpty(typeMatch[1]);
|
||||
argsStr = argsStr.slice(typeMatch[0].length).trim();
|
||||
}
|
||||
|
||||
// Extract quoted arguments
|
||||
const quotedRegex = /"([^"]*?)"/g;
|
||||
let match;
|
||||
while ((match = quotedRegex.exec(argsStr)) !== null) {
|
||||
result.args.push(match[1]);
|
||||
}
|
||||
|
||||
// Extract flags (--key value or --key "value")
|
||||
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
|
||||
while ((match = flagRegex.exec(argsStr)) !== null) {
|
||||
result.flags[match[1]] = match[2] ?? match[3];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerLineCardCommand(api: OpenClawPluginApi): void {
|
||||
api.registerCommand({
|
||||
name: "card",
|
||||
description: "Send a rich card message (LINE).",
|
||||
acceptsArgs: true,
|
||||
requireAuth: false,
|
||||
handler: async (ctx) => {
|
||||
const argsStr = ctx.args?.trim() ?? "";
|
||||
if (!argsStr) {
|
||||
return { text: CARD_USAGE };
|
||||
}
|
||||
|
||||
const parsed = parseCardArgs(argsStr);
|
||||
const { type, args, flags } = parsed;
|
||||
|
||||
if (!type) {
|
||||
return { text: CARD_USAGE };
|
||||
}
|
||||
|
||||
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||
if (ctx.channel !== "line") {
|
||||
const fallbackText = args.join(" - ");
|
||||
return { text: `[${type} card] ${fallbackText}`.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "info": {
|
||||
const [title = "Info", body = "", footer] = args;
|
||||
const bubble = createInfoCard(title, body, footer);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: truncateUtf16Safe(`${title}: ${body}`, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const [title = "Image", caption = ""] = args;
|
||||
const imageUrl = flags.url || flags.image;
|
||||
if (!imageUrl) {
|
||||
return { text: "Error: Image card requires --url <image-url>" };
|
||||
}
|
||||
const bubble = createImageCard(imageUrl, title, caption);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: truncateUtf16Safe(`${title}: ${caption}`, 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: truncateUtf16Safe(`${title}: ${body}`, 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: truncateUtf16Safe(
|
||||
`${title}: ${items.map((i) => i.title).join(", ")}`,
|
||||
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: truncateUtf16Safe(
|
||||
`${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`,
|
||||
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)}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
15
extensions/line/src/channel-access-token.ts
Normal file
15
extensions/line/src/channel-access-token.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Line plugin module implements channel access token behavior.
|
||||
export function resolveLineChannelAccessToken(
|
||||
explicit: string | undefined,
|
||||
params: { accountId: string; channelAccessToken: string },
|
||||
): string {
|
||||
if (explicit?.trim()) {
|
||||
return explicit.trim();
|
||||
}
|
||||
if (!params.channelAccessToken) {
|
||||
throw new Error(
|
||||
`LINE channel access token missing for account "${params.accountId}" (set channels.line.channelAccessToken or LINE_CHANNEL_ACCESS_TOKEN).`,
|
||||
);
|
||||
}
|
||||
return params.channelAccessToken.trim();
|
||||
}
|
||||
18
extensions/line/src/channel-api.ts
Normal file
18
extensions/line/src/channel-api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "./accounts.js";
|
||||
import { resolveExactLineGroupConfigKey } from "./group-keys.js";
|
||||
import type { LineConfig, ResolvedLineAccount } from "./types.js";
|
||||
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveExactLineGroupConfigKey,
|
||||
resolveLineAccount,
|
||||
};
|
||||
|
||||
export type { ChannelPlugin, LineConfig, OpenClawConfig, ResolvedLineAccount };
|
||||
71
extensions/line/src/channel-setup-status.contract.test.ts
Normal file
71
extensions/line/src/channel-setup-status.contract.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Line tests cover channel setup status.contract plugin behavior.
|
||||
import {
|
||||
installChannelSetupContractSuite,
|
||||
installChannelStatusContractSuite,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect } from "vitest";
|
||||
import { linePlugin, lineSetupPlugin } from "../api.js";
|
||||
|
||||
describe("line setup contract", () => {
|
||||
installChannelSetupContractSuite({
|
||||
plugin: lineSetupPlugin,
|
||||
cases: [
|
||||
{
|
||||
name: "default account stores token and secret",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
channelAccessToken: "line-token",
|
||||
channelSecret: "line-secret",
|
||||
} as never,
|
||||
expectedAccountId: "default",
|
||||
assertPatchedConfig: (cfg) => {
|
||||
expect(cfg.channels?.line?.enabled).toBe(true);
|
||||
expect(cfg.channels?.line?.channelAccessToken).toBe("line-token");
|
||||
expect(cfg.channels?.line?.channelSecret).toBe("line-secret");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-default env setup is rejected",
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
useEnv: true,
|
||||
},
|
||||
expectedAccountId: "ops",
|
||||
expectedValidation: "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("line status contract", () => {
|
||||
installChannelStatusContractSuite({
|
||||
plugin: linePlugin,
|
||||
cases: [
|
||||
{
|
||||
name: "configured account produces a webhook status snapshot",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "line-token",
|
||||
channelSecret: "line-secret",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
probe: { ok: true },
|
||||
assertSnapshot: (snapshot) => {
|
||||
expect(snapshot.accountId).toBe("default");
|
||||
expect(snapshot.enabled).toBe(true);
|
||||
expect(snapshot.configured).toBe(true);
|
||||
expect(snapshot.mode).toBe("webhook");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
49
extensions/line/src/channel-shared.ts
Normal file
49
extensions/line/src/channel-shared.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Line plugin module implements channel shared behavior.
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { hasLineCredentials } from "./account-helpers.js";
|
||||
import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { LineChannelConfigSchema } from "./config-schema.js";
|
||||
|
||||
const lineChannelMeta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
} as const;
|
||||
|
||||
export const lineChannelPluginCommon = {
|
||||
meta: {
|
||||
...lineChannelMeta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: LineChannelConfigSchema,
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account),
|
||||
describeAccount: (account: ResolvedLineAccount) =>
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} satisfies Pick<
|
||||
ChannelPlugin<ResolvedLineAccount>,
|
||||
"meta" | "capabilities" | "reload" | "configSchema" | "config"
|
||||
>;
|
||||
146
extensions/line/src/channel.logout.test.ts
Normal file
146
extensions/line/src/channel.logout.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Line tests cover channel.logout plugin behavior.
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
replaceConfigFile: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const replaceConfigFile = vi.fn(async () => {});
|
||||
const resolveLineAccount = vi.fn(
|
||||
({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const entry =
|
||||
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? (lineConfig.accounts?.[accountId] ?? {})
|
||||
: lineConfig;
|
||||
const hasToken =
|
||||
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
|
||||
const hasSecret = Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
|
||||
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = {
|
||||
config: { replaceConfigFile },
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, mocks: { replaceConfigFile, resolveLineAccount } };
|
||||
}
|
||||
|
||||
function resolveAccount(
|
||||
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): ResolvedLineAccount {
|
||||
const resolver = resolveLineAccount as unknown as (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) => ResolvedLineAccount;
|
||||
return resolver({ cfg, accountId });
|
||||
}
|
||||
|
||||
async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{
|
||||
result: Awaited<ReturnType<NonNullable<typeof lineGatewayAdapter.logoutAccount>>>;
|
||||
mocks: LineRuntimeMocks;
|
||||
}> {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId);
|
||||
const result = await lineGatewayAdapter.logoutAccount!({
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
account,
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
return { result, mocks };
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.logoutAccount", () => {
|
||||
beforeEach(() => {
|
||||
setLineRuntime(createRuntime().runtime);
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on default account logout", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { result, mocks } = await runLogoutScenario({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig: {},
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on account logout", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
primary: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { result, mocks } = await runLogoutScenario({
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig: {},
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write config when account has no token/secret fields", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
primary: {
|
||||
name: "Primary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { result, mocks } = await runLogoutScenario({
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(false);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
4
extensions/line/src/channel.runtime.ts
Normal file
4
extensions/line/src/channel.runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Line plugin module implements channel behavior.
|
||||
export { monitorLineProvider } from "./monitor.js";
|
||||
export { probeLineBot } from "./probe.js";
|
||||
export { pushMessageLine } from "./send.js";
|
||||
685
extensions/line/src/channel.sendPayload.test.ts
Normal file
685
extensions/line/src/channel.sendPayload.test.ts
Normal file
@@ -0,0 +1,685 @@
|
||||
// Line tests cover channel.sendPayload plugin behavior.
|
||||
import {
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { lineOutboundAdapter } from "./outbound.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
import { createLineSendReceipt } from "./send-receipt.js";
|
||||
|
||||
const ssrfMocks = vi.hoisted(() => ({
|
||||
resolvePinnedHostnameWithPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
pushMessageLine: ReturnType<typeof vi.fn>;
|
||||
pushMessagesLine: ReturnType<typeof vi.fn>;
|
||||
pushFlexMessage: ReturnType<typeof vi.fn>;
|
||||
pushTemplateMessage: ReturnType<typeof vi.fn>;
|
||||
pushLocationMessage: ReturnType<typeof vi.fn>;
|
||||
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
|
||||
createQuickReplyItems: ReturnType<typeof vi.fn>;
|
||||
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
|
||||
sendMessageLine: ReturnType<typeof vi.fn>;
|
||||
chunkMarkdownText: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(1_800_000_000_000);
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function lineResult(messageId: string, chatId = "c1") {
|
||||
return {
|
||||
messageId,
|
||||
chatId,
|
||||
receipt: createLineSendReceipt({ messageId, chatId, kind: "text" }),
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const pushMessageLine = vi.fn(async () => lineResult("m-text"));
|
||||
const pushMessagesLine = vi.fn(async () => lineResult("m-batch"));
|
||||
const pushFlexMessage = vi.fn(async () => lineResult("m-flex"));
|
||||
const pushTemplateMessage = vi.fn(async () => lineResult("m-template"));
|
||||
const pushLocationMessage = vi.fn(async () => lineResult("m-loc"));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => lineResult("m-quick"));
|
||||
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
|
||||
const sendMessageLine = vi.fn(async () => lineResult("m-media"));
|
||||
const chunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const resolveTextChunkLimit = vi.fn(() => 123);
|
||||
const resolveLineAccount = vi.fn(
|
||||
({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
|
||||
const resolved = accountId ?? "default";
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const accountConfig = resolved !== "default" ? (lineConfig.accounts?.[resolved] ?? {}) : {};
|
||||
return {
|
||||
accountId: resolved,
|
||||
config: { ...lineConfig, ...accountConfig },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
resolveLineAccount,
|
||||
},
|
||||
text: {
|
||||
chunkMarkdownText,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
mocks: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
chunkMarkdownText,
|
||||
resolveLineAccount,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("line outbound sendPayload", () => {
|
||||
it("sends flex message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Now playing:",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Now playing",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:group:1",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports each platform result for text and media payloads", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
const onDeliveryResult = vi.fn();
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:progress",
|
||||
text: "Hello",
|
||||
payload: {
|
||||
text: "Hello",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
},
|
||||
accountId: "default",
|
||||
cfg,
|
||||
onDeliveryResult,
|
||||
});
|
||||
|
||||
expect(onDeliveryResult).toHaveBeenCalledTimes(2);
|
||||
expect(onDeliveryResult.mock.calls.map(([result]) => result.messageId)).toEqual([
|
||||
"m-text",
|
||||
"m-media",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends template message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Choose one:",
|
||||
channelData: {
|
||||
line: {
|
||||
templateMessage: {
|
||||
type: "confirm",
|
||||
text: "Continue?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:1",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches quick replies when no text chunks are present", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:2",
|
||||
text: "",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
|
||||
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:2",
|
||||
[
|
||||
{
|
||||
type: "flex",
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
quickReply: { items: ["One", "Two"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||
});
|
||||
|
||||
it("sends quick-reply-only payloads with fallback text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const result = await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:quick",
|
||||
text: "",
|
||||
payload: {
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||
"line:user:quick",
|
||||
"Options:\n- One\n- Two",
|
||||
["One", "Two"],
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "line",
|
||||
chatId: "c1",
|
||||
messageId: "m-quick",
|
||||
receipt: {
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "m-quick",
|
||||
raw: {
|
||||
channel: "line",
|
||||
chatId: "c1",
|
||||
conversationId: "c1",
|
||||
messageId: "m-quick",
|
||||
meta: { messageCount: 1 },
|
||||
},
|
||||
threadId: "c1",
|
||||
},
|
||||
],
|
||||
platformMessageIds: ["m-quick"],
|
||||
primaryPlatformMessageId: "m-quick",
|
||||
raw: [
|
||||
{
|
||||
channel: "line",
|
||||
chatId: "c1",
|
||||
conversationId: "c1",
|
||||
messageId: "m-quick",
|
||||
meta: { messageCount: 1 },
|
||||
},
|
||||
],
|
||||
sentAt: 1_800_000_000_000,
|
||||
threadId: "c1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends media before quick-reply text so buttons stay visible", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello",
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:3",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
mediaKind: undefined,
|
||||
previewImageUrl: undefined,
|
||||
durationMs: undefined,
|
||||
trackingId: undefined,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||
"line:user:3",
|
||||
"Hello",
|
||||
["One", "Two"],
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
||||
});
|
||||
|
||||
it("keeps generic media payloads on the image-only send path", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:4",
|
||||
text: "",
|
||||
payload: {
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
},
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:4", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses LINE-specific media options for rich media payloads", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:5",
|
||||
text: "",
|
||||
payload: {
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
channelData: {
|
||||
line: {
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:5", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
durationMs: undefined,
|
||||
trackingId: "track-123",
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured text chunk limit for payloads", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: { textChunkLimit: 123 } } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello world",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:3",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(cfg, "line", "primary", {
|
||||
fallbackLimit: 5000,
|
||||
});
|
||||
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
||||
});
|
||||
|
||||
it("omits trackingId for non-user quick-reply inline video media", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One"],
|
||||
mediaKind: "video" as const,
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-group",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:group:C123",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:group:C123",
|
||||
[
|
||||
{
|
||||
type: "video",
|
||||
originalContentUrl: "https://example.com/video.mp4",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
quickReply: { items: ["One"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps trackingId for user quick-reply inline video media", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One"],
|
||||
mediaKind: "video" as const,
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-user",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:U123",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:U123",
|
||||
[
|
||||
{
|
||||
type: "video",
|
||||
originalContentUrl: "https://example.com/video.mp4",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-user",
|
||||
quickReply: { items: ["One"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects quick-reply inline video media without previewImageUrl", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const payload = {
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One"],
|
||||
mediaKind: "video" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
lineOutboundAdapter.sendPayload!({
|
||||
to: "line:user:U123",
|
||||
text: payload.text,
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/require previewimageurl/i);
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as OpenClawConfig;
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "line",
|
||||
adapter: linePlugin.message!,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await linePlugin.message?.send?.text?.({
|
||||
cfg,
|
||||
to: "line:user:U123",
|
||||
text: "hello",
|
||||
accountId: "primary",
|
||||
});
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:U123", "hello", {
|
||||
verbose: false,
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["m-text"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await linePlugin.message?.send?.media?.({
|
||||
cfg,
|
||||
to: "line:user:U123",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
accountId: "primary",
|
||||
});
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:U123", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["m-media"]);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(linePlugin.message?.send?.text).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "messageSendingHooks")?.status).toBe(
|
||||
"verified",
|
||||
);
|
||||
});
|
||||
|
||||
it("declares receive ack policies for immediate LINE webhook acknowledgement", async () => {
|
||||
const proofResults = await verifyChannelMessageReceiveAckPolicyAdapterProofs({
|
||||
adapterName: "line",
|
||||
adapter: linePlugin.message!,
|
||||
proofs: {
|
||||
after_receive_record: () => {
|
||||
expect(linePlugin.message?.receive?.defaultAckPolicy).toBe("after_receive_record");
|
||||
expect(linePlugin.message?.receive?.supportedAckPolicies).toContain(
|
||||
"after_receive_record",
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults.find((result) => result.policy === "after_receive_record")?.status).toBe(
|
||||
"verified",
|
||||
);
|
||||
expect(proofResults.find((result) => result.policy === "after_agent_dispatch")?.status).toBe(
|
||||
"not_declared",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin config.formatAllowFrom", () => {
|
||||
it("strips line:user: prefixes without lowercasing", () => {
|
||||
const formatted = lineConfigAdapter.formatAllowFrom!({
|
||||
cfg: {} as OpenClawConfig,
|
||||
allowFrom: ["line:user:UABC", "line:UDEF"],
|
||||
});
|
||||
expect(formatted).toEqual(["UABC", "UDEF"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin groups.resolveRequireMention", () => {
|
||||
it("uses account-level group settings when provided", () => {
|
||||
const { runtime } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
groups: {
|
||||
"group-1": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const requireMention = resolveLineGroupRequireMention({
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
groupId: "group-1",
|
||||
});
|
||||
|
||||
expect(requireMention).toBe(true);
|
||||
});
|
||||
});
|
||||
12
extensions/line/src/channel.setup.ts
Normal file
12
extensions/line/src/channel.setup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Line plugin module implements channel.setup behavior.
|
||||
import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
|
||||
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
...lineChannelPluginCommon,
|
||||
setupWizard: lineSetupWizard,
|
||||
setup: lineSetupAdapter,
|
||||
};
|
||||
64
extensions/line/src/channel.status.test.ts
Normal file
64
extensions/line/src/channel.status.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Line tests cover channel.status plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelAccountSnapshot } from "../api.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
function collectIssues(accounts: ChannelAccountSnapshot[]) {
|
||||
const collect = lineStatusAdapter.collectStatusIssues;
|
||||
if (!collect) {
|
||||
throw new Error("LINE plugin status collector is unavailable");
|
||||
}
|
||||
return collect(accounts);
|
||||
}
|
||||
|
||||
describe("linePlugin status.collectStatusIssues", () => {
|
||||
it("does not warn when a sanitized snapshot is configured", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
},
|
||||
]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: false,
|
||||
tokenSource: "none",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "LINE channel access token not configured",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
|
||||
expect(
|
||||
collectIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: false,
|
||||
tokenSource: "env",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "LINE channel secret not configured",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
156
extensions/line/src/channel.ts
Normal file
156
extensions/line/src/channel.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Line plugin module implements channel behavior.
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { lineBindingsAdapter } from "./bindings.js";
|
||||
import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
|
||||
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||
resolveDmAllowFrom: (account) => account.config.allowFrom,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
surface: "LINE groups",
|
||||
openScope: "any member in groups",
|
||||
groupPolicyPath: "channels.line.groupPolicy",
|
||||
groupAllowFromPath: "channels.line.groupAllowFrom",
|
||||
mentionGated: false,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
approveHint: "openclaw pairing approve line <code>",
|
||||
normalizeDmEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
});
|
||||
|
||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "line",
|
||||
...lineChannelPluginCommon,
|
||||
setupWizard: lineSetupWizard,
|
||||
groups: {
|
||||
resolveRequireMention: resolveLineGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["line"],
|
||||
normalizeTarget: (target) => {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||
},
|
||||
resolveInboundConversation: lineBindingsAdapter.resolveInboundConversation,
|
||||
transformReplyPayload: ({ payload }) => {
|
||||
if (!payload.text || !hasLineDirectives(payload.text)) {
|
||||
return payload;
|
||||
}
|
||||
return parseLineDirectives(payload);
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||
},
|
||||
hint: "<userId|groupId|roomId>",
|
||||
},
|
||||
},
|
||||
directory: createEmptyChannelDirectoryAdapter(),
|
||||
setup: lineSetupAdapter,
|
||||
status: lineStatusAdapter,
|
||||
gateway: lineGatewayAdapter,
|
||||
message: lineMessageAdapter,
|
||||
bindings: lineBindingsAdapter,
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
"### LINE Rich Messages",
|
||||
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
||||
"",
|
||||
"**Quick Replies** (bottom button suggestions):",
|
||||
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
||||
"",
|
||||
"**Location** (map pin):",
|
||||
" [[location: Place Name | Address | latitude | longitude]]",
|
||||
"",
|
||||
"**Confirm Dialog** (yes/no prompt):",
|
||||
" [[confirm: Question text? | Yes Label | No Label]]",
|
||||
"",
|
||||
"**Button Menu** (title + text + buttons):",
|
||||
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
||||
"",
|
||||
"**Media Player Card** (music status):",
|
||||
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
||||
" - Status: 'playing' or 'paused' (optional)",
|
||||
"",
|
||||
"**Event Card** (calendar events, meetings):",
|
||||
" [[event: Event Title | Date | Time | Location | Description]]",
|
||||
" - Time, Location, Description are optional",
|
||||
"",
|
||||
"**Agenda Card** (multiple events/schedule):",
|
||||
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
||||
"",
|
||||
"**Device Control Card** (smart devices, TVs, etc.):",
|
||||
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
||||
"",
|
||||
"**Apple TV Remote** (full D-pad + transport):",
|
||||
" [[appletv_remote: Apple TV | Playing]]",
|
||||
"",
|
||||
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
||||
"",
|
||||
"When to use rich messages:",
|
||||
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
||||
"- Use [[confirm:...]] for yes/no decisions",
|
||||
"- Use [[buttons:...]] for menus with actions/links",
|
||||
"- Use [[location:...]] when sharing a place",
|
||||
"- Use [[media_player:...]] when showing what's playing",
|
||||
"- Use [[event:...]] for calendar event details",
|
||||
"- Use [[agenda:...]] for a day's schedule or event list",
|
||||
"- Use [[device:...]] for smart device status/controls",
|
||||
"- Tables/code in your response auto-convert to visual cards",
|
||||
],
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "lineUserId",
|
||||
message: "OpenClaw: your access has been approved.",
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const account = (getLineRuntime().channel.line?.resolveLineAccount ?? resolveLineAccount)({
|
||||
cfg,
|
||||
});
|
||||
if (!account.channelAccessToken) {
|
||||
throw new Error("LINE channel access token not configured");
|
||||
}
|
||||
const pushMessageLine =
|
||||
getLineRuntime().channel.line?.pushMessageLine ??
|
||||
(await loadLineChannelRuntime()).pushMessageLine;
|
||||
await pushMessageLine(id, message, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
channelAccessToken: account.channelAccessToken,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
security: lineSecurityAdapter,
|
||||
outbound: lineOutboundAdapter,
|
||||
});
|
||||
27
extensions/line/src/config-adapter.ts
Normal file
27
extensions/line/src/config-adapter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Line helper module supports config adapter behavior.
|
||||
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
type ResolvedLineAccount,
|
||||
} from "./channel-api.js";
|
||||
|
||||
function normalizeLineAllowFrom(entry: string): string {
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
}
|
||||
|
||||
export const lineConfigAdapter = createScopedChannelConfigAdapter<
|
||||
ResolvedLineAccount,
|
||||
ResolvedLineAccount
|
||||
>({
|
||||
sectionKey: "line",
|
||||
listAccountIds: listLineAccountIds,
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
|
||||
defaultAccountId: resolveDefaultLineAccountId,
|
||||
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom).map(normalizeLineAllowFrom),
|
||||
});
|
||||
54
extensions/line/src/config-schema.test.ts
Normal file
54
extensions/line/src/config-schema.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Line tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LineConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("LineConfigSchema", () => {
|
||||
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
|
||||
const result = LineConfigSchema.safeParse({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
dmPolicy: "open",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
throw new Error("Expected config validation to fail");
|
||||
}
|
||||
expect(result.error.issues).toHaveLength(1);
|
||||
expect(result.error.issues[0]?.path).toEqual(["allowFrom"]);
|
||||
expect(result.error.issues[0]?.message).toBe(
|
||||
'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
|
||||
const result = LineConfigSchema.safeParse({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects account dmPolicy="open" without wildcard allowFrom', () => {
|
||||
const result = LineConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
throw new Error("Expected account config validation to fail");
|
||||
}
|
||||
expect(result.error.issues).toHaveLength(1);
|
||||
expect(result.error.issues[0]?.path).toEqual(["accounts", "work", "allowFrom"]);
|
||||
expect(result.error.issues[0]?.message).toBe(
|
||||
'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
|
||||
);
|
||||
});
|
||||
});
|
||||
82
extensions/line/src/config-schema.ts
Normal file
82
extensions/line/src/config-schema.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Line helper module supports config schema behavior.
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { requireChannelOpenAllowFrom } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { z } from "zod";
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
const ThreadBindingsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
idleHours: z.number().optional(),
|
||||
maxAgeHours: z.number().optional(),
|
||||
spawnSessions: z.boolean().optional(),
|
||||
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
|
||||
spawnSubagentSessions: z.boolean().optional(),
|
||||
spawnAcpSessions: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LineCommonConfigSchemaBase = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
channelAccessToken: z.string().optional(),
|
||||
channelSecret: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
secretFile: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
threadBindings: ThreadBindingsSchema.optional(),
|
||||
});
|
||||
|
||||
const LineGroupConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LineAccountConfigSchema = LineCommonConfigSchemaBase.extend({
|
||||
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
requireChannelOpenAllowFrom({
|
||||
channel: "line",
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
requireOpenAllowFrom,
|
||||
});
|
||||
});
|
||||
|
||||
export const LineConfigSchema = LineCommonConfigSchemaBase.extend({
|
||||
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
requireChannelOpenAllowFrom({
|
||||
channel: "line",
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
requireOpenAllowFrom,
|
||||
});
|
||||
});
|
||||
|
||||
export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
|
||||
|
||||
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;
|
||||
177
extensions/line/src/download.test.ts
Normal file
177
extensions/line/src/download.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Line tests cover download plugin behavior.
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
||||
const saveMediaStreamMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: {
|
||||
MessagingApiBlobClient: class {
|
||||
getMessageContent(messageId: string) {
|
||||
return getMessageContentMock(messageId);
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
createSubsystemLogger: () => {
|
||||
const logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
child: () => logger,
|
||||
};
|
||||
return logger;
|
||||
},
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-store", () => ({
|
||||
saveMediaStream: saveMediaStreamMock,
|
||||
}));
|
||||
|
||||
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
||||
|
||||
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMediaStreamCall(): unknown[] {
|
||||
const call = saveMediaStreamMock.mock.calls.at(0);
|
||||
if (!call) {
|
||||
throw new Error("Expected saveMediaStream call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function detectMockContentType(buffer: Buffer, contentType?: string): string | undefined {
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (buffer.toString("ascii", 4, 8) === "ftyp") {
|
||||
return buffer.toString("ascii", 8, 12) === "M4A " ? "audio/x-m4a" : "video/mp4";
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
describe("downloadLineMedia", () => {
|
||||
beforeAll(async () => {
|
||||
({ downloadLineMedia } = await import("./download.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("@line/bot-sdk");
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||||
vi.doUnmock("openclaw/plugin-sdk/media-store");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
getMessageContentMock.mockReset();
|
||||
saveMediaStreamMock.mockReset();
|
||||
saveMediaStreamMock.mockImplementation(
|
||||
async (stream: AsyncIterable<Buffer>, contentType?: string, subdir?: string) => {
|
||||
const chunksLocal: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunksLocal.push(Buffer.from(chunk));
|
||||
}
|
||||
const buffer = Buffer.concat(chunksLocal);
|
||||
return {
|
||||
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
|
||||
contentType: detectMockContentType(buffer, contentType),
|
||||
size: buffer.length,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("persists inbound media with the shared media store", async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
|
||||
const result = await downloadLineMedia("mid-jpeg", "token");
|
||||
|
||||
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
|
||||
const call = saveMediaStreamCall();
|
||||
expect(call[1]).toBeUndefined();
|
||||
expect(call[2]).toBe("inbound");
|
||||
expect(call[3]).toBe(10 * 1024 * 1024);
|
||||
expect(result).toEqual({
|
||||
path: "/home/user/.openclaw/media/inbound/saved-media",
|
||||
contentType: "image/jpeg",
|
||||
size: jpeg.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass the external messageId to saveMediaStream", async () => {
|
||||
const messageId = "a/../../../../etc/passwd";
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
|
||||
const result = await downloadLineMedia(messageId, "token");
|
||||
|
||||
expect(result.size).toBe(jpeg.length);
|
||||
expect(result.contentType).toBe("image/jpeg");
|
||||
for (const arg of saveMediaStreamCall()) {
|
||||
if (typeof arg === "string") {
|
||||
expect(arg).not.toContain(messageId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("delegates oversized media rejection to saveMediaStream", async () => {
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
|
||||
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
|
||||
|
||||
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
|
||||
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses media store content type for M4A media", async () => {
|
||||
const m4aHeader = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
|
||||
|
||||
const result = await downloadLineMedia("mid-audio", "token");
|
||||
|
||||
expect(result.contentType).toBe("audio/x-m4a");
|
||||
expect(saveMediaStreamCall()[2]).toBe("inbound");
|
||||
});
|
||||
|
||||
it("passes original filenames to the media store for extension fallback", async () => {
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.from("unknown-audio-bytes")]));
|
||||
|
||||
await downloadLineMedia("mid-file-audio", "token", 10 * 1024 * 1024, {
|
||||
originalFilename: "voice-note.m4a",
|
||||
});
|
||||
|
||||
const call = saveMediaStreamCall();
|
||||
expect(call[3]).toBe(10 * 1024 * 1024);
|
||||
expect(call[4]).toBe("voice-note.m4a");
|
||||
});
|
||||
|
||||
it("uses media store content type for MP4 video", async () => {
|
||||
const mp4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
|
||||
|
||||
const result = await downloadLineMedia("mid-mp4", "token");
|
||||
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
});
|
||||
|
||||
it("propagates media store failures", async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
|
||||
|
||||
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
});
|
||||
37
extensions/line/src/download.ts
Normal file
37
extensions/line/src/download.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Line plugin module implements download behavior.
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
interface DownloadResult {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function downloadLineMedia(
|
||||
messageId: string,
|
||||
channelAccessToken: string,
|
||||
maxBytes = 10 * 1024 * 1024,
|
||||
options?: { originalFilename?: string },
|
||||
): Promise<DownloadResult> {
|
||||
const client = new messagingApi.MessagingApiBlobClient({
|
||||
channelAccessToken,
|
||||
});
|
||||
|
||||
const response = await client.getMessageContent(messageId);
|
||||
const saved = await saveMediaStream(
|
||||
response as AsyncIterable<Buffer>,
|
||||
undefined,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
options?.originalFilename,
|
||||
);
|
||||
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
|
||||
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
size: saved.size,
|
||||
};
|
||||
}
|
||||
33
extensions/line/src/flex-templates.ts
Normal file
33
extensions/line/src/flex-templates.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Line plugin module implements flex templates behavior.
|
||||
export {
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createNotificationBubble,
|
||||
} from "./flex-templates/basic-cards.js";
|
||||
export {
|
||||
createAgendaCard,
|
||||
createEventCard,
|
||||
createReceiptCard,
|
||||
} from "./flex-templates/schedule-cards.js";
|
||||
export {
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates/media-control-cards.js";
|
||||
export { toFlexMessage } from "./flex-templates/message.js";
|
||||
|
||||
export type {
|
||||
CardAction,
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexCarousel,
|
||||
FlexComponent,
|
||||
FlexContainer,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
ListItem,
|
||||
} from "./flex-templates/types.js";
|
||||
396
extensions/line/src/flex-templates/basic-cards.ts
Normal file
396
extensions/line/src/flex-templates/basic-cards.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
// Line plugin module implements basic cards behavior.
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type {
|
||||
Action,
|
||||
CardAction,
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexCarousel,
|
||||
FlexComponent,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
ListItem,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Create an info card with title, body, and optional footer
|
||||
*
|
||||
* Editorial design: Clean hierarchy with accent bar, generous spacing,
|
||||
* and subtle background zones for visual separation.
|
||||
*/
|
||||
export function createInfoCard(title: string, body: string, footer?: string): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
// Title with accent bar
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: "#06C755",
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
flex: 1,
|
||||
margin: "lg",
|
||||
} as FlexText,
|
||||
],
|
||||
} as FlexBox,
|
||||
// Body text in subtle container
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
color: "#444444",
|
||||
wrap: true,
|
||||
lineSpacing: "6px",
|
||||
} as FlexText,
|
||||
],
|
||||
margin: "xl",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F8F9FA",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
if (footer) {
|
||||
attachFooterText(bubble, footer);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list card with title and multiple items
|
||||
*
|
||||
* Editorial design: Numbered/bulleted list with clear visual hierarchy,
|
||||
* accent dots for each item, and generous spacing.
|
||||
*/
|
||||
export function createListCard(title: string, items: ListItem[]): FlexBubble {
|
||||
const itemContents: FlexComponent[] = items.slice(0, 8).map((item, index) => {
|
||||
const itemContentsLocal: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: item.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (item.subtitle) {
|
||||
itemContentsLocal.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: itemContentsLocal,
|
||||
flex: 1,
|
||||
} as FlexBox,
|
||||
],
|
||||
margin: index > 0 ? "lg" : undefined,
|
||||
};
|
||||
|
||||
if (item.action) {
|
||||
itemBox.action = item.action;
|
||||
}
|
||||
|
||||
return itemBox;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "separator",
|
||||
margin: "lg",
|
||||
color: "#EEEEEE",
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: itemContents,
|
||||
margin: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image card with image, title, and optional body text
|
||||
*/
|
||||
export function createImageCard(
|
||||
imageUrl: string,
|
||||
title: string,
|
||||
body?: string,
|
||||
options?: {
|
||||
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
|
||||
aspectMode?: "cover" | "fit";
|
||||
action?: Action;
|
||||
},
|
||||
): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
hero: {
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: options?.aspectRatio ?? "20:13",
|
||||
aspectMode: options?.aspectMode ?? "cover",
|
||||
action: options?.action,
|
||||
} as FlexImage,
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
if (body && bubble.body) {
|
||||
bubble.body.contents.push({
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
wrap: true,
|
||||
margin: "md",
|
||||
color: "#666666",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action card with title, body, and action buttons
|
||||
*/
|
||||
export function createActionCard(
|
||||
title: string,
|
||||
body: string,
|
||||
actions: CardAction[],
|
||||
options?: {
|
||||
imageUrl?: string;
|
||||
aspectRatio?: "1:1" | "1.51:1" | "1.91:1" | "4:3" | "16:9" | "20:13" | "2:1" | "3:1";
|
||||
},
|
||||
): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: body,
|
||||
size: "md",
|
||||
wrap: true,
|
||||
margin: "md",
|
||||
color: "#666666",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: actions.slice(0, 4).map(
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: action.action,
|
||||
style: index === 0 ? "primary" : "secondary",
|
||||
margin: index > 0 ? "sm" : undefined,
|
||||
}) as FlexButton,
|
||||
),
|
||||
paddingAll: "md",
|
||||
},
|
||||
};
|
||||
|
||||
if (options?.imageUrl) {
|
||||
bubble.hero = {
|
||||
type: "image",
|
||||
url: options.imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: options.aspectRatio ?? "20:13",
|
||||
aspectMode: "cover",
|
||||
} as FlexImage;
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a carousel container from multiple bubbles
|
||||
* LINE allows max 12 bubbles in a carousel
|
||||
*/
|
||||
export function createCarousel(bubbles: FlexBubble[]): FlexCarousel {
|
||||
return {
|
||||
type: "carousel",
|
||||
contents: bubbles.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification bubble (for alerts, status updates)
|
||||
*
|
||||
* Editorial design: Bold status indicator with accent color,
|
||||
* clear typography, optional icon for context.
|
||||
*/
|
||||
export function createNotificationBubble(
|
||||
text: string,
|
||||
options?: {
|
||||
icon?: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
title?: string;
|
||||
},
|
||||
): FlexBubble {
|
||||
// Color based on notification type
|
||||
const colors = {
|
||||
info: { accent: "#3B82F6", bg: "#EFF6FF" },
|
||||
success: { accent: "#06C755", bg: "#F0FDF4" },
|
||||
warning: { accent: "#F59E0B", bg: "#FFFBEB" },
|
||||
error: { accent: "#EF4444", bg: "#FEF2F2" },
|
||||
};
|
||||
const typeColors = colors[options?.type ?? "info"];
|
||||
|
||||
const contents: FlexComponent[] = [];
|
||||
|
||||
// Accent bar
|
||||
contents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: typeColors.accent,
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox);
|
||||
|
||||
// Content section
|
||||
const textContents: FlexComponent[] = [];
|
||||
|
||||
if (options?.title) {
|
||||
textContents.push({
|
||||
type: "text",
|
||||
text: options.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
textContents.push({
|
||||
type: "text",
|
||||
text,
|
||||
size: options?.title ? "sm" : "md",
|
||||
color: options?.title ? "#666666" : "#333333",
|
||||
wrap: true,
|
||||
margin: options?.title ? "sm" : undefined,
|
||||
} as FlexText);
|
||||
|
||||
contents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: textContents,
|
||||
flex: 1,
|
||||
paddingStart: "lg",
|
||||
} as FlexBox);
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: typeColors.bg,
|
||||
},
|
||||
};
|
||||
}
|
||||
21
extensions/line/src/flex-templates/common.ts
Normal file
21
extensions/line/src/flex-templates/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Line plugin module implements common behavior.
|
||||
import type { FlexBox, FlexBubble, FlexText } from "./types.js";
|
||||
|
||||
export function attachFooterText(bubble: FlexBubble, footer: string) {
|
||||
bubble.footer = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: footer,
|
||||
size: "xs",
|
||||
color: "#AAAAAA",
|
||||
wrap: true,
|
||||
align: "center",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#FAFAFA",
|
||||
} as FlexBox;
|
||||
}
|
||||
557
extensions/line/src/flex-templates/media-control-cards.ts
Normal file
557
extensions/line/src/flex-templates/media-control-cards.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
// Line plugin module implements media control cards behavior.
|
||||
import { truncateLineActionLabel } from "../actions.js";
|
||||
import type {
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
FlexButton,
|
||||
FlexComponent,
|
||||
FlexImage,
|
||||
FlexText,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Create a media player card for Sonos, Spotify, Apple Music, etc.
|
||||
*
|
||||
* Editorial design: Album art hero with gradient overlay for text,
|
||||
* prominent now-playing indicator, refined playback controls.
|
||||
*/
|
||||
export function createMediaPlayerCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
source?: string;
|
||||
imageUrl?: string;
|
||||
isPlaying?: boolean;
|
||||
progress?: string;
|
||||
controls?: {
|
||||
previous?: { data: string };
|
||||
play?: { data: string };
|
||||
pause?: { data: string };
|
||||
next?: { data: string };
|
||||
};
|
||||
extraActions?: Array<{ label: string; data: string }>;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, source, imageUrl, isPlaying, progress, controls, extraActions } = params;
|
||||
|
||||
// Track info section
|
||||
const trackInfo: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (subtitle) {
|
||||
trackInfo.push({
|
||||
type: "text",
|
||||
text: subtitle,
|
||||
size: "md",
|
||||
color: "#666666",
|
||||
wrap: true,
|
||||
margin: "sm",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
// Status row with source and playing indicator
|
||||
const statusItems: FlexComponent[] = [];
|
||||
|
||||
if (isPlaying !== undefined) {
|
||||
statusItems.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
backgroundColor: isPlaying ? "#06C755" : "#CCCCCC",
|
||||
cornerRadius: "4px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "text",
|
||||
text: isPlaying ? "Now Playing" : "Paused",
|
||||
size: "xs",
|
||||
color: isPlaying ? "#06C755" : "#888888",
|
||||
weight: "bold",
|
||||
margin: "sm",
|
||||
} as FlexText,
|
||||
],
|
||||
alignItems: "center",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
if (source) {
|
||||
statusItems.push({
|
||||
type: "text",
|
||||
text: source,
|
||||
size: "xs",
|
||||
color: "#AAAAAA",
|
||||
margin: statusItems.length > 0 ? "lg" : undefined,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
statusItems.push({
|
||||
type: "text",
|
||||
text: progress,
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
align: "end",
|
||||
flex: 1,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: trackInfo,
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
if (statusItems.length > 0) {
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: statusItems,
|
||||
margin: "lg",
|
||||
alignItems: "center",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
// Album art hero
|
||||
if (imageUrl) {
|
||||
bubble.hero = {
|
||||
type: "image",
|
||||
url: imageUrl,
|
||||
size: "full",
|
||||
aspectRatio: "1:1",
|
||||
aspectMode: "cover",
|
||||
} as FlexImage;
|
||||
}
|
||||
|
||||
// Control buttons in footer
|
||||
if (controls || extraActions?.length) {
|
||||
const footerContents: FlexComponent[] = [];
|
||||
|
||||
// Main playback controls with refined styling
|
||||
if (controls) {
|
||||
const controlButtons: FlexComponent[] = [];
|
||||
|
||||
if (controls.previous) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏮",
|
||||
data: controls.previous.data,
|
||||
},
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.play) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "▶",
|
||||
data: controls.play.data,
|
||||
},
|
||||
style: isPlaying ? "secondary" : "primary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controls.previous ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.pause) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏸",
|
||||
data: controls.pause.data,
|
||||
},
|
||||
style: isPlaying ? "primary" : "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controlButtons.length > 0 ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controls.next) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏭",
|
||||
data: controls.next.data,
|
||||
},
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
margin: controlButtons.length > 0 ? "md" : undefined,
|
||||
} as FlexButton);
|
||||
}
|
||||
|
||||
if (controlButtons.length > 0) {
|
||||
footerContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: controlButtons,
|
||||
} as FlexBox);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra actions
|
||||
if (extraActions?.length) {
|
||||
footerContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: extraActions.slice(0, 2).map(
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: truncateLineActionLabel(action.label, 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: truncateLineActionLabel(buttonLabel, 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;
|
||||
}
|
||||
14
extensions/line/src/flex-templates/message.ts
Normal file
14
extensions/line/src/flex-templates/message.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Line plugin module implements message behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { FlexContainer } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a FlexContainer in a FlexMessage
|
||||
*/
|
||||
export function toFlexMessage(altText: string, contents: FlexContainer): messagingApi.FlexMessage {
|
||||
return {
|
||||
type: "flex",
|
||||
altText,
|
||||
contents,
|
||||
};
|
||||
}
|
||||
468
extensions/line/src/flex-templates/schedule-cards.ts
Normal file
468
extensions/line/src/flex-templates/schedule-cards.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
// Line plugin module implements schedule cards behavior.
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type { Action, FlexBox, FlexBubble, FlexComponent, FlexText } from "./types.js";
|
||||
|
||||
function buildTitleSubtitleHeader(params: { title: string; subtitle?: string }): FlexComponent[] {
|
||||
const { title, subtitle } = params;
|
||||
const headerContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
if (subtitle) {
|
||||
headerContents.push({
|
||||
type: "text",
|
||||
text: subtitle,
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
margin: "sm",
|
||||
wrap: true,
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
return headerContents;
|
||||
}
|
||||
|
||||
function buildCardHeaderSections(headerContents: FlexComponent[]): FlexComponent[] {
|
||||
return [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: headerContents,
|
||||
paddingBottom: "lg",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "separator",
|
||||
color: "#EEEEEE",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMegaBubbleWithFooter(params: {
|
||||
bodyContents: FlexComponent[];
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const bubble: FlexBubble = {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: params.bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
if (params.footer) {
|
||||
attachFooterText(bubble, params.footer);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a receipt/summary card (for orders, transactions, data tables)
|
||||
*
|
||||
* Editorial design: Clean table layout with alternating row backgrounds,
|
||||
* prominent total section, and clear visual hierarchy.
|
||||
*/
|
||||
export function createReceiptCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: Array<{ name: string; value: string; highlight?: boolean }>;
|
||||
total?: { label: string; value: string };
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, items, total, footer } = params;
|
||||
|
||||
const itemRows: FlexComponent[] = items.slice(0, 12).map(
|
||||
(item, index) =>
|
||||
({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.name,
|
||||
size: "sm",
|
||||
color: item.highlight ? "#111111" : "#666666",
|
||||
weight: item.highlight ? "bold" : "regular",
|
||||
flex: 3,
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: item.value,
|
||||
size: "sm",
|
||||
color: item.highlight ? "#06C755" : "#333333",
|
||||
weight: item.highlight ? "bold" : "regular",
|
||||
flex: 2,
|
||||
align: "end",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "md",
|
||||
backgroundColor: index % 2 === 0 ? "#FFFFFF" : "#FAFAFA",
|
||||
}) as FlexBox,
|
||||
);
|
||||
|
||||
// Header section
|
||||
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
...buildCardHeaderSections(headerContents),
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: itemRows,
|
||||
margin: "md",
|
||||
cornerRadius: "md",
|
||||
borderWidth: "light",
|
||||
borderColor: "#EEEEEE",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
// Total section with emphasis
|
||||
if (total) {
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: total.label,
|
||||
size: "lg",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
flex: 2,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: total.value,
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
color: "#06C755",
|
||||
flex: 2,
|
||||
align: "end",
|
||||
} as FlexText,
|
||||
],
|
||||
margin: "xl",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F0FDF4",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
return createMegaBubbleWithFooter({ bodyContents, footer });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar event card (for meetings, appointments, reminders)
|
||||
*
|
||||
* Editorial design: Date as hero, strong typographic hierarchy,
|
||||
* color-blocked zones, full text wrapping for readability.
|
||||
*/
|
||||
export function createEventCard(params: {
|
||||
title: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
calendar?: string;
|
||||
isAllDay?: boolean;
|
||||
action?: Action;
|
||||
}): FlexBubble {
|
||||
const { title, date, time, location, description, calendar, isAllDay, action } = params;
|
||||
|
||||
// Hero date block - the most important information
|
||||
const dateBlock: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: date.toUpperCase(),
|
||||
size: "sm",
|
||||
weight: "bold",
|
||||
color: "#06C755",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: isAllDay ? "ALL DAY" : (time ?? ""),
|
||||
size: "xxl",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
margin: "xs",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingBottom: "lg",
|
||||
borderWidth: "none",
|
||||
};
|
||||
|
||||
// If no time and not all day, hide the time display
|
||||
if (!time && !isAllDay) {
|
||||
dateBlock.contents = [
|
||||
{
|
||||
type: "text",
|
||||
text: date,
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
color: "#111111",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
}
|
||||
|
||||
// Event title with accent bar
|
||||
const titleBlock: FlexBox = {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "4px",
|
||||
backgroundColor: "#06C755",
|
||||
cornerRadius: "2px",
|
||||
} as FlexBox,
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: title,
|
||||
size: "lg",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
...(calendar
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: calendar,
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
margin: "sm",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
flex: 1,
|
||||
paddingStart: "lg",
|
||||
} as FlexBox,
|
||||
],
|
||||
paddingTop: "lg",
|
||||
paddingBottom: "lg",
|
||||
borderWidth: "light",
|
||||
borderColor: "#EEEEEE",
|
||||
};
|
||||
|
||||
const bodyContents: FlexComponent[] = [dateBlock, titleBlock];
|
||||
|
||||
// Details section (location + description) in subtle background
|
||||
const hasDetails = location || description;
|
||||
if (hasDetails) {
|
||||
const detailItems: FlexComponent[] = [];
|
||||
|
||||
if (location) {
|
||||
detailItems.push({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "📍",
|
||||
size: "sm",
|
||||
flex: 0,
|
||||
} as FlexText,
|
||||
{
|
||||
type: "text",
|
||||
text: location,
|
||||
size: "sm",
|
||||
color: "#444444",
|
||||
margin: "md",
|
||||
flex: 1,
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
alignItems: "flex-start",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
detailItems.push({
|
||||
type: "text",
|
||||
text: description,
|
||||
size: "sm",
|
||||
color: "#666666",
|
||||
wrap: true,
|
||||
margin: location ? "lg" : "none",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
bodyContents.push({
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: detailItems,
|
||||
margin: "lg",
|
||||
paddingAll: "lg",
|
||||
backgroundColor: "#F8F9FA",
|
||||
cornerRadius: "lg",
|
||||
} as FlexBox);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
size: "mega",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar agenda card showing multiple events
|
||||
*
|
||||
* Editorial timeline design: Time-focused left column with event details
|
||||
* on the right. Visual accent bars indicate event priority/recency.
|
||||
*/
|
||||
export function createAgendaCard(params: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
events: Array<{
|
||||
title: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
calendar?: string;
|
||||
isNow?: boolean;
|
||||
}>;
|
||||
footer?: string;
|
||||
}): FlexBubble {
|
||||
const { title, subtitle, events, footer } = params;
|
||||
|
||||
// Header with title and optional subtitle
|
||||
const headerContents = buildTitleSubtitleHeader({ title, subtitle });
|
||||
|
||||
// Event timeline items
|
||||
const eventItems: FlexComponent[] = events.slice(0, 6).map((event, index) => {
|
||||
const isActive = event.isNow || index === 0;
|
||||
const accentColor = isActive ? "#06C755" : "#E5E5E5";
|
||||
|
||||
// Time column (fixed width)
|
||||
const timeColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: event.time ?? "—",
|
||||
size: "sm",
|
||||
weight: isActive ? "bold" : "regular",
|
||||
color: isActive ? "#06C755" : "#666666",
|
||||
align: "end",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
],
|
||||
width: "65px",
|
||||
justifyContent: "flex-start",
|
||||
};
|
||||
|
||||
// Accent dot
|
||||
const dotColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [],
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
backgroundColor: accentColor,
|
||||
cornerRadius: "5px",
|
||||
} as FlexBox,
|
||||
],
|
||||
width: "24px",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: "xs",
|
||||
};
|
||||
|
||||
// Event details column
|
||||
const detailContents: FlexComponent[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: event.title,
|
||||
size: "md",
|
||||
weight: "bold",
|
||||
color: "#1a1a1a",
|
||||
wrap: true,
|
||||
} as FlexText,
|
||||
];
|
||||
|
||||
// Secondary info line
|
||||
const secondaryParts: string[] = [];
|
||||
if (event.location) {
|
||||
secondaryParts.push(event.location);
|
||||
}
|
||||
if (event.calendar) {
|
||||
secondaryParts.push(event.calendar);
|
||||
}
|
||||
|
||||
if (secondaryParts.length > 0) {
|
||||
detailContents.push({
|
||||
type: "text",
|
||||
text: secondaryParts.join(" · "),
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
wrap: true,
|
||||
margin: "xs",
|
||||
} as FlexText);
|
||||
}
|
||||
|
||||
const detailColumn: FlexBox = {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: detailContents,
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [timeColumn, dotColumn, detailColumn],
|
||||
margin: index > 0 ? "xl" : undefined,
|
||||
alignItems: "flex-start",
|
||||
} as FlexBox;
|
||||
});
|
||||
|
||||
const bodyContents: FlexComponent[] = [
|
||||
...buildCardHeaderSections(headerContents),
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: eventItems,
|
||||
paddingTop: "xl",
|
||||
} as FlexBox,
|
||||
];
|
||||
|
||||
return createMegaBubbleWithFooter({ bodyContents, footer });
|
||||
}
|
||||
23
extensions/line/src/flex-templates/types.ts
Normal file
23
extensions/line/src/flex-templates/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Line type declarations define plugin contracts.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
|
||||
export type FlexContainer = messagingApi.FlexContainer;
|
||||
export type FlexBubble = messagingApi.FlexBubble;
|
||||
export type FlexCarousel = messagingApi.FlexCarousel;
|
||||
export type FlexBox = messagingApi.FlexBox;
|
||||
export type FlexText = messagingApi.FlexText;
|
||||
export type FlexImage = messagingApi.FlexImage;
|
||||
export type FlexButton = messagingApi.FlexButton;
|
||||
export type FlexComponent = messagingApi.FlexComponent;
|
||||
export type Action = messagingApi.Action;
|
||||
|
||||
export interface ListItem {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
label: string;
|
||||
action: Action;
|
||||
}
|
||||
130
extensions/line/src/gateway.ts
Normal file
130
extensions/line/src/gateway.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Line plugin module implements gateway behavior.
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import {
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelPlugin,
|
||||
type LineConfig,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "./channel-api.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
|
||||
const loadLineMonitorRuntime = createLazyRuntimeModule(() => import("./monitor.runtime.js"));
|
||||
|
||||
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await (await loadLineProbeRuntime()).probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) {
|
||||
lineBotLabel = ` (${displayName})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
const monitorLineProvider =
|
||||
getLineRuntime().channel.line?.monitorLineProvider ??
|
||||
(await loadLineMonitorRuntime()).monitorLineProvider;
|
||||
|
||||
return await monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accountCleanup = clearAccountEntryFields({
|
||||
accounts: nextLine.accounts,
|
||||
accountId,
|
||||
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||
markClearedOnFieldPresence: true,
|
||||
});
|
||||
if (accountCleanup.changed) {
|
||||
changed = true;
|
||||
if (accountCleanup.cleared) {
|
||||
cleared = true;
|
||||
}
|
||||
if (accountCleanup.nextAccounts) {
|
||||
nextLine.accounts = accountCleanup.nextAccounts;
|
||||
} else {
|
||||
delete nextLine.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.replaceConfigFile({
|
||||
nextConfig: nextCfg,
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
};
|
||||
124
extensions/line/src/group-keys.test.ts
Normal file
124
extensions/line/src/group-keys.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Line tests cover group keys plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveExactLineGroupConfigKey,
|
||||
resolveLineGroupConfigEntry,
|
||||
resolveLineGroupLookupIds,
|
||||
resolveLineGroupsConfig,
|
||||
} from "./group-keys.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
describe("resolveLineGroupLookupIds", () => {
|
||||
it("expands raw ids to both prefixed candidates", () => {
|
||||
expect(resolveLineGroupLookupIds("abc123")).toEqual(["abc123", "group:abc123", "room:abc123"]);
|
||||
});
|
||||
|
||||
it("preserves prefixed ids while also checking the raw id", () => {
|
||||
expect(resolveLineGroupLookupIds("room:abc123")).toEqual(["abc123", "room:abc123"]);
|
||||
expect(resolveLineGroupLookupIds("group:abc123")).toEqual(["abc123", "group:abc123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineGroupConfigEntry", () => {
|
||||
it("matches raw, prefixed, and wildcard group config entries", () => {
|
||||
const groups = {
|
||||
"group:g1": { requireMention: false },
|
||||
"room:r1": { systemPrompt: "Room prompt" },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
|
||||
expect(resolveLineGroupConfigEntry(groups, { groupId: "g1" })).toEqual({
|
||||
requireMention: false,
|
||||
});
|
||||
expect(resolveLineGroupConfigEntry(groups, { roomId: "r1" })).toEqual({
|
||||
systemPrompt: "Room prompt",
|
||||
});
|
||||
expect(resolveLineGroupConfigEntry(groups, { groupId: "missing" })).toEqual({
|
||||
requireMention: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account-scoped LINE groups", () => {
|
||||
it("resolves the effective account-scoped groups map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:g1": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupsConfig(cfg, "work")).toEqual({
|
||||
"group:g1": { requireMention: false },
|
||||
});
|
||||
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe(
|
||||
"group:g1",
|
||||
);
|
||||
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line group policy", () => {
|
||||
it("matches raw and prefixed LINE group keys for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"room:r123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uses account-scoped prefixed LINE group config for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
|
||||
});
|
||||
});
|
||||
66
extensions/line/src/group-keys.ts
Normal file
66
extensions/line/src/group-keys.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Line plugin module implements group keys behavior.
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { LineConfig, LineGroupConfig } from "./types.js";
|
||||
|
||||
export function resolveLineGroupLookupIds(groupId?: string | null): string[] {
|
||||
const normalized = groupId?.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
if (normalized.startsWith("group:") || normalized.startsWith("room:")) {
|
||||
const rawId = normalized.split(":").slice(1).join(":");
|
||||
return rawId ? [rawId, normalized] : [normalized];
|
||||
}
|
||||
return [normalized, `group:${normalized}`, `room:${normalized}`];
|
||||
}
|
||||
|
||||
export function resolveLineGroupConfigEntry<T>(
|
||||
groups: Record<string, T | undefined> | undefined,
|
||||
params: { groupId?: string | null; roomId?: string | null },
|
||||
): T | undefined {
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
for (const candidate of resolveLineGroupLookupIds(params.groupId)) {
|
||||
const hit = groups[candidate];
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
for (const candidate of resolveLineGroupLookupIds(params.roomId)) {
|
||||
const hit = groups[candidate];
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return groups["*"];
|
||||
}
|
||||
|
||||
export function resolveLineGroupsConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): Record<string, LineGroupConfig | undefined> | undefined {
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
if (!lineConfig) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups;
|
||||
return accountGroups ?? lineConfig.groups;
|
||||
}
|
||||
|
||||
export function resolveExactLineGroupConfigKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
}): string | undefined {
|
||||
const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveLineGroupLookupIds(params.groupId).find((candidate) =>
|
||||
Object.hasOwn(groups, candidate),
|
||||
);
|
||||
}
|
||||
23
extensions/line/src/group-policy.ts
Normal file
23
extensions/line/src/group-policy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Line plugin module implements group policy behavior.
|
||||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "./channel-api.js";
|
||||
|
||||
type LineGroupContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
export function resolveLineGroupRequireMention(params: LineGroupContext): boolean {
|
||||
const exactGroupId = resolveExactLineGroupConfigKey({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "line",
|
||||
groupId: exactGroupId ?? params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
377
extensions/line/src/markdown-to-line.test.ts
Normal file
377
extensions/line/src/markdown-to-line.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
// Line tests cover markdown to line plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractMarkdownTables,
|
||||
extractCodeBlocks,
|
||||
extractLinks,
|
||||
stripMarkdown,
|
||||
processLineMessage,
|
||||
convertTableToFlexBubble,
|
||||
convertCodeBlockToFlexBubble,
|
||||
convertLinksToFlexBubble,
|
||||
hasMarkdownToConvert,
|
||||
} from "./markdown-to-line.js";
|
||||
|
||||
describe("extractMarkdownTables", () => {
|
||||
it("extracts a simple 2-column table", () => {
|
||||
const text = `Here is a table:
|
||||
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| foo | 123 |
|
||||
| bar | 456 |
|
||||
|
||||
And some more text.`;
|
||||
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].headers).toEqual(["Name", "Value"]);
|
||||
expect(tables[0].rows).toEqual([
|
||||
["foo", "123"],
|
||||
["bar", "456"],
|
||||
]);
|
||||
expect(textWithoutTables).toContain("Here is a table:");
|
||||
expect(textWithoutTables).toContain("And some more text.");
|
||||
expect(textWithoutTables).not.toContain("|");
|
||||
});
|
||||
|
||||
it("extracts multiple tables", () => {
|
||||
const text = `Table 1:
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
Table 2:
|
||||
|
||||
| X | Y |
|
||||
|---|---|
|
||||
| 3 | 4 |`;
|
||||
|
||||
const { tables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(2);
|
||||
expect(tables[0].headers).toEqual(["A", "B"]);
|
||||
expect(tables[1].headers).toEqual(["X", "Y"]);
|
||||
});
|
||||
|
||||
it("handles tables with alignment markers", () => {
|
||||
const text = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| a | b | c |`;
|
||||
|
||||
const { tables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
|
||||
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("returns empty when no tables present", () => {
|
||||
const text = "Just some plain text without tables.";
|
||||
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
||||
|
||||
expect(tables).toHaveLength(0);
|
||||
expect(textWithoutTables).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCodeBlocks", () => {
|
||||
it("extracts code blocks across language/no-language/multiple variants", () => {
|
||||
const withLanguage = `Here is some code:
|
||||
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
console.log(x);
|
||||
\`\`\`
|
||||
|
||||
And more text.`;
|
||||
const withLanguageResult = extractCodeBlocks(withLanguage);
|
||||
expect(withLanguageResult.codeBlocks).toHaveLength(1);
|
||||
expect(withLanguageResult.codeBlocks[0].language).toBe("javascript");
|
||||
expect(withLanguageResult.codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
|
||||
expect(withLanguageResult.textWithoutCode).toContain("Here is some code:");
|
||||
expect(withLanguageResult.textWithoutCode).toContain("And more text.");
|
||||
expect(withLanguageResult.textWithoutCode).not.toContain("```");
|
||||
|
||||
const withoutLanguage = `\`\`\`
|
||||
plain code
|
||||
\`\`\``;
|
||||
const withoutLanguageResult = extractCodeBlocks(withoutLanguage);
|
||||
expect(withoutLanguageResult.codeBlocks).toHaveLength(1);
|
||||
expect(withoutLanguageResult.codeBlocks[0].language).toBeUndefined();
|
||||
expect(withoutLanguageResult.codeBlocks[0].code).toBe("plain code");
|
||||
|
||||
const multiple = `\`\`\`python
|
||||
print("hello")
|
||||
\`\`\`
|
||||
|
||||
Some text
|
||||
|
||||
\`\`\`bash
|
||||
echo "world"
|
||||
\`\`\``;
|
||||
const multipleResult = extractCodeBlocks(multiple);
|
||||
expect(multipleResult.codeBlocks).toHaveLength(2);
|
||||
expect(multipleResult.codeBlocks[0].language).toBe("python");
|
||||
expect(multipleResult.codeBlocks[1].language).toBe("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLinks", () => {
|
||||
it("extracts markdown links", () => {
|
||||
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
|
||||
|
||||
const { links, textWithLinks } = extractLinks(text);
|
||||
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
|
||||
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
|
||||
expect(textWithLinks).toBe("Check out Google and GitHub.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertLinksToFlexBubble", () => {
|
||||
it("truncates link button labels without leaving lone surrogates", () => {
|
||||
const bubble = convertLinksToFlexBubble([
|
||||
{ text: "1234567890123456789😀", url: "https://example.com" },
|
||||
]);
|
||||
const footer = bubble.footer as { contents: Array<{ action: { label: string } }> };
|
||||
|
||||
expect(footer.contents[0].action.label).toBe("1234567890123456789");
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(footer.contents[0].action.label)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripMarkdown", () => {
|
||||
it("strips inline markdown marker variants", () => {
|
||||
const cases = [
|
||||
["strips bold **", "This is **bold** text", "This is bold text"],
|
||||
["strips bold __", "This is __bold__ text", "This is bold text"],
|
||||
["strips italic *", "This is *italic* text", "This is italic text"],
|
||||
["strips italic _", "This is _italic_ text", "This is italic text"],
|
||||
["strips strikethrough", "This is ~~deleted~~ text", "This is deleted text"],
|
||||
["removes hr ---", "Above\n---\nBelow", "Above\n\nBelow"],
|
||||
["removes hr ***", "Above\n***\nBelow", "Above\n\nBelow"],
|
||||
["strips inline code markers", "Use `const` keyword", "Use const keyword"],
|
||||
] as const;
|
||||
for (const [name, input, expected] of cases) {
|
||||
expect(stripMarkdown(input), name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves underscores inside words", () => {
|
||||
expect(stripMarkdown("here_is_a_message")).toBe("here_is_a_message");
|
||||
expect(stripMarkdown("snake_case_var")).toBe("snake_case_var");
|
||||
expect(stripMarkdown("use foo_bar_baz in code")).toBe("use foo_bar_baz in code");
|
||||
});
|
||||
|
||||
it("still strips proper italic _text_", () => {
|
||||
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
|
||||
expect(stripMarkdown("_italic_ at start")).toBe("italic at start");
|
||||
expect(stripMarkdown("end _italic_")).toBe("end italic");
|
||||
});
|
||||
|
||||
it("strips italic between underscored words", () => {
|
||||
expect(stripMarkdown("foo_bar _italic_ baz_qux")).toBe("foo_bar italic baz_qux");
|
||||
});
|
||||
|
||||
it("preserves underscores inside non-Latin words", () => {
|
||||
expect(stripMarkdown("привет_мир_тест")).toBe("привет_мир_тест");
|
||||
expect(stripMarkdown("東京_駅_前")).toBe("東京_駅_前");
|
||||
expect(stripMarkdown("var_123_end")).toBe("var_123_end");
|
||||
});
|
||||
|
||||
it("strips standalone italic between non-Latin words", () => {
|
||||
expect(stripMarkdown("こんにちは _italic_ テスト")).toBe("こんにちは italic テスト");
|
||||
});
|
||||
|
||||
it("handles complex markdown", () => {
|
||||
const input = `# Title
|
||||
|
||||
This is **bold** and *italic* text.
|
||||
|
||||
> A quote
|
||||
|
||||
Some ~~deleted~~ content.`;
|
||||
|
||||
const result = stripMarkdown(input);
|
||||
|
||||
expect(result).toContain("Title");
|
||||
expect(result).toContain("This is bold and italic text.");
|
||||
expect(result).toContain("A quote");
|
||||
expect(result).toContain("Some deleted content.");
|
||||
expect(result).not.toContain("#");
|
||||
expect(result).not.toContain("**");
|
||||
expect(result).not.toContain("~~");
|
||||
expect(result).not.toContain(">");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTableToFlexBubble", () => {
|
||||
it("replaces empty cells with placeholders", () => {
|
||||
const table = {
|
||||
headers: ["A", "B"],
|
||||
rows: [["", ""]],
|
||||
};
|
||||
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
const body = bubble.body as {
|
||||
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
|
||||
};
|
||||
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
|
||||
|
||||
expect(rowsBox.contents[0].contents[0].text).toBe("-");
|
||||
expect(rowsBox.contents[0].contents[1].text).toBe("-");
|
||||
});
|
||||
|
||||
it("strips bold markers and applies weight for fully bold cells", () => {
|
||||
const table = {
|
||||
headers: ["**Name**", "Status"],
|
||||
rows: [["**Alpha**", "OK"]],
|
||||
};
|
||||
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
const body = bubble.body as {
|
||||
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
|
||||
};
|
||||
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
|
||||
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
|
||||
|
||||
expect(headerRow.contents[0].text).toBe("Name");
|
||||
expect(headerRow.contents[0].weight).toBe("bold");
|
||||
expect(dataRow.contents[0].text).toBe("Alpha");
|
||||
expect(dataRow.contents[0].weight).toBe("bold");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertCodeBlockToFlexBubble", () => {
|
||||
it("creates a code card with language label", () => {
|
||||
const block = { language: "typescript", code: "const x = 1;" };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents[0].text).toBe("Code (typescript)");
|
||||
});
|
||||
|
||||
it("creates a code card without language", () => {
|
||||
const block = { code: "plain code" };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents[0].text).toBe("Code");
|
||||
});
|
||||
|
||||
it("truncates very long code", () => {
|
||||
const longCode = "x".repeat(3000);
|
||||
const block = { code: longCode };
|
||||
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
|
||||
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
|
||||
const codeText = body.contents[1].contents[0].text;
|
||||
expect(codeText.length).toBeLessThan(longCode.length);
|
||||
expect(codeText).toContain("...");
|
||||
});
|
||||
|
||||
it("does not split a surrogate pair at the truncation boundary", () => {
|
||||
// The emoji's surrogate pair straddles the 2000-char cap; a raw slice
|
||||
// would leave a lone high surrogate at the end of the code text.
|
||||
const block = { code: `${"x".repeat(1999)}😀${"y".repeat(500)}` };
|
||||
|
||||
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.endsWith("\n...")).toBe(true);
|
||||
expect(
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(codeText),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processLineMessage", () => {
|
||||
it("processes text with code blocks", () => {
|
||||
const text = `Check this code:
|
||||
|
||||
\`\`\`js
|
||||
console.log("hi");
|
||||
\`\`\`
|
||||
|
||||
That's it.`;
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
expect(result.flexMessages).toHaveLength(1);
|
||||
expect(result.text).toContain("Check this code:");
|
||||
expect(result.text).toContain("That's it.");
|
||||
expect(result.text).not.toContain("```");
|
||||
});
|
||||
|
||||
it("handles mixed content", () => {
|
||||
const text = `# Summary
|
||||
|
||||
Here's **important** info:
|
||||
|
||||
| Item | Count |
|
||||
|------|-------|
|
||||
| A | 5 |
|
||||
|
||||
\`\`\`python
|
||||
print("done")
|
||||
\`\`\`
|
||||
|
||||
> Note: Check the link [here](https://example.com).`;
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
// Should have 2 flex messages (table + code)
|
||||
expect(result.flexMessages).toHaveLength(2);
|
||||
|
||||
// Text should be cleaned
|
||||
expect(result.text).toContain("Summary");
|
||||
expect(result.text).toContain("important");
|
||||
expect(result.text).toContain("Note: Check the link here.");
|
||||
expect(result.text).not.toContain("#");
|
||||
expect(result.text).not.toContain("**");
|
||||
expect(result.text).not.toContain("|");
|
||||
expect(result.text).not.toContain("```");
|
||||
expect(result.text).not.toContain("[here]");
|
||||
});
|
||||
|
||||
it("handles plain text unchanged", () => {
|
||||
const text = "Just plain text with no markdown.";
|
||||
|
||||
const result = processLineMessage(text);
|
||||
|
||||
expect(result.text).toBe(text);
|
||||
expect(result.flexMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMarkdownToConvert", () => {
|
||||
it("detects supported markdown patterns", () => {
|
||||
const cases = [
|
||||
`| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |`,
|
||||
"```js\ncode\n```",
|
||||
"**bold**",
|
||||
"~~deleted~~",
|
||||
"# Title",
|
||||
"> quote",
|
||||
];
|
||||
|
||||
for (const text of cases) {
|
||||
expect(hasMarkdownToConvert(text)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for plain text", () => {
|
||||
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
|
||||
});
|
||||
});
|
||||
416
extensions/line/src/markdown-to-line.ts
Normal file
416
extensions/line/src/markdown-to-line.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
// Line plugin module implements markdown to line behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { uriAction } from "./actions.js";
|
||||
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
|
||||
export { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
||||
|
||||
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 ? truncateUtf16Safe(block.code, 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: uriAction(link.text, link.url),
|
||||
style: index === 0 ? "primary" : "secondary",
|
||||
margin: index > 0 ? "sm" : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Links",
|
||||
weight: "bold",
|
||||
size: "md",
|
||||
color: "#333333",
|
||||
} as FlexText,
|
||||
],
|
||||
paddingAll: "lg",
|
||||
paddingBottom: "sm",
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: buttons,
|
||||
paddingAll: "md",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function: Process text for LINE output
|
||||
* - Extracts tables → Flex Messages
|
||||
* - Extracts code blocks → Flex Messages
|
||||
* - Strips remaining markdown
|
||||
* - Returns processed text + Flex Messages
|
||||
*/
|
||||
export function processLineMessage(text: string): ProcessedLineMessage {
|
||||
const flexMessages: FlexMessage[] = [];
|
||||
let processedText = text;
|
||||
|
||||
// 1. Extract and convert tables
|
||||
const { tables, textWithoutTables } = extractMarkdownTables(processedText);
|
||||
processedText = textWithoutTables;
|
||||
|
||||
for (const table of tables) {
|
||||
const bubble = convertTableToFlexBubble(table);
|
||||
flexMessages.push(toFlexMessage("Table", bubble));
|
||||
}
|
||||
|
||||
// 2. Extract and convert code blocks
|
||||
const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText);
|
||||
processedText = textWithoutCode;
|
||||
|
||||
for (const block of codeBlocks) {
|
||||
const bubble = convertCodeBlockToFlexBubble(block);
|
||||
flexMessages.push(toFlexMessage("Code", bubble));
|
||||
}
|
||||
|
||||
// 3. Handle links - convert [text](url) to plain text for display
|
||||
// (We could also create link buttons, but that can get noisy)
|
||||
const { textWithLinks } = extractLinks(processedText);
|
||||
processedText = textWithLinks;
|
||||
|
||||
// 4. Strip remaining markdown formatting
|
||||
processedText = stripMarkdown(processedText);
|
||||
|
||||
return {
|
||||
text: processedText,
|
||||
flexMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains markdown that needs conversion
|
||||
*/
|
||||
export function hasMarkdownToConvert(text: string): boolean {
|
||||
// Check for tables
|
||||
MARKDOWN_TABLE_REGEX.lastIndex = 0;
|
||||
if (MARKDOWN_TABLE_REGEX.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for code blocks
|
||||
MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
|
||||
if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for other markdown patterns
|
||||
if (/\*\*[^*]+\*\*/.test(text)) {
|
||||
return true;
|
||||
} // bold
|
||||
if (/~~[^~]+~~/.test(text)) {
|
||||
return true;
|
||||
} // strikethrough
|
||||
if (/^#{1,6}\s+/m.test(text)) {
|
||||
return true;
|
||||
} // headers
|
||||
if (/^>\s+/m.test(text)) {
|
||||
return true;
|
||||
} // blockquotes
|
||||
|
||||
return false;
|
||||
}
|
||||
474
extensions/line/src/message-cards.test.ts
Normal file
474
extensions/line/src/message-cards.test.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
// Line tests cover message cards plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { datetimePickerAction, postbackAction, uriAction } from "./actions.js";
|
||||
import { registerLineCardCommand } from "./card-command.js";
|
||||
import {
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates.js";
|
||||
import {
|
||||
createConfirmTemplate,
|
||||
createButtonTemplate,
|
||||
createTemplateCarousel,
|
||||
createCarouselColumn,
|
||||
createImageCarousel,
|
||||
createImageCarouselColumn,
|
||||
createProductCarousel,
|
||||
messageAction,
|
||||
} from "./template-messages.js";
|
||||
|
||||
const loneHighSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
|
||||
|
||||
describe("createConfirmTemplate", () => {
|
||||
it("truncates text to 240 characters", () => {
|
||||
const longText = "x".repeat(300);
|
||||
const template = createConfirmTemplate(longText, messageAction("Yes"), messageAction("No"));
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(240);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from fallback altText instead of splitting it", () => {
|
||||
const template = createConfirmTemplate(
|
||||
`${"x".repeat(399)}😀`,
|
||||
messageAction("Yes"),
|
||||
messageAction("No"),
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createButtonTemplate", () => {
|
||||
it("limits actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => messageAction(`Button ${i}`));
|
||||
const template = createButtonTemplate("Title", "Text", actions);
|
||||
|
||||
expect((template.template as { actions: unknown[] }).actions.length).toBe(4);
|
||||
});
|
||||
|
||||
it("truncates title to 40 characters", () => {
|
||||
const longTitle = "x".repeat(50);
|
||||
const template = createButtonTemplate(longTitle, "Text", [messageAction("OK")]);
|
||||
|
||||
expect((template.template as { title: string }).title.length).toBe(40);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
// 39 chars + an emoji land the truncation boundary inside the surrogate pair;
|
||||
// a raw code-unit slice would keep only the lone high surrogate.
|
||||
const template = createButtonTemplate(`${"x".repeat(39)}😀`, "Text", [messageAction("OK")]);
|
||||
const title = (template.template as { title: string }).title;
|
||||
|
||||
expect(title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(title)).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from explicit altText instead of splitting it", () => {
|
||||
const template = createButtonTemplate("Title", "Text", [messageAction("OK")], {
|
||||
altText: `${"x".repeat(399)}😀`,
|
||||
});
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
|
||||
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("truncates text to 60 chars when title and thumbnail are 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(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCarouselColumn", () => {
|
||||
it("limits actions to 3", () => {
|
||||
const column = createCarouselColumn({
|
||||
text: "Text",
|
||||
actions: [
|
||||
messageAction("A1"),
|
||||
messageAction("A2"),
|
||||
messageAction("A3"),
|
||||
messageAction("A4"),
|
||||
messageAction("A5"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters when no title or image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
|
||||
|
||||
expect(column.text.length).toBe(120);
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a title is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text: longText,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: `${"x".repeat(39)}😀`,
|
||||
text: "Text",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(column.title ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
|
||||
const text = `${"x".repeat(59)}👨👩👧👦after`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(59));
|
||||
});
|
||||
|
||||
it("keeps required text when the first grapheme exceeds the limit", () => {
|
||||
const text = `😀${"\u0301".repeat(59)}`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
expect(column.text.startsWith("😀")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the compact limit when a whitespace-only title is present", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: " ",
|
||||
text: "x".repeat(150),
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(60));
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a thumbnail image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
text: longText,
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carousel column limits", () => {
|
||||
it.each([
|
||||
{
|
||||
createTemplate: () =>
|
||||
createTemplateCarousel(
|
||||
Array.from({ length: 15 }, () =>
|
||||
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
createTemplate: () =>
|
||||
createImageCarousel(
|
||||
Array.from({ length: 15 }, (_, i) =>
|
||||
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
|
||||
),
|
||||
),
|
||||
},
|
||||
])("limits columns to 10", ({ createTemplate }) => {
|
||||
const template = createTemplate();
|
||||
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from image-carousel altText instead of splitting it", () => {
|
||||
const template = createImageCarousel(
|
||||
[createImageCarouselColumn("https://example.com/0.jpg", messageAction("View"))],
|
||||
`${"x".repeat(399)}😀`,
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductCarousel", () => {
|
||||
it.each([
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Buy",
|
||||
actionUrl: "https://shop.com/buy",
|
||||
expectedType: "uri",
|
||||
},
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Select",
|
||||
actionData: "product_id=123",
|
||||
expectedType: "postback",
|
||||
},
|
||||
])("uses expected action type for product action", ({ expectedType, ...item }) => {
|
||||
const template = createProductCarousel([item]);
|
||||
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
||||
.columns;
|
||||
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||
});
|
||||
|
||||
it("preserves the complete price when truncating a long description", () => {
|
||||
const template = createProductCarousel([
|
||||
{
|
||||
title: "Product",
|
||||
description: "x".repeat(59),
|
||||
price: "$12.99",
|
||||
},
|
||||
]);
|
||||
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
|
||||
|
||||
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
|
||||
expect(columns[0].text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flex cards", () => {
|
||||
it("includes footer when provided", () => {
|
||||
const card = createInfoCard("Title", "Body", "Footer text");
|
||||
|
||||
const footer = card.footer as { contents: Array<{ text: string }> };
|
||||
expect(footer.contents[0].text).toBe("Footer text");
|
||||
});
|
||||
|
||||
it("limits list items to 8", () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
|
||||
const card = createListCard("List", items);
|
||||
|
||||
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
|
||||
const listBox = body.contents[2] as { contents: unknown[] };
|
||||
expect(listBox.contents.length).toBe(8);
|
||||
});
|
||||
|
||||
it("includes image-card body text when provided", () => {
|
||||
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
|
||||
|
||||
const body = card.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents.length).toBe(2);
|
||||
expect(body.contents[1].text).toBe("Body text");
|
||||
});
|
||||
|
||||
it("limits action-card actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => ({
|
||||
label: `Action ${i}`,
|
||||
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
|
||||
}));
|
||||
const card = createActionCard("Title", "Body", actions);
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBe(4);
|
||||
});
|
||||
|
||||
it("limits carousels to 12 bubbles", () => {
|
||||
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
|
||||
const carousel = createCarousel(bubbles);
|
||||
|
||||
expect(carousel.contents.length).toBe(12);
|
||||
});
|
||||
|
||||
it("limits device controls to 6", () => {
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: "Device",
|
||||
controls: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Control ${i}`,
|
||||
data: `action=${i}`,
|
||||
})),
|
||||
});
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("keeps event-card optional fields together", () => {
|
||||
const card = createEventCard({
|
||||
title: "Team Offsite",
|
||||
date: "February 15, 2026",
|
||||
time: "9:00 AM - 5:00 PM",
|
||||
location: "Mountain View Office",
|
||||
description: "Annual team building event",
|
||||
});
|
||||
|
||||
expect(card.size).toBe("mega");
|
||||
const body = card.body as { contents: Array<{ type: string }> };
|
||||
expect(body.contents).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action label/data surrogate-safe truncation", () => {
|
||||
// 19 ASCII chars + 😀 (U+1F600, two UTF-16 code units) = 21 code units; a raw
|
||||
// .slice(0, 20) would keep the first 19 chars plus the lone high surrogate.
|
||||
const labelWithEmoji = "1234567890123456789😀";
|
||||
|
||||
it("messageAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = messageAction(labelWithEmoji) as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
it("messageAction leaves a short ASCII label unchanged", () => {
|
||||
const action = messageAction("Yes");
|
||||
|
||||
expect(action.label).toBe("Yes");
|
||||
});
|
||||
|
||||
it("uriAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = uriAction(labelWithEmoji, "https://example.com") as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
it("postbackAction truncates label and data on surrogate boundaries", () => {
|
||||
// 299 ASCII chars + 😀 = 301 code units; the 300-unit slice cuts the emoji.
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = postbackAction(labelWithEmoji, data) as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("postbackAction truncates displayText on surrogate boundaries but keeps undefined", () => {
|
||||
const displayText = `${"t".repeat(299)}😀`;
|
||||
const withDisplay = postbackAction("Label", "data", displayText) as {
|
||||
displayText?: string;
|
||||
};
|
||||
const withoutDisplay = postbackAction("Label", "data") as { displayText?: string };
|
||||
|
||||
expect(withDisplay.displayText).toBe("t".repeat(299));
|
||||
expect(loneHighSurrogate.test(withDisplay.displayText ?? "")).toBe(false);
|
||||
expect(withoutDisplay.displayText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("datetimePickerAction truncates label and data on surrogate boundaries", () => {
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = datetimePickerAction(labelWithEmoji, data, "datetime") as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("/card action command uses surrogate-safe labels and postback data", async () => {
|
||||
const registerCommand = (command: unknown) => {
|
||||
const { handler } = command as {
|
||||
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
|
||||
};
|
||||
return handler({
|
||||
channel: "line",
|
||||
args: `action "Menu" "Body" --actions "${labelWithEmoji}|k=${"d".repeat(297)}😀"`,
|
||||
});
|
||||
};
|
||||
const result = (await registerCommandWithHandler(registerCommand)) as {
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
contents: { footer: { contents: Array<{ action: { label: string; data: string } }> } };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const action = result.channelData.line.flexMessage.contents.footer.contents[0].action;
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe(`k=${"d".repeat(297)}`);
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("/card receipt altText truncates on a surrogate boundary", async () => {
|
||||
// The emoji's surrogate pair straddles the 400-char altText cap; a raw
|
||||
// slice used to leave a lone high surrogate in the receipt flex altText.
|
||||
const registerCommand = (command: unknown) => {
|
||||
const { handler } = command as {
|
||||
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
|
||||
};
|
||||
return handler({
|
||||
channel: "line",
|
||||
args: `receipt "R" "${"a".repeat(395)}:😀x" --total "$30"`,
|
||||
});
|
||||
};
|
||||
const result = (await registerCommandWithHandler(registerCommand)) as {
|
||||
channelData: { line: { flexMessage: { altText: string } } };
|
||||
};
|
||||
const altText = result.channelData.line.flexMessage.altText;
|
||||
|
||||
expect(altText.length).toBeLessThanOrEqual(400);
|
||||
expect(loneHighSurrogate.test(altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("media control postback labels truncate on surrogate boundaries", () => {
|
||||
const card = createMediaPlayerCard({
|
||||
title: "Track",
|
||||
controls: {
|
||||
play: { data: "play" },
|
||||
},
|
||||
extraActions: [{ label: `${"x".repeat(14)}😀`, data: "extra" }],
|
||||
});
|
||||
const footer = card.footer as {
|
||||
contents: Array<{ contents?: Array<{ action?: { data?: string; label: string } }> }>;
|
||||
};
|
||||
const extraAction = footer.contents
|
||||
.flatMap((content) => content.contents ?? [])
|
||||
.find((button) => button.action?.data === "extra")?.action;
|
||||
|
||||
expect(extraAction?.label).toBe("x".repeat(14));
|
||||
expect(loneHighSurrogate.test(extraAction?.label ?? "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function registerCommandWithHandler(
|
||||
runHandler: (command: unknown) => Promise<unknown>,
|
||||
): Promise<unknown> {
|
||||
let result: unknown;
|
||||
registerLineCardCommand({
|
||||
registerCommand(command: unknown) {
|
||||
result = runHandler(command);
|
||||
},
|
||||
} as never);
|
||||
return result;
|
||||
}
|
||||
58
extensions/line/src/monitor-durable.test.ts
Normal file
58
extensions/line/src/monitor-durable.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Line tests cover monitor durable plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
|
||||
|
||||
describe("resolveLineDurableReplyOptions", () => {
|
||||
it("enables durable final delivery for push-only text replies", () => {
|
||||
expect(
|
||||
resolveLineDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "final",
|
||||
to: "U123",
|
||||
replyToken: "reply-token",
|
||||
replyTokenUsed: true,
|
||||
}),
|
||||
).toEqual({
|
||||
to: "U123",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unused reply-token delivery on the legacy path", () => {
|
||||
expect(
|
||||
resolveLineDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "final",
|
||||
to: "U123",
|
||||
replyToken: "reply-token",
|
||||
replyTokenUsed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps rich, media, and non-final replies on the legacy path", () => {
|
||||
expect(
|
||||
resolveLineDurableReplyOptions({
|
||||
payload: { text: "hello", channelData: { line: { quickReplies: ["One"] } } },
|
||||
infoKind: "final",
|
||||
to: "U123",
|
||||
replyTokenUsed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveLineDurableReplyOptions({
|
||||
payload: { text: "photo", mediaUrl: "https://example.com/image.png" },
|
||||
infoKind: "final",
|
||||
to: "U123",
|
||||
replyTokenUsed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveLineDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "block",
|
||||
to: "U123",
|
||||
replyTokenUsed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
38
extensions/line/src/monitor-durable.ts
Normal file
38
extensions/line/src/monitor-durable.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Line plugin module implements monitor durable behavior.
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { LineChannelData } from "./types.js";
|
||||
|
||||
export type LineDurableReplyOptions = {
|
||||
to: string;
|
||||
};
|
||||
|
||||
function hasLineChannelData(payload: ReplyPayload): boolean {
|
||||
const lineData = payload.channelData?.line as LineChannelData | undefined;
|
||||
return Boolean(lineData && Object.keys(lineData).length > 0);
|
||||
}
|
||||
|
||||
export function resolveLineDurableReplyOptions(params: {
|
||||
payload: ReplyPayload;
|
||||
infoKind: string;
|
||||
to: string;
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed: boolean;
|
||||
}): LineDurableReplyOptions | false {
|
||||
if (params.infoKind !== "final") {
|
||||
return false;
|
||||
}
|
||||
if (params.replyToken && !params.replyTokenUsed) {
|
||||
return false;
|
||||
}
|
||||
if (hasLineChannelData(params.payload)) {
|
||||
return false;
|
||||
}
|
||||
const reply = resolveSendableOutboundReplyParts(params.payload);
|
||||
if (reply.hasMedia || !reply.hasText) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
to: params.to,
|
||||
};
|
||||
}
|
||||
552
extensions/line/src/monitor.lifecycle.test.ts
Normal file
552
extensions/line/src/monitor.lifecycle.test.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
// Line tests cover monitor.lifecycle plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
||||
import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
type LineHandleWebhook = (...args: unknown[]) => Promise<void>;
|
||||
|
||||
const {
|
||||
createLineBotMock,
|
||||
createLineNodeWebhookHandlerMock,
|
||||
registerWebhookTargetWithPluginRouteMock,
|
||||
unregisterHttpMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createLineBotMock: vi.fn(() => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: vi.fn<LineHandleWebhook>(),
|
||||
})),
|
||||
createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
|
||||
vi.fn<LineNodeWebhookHandler>(async () => {}),
|
||||
),
|
||||
registerWebhookTargetWithPluginRouteMock: vi.fn(),
|
||||
unregisterHttpMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
|
||||
let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
|
||||
let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
|
||||
let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
|
||||
|
||||
type RegisteredRoute = {
|
||||
accountId?: string;
|
||||
auth?: string;
|
||||
handler?: LineNodeWebhookHandler;
|
||||
path?: string;
|
||||
pluginId?: string;
|
||||
replaceExisting?: boolean;
|
||||
};
|
||||
|
||||
type RegisteredTarget = {
|
||||
accountId?: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type WebhookRegistration = {
|
||||
route: RegisteredRoute;
|
||||
target: RegisteredTarget;
|
||||
};
|
||||
|
||||
function requireWebhookRegistration(): WebhookRegistration {
|
||||
const registration = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0] as
|
||||
| WebhookRegistration
|
||||
| undefined;
|
||||
if (!registration) {
|
||||
throw new Error("expected registered LINE webhook target");
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
function requireRegisteredRoute(): { handler: LineNodeWebhookHandler } {
|
||||
const route = requireWebhookRegistration().route;
|
||||
if (!route.handler) {
|
||||
throw new Error("expected registered LINE webhook route");
|
||||
}
|
||||
return { handler: route.handler };
|
||||
}
|
||||
|
||||
vi.mock("./bot.js", () => ({
|
||||
createLineBot: createLineBotMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
chunkMarkdownText: vi.fn(),
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||||
"openclaw/plugin-sdk/runtime-env",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
danger: (value: unknown) => String(value),
|
||||
logVerbose: vi.fn(),
|
||||
waitForAbortSignal: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/webhook-ingress")>(
|
||||
"openclaw/plugin-sdk/webhook-ingress",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
normalizePluginHttpPath: (path: string | undefined, fallback: string) => path ?? fallback,
|
||||
registerWebhookTargetWithPluginRoute: registerWebhookTargetWithPluginRouteMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./webhook-node.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./webhook-node.js")>("./webhook-node.js");
|
||||
return {
|
||||
...actual,
|
||||
createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auto-reply-delivery.js", () => ({
|
||||
deliverLineAutoReply: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./markdown-to-line.js", () => ({
|
||||
processLineMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-chunks.js", () => ({
|
||||
sendLineReplyChunks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
createFlexMessage: vi.fn(),
|
||||
createImageMessage: vi.fn(),
|
||||
createLocationMessage: vi.fn(),
|
||||
createQuickReplyItems: vi.fn(),
|
||||
createTextMessageWithQuickReplies: vi.fn(),
|
||||
getUserDisplayName: vi.fn(),
|
||||
pushMessageLine: vi.fn(),
|
||||
pushMessagesLine: vi.fn(),
|
||||
pushTextMessageWithQuickReplies: vi.fn(),
|
||||
replyMessageLine: vi.fn(),
|
||||
showLoadingAnimation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./template-messages.js", () => ({
|
||||
buildTemplateMessageFromPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("monitorLineProvider lifecycle", () => {
|
||||
beforeAll(async () => {
|
||||
({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
|
||||
await import("./monitor.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./bot.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/reply-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||||
vi.doUnmock("openclaw/plugin-sdk/webhook-ingress");
|
||||
vi.doUnmock("./webhook-node.js");
|
||||
vi.doUnmock("./auto-reply-delivery.js");
|
||||
vi.doUnmock("./markdown-to-line.js");
|
||||
vi.doUnmock("./reply-chunks.js");
|
||||
vi.doUnmock("./send.js");
|
||||
vi.doUnmock("./template-messages.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearLineRuntimeStateForTests();
|
||||
createLineBotMock.mockReset();
|
||||
createLineBotMock.mockImplementation(() => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: vi.fn<LineHandleWebhook>(),
|
||||
}));
|
||||
innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
|
||||
createLineNodeWebhookHandlerMock
|
||||
.mockReset()
|
||||
.mockImplementation(() => innerLineWebhookHandlerMock);
|
||||
unregisterHttpMock.mockReset();
|
||||
registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
|
||||
const withLeadingSlash = params.target.path.startsWith("/")
|
||||
? params.target.path
|
||||
: `/${params.target.path}`;
|
||||
const key =
|
||||
withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")
|
||||
? withLeadingSlash.slice(0, -1)
|
||||
: withLeadingSlash;
|
||||
const normalizedTarget = { ...params.target, path: key };
|
||||
const existing = params.targetsByPath.get(key) ?? [];
|
||||
params.targetsByPath.set(key, [...existing, normalizedTarget]);
|
||||
return {
|
||||
target: normalizedTarget,
|
||||
unregister: () => {
|
||||
unregisterHttpMock();
|
||||
const updated = (params.targetsByPath.get(key) ?? []).filter(
|
||||
(entry: unknown) => entry !== normalizedTarget,
|
||||
);
|
||||
if (updated.length > 0) {
|
||||
params.targetsByPath.set(key, updated);
|
||||
} else {
|
||||
params.targetsByPath.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearLineRuntimeStateForTests();
|
||||
});
|
||||
|
||||
const createRouteResponse = () => {
|
||||
const resObj = {
|
||||
statusCode: 0,
|
||||
headersSent: false,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
resObj.headersSent = true;
|
||||
}),
|
||||
};
|
||||
return resObj as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
it("waits for abort before resolving", async () => {
|
||||
const abort = new AbortController();
|
||||
let resolved = false;
|
||||
|
||||
const task = monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
abortSignal: abort.signal,
|
||||
}).then((monitor) => {
|
||||
resolved = true;
|
||||
return monitor;
|
||||
});
|
||||
|
||||
expect(registerWebhookTargetWithPluginRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(requireWebhookRegistration().route.auth).toBe("plugin");
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers an account target without replacing existing route ownership", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
accountId: "work",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const registration = requireWebhookRegistration();
|
||||
expect(registration.target.accountId).toBe("work");
|
||||
expect(registration.target.path).toBe("/line/webhook");
|
||||
expect(registration.route.accountId).toBe("work");
|
||||
expect(registration.route.auth).toBe("plugin");
|
||||
expect(registration.route.pluginId).toBe("line");
|
||||
expect(registration.route).not.toHaveProperty("path");
|
||||
expect(registration.route).not.toHaveProperty("replaceExisting");
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("stops immediately when signal is already aborted", async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns immediately without abort signal and stop is idempotent", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(unregisterHttpMock).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
monitor.stop();
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records startup state under configured defaultAccount when accountId is omitted", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
channelAccessToken: "work-token",
|
||||
channelSecret: "work-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(getLineRuntimeState("work")?.running).toBe(true);
|
||||
expect(getLineRuntimeState("default")).toBeUndefined();
|
||||
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("does not record running state when bot startup fails", async () => {
|
||||
createLineBotMock.mockImplementation(() => {
|
||||
throw new Error("line bot startup failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("line bot startup failed");
|
||||
|
||||
expect(getLineRuntimeState("default")?.running).not.toBe(true);
|
||||
expect(registerWebhookTargetWithPluginRouteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches shared-path webhook posts to the account matching the signature", async () => {
|
||||
const firstMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "first-token",
|
||||
channelSecret: "first-secret", // pragma: allowlist secret
|
||||
accountId: "first",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
const secondMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "second-token",
|
||||
channelSecret: "second-secret", // pragma: allowlist secret
|
||||
accountId: "second",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "second-secret").update(payload).digest("base64");
|
||||
const req = Object.assign(createMockIncomingRequest([payload]), {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route.handler(req, res);
|
||||
|
||||
const firstBot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const secondBot = createLineBotMock.mock.results[1]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(firstBot.handleWebhook).not.toHaveBeenCalled();
|
||||
expect(secondBot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstMonitor.stop();
|
||||
secondMonitor.stop();
|
||||
});
|
||||
|
||||
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
webhookPath: "/line/webhook/",
|
||||
accountId: "default",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const registration = requireWebhookRegistration();
|
||||
expect(registration.target.path).toBe("/line/webhook");
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
|
||||
const req = Object.assign(createMockIncomingRequest([payload]), {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route.handler(req, res);
|
||||
|
||||
const bot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("acknowledges shared-path POST requests before matched event processing completes", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
accountId: "default",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
let releaseWebhook: (() => void) | undefined;
|
||||
const bot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn<LineHandleWebhook>>;
|
||||
};
|
||||
bot.handleWebhook.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseWebhook = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
|
||||
const req = Object.assign(createMockIncomingRequest([payload]), {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route.handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headersSent).toBe(true);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
if (!releaseWebhook) {
|
||||
throw new Error("expected pending LINE webhook handler");
|
||||
}
|
||||
releaseWebhook();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("rejects ambiguous shared-path webhook signatures", async () => {
|
||||
const firstMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "first-token",
|
||||
channelSecret: "shared-secret", // pragma: allowlist secret
|
||||
accountId: "first",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
const secondMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "second-token",
|
||||
channelSecret: "shared-secret", // pragma: allowlist secret
|
||||
accountId: "second",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "shared-secret").update(payload).digest("base64");
|
||||
const req = Object.assign(createMockIncomingRequest([payload]), {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route.handler(req, res);
|
||||
|
||||
const firstBot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const secondBot = createLineBotMock.mock.results[1]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: "Ambiguous webhook target" }));
|
||||
expect(firstBot.handleWebhook).not.toHaveBeenCalled();
|
||||
expect(secondBot.handleWebhook).not.toHaveBeenCalled();
|
||||
|
||||
firstMonitor.stop();
|
||||
secondMonitor.stop();
|
||||
});
|
||||
|
||||
it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
|
||||
const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
|
||||
const heldRequests: Array<EventEmitter & { destroy: () => void }> = [];
|
||||
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
const createHeldPostRequest = () => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
destroy(this: EventEmitter & { destroyed: boolean }) {
|
||||
this.destroyed = true;
|
||||
this.emit("close");
|
||||
},
|
||||
});
|
||||
heldRequests.push(req);
|
||||
return Object.assign(req, {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": "pending" },
|
||||
}) as unknown as IncomingMessage;
|
||||
};
|
||||
const createSignedPostRequest = () => {
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
|
||||
const req = createMockIncomingRequest([payload]);
|
||||
return Object.assign(req, {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
};
|
||||
|
||||
const firstRequests = Array.from({ length: limit }, () =>
|
||||
route.handler(createHeldPostRequest(), createRouteResponse()),
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
const overflowResponse = createRouteResponse();
|
||||
await route.handler(createSignedPostRequest(), overflowResponse);
|
||||
|
||||
const bot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
expect(overflowResponse.statusCode).toBe(429);
|
||||
expect(overflowResponse.end).toHaveBeenCalledWith("Too Many Requests");
|
||||
|
||||
heldRequests.splice(0).forEach((req) => req.destroy());
|
||||
await Promise.allSettled(firstRequests);
|
||||
monitor.stop();
|
||||
});
|
||||
});
|
||||
2
extensions/line/src/monitor.runtime.ts
Normal file
2
extensions/line/src/monitor.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Line plugin module implements monitor behavior.
|
||||
export { monitorLineProvider } from "./monitor.js";
|
||||
511
extensions/line/src/monitor.ts
Normal file
511
extensions/line/src/monitor.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
// Line plugin module implements monitor behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
danger,
|
||||
logVerbose,
|
||||
waitForAbortSignal,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
normalizePluginHttpPath,
|
||||
normalizeWebhookPath,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
requestBodyErrorToText,
|
||||
resolveSingleWebhookTarget,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { resolveDefaultLineAccountId } from "./accounts.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { createLineBot } from "./bot.js";
|
||||
import { processLineMessage } from "./markdown-to-line.js";
|
||||
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import {
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
createQuickReplyItems,
|
||||
createTextMessageWithQuickReplies,
|
||||
getUserDisplayName,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
replyMessageLine,
|
||||
showLoadingAnimation,
|
||||
} from "./send.js";
|
||||
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
|
||||
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
||||
|
||||
export interface MonitorLineProviderOptions {
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
accountId?: string;
|
||||
config: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
webhookUrl?: string;
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
export interface LineProviderMonitor {
|
||||
account: ResolvedLineAccount;
|
||||
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const runtimeState = new Map<
|
||||
string,
|
||||
{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
}
|
||||
>();
|
||||
const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
||||
|
||||
type LineWebhookTarget = {
|
||||
accountId: string;
|
||||
bot: ReturnType<typeof createLineBot>;
|
||||
channelSecret: string;
|
||||
path: string;
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
const lineWebhookTargets = new Map<string, LineWebhookTarget[]>();
|
||||
|
||||
function recordChannelRuntimeState(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
state: Partial<{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt: number | null;
|
||||
lastOutboundAt: number | null;
|
||||
}>;
|
||||
}): void {
|
||||
const key = `${params.channel}:${params.accountId}`;
|
||||
const existing = runtimeState.get(key) ?? {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
runtimeState.set(key, { ...existing, ...params.state });
|
||||
}
|
||||
|
||||
export function getLineRuntimeState(accountId: string) {
|
||||
return runtimeState.get(`line:${accountId}`);
|
||||
}
|
||||
|
||||
export function clearLineRuntimeStateForTests() {
|
||||
runtimeState.clear();
|
||||
}
|
||||
|
||||
function startLineLoadingKeepalive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
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, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
loadingSeconds,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
trigger();
|
||||
const timer = setInterval(trigger, intervalMs);
|
||||
|
||||
return () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function monitorLineProvider(
|
||||
opts: MonitorLineProviderOptions,
|
||||
): Promise<LineProviderMonitor> {
|
||||
const {
|
||||
channelAccessToken,
|
||||
channelSecret,
|
||||
accountId,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal,
|
||||
webhookPath,
|
||||
} = opts;
|
||||
const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
|
||||
const token = channelAccessToken.trim();
|
||||
const secret = channelSecret.trim();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel access token.");
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
||||
}
|
||||
|
||||
const bot = createLineBot({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId,
|
||||
runtime,
|
||||
config,
|
||||
onMessage: async (ctx) => {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctxPayload, replyToken, route } = ctx;
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
lastInboundAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
|
||||
|
||||
const displayNamePromise = ctx.userId
|
||||
? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId })
|
||||
: Promise.resolve(ctxPayload.From);
|
||||
|
||||
const stopLoading = shouldShowLoading
|
||||
? startLineLoadingKeepalive({
|
||||
cfg: config,
|
||||
userId: ctx.userId!,
|
||||
accountId: ctx.accountId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const displayName = await displayNamePromise;
|
||||
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
|
||||
|
||||
try {
|
||||
const textLimit = 5000;
|
||||
let replyTokenUsed = false;
|
||||
const core = getLineRuntime();
|
||||
const turnResult = await core.channel.inbound.run({
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
raw: ctx,
|
||||
adapter: {
|
||||
ingest: () => ({
|
||||
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
||||
rawText: ctxPayload.RawBody ?? ctxPayload.BodyForAgent ?? "",
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: config,
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: ctx.turn.storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
record: ctx.turn.record,
|
||||
replyPipeline: {},
|
||||
delivery: {
|
||||
durable: (payload, info) =>
|
||||
resolveLineDurableReplyOptions({
|
||||
payload,
|
||||
infoKind: info.kind,
|
||||
to: ctxPayload.From,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
}),
|
||||
deliver: async (payload) => {
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
|
||||
if (ctx.userId && !ctx.isGroup) {
|
||||
void showLoadingAnimation(ctx.userId, {
|
||||
cfg: config,
|
||||
accountId: ctx.accountId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
|
||||
payload,
|
||||
lineData,
|
||||
to: ctxPayload.From,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
accountId: ctx.accountId,
|
||||
cfg: config,
|
||||
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)}`));
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
||||
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
|
||||
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
|
||||
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(
|
||||
replyToken,
|
||||
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
|
||||
{ cfg: config, accountId: ctx.accountId },
|
||||
);
|
||||
} catch (replyErr) {
|
||||
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stopLoading?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedPath = normalizeWebhookPath(
|
||||
normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook",
|
||||
);
|
||||
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
|
||||
createLineNodeWebhookHandler({
|
||||
channelSecret: target.channelSecret,
|
||||
bot: target.bot,
|
||||
runtime: target.runtime,
|
||||
});
|
||||
const { unregister: unregisterHttp } = registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: lineWebhookTargets,
|
||||
target: {
|
||||
accountId: resolvedAccountId,
|
||||
bot,
|
||||
channelSecret: secret,
|
||||
path: normalizedPath,
|
||||
runtime,
|
||||
},
|
||||
route: {
|
||||
auth: "plugin",
|
||||
pluginId: "line",
|
||||
accountId: resolvedAccountId,
|
||||
log: (msg) => logVerbose(msg),
|
||||
handler: async (req, res) => {
|
||||
const targets = lineWebhookTargets.get(normalizedPath) ?? [];
|
||||
const firstTarget = targets[0];
|
||||
if (req.method !== "POST") {
|
||||
if (!firstTarget) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
await createScopedLineWebhookHandler(firstTarget)(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
inFlightLimiter: lineWebhookInFlightLimiter,
|
||||
inFlightKey: `line:${normalizedPath}`,
|
||||
});
|
||||
if (!requestLifecycle.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureHeader = req.headers["x-line-signature"];
|
||||
const signature =
|
||||
typeof signatureHeader === "string"
|
||||
? signatureHeader.trim()
|
||||
: Array.isArray(signatureHeader)
|
||||
? (signatureHeader[0] ?? "").trim()
|
||||
: "";
|
||||
|
||||
if (!signature) {
|
||||
logVerbose("line: webhook missing X-Line-Signature header");
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readLineWebhookRequestBody(
|
||||
req,
|
||||
LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES,
|
||||
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
);
|
||||
const match = resolveSingleWebhookTarget(targets, (target) =>
|
||||
validateLineSignature(rawBody, signature, target.channelSecret),
|
||||
);
|
||||
if (match.kind === "none") {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||||
return;
|
||||
}
|
||||
if (match.kind === "ambiguous") {
|
||||
logVerbose("line: webhook signature matched multiple accounts");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Ambiguous webhook target" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseLineWebhookBody(rawBody);
|
||||
if (!body) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
||||
return;
|
||||
}
|
||||
|
||||
requestLifecycle.release();
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
void Promise.resolve()
|
||||
.then(() => match.target.bot.handleWebhook(body))
|
||||
.catch((err: unknown) => {
|
||||
match.target.runtime.error?.(
|
||||
danger(`line webhook dispatch failed: ${String(err)}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
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" }));
|
||||
}
|
||||
} finally {
|
||||
requestLifecycle.release();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||
|
||||
let stopped = false;
|
||||
const stopHandler = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
|
||||
unregisterHttp();
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stopHandler();
|
||||
} else if (abortSignal) {
|
||||
abortSignal.addEventListener("abort", stopHandler, { once: true });
|
||||
await waitForAbortSignal(abortSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
account: bot.account,
|
||||
handleWebhook: bot.handleWebhook,
|
||||
stop: () => {
|
||||
stopHandler();
|
||||
abortSignal?.removeEventListener("abort", stopHandler);
|
||||
},
|
||||
};
|
||||
}
|
||||
172
extensions/line/src/outbound-media.test.ts
Normal file
172
extensions/line/src/outbound-media.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Line tests cover outbound media plugin behavior.
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ssrfMocks = vi.hoisted(() => ({
|
||||
resolvePinnedHostnameWithPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import {
|
||||
resolveLineOutboundMedia,
|
||||
validateLineMediaUrl,
|
||||
} from "./outbound-media.js";
|
||||
|
||||
describe("validateLineMediaUrl", () => {
|
||||
beforeEach(() => {
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts HTTPS URL", async () => {
|
||||
await expect(validateLineMediaUrl("https://example.com/image.jpg")).resolves.toBeUndefined();
|
||||
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com", {
|
||||
policy: { allowPrivateNetwork: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts uppercase HTTPS scheme", async () => {
|
||||
await expect(validateLineMediaUrl("HTTPS://EXAMPLE.COM/img.jpg")).resolves.toBeUndefined();
|
||||
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com", {
|
||||
policy: { allowPrivateNetwork: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects HTTP URL", async () => {
|
||||
await expect(validateLineMediaUrl("http://example.com/image.jpg")).rejects.toThrow(
|
||||
/must use HTTPS/i,
|
||||
);
|
||||
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects URL longer than 2000 chars", async () => {
|
||||
const longUrl = `https://example.com/${"a".repeat(1981)}`;
|
||||
expect(longUrl.length).toBeGreaterThan(2000);
|
||||
await expect(validateLineMediaUrl(longUrl)).rejects.toThrow(/2000 chars or less/i);
|
||||
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects private-network targets through the shared SSRF policy", async () => {
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked private network target"),
|
||||
);
|
||||
|
||||
await expect(validateLineMediaUrl("https://127.0.0.1/image.jpg")).rejects.toThrow(
|
||||
/private network/i,
|
||||
);
|
||||
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("127.0.0.1", {
|
||||
policy: { allowPrivateNetwork: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineOutboundMedia", () => {
|
||||
beforeEach(() => {
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
|
||||
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit media kind without remote MIME probing", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/download?id=123", { mediaKind: "video" }),
|
||||
).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/download?id=123",
|
||||
mediaKind: "video",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit video kind when a preview URL is provided", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/download?id=123", {
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/download?id=123",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers audio kind from explicit duration metadata when mediaKind is omitted", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/download?id=audio", {
|
||||
durationMs: 60000,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/download?id=audio",
|
||||
mediaKind: "audio",
|
||||
durationMs: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer video from previewImageUrl alone", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/image.jpg", {
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
mediaKind: "image",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers media kinds from known HTTPS file extensions", async () => {
|
||||
await expect(resolveLineOutboundMedia("https://example.com/audio.mp3")).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/audio.mp3",
|
||||
mediaKind: "audio",
|
||||
});
|
||||
await expect(resolveLineOutboundMedia("https://example.com/video.mp4")).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
});
|
||||
await expect(resolveLineOutboundMedia("https://example.com/image.jpg")).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
mediaKind: "image",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates previewImageUrl when provided", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/video.mp4", {
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "http://example.com/preview.jpg",
|
||||
}),
|
||||
).rejects.toThrow(/must use HTTPS/i);
|
||||
});
|
||||
|
||||
it("falls back to image when no explicit LINE media options or known extension are present", async () => {
|
||||
await expect(
|
||||
resolveLineOutboundMedia("https://example.com/download?id=audio"),
|
||||
).resolves.toEqual({
|
||||
mediaUrl: "https://example.com/download?id=audio",
|
||||
mediaKind: "image",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects local paths because LINE outbound media requires public HTTPS URLs", async () => {
|
||||
await expect(resolveLineOutboundMedia("./assets/image.jpg")).rejects.toThrow(
|
||||
/requires a public https url/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-HTTPS URL explicitly", async () => {
|
||||
await expect(resolveLineOutboundMedia("http://example.com/image.jpg")).rejects.toThrow(
|
||||
/must use HTTPS/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
107
extensions/line/src/outbound-media.ts
Normal file
107
extensions/line/src/outbound-media.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Line plugin module implements outbound media behavior.
|
||||
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type LineOutboundMediaKind = "image" | "video" | "audio";
|
||||
|
||||
export type LineOutboundMediaResolved = {
|
||||
mediaUrl: string;
|
||||
mediaKind: LineOutboundMediaKind;
|
||||
previewImageUrl?: string;
|
||||
durationMs?: number;
|
||||
trackingId?: string;
|
||||
};
|
||||
|
||||
type ResolveLineOutboundMediaOpts = {
|
||||
mediaKind?: LineOutboundMediaKind;
|
||||
previewImageUrl?: string;
|
||||
durationMs?: number;
|
||||
trackingId?: string;
|
||||
};
|
||||
|
||||
const LINE_OUTBOUND_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
||||
allowPrivateNetwork: false,
|
||||
};
|
||||
|
||||
export async function validateLineMediaUrl(url: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`LINE outbound media URL must be a valid URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`LINE outbound media URL must use HTTPS: ${url}`);
|
||||
}
|
||||
if (url.length > 2000) {
|
||||
throw new Error(`LINE outbound media URL must be 2000 chars or less (got ${url.length})`);
|
||||
}
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: LINE_OUTBOUND_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
}
|
||||
|
||||
function isHttpsUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
|
||||
try {
|
||||
const pathname = normalizeLowercaseStringOrEmpty(new URL(url).pathname);
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(pathname)) {
|
||||
return "image";
|
||||
}
|
||||
if (/\.(mp4|mov|m4v|webm)$/i.test(pathname)) {
|
||||
return "video";
|
||||
}
|
||||
if (/\.(mp3|m4a|aac|wav|ogg|oga)$/i.test(pathname)) {
|
||||
return "audio";
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveLineOutboundMedia(
|
||||
mediaUrl: string,
|
||||
opts: ResolveLineOutboundMediaOpts = {},
|
||||
): Promise<LineOutboundMediaResolved> {
|
||||
const trimmedUrl = mediaUrl.trim();
|
||||
if (isHttpsUrl(trimmedUrl)) {
|
||||
await validateLineMediaUrl(trimmedUrl);
|
||||
const previewImageUrl = opts.previewImageUrl?.trim();
|
||||
if (previewImageUrl) {
|
||||
await validateLineMediaUrl(previewImageUrl);
|
||||
}
|
||||
const mediaKind =
|
||||
opts.mediaKind ??
|
||||
(typeof opts.durationMs === "number" ? "audio" : undefined) ??
|
||||
(opts.trackingId?.trim() ? "video" : undefined) ??
|
||||
detectLineMediaKindFromUrl(trimmedUrl) ??
|
||||
"image";
|
||||
return {
|
||||
mediaUrl: trimmedUrl,
|
||||
mediaKind,
|
||||
...(previewImageUrl ? { previewImageUrl } : {}),
|
||||
...(typeof opts.durationMs === "number" ? { durationMs: opts.durationMs } : {}),
|
||||
...(opts.trackingId ? { trackingId: opts.trackingId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmedUrl);
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`LINE outbound media URL must use HTTPS: ${trimmedUrl}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith("LINE outbound")) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error("LINE outbound media currently requires a public HTTPS URL");
|
||||
}
|
||||
13
extensions/line/src/outbound.runtime.ts
Normal file
13
extensions/line/src/outbound.runtime.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Line plugin module implements outbound behavior.
|
||||
export { processLineMessage } from "./markdown-to-line.js";
|
||||
export {
|
||||
createQuickReplyItems,
|
||||
pushFlexMessage,
|
||||
pushLocationMessage,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushTemplateMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
sendMessageLine,
|
||||
} from "./send.js";
|
||||
export { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||
450
extensions/line/src/outbound.ts
Normal file
450
extensions/line/src/outbound.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
// Line plugin module implements outbound behavior.
|
||||
import {
|
||||
defineChannelMessageAdapter,
|
||||
type ChannelMessageSendResult,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createAttachedChannelResultAdapter,
|
||||
createEmptyChannelResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js";
|
||||
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
|
||||
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { createLineSendReceipt } from "./send-receipt.js";
|
||||
import type { LineChannelData, LineSendResult } from "./types.js";
|
||||
|
||||
const loadLineOutboundRuntime = createLazyRuntimeModule(() => import("./outbound.runtime.js"));
|
||||
|
||||
type LineChannelDataWithMedia = LineChannelData & {
|
||||
mediaKind?: "image" | "video" | "audio";
|
||||
previewImageUrl?: string;
|
||||
durationMs?: number;
|
||||
trackingId?: string;
|
||||
};
|
||||
|
||||
function isLineUserTarget(target: string): boolean {
|
||||
const normalized = target
|
||||
.trim()
|
||||
.replace(/^line:(group|room|user):/i, "")
|
||||
.replace(/^line:/i, "");
|
||||
return /^U/i.test(normalized);
|
||||
}
|
||||
|
||||
function hasLineSpecificMediaOptions(lineData: LineChannelDataWithMedia): boolean {
|
||||
return Boolean(
|
||||
lineData.mediaKind ??
|
||||
lineData.previewImageUrl?.trim() ??
|
||||
(typeof lineData.durationMs === "number" ? lineData.durationMs : undefined) ??
|
||||
lineData.trackingId?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function buildLineMediaMessageObject(
|
||||
resolved: LineOutboundMediaResolved,
|
||||
opts?: { allowTrackingId?: boolean },
|
||||
): Record<string, unknown> {
|
||||
switch (resolved.mediaKind) {
|
||||
case "video": {
|
||||
const previewImageUrl = resolved.previewImageUrl?.trim();
|
||||
if (!previewImageUrl) {
|
||||
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
|
||||
}
|
||||
return {
|
||||
type: "video",
|
||||
originalContentUrl: resolved.mediaUrl,
|
||||
previewImageUrl,
|
||||
...(opts?.allowTrackingId && resolved.trackingId
|
||||
? { trackingId: resolved.trackingId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
case "audio":
|
||||
return {
|
||||
type: "audio",
|
||||
originalContentUrl: resolved.mediaUrl,
|
||||
duration: resolved.durationMs ?? 60000,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: "image",
|
||||
originalContentUrl: resolved.mediaUrl,
|
||||
previewImageUrl: resolved.previewImageUrl ?? resolved.mediaUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000,
|
||||
sendPayload: async ({ to, payload, accountId, cfg, onDeliveryResult }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const outboundRuntime = await loadLineOutboundRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelDataWithMedia | undefined) ?? {};
|
||||
const lineRuntime = runtime.channel.line;
|
||||
const sendText = lineRuntime?.pushMessageLine ?? outboundRuntime.pushMessageLine;
|
||||
const sendBatch = lineRuntime?.pushMessagesLine ?? outboundRuntime.pushMessagesLine;
|
||||
const sendFlex = lineRuntime?.pushFlexMessage ?? outboundRuntime.pushFlexMessage;
|
||||
const sendTemplate = lineRuntime?.pushTemplateMessage ?? outboundRuntime.pushTemplateMessage;
|
||||
const sendLocation = lineRuntime?.pushLocationMessage ?? outboundRuntime.pushLocationMessage;
|
||||
const sendQuickReplies =
|
||||
lineRuntime?.pushTextMessageWithQuickReplies ??
|
||||
outboundRuntime.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate =
|
||||
lineRuntime?.buildTemplateMessageFromPayload ??
|
||||
outboundRuntime.buildTemplateMessageFromPayload;
|
||||
|
||||
let lastResult: LineSendResult | null = null;
|
||||
const recordResult = async (
|
||||
resultPromise: Promise<LineSendResult>,
|
||||
): Promise<LineSendResult> => {
|
||||
const result = await resultPromise;
|
||||
lastResult = result;
|
||||
await onDeliveryResult?.(createEmptyChannelResult("line", { ...result }));
|
||||
return result;
|
||||
};
|
||||
const quickReplies = lineData.quickReplies ?? [];
|
||||
const hasQuickReplies = quickReplies.length > 0;
|
||||
const quickReply = hasQuickReplies
|
||||
? (lineRuntime?.createQuickReplyItems ?? outboundRuntime.createQuickReplyItems)(quickReplies)
|
||||
: undefined;
|
||||
|
||||
// LINE SDK expects Message[] but we build dynamically.
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
await recordResult(
|
||||
sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? outboundRuntime.processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
|
||||
fallbackLimit: 5000,
|
||||
}) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||
const useLineSpecificMedia = hasLineSpecificMediaOptions(lineData);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (!useLineSpecificMedia) {
|
||||
await recordResult(
|
||||
(lineRuntime?.sendMessageLine ?? outboundRuntime.sendMessageLine)(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: trimmed,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveLineOutboundMedia(trimmed, {
|
||||
mediaKind: lineData.mediaKind,
|
||||
previewImageUrl: lineData.previewImageUrl,
|
||||
durationMs: lineData.durationMs,
|
||||
trackingId: lineData.trackingId,
|
||||
});
|
||||
await recordResult(
|
||||
(lineRuntime?.sendMessageLine ?? outboundRuntime.sendMessageLine)(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: resolved.mediaUrl,
|
||||
mediaKind: resolved.mediaKind,
|
||||
previewImageUrl: resolved.previewImageUrl,
|
||||
durationMs: resolved.durationMs,
|
||||
trackingId: resolved.trackingId,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
await recordResult(
|
||||
sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
await recordResult(
|
||||
sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
await recordResult(
|
||||
sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents;
|
||||
await recordResult(
|
||||
sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
await recordResult(
|
||||
sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await recordResult(
|
||||
sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: truncateUtf16Safe(lineData.flexMessage.altText, 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: truncateUtf16Safe(lineData.location.title, 100),
|
||||
address: truncateUtf16Safe(lineData.location.address, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: truncateUtf16Safe(flexMsg.altText, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (!useLineSpecificMedia) {
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveLineOutboundMedia(trimmed, {
|
||||
mediaKind: lineData.mediaKind,
|
||||
previewImageUrl: lineData.previewImageUrl,
|
||||
durationMs: lineData.durationMs,
|
||||
trackingId: lineData.trackingId,
|
||||
});
|
||||
quickReplyMessages.push(
|
||||
buildLineMediaMessageObject(resolved, { allowTrackingId: isLineUserTarget(to) }),
|
||||
);
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
} else if (quickReply) {
|
||||
await recordResult(
|
||||
sendQuickReplies(to, buildLineQuickReplyFallbackText(quickReplies), quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
const completedResult = lastResult as LineSendResult | null;
|
||||
if (completedResult) {
|
||||
return createEmptyChannelResult("line", { ...completedResult });
|
||||
}
|
||||
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "line",
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const outboundRuntime = await loadLineOutboundRuntime();
|
||||
const sendText = outboundRuntime.pushMessageLine;
|
||||
const sendFlex = outboundRuntime.pushFlexMessage;
|
||||
const processed = outboundRuntime.processLineMessage(text);
|
||||
let result: LineSendResult;
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
result = {
|
||||
messageId: "processed",
|
||||
chatId: to,
|
||||
receipt: createLineSendReceipt({ messageId: "processed", chatId: to, kind: "card" }),
|
||||
};
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents;
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
|
||||
await (
|
||||
await loadLineOutboundRuntime()
|
||||
).sendMessageLine(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
function toLineMessageSendResult(
|
||||
result: Awaited<ReturnType<NonNullable<typeof lineOutboundAdapter.sendPayload>>>,
|
||||
kind: MessageReceiptPartKind,
|
||||
): ChannelMessageSendResult {
|
||||
const source = result as typeof result & { chatId?: string };
|
||||
const receipt =
|
||||
result.receipt ??
|
||||
(result.messageId
|
||||
? createLineSendReceipt({
|
||||
messageId: result.messageId,
|
||||
chatId: source.chatId ?? "",
|
||||
kind,
|
||||
})
|
||||
: undefined);
|
||||
if (!receipt) {
|
||||
throw new Error("LINE message adapter send did not return a receipt");
|
||||
}
|
||||
return {
|
||||
messageId: result.messageId || receipt.primaryPlatformMessageId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
export const lineMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "line",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async ({ cfg, to, text, accountId, onDeliveryResult }) => {
|
||||
const result = await lineOutboundAdapter.sendPayload!({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
payload: { text },
|
||||
onDeliveryResult: async (deliveryResult) => {
|
||||
await onDeliveryResult?.(toLineMessageSendResult(deliveryResult, "text"));
|
||||
},
|
||||
});
|
||||
return toLineMessageSendResult(result, "text");
|
||||
},
|
||||
media: async ({ cfg, to, text, mediaUrl, accountId, onDeliveryResult }) => {
|
||||
const result = await lineOutboundAdapter.sendPayload!({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
accountId,
|
||||
payload: { text, mediaUrl },
|
||||
onDeliveryResult: async (deliveryResult) => {
|
||||
await onDeliveryResult?.(toLineMessageSendResult(deliveryResult, "media"));
|
||||
},
|
||||
});
|
||||
return toLineMessageSendResult(result, "media");
|
||||
},
|
||||
},
|
||||
receive: {
|
||||
defaultAckPolicy: "after_receive_record",
|
||||
supportedAckPolicies: ["after_receive_record"],
|
||||
},
|
||||
});
|
||||
10
extensions/line/src/probe.contract.test.ts
Normal file
10
extensions/line/src/probe.contract.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Line tests cover probe.contract plugin behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { LineProbeResult } from "./types.js";
|
||||
|
||||
describe("LINE probe contract", () => {
|
||||
it("keeps public probe aligned with base contract", () => {
|
||||
expectTypeOf<LineProbeResult>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
});
|
||||
2
extensions/line/src/probe.runtime.ts
Normal file
2
extensions/line/src/probe.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Line plugin module implements probe behavior.
|
||||
export { probeLineBot } from "./probe.js";
|
||||
35
extensions/line/src/probe.ts
Normal file
35
extensions/line/src/probe.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Line plugin module implements probe behavior.
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { LineProbeResult } from "./types.js";
|
||||
|
||||
export async function probeLineBot(
|
||||
channelAccessToken: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<LineProbeResult> {
|
||||
if (!channelAccessToken?.trim()) {
|
||||
return { ok: false, error: "Channel access token not configured" };
|
||||
}
|
||||
|
||||
const client = new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: channelAccessToken.trim(),
|
||||
});
|
||||
|
||||
try {
|
||||
const profile = await withTimeout(client.getBotInfo(), timeoutMs);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
bot: {
|
||||
displayName: profile.displayName,
|
||||
userId: profile.userId,
|
||||
basicId: profile.basicId,
|
||||
pictureUrl: profile.pictureUrl,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
10
extensions/line/src/quick-reply-fallback.ts
Normal file
10
extensions/line/src/quick-reply-fallback.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Line plugin module implements quick reply fallback behavior.
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function buildLineQuickReplyFallbackText(labels: readonly string[] | undefined): string {
|
||||
const normalized = normalizeStringEntries(labels ?? []).slice(0, 13);
|
||||
if (normalized.length === 0) {
|
||||
return "Choose an option.";
|
||||
}
|
||||
return `Options:\n${normalized.map((label) => `- ${label}`).join("\n")}`;
|
||||
}
|
||||
181
extensions/line/src/reply-chunks.test.ts
Normal file
181
extensions/line/src/reply-chunks.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Line tests cover reply chunks plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
|
||||
const LINE_TEST_CFG = { channels: { line: { channelAccessToken: "line-token" } } };
|
||||
|
||||
function createReplyChunksHarness() {
|
||||
const replyMessageLine = vi.fn(async () => ({}));
|
||||
const pushMessageLine = vi.fn(async () => ({}));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
}));
|
||||
|
||||
return {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sendLineReplyChunks", () => {
|
||||
it("uses reply token for all chunks when possible", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks: ["one", "two", "three"],
|
||||
quickReplies: ["A", "B"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
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" },
|
||||
],
|
||||
{ cfg: LINE_TEST_CFG, accountId: "default" },
|
||||
);
|
||||
expect(pushMessageLine).not.toHaveBeenCalled();
|
||||
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches quick replies to a single reply chunk", async () => {
|
||||
const { replyMessageLine, pushMessageLine, pushTextMessageWithQuickReplies } =
|
||||
createReplyChunksHarness();
|
||||
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
|
||||
type: "text" as const,
|
||||
text,
|
||||
quickReply: { items: [] },
|
||||
}));
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:user:1",
|
||||
chunks: ["only"],
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("only", ["A"]);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageLine).not.toHaveBeenCalled();
|
||||
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replies with up to five chunks before pushing the rest", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
|
||||
const chunks = ["1", "2", "3", "4", "5", "6", "7"];
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks,
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
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" },
|
||||
],
|
||||
{ cfg: LINE_TEST_CFG, accountId: undefined },
|
||||
);
|
||||
expect(pushMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1);
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to push flow when replying fails", async () => {
|
||||
const {
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
} = createReplyChunksHarness();
|
||||
const onReplyError = vi.fn();
|
||||
const replyError = new Error("reply failed");
|
||||
replyMessageLine.mockRejectedValueOnce(replyError);
|
||||
|
||||
const result = await sendLineReplyChunks({
|
||||
to: "line:group:1",
|
||||
chunks: ["1", "2", "3"],
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createTextMessageWithQuickReplies,
|
||||
onReplyError,
|
||||
});
|
||||
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(onReplyError).toHaveBeenCalledWith(replyError);
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(1, "line:group:1", "1", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(2, "line:group:1", "2", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "3", ["A"], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
});
|
||||
111
extensions/line/src/reply-chunks.ts
Normal file
111
extensions/line/src/reply-chunks.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Line plugin module implements reply chunks behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
type LineReplyMessage = messagingApi.TextMessage;
|
||||
|
||||
export type SendLineReplyChunksParams = {
|
||||
to: string;
|
||||
chunks: string[];
|
||||
quickReplies?: string[];
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed?: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
replyMessageLine: (
|
||||
replyToken: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
pushMessageLine: (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
pushTextMessageWithQuickReplies: (
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplies: string[],
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage;
|
||||
onReplyError?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export async function sendLineReplyChunks(
|
||||
params: SendLineReplyChunksParams,
|
||||
): Promise<{ replyTokenUsed: boolean }> {
|
||||
const hasQuickReplies = Boolean(params.quickReplies?.length);
|
||||
let replyTokenUsed = Boolean(params.replyTokenUsed);
|
||||
|
||||
if (params.chunks.length === 0) {
|
||||
return { replyTokenUsed };
|
||||
}
|
||||
|
||||
if (params.replyToken && !replyTokenUsed) {
|
||||
try {
|
||||
const replyBatch = params.chunks.slice(0, 5);
|
||||
const remaining = params.chunks.slice(replyBatch.length);
|
||||
|
||||
const replyMessages: LineReplyMessage[] = replyBatch.map((chunk) => ({
|
||||
type: "text",
|
||||
text: chunk,
|
||||
}));
|
||||
|
||||
if (hasQuickReplies && remaining.length === 0 && replyMessages.length > 0) {
|
||||
const lastIndex = replyMessages.length - 1;
|
||||
replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(
|
||||
replyBatch[lastIndex],
|
||||
params.quickReplies!,
|
||||
);
|
||||
}
|
||||
|
||||
await params.replyMessageLine(params.replyToken, replyMessages, {
|
||||
cfg: params.cfg,
|
||||
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!,
|
||||
{ cfg: params.cfg, accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, remaining[i], {
|
||||
cfg: params.cfg,
|
||||
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!,
|
||||
{ cfg: params.cfg, accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, params.chunks[i], {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { replyTokenUsed };
|
||||
}
|
||||
393
extensions/line/src/reply-payload-transform.test.ts
Normal file
393
extensions/line/src/reply-payload-transform.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// Line tests cover reply payload transform plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
type TestFlexMessage = {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
|
||||
};
|
||||
|
||||
function requireFlexMessage(value: unknown, label: string): TestFlexMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`expected flex message for ${label}`);
|
||||
}
|
||||
return value as TestFlexMessage;
|
||||
}
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
{ text: "Here are options [[quick_replies: A, B, C]]", expected: true },
|
||||
{ text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true },
|
||||
{ text: "[[confirm: Continue? | Yes | No]]", expected: true },
|
||||
{ text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true },
|
||||
{ text: "Just regular text", expected: false },
|
||||
{ text: "[[not_a_directive: something]]", expected: false },
|
||||
{ text: "[[media_player: Song | Artist | Speaker]]", expected: true },
|
||||
{ text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true },
|
||||
{ text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true },
|
||||
{ text: "[[device: TV | Room]]", expected: true },
|
||||
{ text: "[[appletv_remote: Apple TV | Playing]]", expected: true },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(hasLineDirectives(testCase.text)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick replies variants", () => {
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { quickReplies: string[] } };
|
||||
quickReplies: string[];
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
quickReplies: ["Option A", "Option B", "Option C"],
|
||||
outputText: "Choose one:",
|
||||
},
|
||||
{
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
quickReplies: ["A", "B"],
|
||||
outputText: "Before After",
|
||||
},
|
||||
{
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
quickReplies: ["A", "B", "C", "D"],
|
||||
outputText: "Text",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("parses location variants", () => {
|
||||
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { location: typeof existing } };
|
||||
location?: typeof existing;
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
location: {
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
},
|
||||
outputText: "Here's the location:",
|
||||
},
|
||||
{
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
location: undefined,
|
||||
},
|
||||
{
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
location: existing,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).location).toEqual(testCase.location);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("parses confirm directives with default and custom action payloads", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default yes/no data",
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
{
|
||||
name: "custom action data",
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
expect(getLineData(result).templateMessage, testCase.name).toEqual(
|
||||
testCase.expectedTemplate,
|
||||
);
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttons", () => {
|
||||
it("parses message/uri/postback button actions and enforces action caps", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "message actions",
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
expectedTemplate: {
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uri action",
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
expectedFirstAction: {
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "postback action",
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
expectedFirstAction: {
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "action cap",
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
expectedActionCount: 4,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type, testCase.name).toBe("buttons");
|
||||
if ("expectedTemplate" in testCase) {
|
||||
expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate);
|
||||
}
|
||||
if ("expectedFirstAction" in testCase) {
|
||||
expect(templateMessage?.actions?.[0], testCase.name).toEqual(
|
||||
testCase.expectedFirstAction,
|
||||
);
|
||||
}
|
||||
if ("expectedActionCount" in testCase) {
|
||||
expect(templateMessage?.actions?.length, testCase.name).toBe(
|
||||
testCase.expectedActionCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_player", () => {
|
||||
it("parses media_player directives across full/minimal/paused variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "all fields",
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
expectedAltText: "🎵 Bohemian Rhapsody - Queen",
|
||||
expectedText: "Now playing:",
|
||||
expectFooter: true,
|
||||
expectBodyContents: false,
|
||||
},
|
||||
{
|
||||
name: "minimal",
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
expectedAltText: "🎵 Unknown Track",
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
expectBodyContents: false,
|
||||
},
|
||||
{
|
||||
name: "paused status",
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
expectedAltText: undefined,
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
expectBodyContents: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.name);
|
||||
if (testCase.expectedAltText !== undefined) {
|
||||
expect(flexMessage.altText, testCase.name).toBe(testCase.expectedAltText);
|
||||
}
|
||||
if (testCase.expectedText !== undefined) {
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
if (testCase.expectFooter) {
|
||||
expect(flexMessage.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
}
|
||||
if ("expectBodyContents" in testCase && testCase.expectBodyContents) {
|
||||
expect(Array.isArray(flexMessage.contents?.body?.contents), testCase.name).toBe(true);
|
||||
expect(flexMessage.contents?.body?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("parses event variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM",
|
||||
},
|
||||
{
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
altText: "📅 Birthday Party - March 15",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenda", () => {
|
||||
it("parses agenda variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
altText: "📋 Today's Schedule (3 events)",
|
||||
},
|
||||
{
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
altText: "📋 Tasks (3 events)",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("device", () => {
|
||||
it("parses device variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
altText: "📱 TV: Playing",
|
||||
},
|
||||
{
|
||||
text: "[[device: Speaker]]",
|
||||
altText: "📱 Speaker",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv remote variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
contains: "Apple TV",
|
||||
},
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
contains: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
if (testCase.contains) {
|
||||
expect(flexMessage.altText).toContain(testCase.contains);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined directives", () => {
|
||||
it("handles text with no directives", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Just plain text here",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Just plain text here");
|
||||
expect(getLineData(result).quickReplies).toBeUndefined();
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
expect(getLineData(result).templateMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other payload fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Hello [[quick_replies: A, B]]",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
replyToId: "msg123",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
|
||||
expect(result.replyToId).toBe("msg123");
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
extensions/line/src/reply-payload-transform.ts
Normal file
319
extensions/line/src/reply-payload-transform.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Line plugin module implements reply payload transform behavior.
|
||||
import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates.js";
|
||||
import type { LineChannelData } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||
*
|
||||
* Supported directives:
|
||||
* - [[quick_replies: option1, option2, option3]]
|
||||
* - [[location: title | address | latitude | longitude]]
|
||||
* - [[confirm: question | yes_label | no_label]]
|
||||
* - [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
* - [[event: title | date | time | location | description]]
|
||||
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
* - [[appletv_remote: name | status]]
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
if (!text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const result: ReplyPayload = { ...payload };
|
||||
const lineData: LineChannelData = {
|
||||
...(result.channelData?.line as LineChannelData | undefined),
|
||||
};
|
||||
const toSlug = (value: string): string =>
|
||||
normalizeLowercaseStringOrEmpty(value)
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "device";
|
||||
const lineActionData = (action: string, extras?: Record<string, string>): string => {
|
||||
const base = [`line.action=${encodeURIComponent(action)}`];
|
||||
if (extras) {
|
||||
for (const [key, value] of Object.entries(extras)) {
|
||||
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
return base.join("&");
|
||||
};
|
||||
|
||||
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||
if (quickRepliesMatch) {
|
||||
const options = normalizeStringEntries(quickRepliesMatch[1].split(","));
|
||||
if (options.length > 0) {
|
||||
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
|
||||
}
|
||||
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||
if (locationMatch && !lineData.location) {
|
||||
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 4) {
|
||||
const [title, address, latStr, lonStr] = parts;
|
||||
const latitude = parseStrictFiniteNumber(latStr);
|
||||
const longitude = parseStrictFiniteNumber(lonStr);
|
||||
if (latitude !== undefined && longitude !== undefined) {
|
||||
lineData.location = {
|
||||
title: title || "Location",
|
||||
address: address || "",
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(locationMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||
if (confirmMatch && !lineData.templateMessage) {
|
||||
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [question, yesPart, noPart] = parts;
|
||||
const [yesLabel, yesData] = yesPart.includes(":")
|
||||
? yesPart.split(":").map((s) => s.trim())
|
||||
: [yesPart, normalizeLowercaseStringOrEmpty(yesPart)];
|
||||
const [noLabel, noData] = noPart.includes(":")
|
||||
? noPart.split(":").map((s) => s.trim())
|
||||
: [noPart, normalizeLowercaseStringOrEmpty(noPart)];
|
||||
|
||||
lineData.templateMessage = {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel,
|
||||
confirmData: yesData,
|
||||
cancelLabel: noLabel,
|
||||
cancelData: noData,
|
||||
altText: question,
|
||||
};
|
||||
}
|
||||
text = text.replace(confirmMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||
if (buttonsMatch && !lineData.templateMessage) {
|
||||
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [title, bodyText, actionsStr] = parts;
|
||||
|
||||
const actions = actionsStr.split(",").map((actionStr) => {
|
||||
const trimmed = actionStr.trim();
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) {
|
||||
return -1;
|
||||
}
|
||||
return index;
|
||||
})();
|
||||
|
||||
let label: string;
|
||||
let data: string;
|
||||
|
||||
if (colonIndex === -1) {
|
||||
label = trimmed;
|
||||
data = trimmed;
|
||||
} else {
|
||||
label = trimmed.slice(0, colonIndex).trim();
|
||||
data = trimmed.slice(colonIndex + 1).trim();
|
||||
}
|
||||
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
return { type: "uri" as const, label, uri: data };
|
||||
}
|
||||
if (data.includes("=")) {
|
||||
return { type: "postback" as const, label, data };
|
||||
}
|
||||
return { type: "message" as const, label, data: data || label };
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
lineData.templateMessage = {
|
||||
type: "buttons",
|
||||
title,
|
||||
text: bodyText,
|
||||
actions: actions.slice(0, 4),
|
||||
altText: `${title}: ${bodyText}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(buttonsMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||
const isPlaying = normalizeLowercaseStringOrEmpty(statusStr) === "playing";
|
||||
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||
const deviceKey = toSlug(source || title || "media");
|
||||
const card = createMediaPlayerCard({
|
||||
title: title || "Unknown Track",
|
||||
subtitle: artist || undefined,
|
||||
source: source || undefined,
|
||||
imageUrl: validImageUrl,
|
||||
isPlaying: statusStr ? isPlaying : undefined,
|
||||
controls: {
|
||||
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
|
||||
play: { data: lineActionData("play", { "line.device": deviceKey }) },
|
||||
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
|
||||
next: { data: lineActionData("next", { "line.device": deviceKey }) },
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||
if (eventMatch && !lineData.flexMessage) {
|
||||
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, date, time, location, description] = parts;
|
||||
|
||||
const card = createEventCard({
|
||||
title: title || "Event",
|
||||
date: date || "TBD",
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(eventMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||
if (appleTvMatch && !lineData.flexMessage) {
|
||||
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, status] = parts;
|
||||
const deviceKey = toSlug(deviceName || "apple_tv");
|
||||
|
||||
const card = createAppleTvRemoteCard({
|
||||
deviceName: deviceName || "Apple TV",
|
||||
status: status || undefined,
|
||||
actionData: {
|
||||
up: lineActionData("up", { "line.device": deviceKey }),
|
||||
down: lineActionData("down", { "line.device": deviceKey }),
|
||||
left: lineActionData("left", { "line.device": deviceKey }),
|
||||
right: lineActionData("right", { "line.device": deviceKey }),
|
||||
select: lineActionData("select", { "line.device": deviceKey }),
|
||||
menu: lineActionData("menu", { "line.device": deviceKey }),
|
||||
home: lineActionData("home", { "line.device": deviceKey }),
|
||||
play: lineActionData("play", { "line.device": deviceKey }),
|
||||
pause: lineActionData("pause", { "line.device": deviceKey }),
|
||||
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
|
||||
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
|
||||
mute: lineActionData("mute", { "line.device": deviceKey }),
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📺 ${deviceName || "Apple TV"} Remote`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(appleTvMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||
if (agendaMatch && !lineData.flexMessage) {
|
||||
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, eventsStr] = parts;
|
||||
const events = eventsStr.split(",").map((eventStr) => {
|
||||
const trimmed = eventStr.trim();
|
||||
const colonIdx = trimmed.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
return {
|
||||
title: trimmed.slice(0, colonIdx).trim(),
|
||||
time: trimmed.slice(colonIdx + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { title: trimmed };
|
||||
});
|
||||
|
||||
const card = createAgendaCard({
|
||||
title: title || "Agenda",
|
||||
events,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📋 ${title} (${events.length} events)`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(agendaMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||
if (deviceMatch && !lineData.flexMessage) {
|
||||
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||
const deviceKey = toSlug(deviceName || "device");
|
||||
const controls = controlsStr
|
||||
? controlsStr.split(",").map((ctrlStr) => {
|
||||
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
|
||||
const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_");
|
||||
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
|
||||
})
|
||||
: [];
|
||||
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: deviceName || "Device",
|
||||
deviceType: deviceType || undefined,
|
||||
status: status || undefined,
|
||||
controls,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(deviceMatch[0], "").trim();
|
||||
}
|
||||
|
||||
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
result.text = text || undefined;
|
||||
if (Object.keys(lineData).length > 0) {
|
||||
result.channelData = { ...result.channelData, line: lineData };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hasLineDirectives(text: string): boolean {
|
||||
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
365
extensions/line/src/rich-menu.test.ts
Normal file
365
extensions/line/src/rich-menu.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// Line tests cover rich menu plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createRichMenu,
|
||||
createDefaultMenuConfig,
|
||||
createGridLayout,
|
||||
datetimePickerAction,
|
||||
messageAction,
|
||||
postbackAction,
|
||||
uploadRichMenuImage,
|
||||
uriAction,
|
||||
} from "./rich-menu.js";
|
||||
|
||||
const {
|
||||
createRichMenuMock,
|
||||
setRichMenuImageMock,
|
||||
MessagingApiClientMock,
|
||||
MessagingApiBlobClientMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createRichMenuMockLocal = vi.fn();
|
||||
const setRichMenuImageMockLocal = vi.fn();
|
||||
const MessagingApiClientMockLocal = vi.fn(function () {
|
||||
return { createRichMenu: createRichMenuMockLocal };
|
||||
});
|
||||
const MessagingApiBlobClientMockLocal = vi.fn(function () {
|
||||
return { setRichMenuImage: setRichMenuImageMockLocal };
|
||||
});
|
||||
return {
|
||||
createRichMenuMock: createRichMenuMockLocal,
|
||||
setRichMenuImageMock: setRichMenuImageMockLocal,
|
||||
MessagingApiClientMock: MessagingApiClientMockLocal,
|
||||
MessagingApiBlobClientMock: MessagingApiBlobClientMockLocal,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: {
|
||||
MessagingApiClient: MessagingApiClientMock,
|
||||
MessagingApiBlobClient: MessagingApiBlobClientMock,
|
||||
},
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("@line/bot-sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("messageAction", () => {
|
||||
it("creates message actions with explicit or default text", () => {
|
||||
const cases = [
|
||||
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
|
||||
{ name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const action = testCase.text
|
||||
? messageAction(testCase.label, testCase.text)
|
||||
: messageAction(testCase.label);
|
||||
expect(action.type, testCase.name).toBe("message");
|
||||
expect(action.label, testCase.name).toBe(testCase.label);
|
||||
expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("uriAction", () => {
|
||||
it("creates a URI action", () => {
|
||||
const action = uriAction("Open", "https://example.com");
|
||||
|
||||
expect(action.type).toBe("uri");
|
||||
expect(action.label).toBe("Open");
|
||||
expect((action as { uri: string }).uri).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action label truncation", () => {
|
||||
it.each([
|
||||
{
|
||||
createAction: () => messageAction("This is a very long label text"),
|
||||
expectedLabel: "This is a very long ",
|
||||
},
|
||||
{
|
||||
createAction: () => uriAction("Click here to visit our website", "https://example.com"),
|
||||
expectedLabel: "Click here to visit ",
|
||||
},
|
||||
])("truncates labels to 20 characters", ({ createAction, expectedLabel }) => {
|
||||
const action = createAction();
|
||||
expect(action.label).toBe(expectedLabel);
|
||||
expect((action.label ?? "").length).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postbackAction", () => {
|
||||
it("creates a postback action", () => {
|
||||
const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
|
||||
|
||||
expect(action.type).toBe("postback");
|
||||
expect(action.label).toBe("Select");
|
||||
expect((action as { data: string }).data).toBe("action=select&item=1");
|
||||
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
|
||||
});
|
||||
|
||||
it("applies postback payload truncation and displayText behavior", () => {
|
||||
const truncatedData = postbackAction("Test", "x".repeat(400));
|
||||
expect((truncatedData as { data: string }).data.length).toBe(300);
|
||||
|
||||
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
|
||||
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
|
||||
|
||||
const noDisplayText = postbackAction("Test", "data");
|
||||
expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datetimePickerAction", () => {
|
||||
it("creates picker actions for all supported modes", () => {
|
||||
const cases = [
|
||||
{ label: "Pick date", data: "date_picked", mode: "date" as const },
|
||||
{ label: "Pick time", data: "time_picked", mode: "time" as const },
|
||||
{ label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
|
||||
expect(action.type).toBe("datetimepicker");
|
||||
expect(action.label).toBe(testCase.label);
|
||||
expect((action as { mode: string }).mode).toBe(testCase.mode);
|
||||
expect((action as { data: string }).data).toBe(testCase.data);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes initial/min/max when provided", () => {
|
||||
const action = datetimePickerAction("Pick", "data", "date", {
|
||||
initial: "2024-06-15",
|
||||
min: "2024-01-01",
|
||||
max: "2024-12-31",
|
||||
});
|
||||
|
||||
expect((action as { initial: string }).initial).toBe("2024-06-15");
|
||||
expect((action as { min: string }).min).toBe("2024-01-01");
|
||||
expect((action as { max: string }).max).toBe("2024-12-31");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGridLayout", () => {
|
||||
function createSixSimpleActions() {
|
||||
return [
|
||||
messageAction("A1"),
|
||||
messageAction("A2"),
|
||||
messageAction("A3"),
|
||||
messageAction("A4"),
|
||||
messageAction("A5"),
|
||||
messageAction("A6"),
|
||||
] as [
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
];
|
||||
}
|
||||
|
||||
it("computes expected 2x3 layout for supported menu heights", () => {
|
||||
const actions = createSixSimpleActions();
|
||||
const cases = [
|
||||
{ height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
|
||||
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const areas = createGridLayout(testCase.height, actions);
|
||||
expect(areas.length).toBe(6);
|
||||
expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
|
||||
expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
|
||||
expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
|
||||
expect(areas[0]?.bounds.x).toBe(0);
|
||||
expect(areas[1]?.bounds.x).toBe(833);
|
||||
expect(areas[2]?.bounds.x).toBe(1666);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns correct actions to areas", () => {
|
||||
const actions = [
|
||||
messageAction("Help", "/help"),
|
||||
messageAction("Status", "/status"),
|
||||
messageAction("Settings", "/settings"),
|
||||
messageAction("About", "/about"),
|
||||
messageAction("Feedback", "/feedback"),
|
||||
messageAction("Contact", "/contact"),
|
||||
] as [
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
ReturnType<typeof messageAction>,
|
||||
];
|
||||
|
||||
const areas = createGridLayout(843, actions);
|
||||
|
||||
expect((areas[0].action as { text: string }).text).toBe("/help");
|
||||
expect((areas[1].action as { text: string }).text).toBe("/status");
|
||||
expect((areas[2].action as { text: string }).text).toBe("/settings");
|
||||
expect((areas[3].action as { text: string }).text).toBe("/about");
|
||||
expect((areas[4].action as { text: string }).text).toBe("/feedback");
|
||||
expect((areas[5].action as { text: string }).text).toBe("/contact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultMenuConfig", () => {
|
||||
it("creates a valid default menu configuration", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
expect(config.size.width).toBe(2500);
|
||||
expect(config.size.height).toBe(843);
|
||||
expect(config.selected).toBe(false);
|
||||
expect(config.name).toBe("Default Menu");
|
||||
expect(config.chatBarText).toBe("Menu");
|
||||
expect(config.areas.length).toBe(6);
|
||||
});
|
||||
|
||||
it("has valid area bounds", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
for (const area of config.areas) {
|
||||
expect(area.bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(area.bounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(area.bounds.width).toBeGreaterThan(0);
|
||||
expect(area.bounds.height).toBeGreaterThan(0);
|
||||
expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
|
||||
expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses message actions with expected default commands", () => {
|
||||
const config = createDefaultMenuConfig();
|
||||
|
||||
for (const area of config.areas) {
|
||||
expect(area.action.type).toBe("message");
|
||||
}
|
||||
const commands = config.areas.map((a) => (a.action as { text: string }).text);
|
||||
expect(commands).toContain("/help");
|
||||
expect(commands).toContain("/status");
|
||||
expect(commands).toContain("/settings");
|
||||
});
|
||||
});
|
||||
|
||||
const richMenuUploadCfg: OpenClawConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
channelAccessToken: "line-token",
|
||||
channelSecret: "line-secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("createRichMenu", () => {
|
||||
beforeEach(() => {
|
||||
createRichMenuMock.mockReset();
|
||||
createRichMenuMock.mockResolvedValue({ richMenuId: "rich-menu-1" });
|
||||
MessagingApiClientMock.mockClear();
|
||||
});
|
||||
|
||||
it("truncates names and chat bar text by grapheme cluster", async () => {
|
||||
const emoji = "😀";
|
||||
const familyEmoji = "👨👩👧👦";
|
||||
|
||||
await createRichMenu(
|
||||
{
|
||||
size: { width: 2500, height: 843 },
|
||||
name: emoji.repeat(301),
|
||||
chatBarText: familyEmoji.repeat(15),
|
||||
areas: [],
|
||||
},
|
||||
{ cfg: richMenuUploadCfg },
|
||||
);
|
||||
|
||||
expect(MessagingApiClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
|
||||
expect(createRichMenuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: emoji.repeat(300),
|
||||
chatBarText: familyEmoji.repeat(14),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadRichMenuImage", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-rich-menu-"));
|
||||
setRichMenuImageMock.mockReset();
|
||||
MessagingApiBlobClientMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loads local image paths through approved media localRoots", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const imagePath = path.join(workspaceDir, "menu.png");
|
||||
const imageBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
await fs.writeFile(imagePath, imageBytes);
|
||||
|
||||
await uploadRichMenuImage("rich-menu-1", imagePath, {
|
||||
cfg: richMenuUploadCfg,
|
||||
mediaLocalRoots: [workspaceDir],
|
||||
});
|
||||
|
||||
expect(MessagingApiBlobClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
|
||||
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
|
||||
const [richMenuId, blob] = setRichMenuImageMock.mock.calls[0] ?? [];
|
||||
expect(richMenuId).toBe("rich-menu-1");
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect((blob as Blob).type).toBe("image/png");
|
||||
await expect((blob as Blob).arrayBuffer()).resolves.toEqual(
|
||||
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects local image paths outside approved media localRoots before uploading", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
const outsideDir = path.join(tempRoot, "outside");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
const outsideImagePath = path.join(outsideDir, "menu.jpg");
|
||||
await fs.writeFile(outsideImagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
|
||||
await expect(
|
||||
uploadRichMenuImage("rich-menu-1", outsideImagePath, {
|
||||
cfg: richMenuUploadCfg,
|
||||
mediaLocalRoots: [workspaceDir],
|
||||
}),
|
||||
).rejects.toThrow(/Local media path is not under an allowed directory/i);
|
||||
|
||||
expect(setRichMenuImageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves extension-based content-type fallback for approved local paths", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const imagePath = path.join(workspaceDir, "menu.jpg");
|
||||
const imageBytes = Buffer.from("placeholder image bytes");
|
||||
await fs.writeFile(imagePath, imageBytes);
|
||||
|
||||
await uploadRichMenuImage("rich-menu-2", imagePath, {
|
||||
cfg: richMenuUploadCfg,
|
||||
mediaLocalRoots: [workspaceDir],
|
||||
});
|
||||
|
||||
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
|
||||
const blob = setRichMenuImageMock.mock.calls[0]?.[1] as Blob;
|
||||
expect(blob.type).toBe("image/jpeg");
|
||||
await expect(blob.arrayBuffer()).resolves.toEqual(
|
||||
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
|
||||
);
|
||||
});
|
||||
});
|
||||
342
extensions/line/src/rich-menu.ts
Normal file
342
extensions/line/src/rich-menu.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
// Line plugin module implements rich menu behavior.
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
|
||||
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
|
||||
|
||||
type RichMenuRequest = messagingApi.RichMenuRequest;
|
||||
type RichMenuResponse = messagingApi.RichMenuResponse;
|
||||
type RichMenuArea = messagingApi.RichMenuArea;
|
||||
type Action = messagingApi.Action;
|
||||
const USER_BATCH_SIZE = 500;
|
||||
// LINE counts rich-menu names and chat-bar text in grapheme clusters, unlike most message fields.
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
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 {
|
||||
cfg: OpenClawConfig;
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
}
|
||||
|
||||
function getClient(opts: RichMenuOpts): messagingApi.MessagingApiClient {
|
||||
const account = resolveLineAccount({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
|
||||
return new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
function getBlobClient(opts: RichMenuOpts): messagingApi.MessagingApiBlobClient {
|
||||
const account = resolveLineAccount({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
|
||||
return new messagingApi.MessagingApiBlobClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
function chunkUserIds(userIds: string[]): string[][] {
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < userIds.length; i += USER_BATCH_SIZE) {
|
||||
batches.push(userIds.slice(i, i + USER_BATCH_SIZE));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
function truncateGraphemes(input: string, maxLength: number): string {
|
||||
let result = "";
|
||||
let count = 0;
|
||||
for (const { segment } of graphemeSegmenter.segment(input)) {
|
||||
if (count >= maxLength) {
|
||||
break;
|
||||
}
|
||||
result += segment;
|
||||
count += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createRichMenu(
|
||||
menu: CreateRichMenuParams,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<string> {
|
||||
const client = getClient(opts);
|
||||
|
||||
const richMenuRequest: RichMenuRequest = {
|
||||
size: menu.size,
|
||||
selected: menu.selected ?? false,
|
||||
name: truncateGraphemes(menu.name, 300),
|
||||
chatBarText: truncateGraphemes(menu.chatBarText, 14),
|
||||
areas: menu.areas as RichMenuArea[],
|
||||
};
|
||||
|
||||
const response = await client.createRichMenu(richMenuRequest);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: created rich menu ${response.richMenuId}`);
|
||||
}
|
||||
|
||||
return response.richMenuId;
|
||||
}
|
||||
|
||||
export async function uploadRichMenuImage(
|
||||
richMenuId: string,
|
||||
imagePath: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const blobClient = getBlobClient(opts);
|
||||
|
||||
const media = await loadWebMediaRaw(imagePath, {
|
||||
localRoots: opts.mediaLocalRoots ?? getAgentScopedMediaLocalRoots(opts.cfg),
|
||||
});
|
||||
const contentType =
|
||||
media.contentType === "image/png" || media.contentType === "image/jpeg"
|
||||
? media.contentType
|
||||
: mimeTypeFromFilePath(imagePath) === "image/png"
|
||||
? "image/png"
|
||||
: "image/jpeg";
|
||||
|
||||
const imageBytes = new ArrayBuffer(media.buffer.byteLength);
|
||||
new Uint8Array(imageBytes).set(media.buffer);
|
||||
await blobClient.setRichMenuImage(richMenuId, new Blob([imageBytes], { type: contentType }));
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: uploaded image to rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.setDefaultRichMenu(richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: set default rich menu to ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDefaultRichMenu(opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.cancelDefaultRichMenu();
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose("line: cancelled default rich menu");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultRichMenuId(opts: RichMenuOpts): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
const response = await client.getDefaultRichMenuId();
|
||||
return response.richMenuId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkRichMenuToUser(
|
||||
userId: string,
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.linkRichMenuIdToUser(userId, richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: linked rich menu ${richMenuId} to user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkRichMenuToUsers(
|
||||
userIds: string[],
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
for (const batch of chunkUserIds(userIds)) {
|
||||
await client.linkRichMenuIdToUsers({
|
||||
richMenuId,
|
||||
userIds: batch,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkRichMenuFromUser(userId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.unlinkRichMenuIdFromUser(userId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: unlinked rich menu from user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkRichMenuFromUsers(
|
||||
userIds: string[],
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
for (const batch of chunkUserIds(userIds)) {
|
||||
await client.unlinkRichMenuIdFromUsers({
|
||||
userIds: batch,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: unlinked rich menu from ${userIds.length} users`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRichMenuIdOfUser(
|
||||
userId: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
const response = await client.getRichMenuIdOfUser(userId);
|
||||
return response.richMenuId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRichMenuList(opts: RichMenuOpts): Promise<RichMenuResponse[]> {
|
||||
const client = getClient(opts);
|
||||
const response = await client.getRichMenuList();
|
||||
return response.richmenus ?? [];
|
||||
}
|
||||
|
||||
export async function getRichMenu(
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<RichMenuResponse | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
return await client.getRichMenu(richMenuId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.deleteRichMenu(richMenuId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: deleted rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRichMenuAlias(
|
||||
richMenuId: string,
|
||||
aliasId: string,
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
await client.createRichMenuAlias({
|
||||
richMenuId,
|
||||
richMenuAliasId: aliasId,
|
||||
});
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: created alias ${aliasId} for rich menu ${richMenuId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.deleteRichMenuAlias(aliasId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(`line: deleted alias ${aliasId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGridLayout(
|
||||
height: 1686 | 843,
|
||||
actions: [Action, Action, Action, Action, Action, Action],
|
||||
): RichMenuAreaRequest[] {
|
||||
const colWidth = Math.floor(2500 / 3);
|
||||
const rowHeight = Math.floor(height / 2);
|
||||
|
||||
return [
|
||||
{ bounds: { x: 0, y: 0, width: colWidth, height: rowHeight }, action: actions[0] },
|
||||
{ bounds: { x: colWidth, y: 0, width: colWidth, height: rowHeight }, action: actions[1] },
|
||||
{ bounds: { x: colWidth * 2, y: 0, width: colWidth, height: rowHeight }, action: actions[2] },
|
||||
{ bounds: { x: 0, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[3] },
|
||||
{
|
||||
bounds: { x: colWidth, y: rowHeight, width: colWidth, height: rowHeight },
|
||||
action: actions[4],
|
||||
},
|
||||
{
|
||||
bounds: { x: colWidth * 2, y: rowHeight, width: colWidth, height: rowHeight },
|
||||
action: actions[5],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { datetimePickerAction, messageAction, postbackAction, uriAction };
|
||||
|
||||
export function createDefaultMenuConfig(): CreateRichMenuParams {
|
||||
return {
|
||||
size: { width: 2500, height: 843 },
|
||||
selected: false,
|
||||
name: "Default Menu",
|
||||
chatBarText: "Menu",
|
||||
areas: createGridLayout(843, [
|
||||
messageAction("Help", "/help"),
|
||||
messageAction("Status", "/status"),
|
||||
messageAction("Settings", "/settings"),
|
||||
messageAction("About", "/about"),
|
||||
messageAction("Feedback", "/feedback"),
|
||||
messageAction("Contact", "/contact"),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export type { RichMenuRequest, RichMenuResponse, RichMenuArea };
|
||||
33
extensions/line/src/runtime.ts
Normal file
33
extensions/line/src/runtime.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Line plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
type LineChannelRuntime = {
|
||||
buildTemplateMessageFromPayload?: typeof import("./template-messages.js").buildTemplateMessageFromPayload;
|
||||
createQuickReplyItems?: typeof import("./send.js").createQuickReplyItems;
|
||||
monitorLineProvider?: typeof import("./monitor.js").monitorLineProvider;
|
||||
pushFlexMessage?: typeof import("./send.js").pushFlexMessage;
|
||||
pushLocationMessage?: typeof import("./send.js").pushLocationMessage;
|
||||
pushMessageLine?: typeof import("./send.js").pushMessageLine;
|
||||
pushMessagesLine?: typeof import("./send.js").pushMessagesLine;
|
||||
pushTemplateMessage?: typeof import("./send.js").pushTemplateMessage;
|
||||
pushTextMessageWithQuickReplies?: typeof import("./send.js").pushTextMessageWithQuickReplies;
|
||||
resolveLineAccount?: typeof import("./accounts.js").resolveLineAccount;
|
||||
sendMessageLine?: typeof import("./send.js").sendMessageLine;
|
||||
};
|
||||
|
||||
type LineRuntime = PluginRuntime & {
|
||||
channel: PluginRuntime["channel"] & {
|
||||
line?: LineChannelRuntime;
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
setRuntime: setLineRuntime,
|
||||
clearRuntime: clearLineRuntime,
|
||||
getRuntime: getLineRuntime,
|
||||
} = createPluginRuntimeStore<LineRuntime>({
|
||||
pluginId: "line",
|
||||
errorMessage: "LINE runtime not initialized - plugin not registered",
|
||||
});
|
||||
export { clearLineRuntime, getLineRuntime, setLineRuntime };
|
||||
33
extensions/line/src/send-receipt.ts
Normal file
33
extensions/line/src/send-receipt.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Line plugin module implements send receipt behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
|
||||
export function createLineSendReceipt(params: {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
kind?: MessageReceiptPartKind;
|
||||
messageCount?: number;
|
||||
}): MessageReceipt {
|
||||
const messageId = params.messageId.trim();
|
||||
const chatId = params.chatId.trim();
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: messageId
|
||||
? [
|
||||
{
|
||||
channel: "line",
|
||||
messageId,
|
||||
chatId,
|
||||
conversationId: chatId,
|
||||
meta: {
|
||||
messageCount: params.messageCount ?? 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
...(chatId ? { threadId: chatId } : {}),
|
||||
kind: params.kind ?? "unknown",
|
||||
});
|
||||
}
|
||||
464
extensions/line/src/send.test.ts
Normal file
464
extensions/line/src/send.test.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
// Line tests cover send plugin behavior.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
pushMessageMock,
|
||||
replyMessageMock,
|
||||
showLoadingAnimationMock,
|
||||
getProfileMock,
|
||||
MessagingApiClientMock,
|
||||
requireRuntimeConfigMock,
|
||||
resolveLineAccountMock,
|
||||
resolveLineChannelAccessTokenMock,
|
||||
recordChannelActivityMock,
|
||||
logVerboseMock,
|
||||
resolvePinnedHostnameWithPolicyMock,
|
||||
} = vi.hoisted(() => {
|
||||
const pushMessageMockLocal = vi.fn();
|
||||
const replyMessageMockLocal = vi.fn();
|
||||
const showLoadingAnimationMockLocal = vi.fn();
|
||||
const getProfileMockLocal = vi.fn();
|
||||
const MessagingApiClientMockLocal = vi.fn(function () {
|
||||
return {
|
||||
pushMessage: pushMessageMockLocal,
|
||||
replyMessage: replyMessageMockLocal,
|
||||
showLoadingAnimation: showLoadingAnimationMockLocal,
|
||||
getProfile: getProfileMockLocal,
|
||||
};
|
||||
});
|
||||
const requireRuntimeConfigMockLocal = vi.fn((cfg: unknown) => cfg ?? {});
|
||||
const resolveLineAccountMockLocal = vi.fn(() => ({ accountId: "default" }));
|
||||
const resolveLineChannelAccessTokenMockLocal = vi.fn(() => "line-token");
|
||||
const recordChannelActivityMockLocal = vi.fn();
|
||||
const logVerboseMockLocal = vi.fn();
|
||||
const resolvePinnedHostnameWithPolicyMockLocal = vi.fn();
|
||||
return {
|
||||
pushMessageMock: pushMessageMockLocal,
|
||||
replyMessageMock: replyMessageMockLocal,
|
||||
showLoadingAnimationMock: showLoadingAnimationMockLocal,
|
||||
getProfileMock: getProfileMockLocal,
|
||||
MessagingApiClientMock: MessagingApiClientMockLocal,
|
||||
requireRuntimeConfigMock: requireRuntimeConfigMockLocal,
|
||||
resolveLineAccountMock: resolveLineAccountMockLocal,
|
||||
resolveLineChannelAccessTokenMock: resolveLineChannelAccessTokenMockLocal,
|
||||
recordChannelActivityMock: recordChannelActivityMockLocal,
|
||||
logVerboseMock: logVerboseMockLocal,
|
||||
resolvePinnedHostnameWithPolicyMock: resolvePinnedHostnameWithPolicyMockLocal,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
|
||||
requireRuntimeConfig: requireRuntimeConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveLineAccount: resolveLineAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("./channel-access-token.js", () => ({
|
||||
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-activity-runtime", () => ({
|
||||
recordChannelActivity: recordChannelActivityMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||||
"openclaw/plugin-sdk/runtime-env",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
logVerbose: logVerboseMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock,
|
||||
}));
|
||||
|
||||
let sendModule: typeof import("./send.js");
|
||||
|
||||
const LINE_TEST_CFG = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("LINE send helpers", () => {
|
||||
const fixedSentAt = 1_800_000_000_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
sendModule = await import("./send.js");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("@line/bot-sdk");
|
||||
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
|
||||
vi.doUnmock("./accounts.js");
|
||||
vi.doUnmock("./channel-access-token.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/channel-activity-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(fixedSentAt);
|
||||
pushMessageMock.mockReset();
|
||||
replyMessageMock.mockReset();
|
||||
showLoadingAnimationMock.mockReset();
|
||||
getProfileMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
requireRuntimeConfigMock.mockClear();
|
||||
resolveLineAccountMock.mockReset();
|
||||
resolveLineChannelAccessTokenMock.mockReset();
|
||||
recordChannelActivityMock.mockReset();
|
||||
logVerboseMock.mockReset();
|
||||
resolvePinnedHostnameWithPolicyMock.mockReset();
|
||||
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return {
|
||||
pushMessage: pushMessageMock,
|
||||
replyMessage: replyMessageMock,
|
||||
showLoadingAnimation: showLoadingAnimationMock,
|
||||
getProfile: getProfileMock,
|
||||
};
|
||||
});
|
||||
requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
|
||||
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
|
||||
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
|
||||
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
});
|
||||
pushMessageMock.mockResolvedValue({});
|
||||
replyMessageMock.mockResolvedValue({});
|
||||
showLoadingAnimationMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("limits quick reply items to 13", () => {
|
||||
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
|
||||
const quickReply = sendModule.createQuickReplyItems(labels);
|
||||
|
||||
expect(quickReply.items).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("truncates quick reply labels without leaving lone surrogates", () => {
|
||||
const label = "1234567890123456789😀";
|
||||
const quickReply = sendModule.createQuickReplyItems([label]);
|
||||
const item = quickReply.items?.[0] as { action: { label: string; text: string } } | undefined;
|
||||
|
||||
expect(item?.action.label).toBe("1234567890123456789");
|
||||
expect(item?.action.text).toBe(label);
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(item?.action.label ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("pushes images via normalized LINE target", async () => {
|
||||
const result = await sendModule.pushImageMessage(
|
||||
"line:user:U123",
|
||||
"https://example.com/original.jpg",
|
||||
undefined,
|
||||
{ cfg: LINE_TEST_CFG, verbose: true },
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
to: "U123",
|
||||
messages: [
|
||||
{
|
||||
type: "image",
|
||||
originalContentUrl: "https://example.com/original.jpg",
|
||||
previewImageUrl: "https://example.com/original.jpg",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
|
||||
expect(result).toEqual({
|
||||
chatId: "U123",
|
||||
messageId: "push",
|
||||
receipt: {
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "media",
|
||||
platformMessageId: "push",
|
||||
raw: {
|
||||
channel: "line",
|
||||
chatId: "U123",
|
||||
conversationId: "U123",
|
||||
messageId: "push",
|
||||
meta: { messageCount: 1 },
|
||||
},
|
||||
threadId: "U123",
|
||||
},
|
||||
],
|
||||
platformMessageIds: ["push"],
|
||||
primaryPlatformMessageId: "push",
|
||||
raw: [
|
||||
{
|
||||
channel: "line",
|
||||
chatId: "U123",
|
||||
conversationId: "U123",
|
||||
messageId: "push",
|
||||
meta: { messageCount: 1 },
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
threadId: "U123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("replies when reply token is provided", async () => {
|
||||
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
replyToken: "reply-token",
|
||||
mediaUrl: "https://example.com/media.jpg",
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
expect(replyMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||||
expect(replyMessageMock).toHaveBeenCalledWith({
|
||||
replyToken: "reply-token",
|
||||
messages: [
|
||||
{
|
||||
type: "image",
|
||||
originalContentUrl: "https://example.com/media.jpg",
|
||||
previewImageUrl: "https://example.com/media.jpg",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
|
||||
expect(result).toEqual({
|
||||
chatId: "C1",
|
||||
messageId: "reply",
|
||||
receipt: {
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "media",
|
||||
platformMessageId: "reply",
|
||||
raw: {
|
||||
channel: "line",
|
||||
chatId: "C1",
|
||||
conversationId: "C1",
|
||||
messageId: "reply",
|
||||
meta: { messageCount: 2 },
|
||||
},
|
||||
threadId: "C1",
|
||||
},
|
||||
],
|
||||
platformMessageIds: ["reply"],
|
||||
primaryPlatformMessageId: "reply",
|
||||
raw: [
|
||||
{
|
||||
channel: "line",
|
||||
chatId: "C1",
|
||||
conversationId: "C1",
|
||||
messageId: "reply",
|
||||
meta: { messageCount: 2 },
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
threadId: "C1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends video with explicit image preview URL", async () => {
|
||||
await sendModule.sendMessageLine("line:user:U100", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-1",
|
||||
});
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
to: "U100",
|
||||
messages: [
|
||||
{
|
||||
type: "video",
|
||||
originalContentUrl: "https://example.com/video.mp4",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-1",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Video",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when video preview URL is missing", async () => {
|
||||
await expect(
|
||||
sendModule.sendMessageLine("line:user:U200", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
}),
|
||||
).rejects.toThrow(/require previewimageurl/i);
|
||||
});
|
||||
|
||||
it("blocks private-network media URLs before calling LINE", async () => {
|
||||
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked private network target"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendModule.sendMessageLine("line:user:U200", "Image", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://127.0.0.1/image.jpg",
|
||||
}),
|
||||
).rejects.toThrow(/private network/i);
|
||||
|
||||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits trackingId for non-user destinations", async () => {
|
||||
await sendModule.sendMessageLine("line:group:C100", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
trackingId: "track-group",
|
||||
});
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
to: "C100",
|
||||
messages: [
|
||||
{
|
||||
type: "video",
|
||||
originalContentUrl: "https://example.com/video.mp4",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Video",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when push messages are empty", async () => {
|
||||
await expect(sendModule.pushMessagesLine("U123", [], { cfg: LINE_TEST_CFG })).rejects.toThrow(
|
||||
"Message must be non-empty for LINE sends",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects lowercased LINE-shaped recipients (#81628 safety net)", async () => {
|
||||
// 33-char value with lowercase leading char — what an upstream session-key
|
||||
// fragment looked like before the cron-tool fix. LINE rejects with HTTP 400
|
||||
// anyway; throwing locally keeps the failure permanent so delivery-recovery
|
||||
// moves the entry to failed/ immediately instead of silently retrying 5×.
|
||||
await expect(
|
||||
sendModule.pushMessagesLine(
|
||||
"cabcdef0123456789abcdef0123456789",
|
||||
[{ type: "text", text: "hello" }],
|
||||
{ cfg: LINE_TEST_CFG },
|
||||
),
|
||||
).rejects.toThrow(/Recipient is not a valid LINE id/);
|
||||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts case-exact LINE recipients with the leading capital preserved", async () => {
|
||||
await sendModule.pushMessagesLine(
|
||||
"Cabcdef0123456789abcdef0123456789",
|
||||
[{ type: "text", text: "hello" }],
|
||||
{ cfg: LINE_TEST_CFG },
|
||||
);
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
to: "Cabcdef0123456789abcdef0123456789",
|
||||
messages: [{ type: "text", text: "hello" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("logs HTTP body when push fails", async () => {
|
||||
const err = new Error("LINE push failed") as Error & {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: string;
|
||||
};
|
||||
err.status = 400;
|
||||
err.statusText = "Bad Request";
|
||||
err.body = "invalid flex payload";
|
||||
pushMessageMock.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(
|
||||
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
}),
|
||||
).rejects.toThrow("LINE push failed");
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
"line: push message failed (400 Bad Request): invalid flex payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("caches profile results by default", async () => {
|
||||
getProfileMock.mockResolvedValue({
|
||||
displayName: "Peter",
|
||||
pictureUrl: "https://example.com/peter.jpg",
|
||||
});
|
||||
|
||||
const first = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||||
const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||||
|
||||
expect(first).toEqual({
|
||||
displayName: "Peter",
|
||||
pictureUrl: "https://example.com/peter.jpg",
|
||||
});
|
||||
expect(second).toEqual(first);
|
||||
expect(getProfileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues when loading animation is unsupported", async () => {
|
||||
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
|
||||
|
||||
await expect(
|
||||
sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
"line: loading animation failed (non-fatal): Error: unsupported",
|
||||
);
|
||||
});
|
||||
|
||||
it("pushes quick-reply text and caps to 13 buttons", async () => {
|
||||
await sendModule.pushTextMessageWithQuickReplies(
|
||||
"U-quick",
|
||||
"Pick one",
|
||||
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
|
||||
{ cfg: LINE_TEST_CFG },
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledTimes(1);
|
||||
const firstCall = pushMessageMock.mock.calls.at(0) as [
|
||||
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
|
||||
];
|
||||
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
|
||||
});
|
||||
});
|
||||
529
extensions/line/src/send.ts
Normal file
529
extensions/line/src/send.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
// Line plugin module implements send behavior.
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { messageAction } from "./actions.js";
|
||||
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
|
||||
import { validateLineMediaUrl } from "./outbound-media.js";
|
||||
import { createLineSendReceipt } from "./send-receipt.js";
|
||||
import type { LineSendResult } from "./types.js";
|
||||
|
||||
type Message = messagingApi.Message;
|
||||
type TextMessage = messagingApi.TextMessage;
|
||||
type ImageMessage = messagingApi.ImageMessage;
|
||||
type VideoMessage = messagingApi.VideoMessage & { trackingId?: string };
|
||||
type AudioMessage = messagingApi.AudioMessage;
|
||||
type LocationMessage = messagingApi.LocationMessage;
|
||||
type FlexMessage = messagingApi.FlexMessage;
|
||||
type FlexContainer = messagingApi.FlexContainer;
|
||||
type TemplateMessage = messagingApi.TemplateMessage;
|
||||
type QuickReply = messagingApi.QuickReply;
|
||||
type QuickReplyItem = messagingApi.QuickReplyItem;
|
||||
|
||||
const userProfileCache = new Map<
|
||||
string,
|
||||
{ displayName: string; pictureUrl?: string; fetchedAt: number }
|
||||
>();
|
||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface LineSendOpts {
|
||||
cfg: OpenClawConfig;
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaKind?: "image" | "video" | "audio";
|
||||
previewImageUrl?: string;
|
||||
durationMs?: number;
|
||||
trackingId?: string;
|
||||
replyToken?: string;
|
||||
}
|
||||
|
||||
type LineClientOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
|
||||
type LinePushOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId" | "verbose">;
|
||||
|
||||
interface LinePushBehavior {
|
||||
errorContext?: string;
|
||||
verboseMessage?: (chatId: string, messageCount: number) => string;
|
||||
}
|
||||
|
||||
interface LineReplyBehavior {
|
||||
verboseMessage?: (messageCount: number) => string;
|
||||
}
|
||||
|
||||
function normalizeTarget(to: string): string {
|
||||
const trimmed = to.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for LINE sends");
|
||||
}
|
||||
|
||||
const normalized = trimmed
|
||||
.replace(/^line:group:/i, "")
|
||||
.replace(/^line:room:/i, "")
|
||||
.replace(/^line:user:/i, "")
|
||||
.replace(/^line:/i, "");
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Recipient is required for LINE sends");
|
||||
}
|
||||
|
||||
// Real LINE chat ids are a capital C/U/R followed by 32 lowercase hex chars
|
||||
// (33 chars total) and are case-sensitive — push returns HTTP 400 otherwise.
|
||||
// Reject values that match the LINE id shape but lost their leading capital
|
||||
// so the failure is surfaced as a permanent error (recovery moves the entry
|
||||
// to failed/ immediately instead of silently retrying 5 times). Short test
|
||||
// fixtures (e.g. "U123") are left alone. openclaw/openclaw#81628
|
||||
if (normalized.length >= 33 && !/^[CUR]/.test(normalized)) {
|
||||
throw new Error(
|
||||
`Recipient is not a valid LINE id (case-sensitive; expected leading capital C/U/R): ${normalized.slice(0, 4)}…`,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isLineUserChatId(chatId: string): boolean {
|
||||
return /^U/i.test(chatId);
|
||||
}
|
||||
|
||||
function createLineMessagingClient(opts: LineClientOpts): {
|
||||
account: ReturnType<typeof resolveLineAccount>;
|
||||
client: messagingApi.MessagingApiClient;
|
||||
} {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "LINE send");
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
const client = new messagingApi.MessagingApiClient({
|
||||
channelAccessToken: token,
|
||||
});
|
||||
return { account, client };
|
||||
}
|
||||
|
||||
function createLinePushContext(
|
||||
to: string,
|
||||
opts: LineClientOpts,
|
||||
): {
|
||||
account: ReturnType<typeof resolveLineAccount>;
|
||||
client: messagingApi.MessagingApiClient;
|
||||
chatId: string;
|
||||
} {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
const chatId = normalizeTarget(to);
|
||||
return { account, client, chatId };
|
||||
}
|
||||
|
||||
function createTextMessage(text: string): TextMessage {
|
||||
return { type: "text", text };
|
||||
}
|
||||
|
||||
export function createImageMessage(
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
): ImageMessage {
|
||||
return {
|
||||
type: "image",
|
||||
originalContentUrl,
|
||||
previewImageUrl: previewImageUrl ?? originalContentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function createVideoMessage(
|
||||
originalContentUrl: string,
|
||||
previewImageUrl: string,
|
||||
trackingId?: string,
|
||||
): VideoMessage {
|
||||
return {
|
||||
type: "video",
|
||||
originalContentUrl,
|
||||
previewImageUrl,
|
||||
...(trackingId ? { trackingId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAudioMessage(originalContentUrl: string, durationMs: number): AudioMessage {
|
||||
return {
|
||||
type: "audio",
|
||||
originalContentUrl,
|
||||
duration: durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocationMessage(location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}): LocationMessage {
|
||||
return {
|
||||
type: "location",
|
||||
title: truncateUtf16Safe(location.title, 100),
|
||||
address: truncateUtf16Safe(location.address, 100),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
function logLineHttpError(err: unknown, context: string): void {
|
||||
if (!err || typeof err !== "object") {
|
||||
return;
|
||||
}
|
||||
const { status, statusText, body } = err as {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
body?: string;
|
||||
};
|
||||
if (typeof body === "string") {
|
||||
const summary = status ? `${status} ${statusText ?? ""}`.trim() : "unknown status";
|
||||
logVerbose(`line: ${context} failed (${summary}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
function recordLineOutboundActivity(accountId: string): void {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLineReceiptKind(messages: readonly Message[]) {
|
||||
const types = new Set(messages.map((message) => message.type));
|
||||
if (types.has("audio")) {
|
||||
return "voice";
|
||||
}
|
||||
if (types.has("image") || types.has("video")) {
|
||||
return "media";
|
||||
}
|
||||
if (types.has("flex") || types.has("template") || types.has("location")) {
|
||||
return "card";
|
||||
}
|
||||
if (types.has("text")) {
|
||||
return "text";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function pushLineMessages(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts,
|
||||
behavior: LinePushBehavior = {},
|
||||
): Promise<LineSendResult> {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Message must be non-empty for LINE sends");
|
||||
}
|
||||
|
||||
const { account, client, chatId } = createLinePushContext(to, opts);
|
||||
const pushRequest = client.pushMessage({
|
||||
to: chatId,
|
||||
messages,
|
||||
});
|
||||
|
||||
if (behavior.errorContext) {
|
||||
await pushRequest.catch((err: unknown) => {
|
||||
logLineHttpError(err, behavior.errorContext!);
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
await pushRequest;
|
||||
}
|
||||
|
||||
recordLineOutboundActivity(account.accountId);
|
||||
|
||||
if (opts.verbose) {
|
||||
const logMessage =
|
||||
behavior.verboseMessage?.(chatId, messages.length) ??
|
||||
`line: pushed ${messages.length} messages to ${chatId}`;
|
||||
logVerbose(logMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: "push",
|
||||
chatId,
|
||||
receipt: createLineSendReceipt({
|
||||
messageId: "push",
|
||||
chatId,
|
||||
kind: resolveLineReceiptKind(messages),
|
||||
messageCount: messages.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function replyLineMessages(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts,
|
||||
behavior: LineReplyBehavior = {},
|
||||
): Promise<void> {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
|
||||
await client.replyMessage({
|
||||
replyToken,
|
||||
messages,
|
||||
});
|
||||
|
||||
recordLineOutboundActivity(account.accountId);
|
||||
|
||||
if (opts.verbose) {
|
||||
logVerbose(
|
||||
behavior.verboseMessage?.(messages.length) ??
|
||||
`line: replied with ${messages.length} messages`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const chatId = normalizeTarget(to);
|
||||
const messages: Message[] = [];
|
||||
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
await validateLineMediaUrl(mediaUrl);
|
||||
switch (opts.mediaKind) {
|
||||
case "video": {
|
||||
const previewImageUrl = opts.previewImageUrl?.trim();
|
||||
if (!previewImageUrl) {
|
||||
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
|
||||
}
|
||||
await validateLineMediaUrl(previewImageUrl);
|
||||
const trackingId = isLineUserChatId(chatId) ? opts.trackingId : undefined;
|
||||
messages.push(createVideoMessage(mediaUrl, previewImageUrl, trackingId));
|
||||
break;
|
||||
}
|
||||
case "audio":
|
||||
messages.push(createAudioMessage(mediaUrl, opts.durationMs ?? 60000));
|
||||
break;
|
||||
default:
|
||||
// Backward compatibility: keep image as default when media kind is unspecified.
|
||||
{
|
||||
const previewImageUrl = opts.previewImageUrl?.trim() || mediaUrl;
|
||||
await validateLineMediaUrl(previewImageUrl);
|
||||
messages.push(createImageMessage(mediaUrl, previewImageUrl));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (text?.trim()) {
|
||||
messages.push(createTextMessage(text.trim()));
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Message must be non-empty for LINE sends");
|
||||
}
|
||||
|
||||
if (opts.replyToken) {
|
||||
await replyLineMessages(opts.replyToken, messages, opts, {
|
||||
verboseMessage: () => `line: replied to ${chatId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: "reply",
|
||||
chatId,
|
||||
receipt: createLineSendReceipt({
|
||||
messageId: "reply",
|
||||
chatId,
|
||||
kind: resolveLineReceiptKind(messages),
|
||||
messageCount: messages.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return pushLineMessages(chatId, messages, opts, {
|
||||
verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
|
||||
}
|
||||
|
||||
export async function replyMessageLine(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts,
|
||||
): Promise<void> {
|
||||
await replyLineMessages(replyToken, messages, opts);
|
||||
}
|
||||
|
||||
export async function pushMessagesLine(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, messages, opts, {
|
||||
errorContext: "push message",
|
||||
});
|
||||
}
|
||||
|
||||
export function createFlexMessage(
|
||||
altText: string,
|
||||
contents: messagingApi.FlexContainer,
|
||||
): messagingApi.FlexMessage {
|
||||
return {
|
||||
type: "flex",
|
||||
altText,
|
||||
contents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pushImageMessage(
|
||||
to: string,
|
||||
originalContentUrl: string,
|
||||
previewImageUrl: string | undefined,
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
await validateLineMediaUrl(originalContentUrl);
|
||||
if (previewImageUrl) {
|
||||
await validateLineMediaUrl(previewImageUrl);
|
||||
}
|
||||
return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed image to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushLocationMessage(
|
||||
to: string,
|
||||
location: {
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [createLocationMessage(location)], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed location to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushFlexMessage(
|
||||
to: string,
|
||||
altText: string,
|
||||
contents: FlexContainer,
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const flexMessage: FlexMessage = {
|
||||
type: "flex",
|
||||
altText: truncateUtf16Safe(altText, 400),
|
||||
contents,
|
||||
};
|
||||
|
||||
return pushLineMessages(to, [flexMessage], opts, {
|
||||
errorContext: "push flex message",
|
||||
verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushTemplateMessage(
|
||||
to: string,
|
||||
template: TemplateMessage,
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [template], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed template message to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushTextMessageWithQuickReplies(
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplyLabels: string[],
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
|
||||
|
||||
return pushLineMessages(to, [message], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function createQuickReplyItems(labels: string[]): QuickReply {
|
||||
const items: QuickReplyItem[] = labels.slice(0, 13).map((label) => ({
|
||||
type: "action",
|
||||
action: messageAction(label, label),
|
||||
}));
|
||||
return { items };
|
||||
}
|
||||
|
||||
export function createTextMessageWithQuickReplies(
|
||||
text: string,
|
||||
quickReplyLabels: string[],
|
||||
): TextMessage & { quickReply: QuickReply } {
|
||||
return {
|
||||
type: "text",
|
||||
text,
|
||||
quickReply: createQuickReplyItems(quickReplyLabels),
|
||||
};
|
||||
}
|
||||
|
||||
export async function showLoadingAnimation(
|
||||
chatId: string,
|
||||
opts: LineClientOpts & { loadingSeconds?: number },
|
||||
): Promise<void> {
|
||||
const { client } = createLineMessagingClient(opts);
|
||||
|
||||
try {
|
||||
await client.showLoadingAnimation({
|
||||
chatId: normalizeTarget(chatId),
|
||||
loadingSeconds: opts.loadingSeconds ?? 20,
|
||||
});
|
||||
logVerbose(`line: showing loading animation to ${chatId}`);
|
||||
} catch (err) {
|
||||
logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserProfile(
|
||||
userId: string,
|
||||
opts: LineClientOpts & { useCache?: boolean },
|
||||
): Promise<{ displayName: string; pictureUrl?: string } | null> {
|
||||
const useCache = opts.useCache ?? true;
|
||||
|
||||
if (useCache) {
|
||||
const cached = userProfileCache.get(userId);
|
||||
if (cached && Date.now() - cached.fetchedAt < PROFILE_CACHE_TTL_MS) {
|
||||
return { displayName: cached.displayName, pictureUrl: cached.pictureUrl };
|
||||
}
|
||||
}
|
||||
|
||||
const { client } = createLineMessagingClient(opts);
|
||||
|
||||
try {
|
||||
const profile = await client.getProfile(userId);
|
||||
const result = {
|
||||
displayName: profile.displayName,
|
||||
pictureUrl: profile.pictureUrl,
|
||||
};
|
||||
|
||||
userProfileCache.set(userId, {
|
||||
...result,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(userId: string, opts: LineClientOpts): Promise<string> {
|
||||
const profile = await getUserProfile(userId, opts);
|
||||
return profile?.displayName ?? userId;
|
||||
}
|
||||
150
extensions/line/src/setup-core.ts
Normal file
150
extensions/line/src/setup-core.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Line plugin module implements setup core behavior.
|
||||
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
|
||||
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup";
|
||||
import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listLineAccountIds,
|
||||
normalizeAccountId,
|
||||
resolveLineAccount,
|
||||
type LineConfig,
|
||||
} from "./setup-runtime-api.js";
|
||||
|
||||
export function patchLineAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
clearFields?: string[];
|
||||
enabled?: boolean;
|
||||
}): OpenClawConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig;
|
||||
const clearFields = params.clearFields ?? [];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const nextLine = { ...lineConfig } as Record<string, unknown>;
|
||||
for (const field of clearFields) {
|
||||
delete nextLine[field];
|
||||
}
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...params.cfg.channels,
|
||||
line: {
|
||||
...nextLine,
|
||||
...(params.enabled ? { enabled: true } : {}),
|
||||
...params.patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextAccount = {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
} as Record<string, unknown>;
|
||||
for (const field of clearFields) {
|
||||
delete nextAccount[field];
|
||||
}
|
||||
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...params.cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
...(params.enabled ? { enabled: true } : {}),
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...nextAccount,
|
||||
...(params.enabled ? { enabled: true } : {}),
|
||||
...params.patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {
|
||||
return hasLineCredentials(resolveLineAccount({ cfg, accountId }));
|
||||
}
|
||||
|
||||
export { parseLineAllowFromId };
|
||||
|
||||
export const lineSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch: name?.trim() ? { name: name.trim() } : {},
|
||||
}),
|
||||
validateInput: createSetupInputPresenceValidator({
|
||||
defaultAccountOnlyEnvError:
|
||||
"LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
|
||||
whenNotUseEnv: [
|
||||
{
|
||||
someOf: ["channelAccessToken", "tokenFile"],
|
||||
message: "LINE requires channelAccessToken or --token-file (or --use-env).",
|
||||
},
|
||||
{
|
||||
someOf: ["channelSecret", "secretFile"],
|
||||
message: "LINE requires channelSecret or --secret-file (or --use-env).",
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
useEnv?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
return patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId: normalizedAccountId,
|
||||
enabled: true,
|
||||
clearFields: typedInput.useEnv
|
||||
? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"]
|
||||
: undefined,
|
||||
patch: typedInput.useEnv
|
||||
? {}
|
||||
: {
|
||||
...(typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId: normalizedAccountId,
|
||||
enabled: true,
|
||||
patch: {
|
||||
...(typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export { listLineAccountIds };
|
||||
10
extensions/line/src/setup-runtime-api.ts
Normal file
10
extensions/line/src/setup-runtime-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Line API module exposes the plugin public contract.
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js";
|
||||
export type { LineConfig } from "./types.js";
|
||||
485
extensions/line/src/setup-surface.test.ts
Normal file
485
extensions/line/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
// Line tests cover setup surface plugin behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import ts from "typescript";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { probeLineBot } from "./probe.js";
|
||||
import { clearLineRuntime, setLineRuntime } from "./runtime.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
|
||||
const getBotInfoMockLocal = vi.fn();
|
||||
const MessagingApiClientMockLocal = vi.fn(function () {
|
||||
return { getBotInfo: getBotInfoMockLocal };
|
||||
});
|
||||
return {
|
||||
getBotInfoMock: getBotInfoMockLocal,
|
||||
MessagingApiClientMock: MessagingApiClientMockLocal,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("@line/bot-sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const lineConfigure = createPluginSetupWizardConfigure(linePlugin);
|
||||
const LINE_SRC_PREFIX = `../../${bundledPluginRoot("line")}/src/`;
|
||||
|
||||
function normalizeModuleSpecifier(specifier: string): string | null {
|
||||
if (specifier.startsWith("./src/")) {
|
||||
return specifier;
|
||||
}
|
||||
if (specifier.startsWith(LINE_SRC_PREFIX)) {
|
||||
return `./src/${specifier.slice(LINE_SRC_PREFIX.length)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectModuleExportNames(filePath: string): string[] {
|
||||
const sourcePath = filePath.replace(/\.js$/, ".ts");
|
||||
const sourceText = readFileSync(sourcePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause)
|
||||
) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
names.add(element.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
||||
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (!isExported) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isFunctionDeclaration(statement) ||
|
||||
ts.isClassDeclaration(statement) ||
|
||||
ts.isEnumDeclaration(statement)
|
||||
) {
|
||||
if (statement.name) {
|
||||
names.add(statement.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names).toSorted();
|
||||
}
|
||||
|
||||
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
|
||||
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
|
||||
const runtimeApiFile = ts.createSourceFile(
|
||||
runtimeApiPath,
|
||||
runtimeApiSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const preExports = new Set<string>();
|
||||
let pluginSdkLineRuntimeSeen = false;
|
||||
const removedLineRuntimeSpecifier = ["openclaw", "plugin-sdk", "line-runtime"].join("/");
|
||||
|
||||
for (const statement of runtimeApiFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!moduleSpecifier) {
|
||||
continue;
|
||||
}
|
||||
if (moduleSpecifier === removedLineRuntimeSpecifier) {
|
||||
pluginSdkLineRuntimeSeen = true;
|
||||
break;
|
||||
}
|
||||
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!statement.exportClause) {
|
||||
for (const name of collectModuleExportNames(
|
||||
path.join(process.cwd(), "extensions", "line", normalized),
|
||||
)) {
|
||||
preExports.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isNamedExports(statement.exportClause)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
preExports.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pluginSdkLineRuntimeSeen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(preExports).toSorted();
|
||||
}
|
||||
|
||||
describe("line setup wizard", () => {
|
||||
it("configures token and secret for the default account", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Enter LINE channel access token") {
|
||||
return "line-token";
|
||||
}
|
||||
if (message === "Enter LINE channel secret") {
|
||||
return "line-secret";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: lineConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.line?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.line?.channelAccessToken).toBe("line-token");
|
||||
expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret");
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
lineSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
line: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"work",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(lineSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual({
|
||||
policyKey: "channels.line.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.line.accounts.work.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "disabled",
|
||||
allowFrom: ["Uroot"],
|
||||
accounts: {
|
||||
work: {
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(lineSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(lineSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.line.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.line.accounts.work.allowFrom",
|
||||
});
|
||||
|
||||
const next = lineSetupWizard.dmPolicy?.setPolicy(cfg, "open");
|
||||
const workAccount = next?.channels?.line?.accounts?.work as
|
||||
| {
|
||||
dmPolicy?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(next?.channels?.line?.dmPolicy).toBe("disabled");
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = lineSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
line: {
|
||||
allowFrom: ["Uroot"],
|
||||
accounts: {
|
||||
work: {
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"work",
|
||||
);
|
||||
|
||||
const workAccount = next?.channels?.line?.accounts?.work as
|
||||
| {
|
||||
dmPolicy?: string;
|
||||
allowFrom?: string[];
|
||||
}
|
||||
| undefined;
|
||||
expect(next?.channels?.line?.dmPolicy).toBeUndefined();
|
||||
expect(next?.channels?.line?.allowFrom).toEqual(["Uroot"]);
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
expect(workAccount?.allowFrom).toEqual(["Uroot", "*"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const configured = await lineSetupWizard.status.resolveConfigured({
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "work",
|
||||
channelAccessToken: "root-token",
|
||||
channelSecret: "root-secret",
|
||||
accounts: {
|
||||
alerts: {
|
||||
channelAccessToken: "alerts-token",
|
||||
channelSecret: "alerts-secret",
|
||||
},
|
||||
work: {
|
||||
channelAccessToken: "",
|
||||
channelSecret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
beforeEach(() => {
|
||||
getBotInfoMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearLineRuntime();
|
||||
vi.useRealTimers();
|
||||
getBotInfoMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns timeout when bot info stalls", async () => {
|
||||
vi.useFakeTimers();
|
||||
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const probePromise = probeLineBot("token", 10);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("timeout");
|
||||
});
|
||||
|
||||
it("returns bot info when available", async () => {
|
||||
getBotInfoMock.mockResolvedValue({
|
||||
displayName: "OpenClaw",
|
||||
userId: "U123",
|
||||
basicId: "@openclaw",
|
||||
pictureUrl: "https://example.com/bot.png",
|
||||
});
|
||||
|
||||
const result = await probeLineBot("token", 50);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.bot?.userId).toBe("U123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin status.probeAccount", () => {
|
||||
it("falls back to the direct probe helper when runtime is not initialized", async () => {
|
||||
MessagingApiClientMock.mockReset();
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
getBotInfoMock.mockResolvedValue({
|
||||
displayName: "OpenClaw",
|
||||
userId: "U123",
|
||||
basicId: "@openclaw",
|
||||
pictureUrl: "https://example.com/bot.png",
|
||||
});
|
||||
|
||||
const params = {
|
||||
cfg: {} as OpenClawConfig,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
} as ResolvedLineAccount,
|
||||
timeoutMs: 50,
|
||||
};
|
||||
|
||||
clearLineRuntime();
|
||||
|
||||
await expect(lineStatusAdapter.probeAccount!(params)).resolves.toEqual(
|
||||
await probeLineBot("token", 50),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line runtime api", () => {
|
||||
it("keeps the LINE runtime barrel self-contained", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
|
||||
expect(collectRuntimeApiPreExports(runtimeApiPath)).toStrictEqual([]);
|
||||
expect(collectRuntimeApiPreExports(runtimeApiPath)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createRuntime() {
|
||||
const monitorLineProvider = vi.fn(
|
||||
async (_opts: { accountId?: string; channelAccessToken: string; channelSecret: string }) => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: async () => {},
|
||||
stop: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
monitorLineProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: params.token,
|
||||
channelSecret: params.secret,
|
||||
tokenSource: "config",
|
||||
config: {} as ResolvedLineAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
|
||||
const { runtime, monitorLineProvider } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
return {
|
||||
monitorLineProvider,
|
||||
task: lineGatewayAdapter.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: params.account,
|
||||
abortSignal: params.abortSignal,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.startAccount", () => {
|
||||
it("fails startup when channel secret is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: " " }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel secret for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails startup when channel access token is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: " ", secret: "secret" }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel access token for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts provider when token and secret are present", async () => {
|
||||
const abort = new AbortController();
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: "secret" }),
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(monitorLineProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const startupParams = (monitorLineProvider.mock.calls as unknown[][])[0]?.[0] as
|
||||
| { accountId?: string; channelAccessToken?: string; channelSecret?: string }
|
||||
| undefined;
|
||||
expect(startupParams?.channelAccessToken).toBe("token");
|
||||
expect(startupParams?.channelSecret).toBe("secret");
|
||||
expect(startupParams?.accountId).toBe("default");
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
});
|
||||
230
extensions/line/src/setup-surface.ts
Normal file
230
extensions/line/src/setup-surface.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// Line plugin module implements setup surface behavior.
|
||||
import {
|
||||
createAllowFromSection,
|
||||
createStandardChannelSetupStatus,
|
||||
mergeAllowFromEntries,
|
||||
createSetupTranslator,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultLineAccountId } from "./accounts.js";
|
||||
import {
|
||||
isLineConfigured,
|
||||
listLineAccountIds,
|
||||
parseLineAllowFromId,
|
||||
patchLineAccountConfig,
|
||||
} from "./setup-core.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
resolveLineAccount,
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "./setup-runtime-api.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "line" as const;
|
||||
|
||||
const LINE_SETUP_HELP_LINES = [
|
||||
t("wizard.line.helpOpenConsole"),
|
||||
t("wizard.line.helpCopyCredentials"),
|
||||
t("wizard.line.helpEnableWebhook"),
|
||||
t("wizard.line.helpWebhookUrl"),
|
||||
t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
|
||||
];
|
||||
|
||||
const LINE_ALLOW_FROM_HELP_LINES = [
|
||||
t("wizard.line.allowlistIntro"),
|
||||
t("wizard.line.idsCaseSensitive"),
|
||||
t("wizard.line.examples"),
|
||||
"- U1234567890abcdef1234567890abcdef",
|
||||
"- line:user:U1234567890abcdef1234567890abcdef",
|
||||
t("wizard.line.multipleEntries"),
|
||||
t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
|
||||
];
|
||||
|
||||
const lineDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "LINE",
|
||||
channel,
|
||||
policyKey: "channels.line.dmPolicy",
|
||||
allowFromKey: "channels.line.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultLineAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.dmPolicy`,
|
||||
allowFromKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.line.dmPolicy",
|
||||
allowFromKey: "channels.line.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveLineAccount({ cfg, accountId: accountId ?? resolveDefaultLineAccountId(cfg) }).config
|
||||
.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultLineAccountId(cfg),
|
||||
enabled: true,
|
||||
patch:
|
||||
policy === "open"
|
||||
? {
|
||||
dmPolicy: "open",
|
||||
allowFrom: mergeAllowFromEntries(
|
||||
resolveLineAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultLineAccountId(cfg),
|
||||
}).config.allowFrom,
|
||||
["*"],
|
||||
),
|
||||
}
|
||||
: { dmPolicy: policy },
|
||||
clearFields: policy === "pairing" || policy === "disabled" ? ["allowFrom"] : undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
export const lineSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "LINE",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsTokenSecret"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsTokenSecret"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
|
||||
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
|
||||
}),
|
||||
introNote: {
|
||||
title: t("wizard.line.messagingApiTitle"),
|
||||
lines: LINE_SETUP_HELP_LINES,
|
||||
shouldShow: ({ cfg, accountId }) =>
|
||||
!isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: t("wizard.line.channelAccessToken"),
|
||||
preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN",
|
||||
helpTitle: t("wizard.line.messagingApiTitle"),
|
||||
helpLines: LINE_SETUP_HELP_LINES,
|
||||
envPrompt: t("wizard.line.tokenEnvPrompt"),
|
||||
keepPrompt: t("wizard.line.tokenKeepPrompt"),
|
||||
inputPrompt: t("wizard.line.tokenInputPrompt"),
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolved = resolveLineAccount({ cfg, accountId });
|
||||
return {
|
||||
accountConfigured: Boolean(
|
||||
normalizeOptionalString(resolved.channelAccessToken) &&
|
||||
normalizeOptionalString(resolved.channelSecret),
|
||||
),
|
||||
hasConfiguredValue: Boolean(
|
||||
normalizeOptionalString(resolved.config.channelAccessToken) ??
|
||||
normalizeOptionalString(resolved.config.tokenFile),
|
||||
),
|
||||
resolvedValue: normalizeOptionalString(resolved.channelAccessToken),
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? normalizeOptionalString(process.env.LINE_CHANNEL_ACCESS_TOKEN)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
applyUseEnv: ({ cfg, accountId }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled: true,
|
||||
clearFields: ["channelAccessToken", "tokenFile"],
|
||||
patch: {},
|
||||
}),
|
||||
applySet: ({ cfg, accountId, resolvedValue }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled: true,
|
||||
clearFields: ["tokenFile"],
|
||||
patch: { channelAccessToken: resolvedValue },
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "password",
|
||||
providerHint: "line-secret",
|
||||
credentialLabel: t("wizard.line.channelSecret"),
|
||||
preferredEnvVar: "LINE_CHANNEL_SECRET",
|
||||
helpTitle: t("wizard.line.messagingApiTitle"),
|
||||
helpLines: LINE_SETUP_HELP_LINES,
|
||||
envPrompt: t("wizard.line.secretEnvPrompt"),
|
||||
keepPrompt: t("wizard.line.secretKeepPrompt"),
|
||||
inputPrompt: t("wizard.line.secretInputPrompt"),
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolved = resolveLineAccount({ cfg, accountId });
|
||||
return {
|
||||
accountConfigured: Boolean(
|
||||
normalizeOptionalString(resolved.channelAccessToken) &&
|
||||
normalizeOptionalString(resolved.channelSecret),
|
||||
),
|
||||
hasConfiguredValue: Boolean(
|
||||
normalizeOptionalString(resolved.config.channelSecret) ??
|
||||
normalizeOptionalString(resolved.config.secretFile),
|
||||
),
|
||||
resolvedValue: normalizeOptionalString(resolved.channelSecret),
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? normalizeOptionalString(process.env.LINE_CHANNEL_SECRET)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
applyUseEnv: ({ cfg, accountId }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled: true,
|
||||
clearFields: ["channelSecret", "secretFile"],
|
||||
patch: {},
|
||||
}),
|
||||
applySet: ({ cfg, accountId, resolvedValue }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled: true,
|
||||
clearFields: ["secretFile"],
|
||||
patch: { channelSecret: resolvedValue },
|
||||
}),
|
||||
},
|
||||
],
|
||||
allowFrom: createAllowFromSection({
|
||||
helpTitle: t("wizard.line.allowlistTitle"),
|
||||
helpLines: LINE_ALLOW_FROM_HELP_LINES,
|
||||
message: t("wizard.line.allowFromPrompt"),
|
||||
placeholder: "U1234567890abcdef1234567890abcdef",
|
||||
invalidWithoutCredentialNote: t("wizard.line.allowFromInvalid"),
|
||||
parseInputs: splitSetupEntries,
|
||||
parseId: parseLineAllowFromId,
|
||||
apply: ({ cfg, accountId, allowFrom }) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled: true,
|
||||
patch: { dmPolicy: "allowlist", allowFrom },
|
||||
}),
|
||||
}),
|
||||
dmPolicy: lineDmPolicy,
|
||||
completionNote: {
|
||||
title: t("wizard.line.webhookTitle"),
|
||||
lines: [
|
||||
t("wizard.line.completionEnableWebhook"),
|
||||
t("wizard.line.completionDefaultWebhook"),
|
||||
t("wizard.line.completionWebhookPath"),
|
||||
t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
|
||||
],
|
||||
},
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
35
extensions/line/src/signature.test.ts
Normal file
35
extensions/line/src/signature.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Line tests cover signature plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { validateLineSignature } from "./signature.js";
|
||||
|
||||
function sign(body: string, secret: string): string {
|
||||
return crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||
}
|
||||
|
||||
describe("validateLineSignature", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accepts a valid signature", () => {
|
||||
const body = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const secret = "top-secret";
|
||||
|
||||
expect(validateLineSignature(body, sign(body, secret), secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("still performs timing-safe comparison when signature length mismatches", () => {
|
||||
const body = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const secret = "top-secret";
|
||||
const spy = vi.spyOn(crypto, "timingSafeEqual");
|
||||
|
||||
expect(validateLineSignature(body, "short", secret)).toBe(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [left, right] = spy.mock.calls[0] ?? [];
|
||||
expect(left).toBeInstanceOf(Buffer);
|
||||
expect(right).toBeInstanceOf(Buffer);
|
||||
expect(left?.byteLength).toBe(right?.byteLength);
|
||||
});
|
||||
});
|
||||
25
extensions/line/src/signature.ts
Normal file
25
extensions/line/src/signature.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Line plugin module implements signature behavior.
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function validateLineSignature(
|
||||
body: string,
|
||||
signature: string,
|
||||
channelSecret: string,
|
||||
): boolean {
|
||||
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
|
||||
const hashBuffer = Buffer.from(hash);
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
|
||||
// Pad to equal length before constant-time comparison to prevent
|
||||
// leaking length information via early-return timing.
|
||||
const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
|
||||
const paddedHash = Buffer.alloc(maxLen);
|
||||
const paddedSig = Buffer.alloc(maxLen);
|
||||
hashBuffer.copy(paddedHash);
|
||||
signatureBuffer.copy(paddedSig);
|
||||
|
||||
// Call timingSafeEqual unconditionally to ensure constant-time execution
|
||||
// regardless of length mismatch (avoids && short-circuit timing leak).
|
||||
const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
|
||||
return hashBuffer.length === signatureBuffer.length && timingResult;
|
||||
}
|
||||
38
extensions/line/src/status.ts
Normal file
38
extensions/line/src/status.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Line plugin module implements status behavior.
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
createDependentCredentialStatusIssueCollector,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { hasLineCredentials } from "./account-helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
|
||||
|
||||
const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
|
||||
|
||||
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
|
||||
channel: "line",
|
||||
dependencySourceKey: "tokenSource",
|
||||
missingPrimaryMessage: "LINE channel access token not configured",
|
||||
missingDependentMessage: "LINE channel secret not configured",
|
||||
});
|
||||
|
||||
export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
|
||||
createComputedAccountStatusAdapter<ResolvedLineAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectLineStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await (await loadLineProbeRuntime()).probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: "webhook",
|
||||
},
|
||||
}),
|
||||
});
|
||||
394
extensions/line/src/template-messages.ts
Normal file
394
extensions/line/src/template-messages.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
// Line plugin module implements template messages behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { messageAction, postbackAction, uriAction, type Action } from "./actions.js";
|
||||
import type { LineTemplateMessagePayload } from "./types.js";
|
||||
|
||||
export { messageAction };
|
||||
|
||||
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;
|
||||
|
||||
const COMPACT_TEMPLATE_TEXT_LIMIT = 60;
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
type TemplatePayloadAction = {
|
||||
type?: "uri" | "postback" | "message";
|
||||
uri?: string;
|
||||
data?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
|
||||
if (action.type === "uri" && action.uri) {
|
||||
return uriAction(action.label, action.uri);
|
||||
}
|
||||
if (action.type === "postback" && action.data) {
|
||||
return postbackAction(action.label, action.data, action.label);
|
||||
}
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
}
|
||||
|
||||
function resolveTemplateTextLimit(params: {
|
||||
title?: string;
|
||||
thumbnailImageUrl?: string;
|
||||
textOnlyLimit: number;
|
||||
}): number {
|
||||
return params.title !== undefined || params.thumbnailImageUrl !== undefined
|
||||
? COMPACT_TEMPLATE_TEXT_LIMIT
|
||||
: params.textOnlyLimit;
|
||||
}
|
||||
|
||||
function truncateTemplateText(text: string, limit: number): string {
|
||||
let result = "";
|
||||
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||
if (result.length + segment.length > limit) {
|
||||
// A pathological grapheme can exceed LINE's whole field limit. Preserve
|
||||
// graphemes normally, but keep required text non-empty without splitting
|
||||
// a surrogate pair when the first grapheme alone cannot fit.
|
||||
if (!result) {
|
||||
for (const codePoint of segment) {
|
||||
if (result.length + codePoint.length > limit) {
|
||||
break;
|
||||
}
|
||||
result += codePoint;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
result += segment;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function truncateOptionalTemplateText(
|
||||
value: string | undefined,
|
||||
limit: number,
|
||||
): string | undefined {
|
||||
return value === undefined ? undefined : truncateTemplateText(value, limit);
|
||||
}
|
||||
|
||||
function formatProductCarouselText(description: string, price?: string): string {
|
||||
if (!price) {
|
||||
return description;
|
||||
}
|
||||
const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT);
|
||||
const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1);
|
||||
const descriptionText = truncateTemplateText(description, descriptionLimit);
|
||||
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: truncateTemplateText(text, 240), // LINE limit
|
||||
actions: [confirmAction, cancelAction],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: truncateOptionalTemplateText(altText, 400) ?? truncateTemplateText(text, 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 textLimit = resolveTemplateTextLimit({
|
||||
title,
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
textOnlyLimit: 160,
|
||||
});
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: truncateTemplateText(title, 40), // LINE limit
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
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:
|
||||
truncateOptionalTemplateText(options?.altText, 400) ??
|
||||
truncateTemplateText(`${title}: ${text}`, 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: truncateOptionalTemplateText(options?.altText, 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 {
|
||||
// LINE caps a carousel column's text at 60 chars when the column carries a
|
||||
// title or thumbnail image, and 120 chars otherwise. Sending an over-length
|
||||
// text makes LINE reject the whole carousel, so mirror the conditional limit
|
||||
// the buttons template already applies above.
|
||||
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
|
||||
return {
|
||||
title: truncateOptionalTemplateText(params.title, 40),
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
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: truncateOptionalTemplateText(altText, 400) ?? "View images",
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image carousel column for use with createImageCarousel
|
||||
*/
|
||||
export function createImageCarouselColumn(imageUrl: string, action: Action): ImageCarouselColumn {
|
||||
return {
|
||||
imageUrl,
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple yes/no confirmation dialog
|
||||
*/
|
||||
export function createYesNoConfirm(
|
||||
question: string,
|
||||
options?: {
|
||||
yesText?: string;
|
||||
noText?: string;
|
||||
yesData?: string;
|
||||
noData?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const yesAction: Action = options?.yesData
|
||||
? postbackAction(options.yesText ?? "Yes", options.yesData, options.yesText ?? "Yes")
|
||||
: messageAction(options?.yesText ?? "Yes");
|
||||
|
||||
const noAction: Action = options?.noData
|
||||
? postbackAction(options.noText ?? "No", options.noData, options.noText ?? "No")
|
||||
: messageAction(options?.noText ?? "No");
|
||||
|
||||
return createConfirmTemplate(question, yesAction, noAction, options?.altText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button menu with simple text buttons
|
||||
*/
|
||||
export function createButtonMenu(
|
||||
title: string,
|
||||
text: string,
|
||||
buttons: Array<{ label: string; text?: string }>,
|
||||
options?: {
|
||||
thumbnailImageUrl?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const actions = buttons.slice(0, 4).map((btn) => messageAction(btn.label, btn.text));
|
||||
|
||||
return createButtonTemplate(title, text, actions, {
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
altText: options?.altText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button menu with URL links
|
||||
*/
|
||||
export function createLinkMenu(
|
||||
title: string,
|
||||
text: string,
|
||||
links: Array<{ label: string; url: string }>,
|
||||
options?: {
|
||||
thumbnailImageUrl?: string;
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const actions = links.slice(0, 4).map((link) => uriAction(link.label, link.url));
|
||||
|
||||
return createButtonTemplate(title, text, actions, {
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
altText: options?.altText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple product/item carousel
|
||||
*/
|
||||
export function createProductCarousel(
|
||||
products: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
price?: string;
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
actionData?: string;
|
||||
}>,
|
||||
altText?: string,
|
||||
): TemplateMessage {
|
||||
const columns = products.slice(0, 10).map((product) => {
|
||||
const actions: Action[] = [];
|
||||
|
||||
if (product.actionUrl) {
|
||||
actions.push(uriAction(product.actionLabel ?? "View", product.actionUrl));
|
||||
} else if (product.actionData) {
|
||||
actions.push(postbackAction(product.actionLabel ?? "Select", product.actionData));
|
||||
} else {
|
||||
actions.push(messageAction(product.actionLabel ?? "Select", product.title));
|
||||
}
|
||||
|
||||
return createCarouselColumn({
|
||||
title: product.title,
|
||||
text: formatProductCarouselText(product.description, product.price),
|
||||
thumbnailImageUrl: product.imageUrl,
|
||||
actions,
|
||||
});
|
||||
});
|
||||
|
||||
return createTemplateCarousel(columns, { altText });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a TemplateMessagePayload from ReplyPayload to a LINE TemplateMessage
|
||||
*/
|
||||
export function buildTemplateMessageFromPayload(
|
||||
payload: LineTemplateMessagePayload,
|
||||
): TemplateMessage | null {
|
||||
switch (payload.type) {
|
||||
case "confirm": {
|
||||
const confirmAction = payload.confirmData.startsWith("http")
|
||||
? uriAction(payload.confirmLabel, payload.confirmData)
|
||||
: payload.confirmData.includes("=")
|
||||
? postbackAction(payload.confirmLabel, payload.confirmData, payload.confirmLabel)
|
||||
: messageAction(payload.confirmLabel, payload.confirmData);
|
||||
|
||||
const cancelAction = payload.cancelData.startsWith("http")
|
||||
? uriAction(payload.cancelLabel, payload.cancelData)
|
||||
: payload.cancelData.includes("=")
|
||||
? postbackAction(payload.cancelLabel, payload.cancelData, payload.cancelLabel)
|
||||
: messageAction(payload.cancelLabel, payload.cancelData);
|
||||
|
||||
return createConfirmTemplate(payload.text, confirmAction, cancelAction, payload.altText);
|
||||
}
|
||||
|
||||
case "buttons": {
|
||||
const actions: Action[] = payload.actions
|
||||
.slice(0, 4)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createButtonTemplate(payload.title, payload.text, actions, {
|
||||
thumbnailImageUrl: payload.thumbnailImageUrl,
|
||||
altText: payload.altText,
|
||||
});
|
||||
}
|
||||
|
||||
case "carousel": {
|
||||
const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
|
||||
const colActions: Action[] = col.actions
|
||||
.slice(0, 3)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createCarouselColumn({
|
||||
title: col.title,
|
||||
text: col.text,
|
||||
thumbnailImageUrl: col.thumbnailImageUrl,
|
||||
actions: colActions,
|
||||
});
|
||||
});
|
||||
|
||||
return createTemplateCarousel(columns, { altText: payload.altText });
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
TemplateMessage,
|
||||
ConfirmTemplate,
|
||||
ButtonsTemplate,
|
||||
CarouselTemplate,
|
||||
CarouselColumn,
|
||||
ImageCarouselTemplate,
|
||||
ImageCarouselColumn,
|
||||
};
|
||||
131
extensions/line/src/types.ts
Normal file
131
extensions/line/src/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Line type declarations define plugin contracts.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
|
||||
export type LineTokenSource = "config" | "env" | "file" | "none";
|
||||
|
||||
interface LineThreadBindingsConfig {
|
||||
enabled?: boolean;
|
||||
idleHours?: number;
|
||||
maxAgeHours?: number;
|
||||
spawnSessions?: boolean;
|
||||
defaultSpawnContext?: "isolated" | "fork";
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnSubagentSessions?: boolean;
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnAcpSessions?: boolean;
|
||||
}
|
||||
|
||||
interface LineAccountBaseConfig {
|
||||
enabled?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
name?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
threadBindings?: LineThreadBindingsConfig;
|
||||
groups?: Record<string, LineGroupConfig>;
|
||||
}
|
||||
|
||||
export interface LineConfig extends LineAccountBaseConfig {
|
||||
accounts?: Record<string, LineAccountConfig>;
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
export interface LineAccountConfig extends LineAccountBaseConfig {}
|
||||
|
||||
export interface LineGroupConfig {
|
||||
enabled?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
requireMention?: boolean;
|
||||
systemPrompt?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedLineAccount {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
tokenSource: LineTokenSource;
|
||||
config: LineConfig & LineAccountConfig;
|
||||
}
|
||||
|
||||
export interface LineSendResult {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
receipt: MessageReceipt;
|
||||
}
|
||||
|
||||
export type LineProbeResult = BaseProbeResult<string> & {
|
||||
bot?: {
|
||||
displayName?: string;
|
||||
userId?: string;
|
||||
basicId?: string;
|
||||
pictureUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
599
extensions/line/src/webhook-node.test.ts
Normal file
599
extensions/line/src/webhook-node.test.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
// Line tests cover webhook node plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
|
||||
import { createLineWebhookMiddleware } from "./webhook.js";
|
||||
|
||||
const sign = (body: string, secret: string) =>
|
||||
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||
|
||||
function createRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
const resObj = {
|
||||
statusCode: 0,
|
||||
headersSent: false,
|
||||
setHeader: (k: string, v: string) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
},
|
||||
end: vi.fn((data?: unknown) => {
|
||||
resObj.headersSent = true;
|
||||
// Keep payload available for assertions
|
||||
resObj.body = data;
|
||||
}),
|
||||
body: undefined as unknown,
|
||||
};
|
||||
const res = resObj as unknown as ServerResponse & { body?: unknown };
|
||||
return { res, headers };
|
||||
}
|
||||
|
||||
const SECRET = "secret";
|
||||
|
||||
type ParsedLineWebhookPayload = {
|
||||
events: unknown;
|
||||
};
|
||||
|
||||
function firstMockCall(
|
||||
mock: { mock: { calls: Array<readonly unknown[]> } },
|
||||
label: string,
|
||||
): readonly unknown[] {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function firstParsedPayload(
|
||||
mock: { mock: { calls: Array<readonly unknown[]> } },
|
||||
label: string,
|
||||
): ParsedLineWebhookPayload {
|
||||
return firstMockCall(mock, label)[0] as ParsedLineWebhookPayload;
|
||||
}
|
||||
|
||||
type RuntimeEnvMock = RuntimeEnv & {
|
||||
error: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>;
|
||||
exit: ReturnType<typeof vi.fn<(code: number) => void>>;
|
||||
log: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>;
|
||||
};
|
||||
|
||||
function createRuntimeMock(): RuntimeEnvMock {
|
||||
return {
|
||||
error: vi.fn<(...args: unknown[]) => void>(),
|
||||
exit: vi.fn<(code: number) => void>(),
|
||||
log: vi.fn<(...args: unknown[]) => void>(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMiddlewareRes() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
headersSent: false,
|
||||
} as any;
|
||||
res.status.mockReturnValue(res);
|
||||
res.json.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = createRuntimeMock();
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: secret,
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => rawBody,
|
||||
});
|
||||
return { bot, handler, secret };
|
||||
}
|
||||
|
||||
const runSignedPost = async (params: {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
rawBody: string;
|
||||
secret: string;
|
||||
res: ServerResponse;
|
||||
}) =>
|
||||
await params.handler(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": sign(params.rawBody, params.secret) },
|
||||
} as unknown as IncomingMessage,
|
||||
params.res,
|
||||
);
|
||||
|
||||
async function invokeWebhook(params: {
|
||||
body: unknown;
|
||||
headers?: Record<string, string>;
|
||||
onEvents?: ReturnType<typeof vi.fn>;
|
||||
autoSign?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
}) {
|
||||
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents: onEventsMock as never,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
const headers = { ...params.headers };
|
||||
const autoSign = params.autoSign ?? true;
|
||||
if (autoSign && !headers["x-line-signature"]) {
|
||||
if (typeof params.body === "string") {
|
||||
headers["x-line-signature"] = sign(params.body, SECRET);
|
||||
} else if (Buffer.isBuffer(params.body)) {
|
||||
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
|
||||
}
|
||||
}
|
||||
|
||||
const req = {
|
||||
headers,
|
||||
body: params.body,
|
||||
} as any;
|
||||
const res = createMiddlewareRes();
|
||||
await middleware(req, res, {} as any);
|
||||
return { res, onEvents: onEventsMock };
|
||||
}
|
||||
|
||||
const parseResponseBody = (body: unknown) => {
|
||||
if (typeof body !== "string") {
|
||||
return body;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
|
||||
type WebhookPostResult = {
|
||||
body: unknown;
|
||||
contentType?: string;
|
||||
dispatched: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
status: number | undefined;
|
||||
};
|
||||
|
||||
type WebhookPostInvoker = (params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) => Promise<WebhookPostResult>;
|
||||
|
||||
async function invokeNodePostContract(params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const dispatched = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
const runtime = createRuntimeMock();
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: SECRET,
|
||||
bot: { handleWebhook: dispatched },
|
||||
runtime,
|
||||
readBody: async () => params.rawBody,
|
||||
});
|
||||
const { res, headers } = createRes();
|
||||
await handler(
|
||||
{
|
||||
method: "POST",
|
||||
headers: params.signed ? { "x-line-signature": sign(params.rawBody, SECRET) } : {},
|
||||
} as unknown as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
return {
|
||||
body: parseResponseBody(res.body),
|
||||
contentType: headers["content-type"],
|
||||
dispatched,
|
||||
runtimeError: runtime.error,
|
||||
status: res.statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeMiddlewarePostContract(params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const runtime = createRuntimeMock();
|
||||
const onEvents = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
const { res, onEvents: dispatched } = await invokeWebhook({
|
||||
body: params.rawBody,
|
||||
headers: params.signed ? undefined : {},
|
||||
autoSign: params.signed,
|
||||
onEvents,
|
||||
runtime,
|
||||
});
|
||||
return {
|
||||
body: res.json.mock.calls.at(-1)?.[0],
|
||||
contentType: undefined,
|
||||
dispatched,
|
||||
runtimeError: runtime.error,
|
||||
status: res.status.mock.calls.at(-1)?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
const sharedWebhookPostContractCases = [
|
||||
{ name: "node handler", invoke: invokeNodePostContract },
|
||||
{ name: "middleware", invoke: invokeMiddlewarePostContract },
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
invoke: WebhookPostInvoker;
|
||||
}>;
|
||||
|
||||
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
|
||||
const onEvents = vi.fn(async () => {});
|
||||
const reqBody = {
|
||||
events: [{ type: "message", source: { userId: "tampered-user" } }],
|
||||
};
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
});
|
||||
const rawBodyText =
|
||||
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
|
||||
rawBody: params.rawBody,
|
||||
body: reqBody,
|
||||
} as any;
|
||||
const res = createMiddlewareRes();
|
||||
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(onEvents).toHaveBeenCalledTimes(1);
|
||||
const processedBody = firstMockCall(onEvents, "LINE webhook events")[0] as {
|
||||
events?: Array<{ source?: { userId?: string } }>;
|
||||
};
|
||||
expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId);
|
||||
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
|
||||
}
|
||||
|
||||
describe("LINE webhook shared POST contract", () => {
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name rejects verification-shaped requests without a signature",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({ rawBody: JSON.stringify({ events: [] }), signed: false });
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body).toEqual({ error: "Missing X-Line-Signature header" });
|
||||
if (result.contentType) {
|
||||
expect(result.contentType).toBe("application/json");
|
||||
}
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name accepts signed verification-shaped requests without dispatching events",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({ rawBody: JSON.stringify({ events: [] }), signed: true });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ status: "ok" });
|
||||
if (result.contentType) {
|
||||
expect(result.contentType).toBe("application/json");
|
||||
}
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name rejects missing signature when events are non-empty",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({
|
||||
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
signed: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body).toEqual({ error: "Missing X-Line-Signature header" });
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name acknowledges signed events before failed background processing is logged",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({
|
||||
failWith: new Error("transient failure"),
|
||||
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
signed: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ status: "ok" });
|
||||
expect(result.dispatched).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => {
|
||||
expect(result.runtimeError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createLineNodeWebhookHandler", () => {
|
||||
it("returns 200 for GET", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = createRuntimeMock();
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => "",
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("OK");
|
||||
});
|
||||
|
||||
it("returns 204 for HEAD", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = createRuntimeMock();
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => "",
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "HEAD", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 405 for non-GET/HEAD/POST methods", async () => {
|
||||
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(405);
|
||||
expect(headers.allow).toBe("GET, HEAD, POST");
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsigned POST requests before reading the body", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = createRuntimeMock();
|
||||
const readBody = vi.fn(async () => JSON.stringify({ events: [{ type: "message" }] }));
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readBody).not.toHaveBeenCalled();
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses strict pre-auth limits for signed POST requests", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = createRuntimeMock();
|
||||
const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
|
||||
expect(maxBytes).toBe(64 * 1024);
|
||||
expect(timeoutMs).toBe(5_000);
|
||||
return rawBody;
|
||||
});
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: "secret",
|
||||
bot,
|
||||
runtime,
|
||||
readBody,
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret: "secret", res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readBody).toHaveBeenCalledTimes(1);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects invalid signature", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await handler(
|
||||
{ method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts valid signature and dispatches events", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
const payload = firstParsedPayload(bot.handleWebhook, "LINE node webhook payload");
|
||||
expect(payload.events).toEqual([{ type: "message" }]);
|
||||
});
|
||||
|
||||
it("acknowledges signed event requests before event processing completes", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
let releaseAuthenticated: (() => void) | undefined;
|
||||
const bot = {
|
||||
handleWebhook: vi.fn(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseAuthenticated = resolve;
|
||||
}),
|
||||
),
|
||||
};
|
||||
const onRequestAuthenticated = vi.fn();
|
||||
const runtime = createRuntimeMock();
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: SECRET,
|
||||
bot,
|
||||
runtime,
|
||||
readBody: async () => rawBody,
|
||||
onRequestAuthenticated,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
const request = runSignedPost({ handler, rawBody, secret: SECRET, res });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRequestAuthenticated).toHaveBeenCalledTimes(1);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await request;
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headersSent).toBe(true);
|
||||
if (!releaseAuthenticated) {
|
||||
throw new Error("Expected LINE authenticated request release callback to be initialized");
|
||||
}
|
||||
releaseAuthenticated();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
|
||||
const rawBody = "not json";
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readLineWebhookRequestBody", () => {
|
||||
it("reads body within limit", async () => {
|
||||
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
|
||||
const body = await readLineWebhookRequestBody(req, 1024);
|
||||
expect(body).toContain('"events"');
|
||||
});
|
||||
|
||||
it("rejects oversized body", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(2048)]);
|
||||
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLineWebhookMiddleware", () => {
|
||||
it.each([
|
||||
["raw string body", JSON.stringify({ events: [{ type: "message" }] }), [{ type: "message" }]],
|
||||
[
|
||||
"raw buffer body",
|
||||
Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8"),
|
||||
[{ type: "follow" }],
|
||||
],
|
||||
])("parses JSON from %s", async (_label, body, expectedEvents) => {
|
||||
const { res, onEvents } = await invokeWebhook({ body });
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(onEvents).toHaveBeenCalledTimes(1);
|
||||
const payload = firstParsedPayload(onEvents, "LINE middleware payload");
|
||||
expect(payload.events).toEqual(expectedEvents);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON payloads", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({ body: "not json" });
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects webhooks with invalid signatures", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
headers: { "x-line-signature": "invalid-signature" },
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized signed payloads before JSON parsing", async () => {
|
||||
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
|
||||
const { res, onEvents } = await invokeWebhook({ body: largeBody });
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects signed requests when raw body is missing", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: { events: [{ type: "message" }] },
|
||||
headers: { "x-line-signature": "signed" },
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Missing raw request body for signature verification",
|
||||
});
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
|
||||
await expectSignedRawBodyWins({
|
||||
rawBody: JSON.stringify({
|
||||
events: [{ type: "message", source: { userId: "signed-user" } }],
|
||||
}),
|
||||
signedUserId: "signed-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
|
||||
await expectSignedRawBodyWins({
|
||||
rawBody: Buffer.from(
|
||||
JSON.stringify({
|
||||
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
|
||||
}),
|
||||
"utf-8",
|
||||
),
|
||||
signedUserId: "signed-buffer-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
|
||||
const onEvents = vi.fn(async () => {});
|
||||
const rawBody = "not-json";
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
||||
rawBody,
|
||||
body: { events: [{ type: "message" }] },
|
||||
} as any;
|
||||
const res = createMiddlewareRes();
|
||||
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
156
extensions/line/src/webhook-node.ts
Normal file
156
extensions/line/src/webhook-node.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Line plugin module implements webhook node behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import {
|
||||
createMessageReceiveContext,
|
||||
type MessageReceiveContext,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
||||
|
||||
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
||||
|
||||
export async function readLineWebhookRequestBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
|
||||
timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
return await readRequestBodyWithLimit(req, {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
|
||||
|
||||
function logLineWebhookDispatchError(runtime: RuntimeEnv | undefined, err: unknown): void {
|
||||
runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
|
||||
}
|
||||
|
||||
export function createLineNodeWebhookHandler(params: {
|
||||
channelSecret: string;
|
||||
bot: { handleWebhook: (body: webhook.CallbackRequest) => Promise<void> };
|
||||
runtime: RuntimeEnv;
|
||||
readBody?: ReadBodyFn;
|
||||
maxBodyBytes?: number;
|
||||
onRequestAuthenticated?: () => void;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
|
||||
const readBody = params.readBody ?? readLineWebhookRequestBody;
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
if (req.method === "HEAD") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("OK");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET, HEAD, POST");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
|
||||
try {
|
||||
const signatureHeader = req.headers["x-line-signature"];
|
||||
const signature =
|
||||
typeof signatureHeader === "string"
|
||||
? signatureHeader.trim()
|
||||
: Array.isArray(signatureHeader)
|
||||
? (signatureHeader[0] ?? "").trim()
|
||||
: "";
|
||||
|
||||
if (!signature) {
|
||||
logVerbose("line: webhook missing X-Line-Signature header");
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readBody(
|
||||
req,
|
||||
Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES),
|
||||
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!validateLineSignature(rawBody, signature, params.channelSecret)) {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseLineWebhookBody(rawBody);
|
||||
|
||||
if (!body) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
||||
return;
|
||||
}
|
||||
|
||||
params.onRequestAuthenticated?.();
|
||||
|
||||
receiveContext = createMessageReceiveContext({
|
||||
id: `${Date.now()}:line:webhook`,
|
||||
channel: "line",
|
||||
message: body,
|
||||
ackPolicy: "after_receive_record",
|
||||
onAck: () => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
},
|
||||
});
|
||||
|
||||
if (receiveContext.shouldAckAfter("receive_record")) {
|
||||
await receiveContext.ack();
|
||||
}
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
void Promise.resolve()
|
||||
.then(() => params.bot.handleWebhook(body))
|
||||
.catch((err: unknown) => logLineWebhookDispatchError(params.runtime, err));
|
||||
}
|
||||
} catch (err) {
|
||||
await receiveContext?.nack(err);
|
||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
11
extensions/line/src/webhook-utils.ts
Normal file
11
extensions/line/src/webhook-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Line helper module supports webhook utils behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
export { validateLineSignature } from "./signature.js";
|
||||
|
||||
export function parseLineWebhookBody(rawBody: string): webhook.CallbackRequest | null {
|
||||
try {
|
||||
return JSON.parse(rawBody) as webhook.CallbackRequest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
136
extensions/line/src/webhook.ts
Normal file
136
extensions/line/src/webhook.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Line plugin module implements webhook behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import {
|
||||
createMessageReceiveContext,
|
||||
type MessageReceiveContext,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
||||
|
||||
const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024;
|
||||
|
||||
export interface LineWebhookOptions {
|
||||
channelSecret: string;
|
||||
onEvents: (body: webhook.CallbackRequest) => Promise<void>;
|
||||
runtime?: RuntimeEnv;
|
||||
}
|
||||
|
||||
function readRawBody(req: Request): string | null {
|
||||
const rawBody =
|
||||
(req as { rawBody?: string | Buffer }).rawBody ??
|
||||
(typeof req.body === "string" || Buffer.isBuffer(req.body) ? req.body : null);
|
||||
if (!rawBody) {
|
||||
return null;
|
||||
}
|
||||
return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody;
|
||||
}
|
||||
|
||||
function parseWebhookBody(rawBody?: string | null): webhook.CallbackRequest | null {
|
||||
if (!rawBody) {
|
||||
return null;
|
||||
}
|
||||
return parseLineWebhookBody(rawBody);
|
||||
}
|
||||
|
||||
function logLineWebhookDispatchError(runtime: RuntimeEnv | undefined, err: unknown): void {
|
||||
runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
|
||||
}
|
||||
|
||||
export function createLineWebhookMiddleware(
|
||||
options: LineWebhookOptions,
|
||||
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
|
||||
const { channelSecret, onEvents, runtime } = options;
|
||||
|
||||
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
||||
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
|
||||
try {
|
||||
const signature = req.headers["x-line-signature"];
|
||||
|
||||
if (!signature || typeof signature !== "string") {
|
||||
res.status(400).json({ error: "Missing X-Line-Signature header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = readRawBody(req);
|
||||
|
||||
if (!rawBody) {
|
||||
res.status(400).json({ error: "Missing raw request body for signature verification" });
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(rawBody, "utf-8") > LINE_WEBHOOK_MAX_RAW_BODY_BYTES) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateLineSignature(rawBody, signature, channelSecret)) {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.status(401).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseWebhookBody(rawBody);
|
||||
|
||||
if (!body) {
|
||||
res.status(400).json({ error: "Invalid webhook payload" });
|
||||
return;
|
||||
}
|
||||
|
||||
receiveContext = createMessageReceiveContext({
|
||||
id: `${Date.now()}:line:webhook`,
|
||||
channel: "line",
|
||||
message: body,
|
||||
ackPolicy: "after_receive_record",
|
||||
onAck: () => {
|
||||
res.status(200).json({ status: "ok" });
|
||||
},
|
||||
});
|
||||
|
||||
if (receiveContext.shouldAckAfter("receive_record")) {
|
||||
await receiveContext.ack();
|
||||
}
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
void Promise.resolve()
|
||||
.then(() => onEvents(body))
|
||||
.catch((err: unknown) => logLineWebhookDispatchError(runtime, err));
|
||||
}
|
||||
} catch (err) {
|
||||
await receiveContext?.nack(err);
|
||||
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartLineWebhookOptions {
|
||||
channelSecret: string;
|
||||
onEvents: (body: webhook.CallbackRequest) => Promise<void>;
|
||||
runtime?: RuntimeEnv;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export function startLineWebhook(options: StartLineWebhookOptions): {
|
||||
path: string;
|
||||
handler: (req: Request, res: Response, _next: NextFunction) => Promise<void>;
|
||||
} {
|
||||
const channelSecret =
|
||||
typeof options.channelSecret === "string" ? options.channelSecret.trim() : "";
|
||||
if (!channelSecret) {
|
||||
throw new Error(
|
||||
"LINE webhook mode requires a non-empty channel secret. " +
|
||||
"Set channels.line.channelSecret in your config.",
|
||||
);
|
||||
}
|
||||
const path = options.path ?? "/line/webhook";
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret,
|
||||
onEvents: options.onEvents,
|
||||
runtime: options.runtime,
|
||||
});
|
||||
|
||||
return { path, handler: middleware };
|
||||
}
|
||||
16
extensions/line/tsconfig.json
Normal file
16
extensions/line/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user