Compare commits

...

21 Commits

Author SHA1 Message Date
Peter Steinberger
6c93143c4a fix: honor cron allowAgents override (#1771) (thanks @Noctivoro) 2026-01-25 13:10:14 +00:00
Peter Steinberger
11a6f6db2e fix: wire requester agent override for cron tools 2026-01-25 13:05:53 +00:00
Nick DiMoro
19e0b701cf fix: cron sessions inherit allowAgents from parent agent config
When a cron job runs in isolated mode, the sessions_spawn tool now correctly
inherits the allowAgents permissions from the parent agent's config.

The fix adds a requesterAgentIdOverride parameter that flows through the
tool creation chain:
- resolveEffectiveToolPolicy() extracts the correct agentId from the session key
- This agentId is passed to sessions_spawn and agents_list tools
- The tools use this override instead of re-parsing the session key

This fixes #1767
2026-01-25 13:05:32 +00:00
Yuanhai
015c256984 docs: fix Slack API documentation URLs 2026-01-25 13:01:55 +00:00
Peter Steinberger
5a21722f32 docs: expand 2026.1.24 highlights 2026-01-25 13:00:52 +00:00
Peter Steinberger
6110514606 docs: reorder 2026.1.24 changelog 2026-01-25 12:58:31 +00:00
Peter Steinberger
7a5e103a6a fix: treat Windows platform labels as Windows for node shell (#1760)
Thanks @ymat19.

Co-authored-by: ymat19 <45934497+ymat19@users.noreply.github.com>
2026-01-25 12:57:06 +00:00
ymat19
4e23b7f654 fix: use exact match for win32 platform detection
The previous check used includes("win") which incorrectly matched
"darwin" (macOS) because it contains "win". This caused cmd.exe to be
used on macOS instead of /bin/sh.
2026-01-25 12:57:06 +00:00
Senol Dogan
7253bf398d feat: audit fixes and documentation improvements (#1762)
* feat: audit fixes and documentation improvements

- Refactored model selection to drop legacy fallback and add warning
- Improved heartbeat content validation
- Added Skill Creation guide
- Updated CONTRIBUTING.md with roadmap

* style: fix formatting in model-selection.ts

* style: fix formatting and improve model selection logic with tests
2026-01-25 12:54:48 +00:00
Peter Steinberger
026def686e fix(matrix): decrypt E2EE media + size guard (#1744)
Thanks @araa47.

Co-authored-by: Akshay <araa47@users.noreply.github.com>
2026-01-25 12:53:57 +00:00
Robby
003fff067a fix: add text overflow ellipsis to config section titles
Fixes #1728

Config section header titles were being truncated without visual
indication. Added standard CSS truncation to BOTH title classes:
- .config-section-hero__title (main section headers)
- .config-section-card__title (card headers)

Properties added:
- white-space: nowrap
- overflow: hidden
- text-overflow: ellipsis
2026-01-25 12:48:19 +00:00
Peter Steinberger
8f3da653b0 fix: allow control ui token auth without pairing 2026-01-25 12:47:17 +00:00
Peter Steinberger
0f5f7ec22a ci: stabilize pnpm setup 2026-01-25 12:34:16 +00:00
David Gelberg
2fcbed2111 UI: refresh dashboard design system (#1786)
* UI: refresh dashboard design system

- Typography: swap Inter for Space Grotesk (geometric, techy)
- Colors: punchier accent red, add teal secondary, warmer darks
- Cards: better shadows, hover lift effect, increased padding
- Stats: uppercase labels, larger bold values
- Buttons: hover lift micro-interaction, glow on primary
- Status dots: glow effects and subtle pulse animation
- Callouts: gradient backgrounds for depth
- Navigation: active state accent bar indicator
- Layout: more breathing room, bolder page titles

* UI: remove nav active bar indicator

* UI: hide nav scrollbar, remove nav border

* fix: add changelog entry for dashboard refresh (#1786) (thanks @mousberg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 12:29:25 +00:00
plum-dawg
c96ffa7186 feat: Add Line plugin (#1630)
* feat: add LINE plugin (#1630) (thanks @plum-dawg)

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

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

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

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

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

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 12:22:36 +00:00
Dan Guido
101d0f451f fix(voice-call): prevent audio overlap with TTS queue (#1713)
* fix(voice-call): prevent audio overlap with TTS queue

Add a TTS queue to serialize audio playback and prevent overlapping
speech during voice calls. Previously, concurrent speak() calls could
send audio chunks simultaneously, causing garbled/choppy output.

Changes:
- Add queueTts() to MediaStreamHandler for sequential TTS playback
- Wrap playTtsViaStream() audio sending in the queue
- Clear queue on barge-in (when user starts speaking)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(voice-call): use iterative queue processing to prevent heap exhaustion

The recursive processQueue() pattern accumulated stack frames, causing
JavaScript heap out of memory errors on macOS CI. Convert to while loop
for constant stack usage regardless of queue depth.

* fix: prevent voice-call TTS overlap (#1713) (thanks @dguido)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 12:02:17 +00:00
Peter Steinberger
875b018ea1 fix: stop sending tool summaries to channels 2026-01-25 11:54:29 +00:00
Nimrod Gutman
b6581e77f6 refactor(gateway): share request encoding 2026-01-25 11:48:22 +00:00
Nimrod Gutman
81e915110e fix(node): avoid invoke result deadlock 2026-01-25 11:48:22 +00:00
Peter Steinberger
7e9aa3c275 fix(telegram): honor outbound proxy config (#1774, thanks @radek-paclt)
Co-authored-by: Radek Paclt <developer@muj-partak.cz>
2026-01-25 11:41:54 +00:00
Developer
65e2d939e1 fix(telegram): use configured proxy for outbound API calls
The proxy configuration (`channels.telegram.proxy`) was only used for
the gateway monitor (polling), but not for outbound sends (sendMessage,
reactMessage, deleteMessage). This caused outbound messages to bypass
the configured proxy, which is problematic for users behind corporate
proxies or those who want to route all traffic through a specific proxy.

This change ensures that all three outbound functions use the same
proxy configuration as the monitor:
- sendMessageTelegram
- reactMessageTelegram
- deleteMessageTelegram

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:41:54 +00:00
137 changed files with 12763 additions and 741 deletions

View File

@@ -32,20 +32,21 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies (frozen)
env:
CI: true
@@ -108,6 +109,12 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -118,16 +125,11 @@ jobs:
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
@@ -212,6 +214,12 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -222,16 +230,11 @@ jobs:
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
@@ -279,20 +282,21 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true

View File

@@ -14,11 +14,10 @@ jobs:
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
with:
version: 10
- name: Enable Corepack
run: corepack enable
version: 10.23.0
run_install: false
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile

View File

@@ -5,54 +5,64 @@ Docs: https://docs.clawd.bot
## 2026.1.24
### Highlights
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
### Changes
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
- Docs: update Fly.io guide notes.
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
### Fixes
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Agents: let cron isolated runs inherit subagent allowlists from the parent agent. (#1771) Thanks @Noctivoro.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Models: default missing custom provider fields so minimal configs are accepted.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Heartbeat: normalize target identifiers for consistent routing.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.

View File

@@ -40,3 +40,13 @@ Please include in your PR:
- [ ] Confirm you understand what the code does
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
## Current Focus & Roadmap 🗺
We are currently prioritizing:
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
- **UX**: Improving the onboarding wizard and error messages.
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!

View File

@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
try await self.connectOrThrow(context: "gateway connect")
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[id] = cont
self.pending[payload.id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
}
Task {
do {
try await self.task?.send(.data(data))
try await self.task?.send(.data(payload.data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: id)
let waiter = self.pending.removeValue(forKey: payload.id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
@@ -643,6 +619,29 @@ public actor GatewayChannelActor {
return Data() // Should not happen, but tolerate empty payloads.
}
public func send(method: String, params: [String: AnyCodable]?) async throws {
try await self.connectOrThrow(context: "gateway connect")
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
}
do {
try await task.send(.data(payload.data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
Task { [weak self] in
guard let self else { return }
await self.scheduleReconnect()
}
throw wrapped
}
}
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if let urlError = error as? URLError {
@@ -657,6 +656,42 @@ public actor GatewayChannelActor {
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
private func connectOrThrow(context: String) async throws {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: context)
}
}
private func encodeRequest(
method: String,
params: [String: AnyCodable]?,
kind: String) throws -> (id: String, data: Data)
{
let id = UUID().uuidString
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
do {
let data = try self.encoder.encode(frame)
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()

View File

@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
]
do {
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
try await channel.send(method: "node.event", params: params)
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
}
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
])
}
do {
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
try await channel.send(method: "node.invoke.result", params: params)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
}

View File

@@ -26,7 +26,7 @@ Minimal config:
```
### Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
@@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa
## Scopes (current vs optional)
Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
https://api.channels.slack.com/docs/conversations-api for the overview.
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.channels.slack.com/methods/chat.postMessage
https://docs.slack.dev/reference/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs)
https://api.channels.slack.com/methods/conversations.open
https://docs.slack.dev/reference/methods/conversations.open
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
https://api.channels.slack.com/methods/conversations.history
https://docs.slack.dev/reference/methods/conversations.history
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
https://api.channels.slack.com/methods/conversations.info
https://docs.slack.dev/reference/methods/conversations.info
- `users:read` (user lookup)
https://api.channels.slack.com/methods/users.info
https://docs.slack.dev/reference/methods/users.info
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
https://api.channels.slack.com/methods/reactions.get
https://api.channels.slack.com/methods/reactions.add
https://docs.slack.dev/reference/methods/reactions.get
https://docs.slack.dev/reference/methods/reactions.add
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
https://api.channels.slack.com/scopes/pins:read
https://api.channels.slack.com/scopes/pins:write
https://docs.slack.dev/reference/scopes/pins.read
https://docs.slack.dev/reference/scopes/pins.write
- `emoji:read` (`emoji.list`)
https://api.channels.slack.com/scopes/emoji:read
https://docs.slack.dev/reference/scopes/emoji.read
- `files:write` (uploads via `files.uploadV2`)
https://api.channels.slack.com/messaging/files/uploading
https://docs.slack.dev/messaging/working-with-files/#upload
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
@@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
- `chat:write.public` (only if we want to post to channels the bot isn't in)
https://api.channels.slack.com/scopes/chat:write.public
https://docs.slack.dev/reference/scopes/chat.write.public
- `users:read.email` (only if we need email fields from `users.info`)
https://api.channels.slack.com/changelog/2017-04-narrowing-email-access
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
- `files:read` (only if we start listing/reading file metadata)
## Config

View File

@@ -2847,8 +2847,9 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`.
Related docs:
- [Control UI](/web/control-ui)

View File

@@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order:
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** on plain HTTP and skips device pairing. This is a security
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.

View File

@@ -0,0 +1,41 @@
# Creating Custom Skills 🛠
Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
## What is a Skill?
A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
## Step-by-Step: Your First Skill
### 1. Create the Directory
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
```bash
mkdir -p ~/clawd/skills/hello-world
```
### 2. Define the `SKILL.md`
Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
```markdown
---
name: hello_world
description: A simple skill that says hello.
---
# Hello World Skill
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
```
### 3. Add Tools (Optional)
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
### 4. Refresh Clawdbot
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
## Best Practices
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
## Shared Skills
You can also browse and contribute skills to [ClawdHub](https://clawdhub.com).

View File

@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
}
```
This disables device identity + pairing for the Control UI. Use only if you
trust the network.
This disables device identity + pairing for the Control UI (even on HTTPS). Use
only if you trust the network.
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.

View File

@@ -0,0 +1,11 @@
{
"id": "line",
"channels": [
"line"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

20
extensions/line/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { linePlugin } from "./src/channel.js";
import { registerLineCardCommand } from "./src/card-command.js";
import { setLineRuntime } from "./src/runtime.js";
const plugin = {
id: "line",
name: "LINE",
description: "LINE Messaging API channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setLineRuntime(api.runtime);
api.registerChannel({ plugin: linePlugin });
registerLineCardCommand(api);
},
};
export default plugin;

View File

@@ -0,0 +1,29 @@
{
"name": "@clawdbot/line",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "line",
"label": "LINE",
"selectionLabel": "LINE (Messaging API)",
"docsPath": "/channels/line",
"docsLabel": "line",
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
"order": 75,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
}
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}

View File

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

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
const DEFAULT_ACCOUNT_ID = "default";
type LineRuntimeMocks = {
writeConfigFile: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const writeConfigFile = vi.fn(async () => {});
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
const lineConfig = (cfg.channels?.line ?? {}) as {
tokenFile?: string;
secretFile?: string;
channelAccessToken?: string;
channelSecret?: string;
accounts?: Record<string, Record<string, unknown>>;
};
const entry =
accountId && accountId !== DEFAULT_ACCOUNT_ID
? lineConfig.accounts?.[accountId] ?? {}
: lineConfig;
const hasToken =
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
const hasSecret =
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
});
const runtime = {
config: { writeConfigFile },
channel: { line: { resolveLineAccount } },
} as unknown as PluginRuntime;
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
describe("linePlugin gateway.logoutAccount", () => {
beforeEach(() => {
setLineRuntime(createRuntime().runtime);
});
it("clears tokenFile/secretFile on default account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: ClawdbotConfig = {
channels: {
line: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
};
const result = await linePlugin.gateway.logoutAccount({
accountId: DEFAULT_ACCOUNT_ID,
cfg,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("clears tokenFile/secretFile on account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: ClawdbotConfig = {
channels: {
line: {
accounts: {
primary: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
},
},
};
const result = await linePlugin.gateway.logoutAccount({
accountId: "primary",
cfg,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
});

View File

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

View File

@@ -0,0 +1,773 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
type ChannelPlugin,
type ClawdbotConfig,
type LineConfig,
type LineChannelData,
type ResolvedLineAccount,
} from "clawdbot/plugin-sdk";
import { getLineRuntime } from "./runtime.js";
// LINE channel metadata
const meta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
};
function parseThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) return undefined;
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
const trimmed = threadId.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
idLabel: "lineUserId",
normalizeAllowEntry: (entry) => {
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
return entry.replace(/^line:(?:user:)?/i, "");
},
notifyApproval: async ({ cfg, id }) => {
const line = getLineRuntime().channel.line;
const account = line.resolveLineAccount({ cfg });
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
channelAccessToken: account.channelAccessToken,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
return {
...cfg,
channels: {
...cfg.channels,
line: rest,
},
};
}
const accounts = { ...lineConfig.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
// LINE sender IDs are case-sensitive; keep original casing.
return entry.replace(/^line:(?:user:)?/i, "");
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.line.accounts.${resolvedAccountId}.`
: "channels.line.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "clawdbot pairing approve line <code>",
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy =
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups;
if (!groups) return false;
const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false;
},
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) return null;
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) =>
getLineRuntime().channel.line.normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
name,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
name,
},
},
},
},
};
},
validateInput: ({ accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
}
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
return "LINE requires channelAccessToken or --token-file (or --use-env).";
}
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
return "LINE requires channelSecret or --secret-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
name?: string;
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.useEnv
? {}
: typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.useEnv
? {}
: typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
const quickReply = hasQuickReplies
? createQuickReplyItems(lineData.quickReplies!)
: undefined;
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) return;
for (let i = 0; i < messages.length; i += 5) {
const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(
cfg,
"line",
accountId ?? undefined,
{
fallbackLimit: 5000,
},
) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
lastResult = await sendFlex(
to,
lineData.flexMessage.altText,
lineData.flexMessage.contents,
{
verbose: false,
accountId: accountId ?? undefined,
},
);
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i]!, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) continue;
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (lastResult) return { channel: "line", ...lastResult };
return { channel: "line", messageId: "empty", chatId: to };
},
sendText: async ({ to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
// Process markdown: extract tables/code blocks, strip formatting
const processed = processLineMessage(text);
// Send cleaned text first (if non-empty)
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
// If text is empty after processing, still need a result
result = { messageId: "processed", chatId: to };
}
// Send flex messages for tables/code blocks
for (const flexMsg of processed.flexMessages) {
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
return { channel: "line", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const send = getLineRuntime().channel.line.sendMessageLine;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
});
return { channel: "line", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: ({ account }) => {
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
if (!account.channelAccessToken?.trim()) {
issues.push({
level: "error",
message: "LINE channel access token not configured",
});
}
if (!account.channelSecret?.trim()) {
issues.push({
level: "error",
message: "LINE channel secret not configured",
});
}
return issues;
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.channelAccessToken?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`;
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as ClawdbotConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if (
"channelAccessToken" in nextEntry ||
"channelSecret" in nextEntry ||
"tokenFile" in nextEntry ||
"secretFile" in nextEntry
) {
cleared = true;
delete nextEntry.channelAccessToken;
delete nextEntry.channelSecret;
delete nextEntry.tokenFile;
delete nextEntry.secretFile;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLine.accounts;
changed = true;
} else {
nextLine.accounts = accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### LINE Rich Messages",
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
"",
"**Quick Replies** (bottom button suggestions):",
" [[quick_replies: Option 1, Option 2, Option 3]]",
"",
"**Location** (map pin):",
" [[location: Place Name | Address | latitude | longitude]]",
"",
"**Confirm Dialog** (yes/no prompt):",
" [[confirm: Question text? | Yes Label | No Label]]",
"",
"**Button Menu** (title + text + buttons):",
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
"",
"**Media Player Card** (music status):",
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
" - Status: 'playing' or 'paused' (optional)",
"",
"**Event Card** (calendar events, meetings):",
" [[event: Event Title | Date | Time | Location | Description]]",
" - Time, Location, Description are optional",
"",
"**Agenda Card** (multiple events/schedule):",
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
"",
"**Device Control Card** (smart devices, TVs, etc.):",
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
"",
"**Apple TV Remote** (full D-pad + transport):",
" [[appletv_remote: Apple TV | Playing]]",
"",
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
"",
"When to use rich messages:",
"- Use [[quick_replies:...]] when offering 2-4 clear options",
"- Use [[confirm:...]] for yes/no decisions",
"- Use [[buttons:...]] for menus with actions/links",
"- Use [[location:...]] when sharing a place",
"- Use [[media_player:...]] when showing what's playing",
"- Use [[event:...]] for calendar event details",
"- Use [[agenda:...]] for a day's schedule or event list",
"- Use [[device:...]] for smart device status/controls",
"- Tables/code in your response auto-convert to visual cards",
],
},
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setLineRuntime(r: PluginRuntime): void {
runtime = r;
}
export function getLineRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("LINE runtime not initialized - plugin not registered");
}
return runtime;
}

View File

@@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
const contentType =
"info" in content && content.info && "mimetype" in content.info
? (content.info as { mimetype?: string }).mimetype
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize =
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});

View File

@@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
const client = {
downloadContent,
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
@@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
file,
});
expect(decryptMedia).toHaveBeenCalled();
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
@@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});

View File

@@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
@@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// Download the encrypted content
const encryptedBuffer = await params.client.downloadContent(params.file.url);
if (encryptedBuffer.byteLength > params.maxBytes) {
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(params.file);
if (decrypted.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
// Decrypt using matrix-bot-sdk crypto
const decrypted = await params.client.crypto.decryptMedia(
Buffer.from(encryptedBuffer),
params.file,
);
return { buffer: decrypted };
}
@@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
@@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: {
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (
typeof params.sizeBytes === "number" &&
params.sizeBytes > params.maxBytes
) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media

View File

@@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import type {
OpenAIRealtimeSTTProvider,
RealtimeSTTSession,
} from "./providers/stt-openai-realtime.js";
import { MediaStreamHandler } from "./media-stream.js";
const createStubSession = (): RealtimeSTTSession => ({
connect: async () => {},
sendAudio: () => {},
waitForTranscript: async () => "",
onPartial: () => {},
onTranscript: () => {},
onSpeechStart: () => {},
close: () => {},
isConnected: () => true,
});
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
({
createSession: () => createStubSession(),
}) as unknown as OpenAIRealtimeSTTProvider;
const flush = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0));
};
const waitForAbort = (signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
describe("MediaStreamHandler TTS queue", () => {
it("serializes TTS playback and resolves in order", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
const started: number[] = [];
const finished: number[] = [];
let resolveFirst!: () => void;
const firstGate = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const first = handler.queueTts("stream-1", async () => {
started.push(1);
await firstGate;
finished.push(1);
});
const second = handler.queueTts("stream-1", async () => {
started.push(2);
finished.push(2);
});
await flush();
expect(started).toEqual([1]);
resolveFirst();
await first;
await second;
expect(started).toEqual([1, 2]);
expect(finished).toEqual([1, 2]);
});
it("cancels active playback and clears queued items", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
let queuedRan = false;
const started: string[] = [];
const active = handler.queueTts("stream-1", async (signal) => {
started.push("active");
await waitForAbort(signal);
});
void handler.queueTts("stream-1", async () => {
queuedRan = true;
});
await flush();
expect(started).toEqual(["active"]);
handler.clearTtsQueue("stream-1");
await active;
await flush();
expect(queuedRan).toBe(false);
});
});

View File

@@ -29,6 +29,8 @@ export interface MediaStreamConfig {
onPartialTranscript?: (callId: string, partial: string) => void;
/** Callback when stream connects */
onConnect?: (callId: string, streamSid: string) => void;
/** Callback when speech starts (barge-in) */
onSpeechStart?: (callId: string) => void;
/** Callback when stream disconnects */
onDisconnect?: (callId: string) => void;
}
@@ -43,6 +45,13 @@ interface StreamSession {
sttSession: RealtimeSTTSession;
}
type TtsQueueEntry = {
playFn: (signal: AbortSignal) => Promise<void>;
controller: AbortController;
resolve: () => void;
reject: (error: unknown) => void;
};
/**
* Manages WebSocket connections for Twilio media streams.
*/
@@ -50,6 +59,12 @@ export class MediaStreamHandler {
private wss: WebSocketServer | null = null;
private sessions = new Map<string, StreamSession>();
private config: MediaStreamConfig;
/** TTS playback queues per stream (serialize audio to prevent overlap) */
private ttsQueues = new Map<string, TtsQueueEntry[]>();
/** Whether TTS is currently playing per stream */
private ttsPlaying = new Map<string, boolean>();
/** Active TTS playback controllers per stream */
private ttsActiveControllers = new Map<string, AbortController>();
constructor(config: MediaStreamConfig) {
this.config = config;
@@ -148,6 +163,10 @@ export class MediaStreamHandler {
this.config.onTranscript?.(callSid, transcript);
});
sttSession.onSpeechStart(() => {
this.config.onSpeechStart?.(callSid);
});
const session: StreamSession = {
callId: callSid,
streamSid,
@@ -177,6 +196,7 @@ export class MediaStreamHandler {
private handleStop(session: StreamSession): void {
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
this.clearTtsState(session.streamSid);
session.sttSession.close();
this.sessions.delete(session.streamSid);
this.config.onDisconnect?.(session.callId);
@@ -228,6 +248,46 @@ export class MediaStreamHandler {
this.sendToStream(streamSid, { event: "clear", streamSid });
}
/**
* Queue a TTS operation for sequential playback.
* Only one TTS operation plays at a time per stream to prevent overlap.
*/
async queueTts(
streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> {
const queue = this.getTtsQueue(streamSid);
let resolveEntry: () => void;
let rejectEntry: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolveEntry = resolve;
rejectEntry = reject;
});
queue.push({
playFn,
controller: new AbortController(),
resolve: resolveEntry!,
reject: rejectEntry!,
});
if (!this.ttsPlaying.get(streamSid)) {
void this.processQueue(streamSid);
}
return promise;
}
/**
* Clear TTS queue and interrupt current playback (barge-in).
*/
clearTtsQueue(streamSid: string): void {
const queue = this.getTtsQueue(streamSid);
queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
@@ -242,11 +302,65 @@ export class MediaStreamHandler {
*/
closeAll(): void {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
const existing = this.ttsQueues.get(streamSid);
if (existing) return existing;
const queue: TtsQueueEntry[] = [];
this.ttsQueues.set(streamSid, queue);
return queue;
}
/**
* Process the TTS queue for a stream.
* Uses iterative approach to avoid stack accumulation from recursion.
*/
private async processQueue(streamSid: string): Promise<void> {
this.ttsPlaying.set(streamSid, true);
while (true) {
const queue = this.ttsQueues.get(streamSid);
if (!queue || queue.length === 0) {
this.ttsPlaying.set(streamSid, false);
this.ttsActiveControllers.delete(streamSid);
return;
}
const entry = queue.shift()!;
this.ttsActiveControllers.set(streamSid, entry.controller);
try {
await entry.playFn(entry.controller.signal);
entry.resolve();
} catch (error) {
if (entry.controller.signal.aborted) {
entry.resolve();
} else {
console.error("[MediaStream] TTS playback error:", error);
entry.reject(error);
}
} finally {
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
this.ttsActiveControllers.delete(streamSid);
}
}
}
}
private clearTtsState(streamSid: string): void {
const queue = this.ttsQueues.get(streamSid);
if (queue) queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
}
/**

View File

@@ -38,6 +38,8 @@ export interface RealtimeSTTSession {
onPartial(callback: (partial: string) => void): void;
/** Set callback for final transcripts */
onTranscript(callback: (transcript: string) => void): void;
/** Set callback when speech starts (VAD) */
onSpeechStart(callback: () => void): void;
/** Close the session */
close(): void;
/** Check if session is connected */
@@ -91,6 +93,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
private pendingTranscript = "";
private onTranscriptCallback: ((transcript: string) => void) | null = null;
private onPartialCallback: ((partial: string) => void) | null = null;
private onSpeechStartCallback: (() => void) | null = null;
constructor(
private readonly apiKey: string,
@@ -243,6 +246,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
case "input_audio_buffer.speech_started":
console.log("[RealtimeSTT] Speech started");
this.pendingTranscript = "";
this.onSpeechStartCallback?.();
break;
case "error":
@@ -273,6 +277,10 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
this.onTranscriptCallback = callback;
}
onSpeechStart(callback: () => void): void {
this.onSpeechStartCallback = callback;
}
async waitForTranscript(timeoutMs = 30000): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {

View File

@@ -135,6 +135,17 @@ export class TwilioProvider implements VoiceCallProvider {
this.callStreamMap.delete(callSid);
}
/**
* Clear TTS queue for a call (barge-in).
* Used when user starts speaking to interrupt current TTS playback.
*/
clearTtsQueue(callSid: string): void {
const streamSid = this.callStreamMap.get(callSid);
if (streamSid && this.mediaStreamHandler) {
this.mediaStreamHandler.clearTtsQueue(streamSid);
}
}
/**
* Make an authenticated request to the Twilio API.
*/
@@ -504,7 +515,7 @@ export class TwilioProvider implements VoiceCallProvider {
/**
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations.
* Uses a queue to serialize playback and prevent overlapping audio.
*/
private async playTtsViaStream(
text: string,
@@ -514,22 +525,29 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required");
}
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160;
const CHUNK_DELAY_MS = 20;
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
this.mediaStreamHandler.sendAudio(streamSid, chunk);
const handler = this.mediaStreamHandler;
const ttsProvider = this.ttsProvider;
await handler.queueTts(streamSid, async (signal) => {
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
if (signal.aborted) break;
handler.sendAudio(streamSid, chunk);
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
}
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
if (signal.aborted) break;
}
// Send a mark to track when audio finishes
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
if (!signal.aborted) {
// Send a mark to track when audio finishes
handler.sendMark(streamSid, `tts-${Date.now()}`);
}
});
}
/**

View File

@@ -78,6 +78,11 @@ export class VoiceCallWebhookServer {
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
);
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
}
// Look up our internal call ID from the provider call ID
const call = this.manager.getCallByProviderCallId(providerCallId);
if (!call) {
@@ -109,6 +114,11 @@ export class VoiceCallWebhookServer {
});
}
},
onSpeechStart: (providerCallId) => {
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
}
},
onPartialTranscript: (callId, partial) => {
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
},

View File

@@ -42,6 +42,7 @@
"dist/signal/**",
"dist/slack/**",
"dist/telegram/**",
"dist/line/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
@@ -154,6 +155,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.49.3",
"@mariozechner/pi-ai": "0.49.3",

28
pnpm-lock.yaml generated
View File

@@ -34,6 +34,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.4
version: 1.3.4
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0
'@lydell/node-pty':
specifier: 1.2.0-beta.3
version: 1.2.0-beta.3
@@ -317,6 +320,12 @@ importers:
extensions/imessage: {}
extensions/line:
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/llm-task: {}
extensions/lobster: {}
@@ -1260,6 +1269,10 @@ packages:
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@line/bot-sdk@10.6.0':
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
engines: {node: '>=20'}
'@lit-labs/signals@0.2.0':
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
@@ -2647,6 +2660,9 @@ packages:
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
'@types/node@24.10.9':
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
@@ -6721,6 +6737,14 @@ snapshots:
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
'@line/bot-sdk@10.6.0':
dependencies:
'@types/node': 24.10.9
optionalDependencies:
axios: 1.13.2(debug@4.4.3)
transitivePeerDependencies:
- debug
'@lit-labs/signals@0.2.0':
dependencies:
lit: 3.3.2
@@ -8298,6 +8322,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@24.10.9':
dependencies:
undici-types: 7.16.0
'@types/node@25.0.10':
dependencies:
undici-types: 7.16.0

View File

@@ -28,9 +28,10 @@ const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "",
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
// Keep worker counts predictable for local runs and for CI on macOS.
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning",

View File

@@ -157,4 +157,44 @@ describe("agents_list", () => {
const research = agents?.find((agent) => agent.id === "research");
expect(research?.configured).toBe(false);
});
it("uses requesterAgentIdOverride when resolving allowlists", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "cron-owner",
subagents: {
allowAgents: ["research"],
},
},
{
id: "research",
name: "Research",
},
],
},
};
const tool = createClawdbotTools({
agentSessionKey: "cron:job-1",
requesterAgentIdOverride: "cron-owner",
}).find((candidate) => candidate.name === "agents_list");
if (!tool) throw new Error("missing agents_list tool");
const result = await tool.execute("call5", {});
const agents = (
result.details as {
agents?: Array<{ id: string }>;
}
).agents;
expect(agents?.map((agent) => agent.id)).toEqual(["cron-owner", "research"]);
expect(result.details).toMatchObject({
requester: "cron-owner",
});
});
});

View File

@@ -87,6 +87,52 @@ describe("clawdbot-tools: subagents", () => {
});
expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true);
});
it("sessions_spawn honors requesterAgentIdOverride for cron sessions", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "cron-owner",
subagents: {
allowAgents: ["research"],
},
},
],
},
};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
return { runId: "run-2", status: "accepted", acceptedAt: 5200 };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "cron:job-1",
requesterAgentIdOverride: "cron-owner",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call11", {
task: "do thing",
agentId: "research",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-2",
});
});
it("sessions_spawn forbids cross-agent spawning when not allowed", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();

View File

@@ -54,6 +54,8 @@ export function createClawdbotTools(options?: {
hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Explicit agent ID override for cron/hook sessions. */
requesterAgentIdOverride?: string;
}): AnyAgentTool[] {
const imageTool = options?.agentDir?.trim()
? createImageTool({
@@ -105,7 +107,10 @@ export function createClawdbotTools(options?: {
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
createAgentsListTool({
agentSessionKey: options?.agentSessionKey,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
}),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
@@ -129,6 +134,7 @@ export function createClawdbotTools(options?: {
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
}),
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
@@ -144,10 +150,12 @@ export function createClawdbotTools(options?: {
config: options?.config,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
agentId: resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
}),
agentId:
options?.requesterAgentIdOverride ??
resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
}),
sessionKey: options?.agentSessionKey,
messageChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,

View File

@@ -1,252 +1,139 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import { describe, it, expect, vi } from "vitest";
import {
buildAllowedModelSet,
modelKey,
parseModelRef,
resolveAllowedModelRef,
resolveHooksGmailModel,
resolveModelRefFromString,
resolveConfiguredModelRef,
buildModelAliasIndex,
normalizeProviderId,
modelKey,
} from "./model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";
const catalog = [
{
provider: "openai",
id: "gpt-4",
name: "GPT-4",
},
];
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("includes the default model when no allowlist is set", () => {
const cfg = {
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("allows explicit custom providers from models.providers", () => {
const cfg = {
agents: {
defaults: {
models: {
"moonshot/kimi-k2-0905-preview": { alias: "kimi" },
},
},
},
models: {
mode: "merge",
providers: {
moonshot: {
baseUrl: "https://api.moonshot.ai/v1",
apiKey: "x",
api: "openai-completions",
models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }],
},
},
},
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog: [],
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
});
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
});
});
describe("parseModelRef", () => {
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
expect(ref).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
describe("model-selection", () => {
describe("normalizeProviderId", () => {
it("should normalize provider names", () => {
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
expect(normalizeProviderId("Z.ai")).toBe("zai");
expect(normalizeProviderId("z-ai")).toBe("zai");
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
});
});
it("normalizes google gemini 3 models to preview ids", () => {
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-flash-preview",
});
});
it("normalizes default-provider google models", () => {
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
});
});
describe("resolveHooksGmailModel", () => {
it("returns null when hooks.gmail.model is not set", () => {
const cfg = {} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("returns null when hooks.gmail.model is empty", () => {
const cfg = {
hooks: { gmail: { model: "" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("parses provider/model from hooks.gmail.model", () => {
const cfg = {
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "openrouter",
model: "meta-llama/llama-3.3-70b:free",
});
});
it("resolves alias from agent.models", () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
},
},
},
hooks: { gmail: { model: "Sonnet" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
});
});
it("uses default provider when model omits provider", () => {
const cfg = {
hooks: { gmail: { model: "claude-haiku-3-5" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: "anthropic",
});
expect(result).toEqual({
provider: "anthropic",
model: "claude-haiku-3-5",
});
});
});
describe("resolveAllowedModelRef", () => {
it("resolves aliases when allowed", () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
},
},
},
} satisfies ClawdbotConfig;
const resolved = resolveAllowedModelRef({
cfg,
catalog: [
{
provider: "anthropic",
id: "claude-sonnet-4-1",
name: "Sonnet",
},
],
raw: "Sonnet",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
});
expect("error" in resolved).toBe(false);
if ("ref" in resolved) {
expect(resolved.ref).toEqual({
describe("parseModelRef", () => {
it("should parse full model refs", () => {
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
model: "claude-3-5-sonnet",
});
}
});
it("should use default provider if none specified", () => {
expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({
provider: "anthropic",
model: "claude-3-5-sonnet",
});
});
it("should return null for empty strings", () => {
expect(parseModelRef("", "anthropic")).toBeNull();
expect(parseModelRef(" ", "anthropic")).toBeNull();
});
it("should handle invalid slash usage", () => {
expect(parseModelRef("/", "anthropic")).toBeNull();
expect(parseModelRef("anthropic/", "anthropic")).toBeNull();
expect(parseModelRef("/model", "anthropic")).toBeNull();
});
});
it("rejects disallowed models", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "GPT4" },
describe("buildModelAliasIndex", () => {
it("should build alias index from config", () => {
const cfg: Partial<ClawdbotConfig> = {
agents: {
defaults: {
models: {
"anthropic/claude-3-5-sonnet": { alias: "fast" },
"openai/gpt-4o": { alias: "smart" },
},
},
},
},
} satisfies ClawdbotConfig;
const resolved = resolveAllowedModelRef({
cfg,
catalog: [
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
],
raw: "anthropic/claude-sonnet-4-1",
defaultProvider: "openai",
defaultModel: "gpt-4",
};
const index = buildModelAliasIndex({
cfg: cfg as ClawdbotConfig,
defaultProvider: "anthropic",
});
expect(index.byAlias.get("fast")?.ref).toEqual({
provider: "anthropic",
model: "claude-3-5-sonnet",
});
expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" });
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
});
expect(resolved).toEqual({
error: "model not allowed: anthropic/claude-sonnet-4-1",
});
describe("resolveModelRefFromString", () => {
it("should resolve from string with alias", () => {
const index = {
byAlias: new Map([
["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
]),
byKey: new Map(),
};
const resolved = resolveModelRefFromString({
raw: "fast",
defaultProvider: "openai",
aliasIndex: index,
});
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
expect(resolved?.alias).toBe("fast");
});
it("should resolve direct ref if no alias match", () => {
const resolved = resolveModelRefFromString({
raw: "openai/gpt-4",
defaultProvider: "anthropic",
});
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
});
});
describe("resolveConfiguredModelRef", () => {
it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const cfg: Partial<ClawdbotConfig> = {
agents: {
defaults: {
model: "claude-3-5-sonnet",
},
},
};
const result = resolveConfiguredModelRef({
cfg: cfg as ClawdbotConfig,
defaultProvider: "google",
defaultModel: "gemini-pro",
});
expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'),
);
warnSpy.mockRestore();
});
it("should use default provider/model if config is empty", () => {
const cfg: Partial<ClawdbotConfig> = {};
const result = resolveConfiguredModelRef({
cfg: cfg as ClawdbotConfig,
defaultProvider: "openai",
defaultModel: "gpt-4",
});
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
});
});
});

View File

@@ -131,14 +131,24 @@ export function resolveConfiguredModelRef(params: {
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
if (!trimmed.includes("/")) {
const aliasKey = normalizeAliasKey(trimmed);
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
if (aliasMatch) return aliasMatch.ref;
// Default to anthropic if no provider is specified, but warn as this is deprecated.
console.warn(
`[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
);
return { provider: "anthropic", model: trimmed };
}
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed };
}
return { provider: params.defaultProvider, model: params.defaultModel };
}

View File

@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
inlineToolResultsAllowed: false,
});
log.debug(

View File

@@ -293,6 +293,7 @@ export function createClawdbotCodingTools(options?: {
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentDir: options?.agentDir,
requesterAgentIdOverride: agentId,
sandboxRoot,
workspaceDir: options?.workspaceDir,
sandboxed: !!sandbox,

View File

@@ -19,7 +19,11 @@ type AgentListEntry = {
configured: boolean;
};
export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAgentTool {
export function createAgentsListTool(opts?: {
agentSessionKey?: string;
/** Explicit agent ID override for cron/hook sessions. */
requesterAgentIdOverride?: string;
}): AnyAgentTool {
return {
label: "Agents",
name: "agents_list",
@@ -37,7 +41,9 @@ export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAg
})
: alias;
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID,
opts?.requesterAgentIdOverride ??
parseAgentSessionKey(requesterInternalKey)?.agentId ??
DEFAULT_AGENT_ID,
);
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];

View File

@@ -67,6 +67,8 @@ export function createSessionsSpawnTool(opts?: {
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
sandboxed?: boolean;
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
requesterAgentIdOverride?: string;
}): AnyAgentTool {
return {
label: "Sessions",
@@ -129,7 +131,7 @@ export function createSessionsSpawnTool(opts?: {
});
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = requestedAgentId
? normalizeAgentId(requestedAgentId)

View File

@@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/context",
acceptsArgs: true,
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Configure text-to-speech.",
textAlias: "/tts",
acceptsArgs: true,
}),
defineChatCommand({
key: "whoami",
nativeName: "whoami",
@@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
args: [
{
name: "action",
description: "on | off | status | provider | limit | summary | audio | help",
type: "string",
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "stop",
nativeName: "stop",

View File

@@ -32,6 +32,8 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
// (Those aren't valid markdown headers - ATX headers require space after #)
if (/^#+(\s|$)/.test(trimmed)) continue;
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
// Found a non-empty, non-comment line - there's actionable content
return false;
}

View File

@@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
*/
export const handlePluginCommand: CommandHandler = async (
params,
_allowTextCommands,
allowTextCommands,
): Promise<CommandHandlerResult | null> => {
const { command, cfg } = params;
if (!allowTextCommands) return null;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
@@ -36,6 +38,6 @@ export const handlePluginCommand: CommandHandler = async (
return {
shouldContinue: false,
reply: { text: result.text },
reply: result,
};
};

View File

@@ -10,11 +10,26 @@ import {
} from "../../agents/subagent-registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { MsgContext } from "../templating.js";
import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
// Avoid expensive workspace scans during /context tests.
vi.mock("./commands-context-report.js", () => ({
buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => {
const normalized = params.command.commandBodyNormalized;
if (normalized === "/context list") {
return { text: "Injected workspace files:\n- AGENTS.md" };
}
if (normalized === "/context detail") {
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
}
return { text: "/context\n- /context list\nInline shortcut" };
},
}));
let testWorkspaceDir = os.tmpdir();
beforeAll(async () => {
@@ -143,6 +158,29 @@ describe("handleCommands bash alias", () => {
});
});
describe("handleCommands plugin commands", () => {
it("dispatches registered plugin commands", async () => {
clearPluginCommands();
const result = registerPluginCommand("test-plugin", {
name: "card",
description: "Test card",
handler: async () => ({ text: "from plugin" }),
});
expect(result.ok).toBe(true);
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/card", cfg);
const commandResult = await handleCommands(params);
expect(commandResult.shouldContinue).toBe(false);
expect(commandResult.reply?.text).toBe("from plugin");
clearPluginCommands();
});
});
describe("handleCommands identity", () => {
it("returns sender details for /whoami", async () => {
const cfg = {

View File

@@ -138,6 +138,38 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("does not provide onToolResult when routing cross-provider", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
mocks.routeReply.mockClear();
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:999",
});
const replyResolver = async (
_ctx: MsgContext,
opts: GetReplyOptions | undefined,
_cfg: ClawdbotConfig,
) => {
expect(opts?.onToolResult).toBeUndefined();
return { text: "hi" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ text: "hi" }),
}),
);
});
it("fast-aborts without calling the reply resolver", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: true,

View File

@@ -206,6 +206,7 @@ export async function dispatchReplyFromConfig(params: {
const sendPayloadAsync = async (
payload: ReplyPayload,
abortSignal?: AbortSignal,
mirror?: boolean,
): Promise<void> => {
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
// but they're guaranteed non-null when this function is called.
@@ -220,6 +221,7 @@ export async function dispatchReplyFromConfig(params: {
threadId: ctx.MessageThreadId,
cfg,
abortSignal,
mirror,
});
if (!result.ok) {
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
@@ -268,24 +270,6 @@ export async function dispatchReplyFromConfig(params: {
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "tool",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload);
} else {
dispatcher.sendToolResult(ttsPayload);
}
};
return run();
},
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
@@ -297,7 +281,7 @@ export async function dispatchReplyFromConfig(params: {
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal);
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
} else {
dispatcher.sendBlockReply(ttsPayload);
}

View File

@@ -170,6 +170,7 @@ export async function handleInlineActions(params: {
agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentTo: ctx.OriginatingTo ?? ctx.To,
agentThreadId: ctx.MessageThreadId ?? undefined,
requesterAgentIdOverride: agentId,
agentDir,
workspaceDir,
config: cfg,

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { normalizeReplyPayload } from "./normalize-reply.js";
// Keep channelData-only payloads so channel-specific replies survive normalization.
describe("normalizeReplyPayload", () => {
it("keeps channelData-only replies", () => {
const payload = {
channelData: {
line: {
flexMessage: { type: "bubble" },
},
},
};
const normalized = normalizeReplyPayload(payload);
expect(normalized).not.toBeNull();
expect(normalized?.text).toBeUndefined();
expect(normalized?.channelData).toEqual(payload.channelData);
});
});

View File

@@ -6,6 +6,7 @@ import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
export type NormalizeReplyOptions = {
responsePrefix?: string;
@@ -21,13 +22,16 @@ export function normalizeReplyPayload(
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
const hasChannelData = Boolean(
payload.channelData && Object.keys(payload.channelData).length > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
if (!trimmed && !hasMedia && !hasChannelData) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia) return null;
if (!hasMedia && !hasChannelData) return null;
text = "";
}
if (text && !trimmed) {
@@ -39,14 +43,21 @@ export function normalizeReplyPayload(
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
text = stripped.text;
}
if (text) {
text = sanitizeUserFacingText(text);
}
if (!text?.trim() && !hasMedia) return null;
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text };
if (text && hasLineDirectives(text)) {
enrichedPayload = parseLineDirectives(enrichedPayload);
text = enrichedPayload.text;
}
// Resolve template variables in responsePrefix if context is provided
const effectivePrefix = opts.responsePrefixContext
@@ -62,5 +73,5 @@ export function normalizeReplyPayload(
text = `${effectivePrefix} ${text}`;
}
return { ...payload, text };
return { ...enrichedPayload, text };
}

View File

@@ -45,7 +45,8 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
payload.audioAsVoice,
payload.audioAsVoice ||
payload.channelData,
);
}

View File

@@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -379,6 +380,23 @@ describe("routeReply", () => {
}),
);
});
it("skips mirror data when mirror is false", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:main:main",
mirror: false,
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: undefined,
}),
);
});
});
const emptyRegistry = createRegistry([]);

View File

@@ -33,6 +33,8 @@ export type RouteReplyParams = {
cfg: ClawdbotConfig;
/** Optional abort signal for cooperative cancellation. */
abortSignal?: AbortSignal;
/** Mirror reply into session transcript (default: true when sessionKey is set). */
mirror?: boolean;
};
export type RouteReplyResult = {
@@ -118,14 +120,15 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
abortSignal,
mirror: params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
mirror:
params.mirror !== false && params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
});
const last = results.at(-1);

View File

@@ -30,6 +30,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@@ -473,5 +474,14 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("");
lines.push("Plugin commands:");
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
}
}
return lines.join("\n");
}

View File

@@ -52,4 +52,6 @@ export type ReplyPayload = {
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;
/** Channel-specific payload data (per-channel envelope). */
channelData?: Record<string, unknown>;
};

View File

@@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -81,6 +82,10 @@ export type ChannelOutboundContext = {
deps?: OutboundSendDeps;
};
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
payload: ReplyPayload;
};
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
@@ -94,6 +99,7 @@ export type ChannelOutboundAdapter = {
accountId?: string | null;
mode?: ChannelOutboundTargetMode;
}) => { ok: true; to: string } | { ok: false; error: Error };
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;

View File

@@ -134,6 +134,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -34,15 +34,13 @@ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
beforeEach(() => {
dispatchMock.mockReset().mockImplementation(async (params) => {
if ("dispatcher" in params && params.dispatcher) {
params.dispatcher.sendToolResult({ text: "tool update" });
params.dispatcher.sendFinalReply({ text: "final reply" });
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
}
if ("dispatcherOptions" in params && params.dispatcherOptions) {
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
dispatcher.sendToolResult({ text: "tool update" });
dispatcher.sendFinalReply({ text: "final reply" });
await dispatcher.waitForIdle();
markDispatchIdle();
@@ -53,7 +51,7 @@ beforeEach(() => {
});
describe("discord native commands", () => {
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
it("skips tool results for native slash commands", { timeout: 60_000 }, async () => {
const { ChannelType } = await import("@buape/carbon");
const { createDiscordNativeCommand } = await import("./monitor.js");
@@ -97,8 +95,7 @@ describe("discord native commands", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledTimes(1);
expect(followUp).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("tool");
expect(followUp.mock.calls[0]?.[0]?.content).toContain("final");
expect(followUp).toHaveBeenCalledTimes(0);
expect(reply.mock.calls[0]?.[0]?.content).toContain("final");
});
});

View File

@@ -230,8 +230,6 @@ export function createGatewayHttpServer(opts: {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
@@ -239,6 +237,8 @@ export function createGatewayHttpServer(opts: {
})
)
return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {

View File

@@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics,

View File

@@ -40,6 +40,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -81,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -49,6 +49,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -78,6 +79,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import {
connectReq,
getFreePort,
@@ -286,6 +287,70 @@ describe("gateway server auth/connect", () => {
}
});
test("allows control ui with device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: {
trustedProxies: ["127.0.0.1"],
},
} as any);
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { "x-forwarded-for": "203.0.113.10" },
});
const challengePromise = onceMessage<{ payload?: unknown }>(
ws,
(o) => o.type === "event" && o.event === "connect.challenge",
);
await new Promise<void>((resolve) => ws.once("open", resolve));
const challenge = await challengePromise;
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
role: "operator",
scopes: [],
signedAtMs,
token: "secret",
nonce: String(nonce),
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce: String(nonce),
};
const res = await connectReq(ws, {
token: "secret",
device,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(true);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =

View File

@@ -21,6 +21,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -47,6 +48,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],

View File

@@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
@@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
...merged,
gatewayHandlers: merged.gatewayHandlers ?? {},
httpHandlers: merged.httpHandlers ?? [],
httpRoutes: merged.httpRoutes ?? [],
};
};

View File

@@ -56,6 +56,35 @@ describe("createGatewayPluginRequestHandler", () => {
expect(second).toHaveBeenCalledTimes(1);
});
it("handles registered http routes before generic handlers", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/demo",
handler: routeHandler,
source: "route",
},
],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
});
const { res } = makeResponse();
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(routeHandler).toHaveBeenCalledTimes(1);
expect(fallback).not.toHaveBeenCalled();
});
it("logs and responds with 500 when a handler throws", async () => {
const log = { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler

View File

@@ -16,8 +16,30 @@ export function createGatewayPluginRequestHandler(params: {
}): PluginHttpRequestHandler {
const { registry, log } = params;
return async (req, res) => {
if (registry.httpHandlers.length === 0) return false;
for (const entry of registry.httpHandlers) {
const routes = registry.httpRoutes ?? [];
const handlers = registry.httpHandlers ?? [];
if (routes.length === 0 && handlers.length === 0) return false;
if (routes.length > 0) {
const url = new URL(req.url ?? "/", "http://localhost");
const route = routes.find((entry) => entry.path === url.pathname);
if (route) {
try {
await route.handler(req, res);
return true;
} catch (err) {
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Internal Server Error");
}
return true;
}
}
}
for (const entry of handlers) {
try {
const handled = await entry.handler(req, res);
if (handled) return true;

View File

@@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: {
let devicePublicKey: string | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
if (!device) {
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const canSkipDevice =
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
if (isControlUi && !allowInsecureControlUi) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
@@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
if (device && devicePublicKey) {
const skipPairing = allowInsecureControlUi && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
deviceId: device.id,

View File

@@ -138,6 +138,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { testState } from "./test-helpers.mocks.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
installGatewayTestHooks({ scope: "suite" });
@@ -70,6 +72,58 @@ describe("POST /tools/invoke", () => {
await server.close();
});
it("routes tools invoke before plugin HTTP handlers", async () => {
const pluginHandler = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
res.statusCode = 418;
res.end("plugin");
return true;
});
const registry = createTestRegistry();
registry.httpHandlers = [
{
pluginId: "test-plugin",
source: "test",
handler: pluginHandler as unknown as (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
) => Promise<boolean>,
},
];
setTestPluginRegistry(registry);
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
try {
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool: "sessions_list",
action: "json",
args: {},
sessionKey: "main",
}),
});
expect(res.status).toBe(200);
expect(pluginHandler).not.toHaveBeenCalled();
} finally {
await server.close();
resetTestPluginRegistry();
}
});
it("rejects unauthorized when auth mode is token and header is missing", async () => {
testState.agentsConfig = {
list: [

View File

@@ -148,6 +148,7 @@ export async function handleToolsInvokeHttpRequest(
agentSessionKey: sessionKey,
agentChannel: messageChannel ?? undefined,
agentAccountId: accountId,
requesterAgentIdOverride: agentId,
config: cfg,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,

View File

@@ -277,15 +277,12 @@ describe("monitorIMessageProvider", () => {
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
});
it("prefixes tool and final replies with responsePrefix", async () => {
it("prefixes final replies with responsePrefix", async () => {
config = {
...config,
messages: { responsePrefix: "PFX" },
};
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
replyMock.mockResolvedValue({ text: "final reply" });
const run = monitorIMessageProvider();
await waitForSubscribe();
@@ -307,9 +304,8 @@ describe("monitorIMessageProvider", () => {
closeResolve?.();
await run;
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildNodeShellCommand } from "./node-shell.js";
describe("buildNodeShellCommand", () => {
it("uses cmd.exe for win32", () => {
expect(buildNodeShellCommand("echo hi", "win32")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
});
it("uses cmd.exe for windows labels", () => {
expect(buildNodeShellCommand("echo hi", "windows")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
});
it("uses /bin/sh for darwin", () => {
expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]);
});
it("uses /bin/sh when platform missing", () => {
expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]);
});
});

View File

@@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
if (normalized.includes("win")) {
if (normalized.startsWith("win")) {
return ["cmd.exe", "/d", "/s", "/c", command];
}
return ["/bin/sh", "-lc", command];

View File

@@ -311,6 +311,28 @@ describe("deliverOutboundPayloads", () => {
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
});
it("passes normalized payload to onError", async () => {
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const cfg: ClawdbotConfig = {};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
);
});
it("mirrors delivered output when mirror options are provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {

View File

@@ -22,7 +22,7 @@ import {
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeOutboundPayloads } from "./payloads.js";
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js";
@@ -69,6 +69,7 @@ type ChannelHandler = {
chunker: Chunker | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sendPayload?: (payload: ReplyPayload) => Promise<OutboundDeliveryResult>;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
};
@@ -132,6 +133,21 @@ function createPluginHandler(params: {
chunker,
chunkerMode,
textChunkLimit: outbound.textChunkLimit,
sendPayload: outbound.sendPayload
? async (payload) =>
outbound.sendPayload!({
cfg: params.cfg,
to: params.to,
text: payload.text ?? "",
mediaUrl: payload.mediaUrl,
accountId: params.accountId,
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
deps: params.deps,
payload,
})
: undefined,
sendText: async (text) =>
sendText({
cfg: params.cfg,
@@ -294,24 +310,33 @@ export async function deliverOutboundPayloads(params: {
})),
};
};
const normalizedPayloads = normalizeOutboundPayloads(payloads);
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
channelData: payload.channelData,
};
try {
throwIfAborted(abortSignal);
params.onPayload?.(payload);
if (payload.mediaUrls.length === 0) {
params.onPayload?.(payloadSummary);
if (handler.sendPayload && payload.channelData) {
results.push(await handler.sendPayload(payload));
continue;
}
if (payloadSummary.mediaUrls.length === 0) {
if (isSignalChannel) {
await sendSignalTextChunks(payload.text);
await sendSignalTextChunks(payloadSummary.text);
} else {
await sendTextChunks(payload.text);
await sendTextChunks(payloadSummary.text);
}
continue;
}
let first = true;
for (const url of payload.mediaUrls) {
for (const url of payloadSummary.mediaUrls) {
throwIfAborted(abortSignal);
const caption = first ? payload.text : "";
const caption = first ? payloadSummary.text : "";
first = false;
if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url));
@@ -321,7 +346,7 @@ export async function deliverOutboundPayloads(params: {
}
} catch (err) {
if (!params.bestEffort) throw err;
params.onError?.(err, payload);
params.onError?.(err, payloadSummary);
}
}
if (params.mirror && results.length > 0) {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
import {
formatOutboundPayloadLog,
normalizeOutboundPayloads,
normalizeOutboundPayloadsForJson,
} from "./payloads.js";
describe("normalizeOutboundPayloadsForJson", () => {
it("normalizes payloads with mediaUrl and mediaUrls", () => {
@@ -11,16 +15,18 @@ describe("normalizeOutboundPayloadsForJson", () => {
{ text: "multi", mediaUrls: ["https://x.test/1.png"] },
]),
).toEqual([
{ text: "hi", mediaUrl: null, mediaUrls: undefined },
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
{
text: "photo",
mediaUrl: "https://x.test/a.jpg",
mediaUrls: ["https://x.test/a.jpg"],
channelData: undefined,
},
{
text: "multi",
mediaUrl: null,
mediaUrls: ["https://x.test/1.png"],
channelData: undefined,
},
]);
});
@@ -37,11 +43,20 @@ describe("normalizeOutboundPayloadsForJson", () => {
text: "",
mediaUrl: null,
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
channelData: undefined,
},
]);
});
});
describe("normalizeOutboundPayloads", () => {
it("keeps channelData-only payloads", () => {
const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } };
const normalized = normalizeOutboundPayloads([{ channelData }]);
expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]);
});
});
describe("formatOutboundPayloadLog", () => {
it("trims trailing text and appends media lines", () => {
expect(

View File

@@ -5,12 +5,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
export type NormalizedOutboundPayload = {
text: string;
mediaUrls: string[];
channelData?: Record<string, unknown>;
};
export type OutboundPayloadJson = {
text: string;
mediaUrl: string | null;
mediaUrls?: string[];
channelData?: Record<string, unknown>;
};
function mergeMediaUrls(...lists: Array<Array<string | undefined> | undefined>): string[] {
@@ -58,11 +60,23 @@ export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): Rep
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
return normalizeReplyPayloadsForDelivery(payloads)
.map((payload) => ({
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
}))
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
.map((payload) => {
const channelData = payload.channelData;
const normalized: NormalizedOutboundPayload = {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
};
if (channelData && Object.keys(channelData).length > 0) {
normalized.channelData = channelData;
}
return normalized;
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrls.length > 0 ||
Boolean(payload.channelData && Object.keys(payload.channelData).length > 0),
);
}
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
@@ -70,6 +84,7 @@ export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): Outb
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
channelData: payload.channelData,
}));
}

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

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

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

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

View File

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

View File

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

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

@@ -0,0 +1,48 @@
export type NormalizedAllowFrom = {
entries: string[];
hasWildcard: boolean;
hasEntries: boolean;
};
function normalizeAllowEntry(value: string | number): string {
const trimmed = String(value).trim();
if (!trimmed) return "";
if (trimmed === "*") return "*";
return trimmed.replace(/^line:(?:user:)?/i, "");
}
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean);
const hasWildcard = entries.includes("*");
return {
entries,
hasWildcard,
hasEntries: entries.length > 0,
};
};
export const normalizeAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])];
return normalizeAllowFrom(combined);
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
};
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
}): boolean => {
const { allow, senderId } = params;
if (!allow.hasEntries) return false;
if (allow.hasWildcard) return true;
if (!senderId) return false;
return allow.entries.includes(senderId);
};

View File

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

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

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,53 @@
import { z } from "zod";
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
const LineGroupConfigSchema = z
.object({
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
systemPrompt: z.string().optional(),
skills: z.array(z.string()).optional(),
})
.strict();
const LineAccountConfigSchema = z
.object({
enabled: z.boolean().optional(),
channelAccessToken: z.string().optional(),
channelSecret: z.string().optional(),
tokenFile: z.string().optional(),
secretFile: z.string().optional(),
name: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
})
.strict();
export const LineConfigSchema = z
.object({
enabled: z.boolean().optional(),
channelAccessToken: z.string().optional(),
channelSecret: z.string().optional(),
tokenFile: z.string().optional(),
secretFile: z.string().optional(),
name: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
})
.strict();
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;

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

@@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { messagingApi } from "@line/bot-sdk";
import { logVerbose } from "../globals.js";
interface DownloadResult {
path: string;
contentType?: string;
size: number;
}
export async function downloadLineMedia(
messageId: string,
channelAccessToken: string,
maxBytes = 10 * 1024 * 1024,
): Promise<DownloadResult> {
const client = new messagingApi.MessagingApiBlobClient({
channelAccessToken,
});
const response = await client.getMessageContent(messageId);
// response is a Readable stream
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of response as AsyncIterable<Buffer>) {
totalSize += chunk.length;
if (totalSize > maxBytes) {
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Determine content type from magic bytes
const contentType = detectContentType(buffer);
const ext = getExtensionForContentType(contentType);
// Write to temp file
const tempDir = os.tmpdir();
const fileName = `line-media-${messageId}-${Date.now()}${ext}`;
const filePath = path.join(tempDir, fileName);
await fs.promises.writeFile(filePath, buffer);
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
return {
path: filePath,
contentType,
size: buffer.length,
};
}
function detectContentType(buffer: Buffer): string {
// Check magic bytes
if (buffer.length >= 2) {
// JPEG
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
// PNG
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return "image/png";
}
// GIF
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return "image/gif";
}
// WebP
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return "image/webp";
}
// MP4
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "video/mp4";
}
// M4A/AAC
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x00) {
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "audio/mp4";
}
}
}
return "application/octet-stream";
}
function getExtensionForContentType(contentType: string): string {
switch (contentType) {
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "video/mp4":
return ".mp4";
case "audio/mp4":
return ".m4a";
case "audio/mpeg":
return ".mp3";
default:
return ".bin";
}
}

View File

@@ -0,0 +1,499 @@
import { describe, expect, it } from "vitest";
import {
createInfoCard,
createListCard,
createImageCard,
createActionCard,
createCarousel,
createNotificationBubble,
createReceiptCard,
createEventCard,
createAgendaCard,
createMediaPlayerCard,
createAppleTvRemoteCard,
createDeviceControlCard,
toFlexMessage,
} from "./flex-templates.js";
describe("createInfoCard", () => {
it("creates a bubble with title and body", () => {
const card = createInfoCard("Test Title", "Test body content");
expect(card.type).toBe("bubble");
expect(card.size).toBe("mega");
expect(card.body).toBeDefined();
expect(card.body?.type).toBe("box");
});
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
expect(card.footer).toBeDefined();
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
it("omits footer when not provided", () => {
const card = createInfoCard("Title", "Body");
expect(card.footer).toBeUndefined();
});
});
describe("createListCard", () => {
it("creates a list with title and items", () => {
const items = [{ title: "Item 1", subtitle: "Description 1" }, { title: "Item 2" }];
const card = createListCard("My List", items);
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("limits items to 8", () => {
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
const card = createListCard("List", items);
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
// The list items are in the third content (after title and separator)
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
it("includes actions on items when provided", () => {
const items = [
{
title: "Clickable",
action: { type: "message" as const, label: "Click", text: "clicked" },
},
];
const card = createListCard("List", items);
expect(card.body).toBeDefined();
});
});
describe("createImageCard", () => {
it("creates a card with hero image", () => {
const card = createImageCard("https://example.com/image.jpg", "Image Title");
expect(card.type).toBe("bubble");
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/image.jpg");
});
it("includes body text when provided", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
const body = card.body as { contents: Array<{ text: string }> };
expect(body.contents.length).toBe(2);
expect(body.contents[1].text).toBe("Body text");
});
it("applies custom aspect ratio", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", undefined, {
aspectRatio: "16:9",
});
expect((card.hero as { aspectRatio: string }).aspectRatio).toBe("16:9");
});
});
describe("createActionCard", () => {
it("creates a card with action buttons", () => {
const actions = [
{ label: "Action 1", action: { type: "message" as const, label: "Act1", text: "action1" } },
{
label: "Action 2",
action: { type: "uri" as const, label: "Act2", uri: "https://example.com" },
},
];
const card = createActionCard("Title", "Description", actions);
expect(card.type).toBe("bubble");
expect(card.footer).toBeDefined();
const footer = card.footer as { contents: Array<{ type: string }> };
expect(footer.contents.length).toBe(2);
});
it("limits actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => ({
label: `Action ${i}`,
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
}));
const card = createActionCard("Title", "Body", actions);
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBe(4);
});
it("includes hero image when provided", () => {
const card = createActionCard("Title", "Body", [], {
imageUrl: "https://example.com/hero.jpg",
});
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/hero.jpg");
});
});
describe("createCarousel", () => {
it("creates a carousel from bubbles", () => {
const bubbles = [createInfoCard("Card 1", "Body 1"), createInfoCard("Card 2", "Body 2")];
const carousel = createCarousel(bubbles);
expect(carousel.type).toBe("carousel");
expect(carousel.contents.length).toBe(2);
});
it("limits to 12 bubbles", () => {
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
const carousel = createCarousel(bubbles);
expect(carousel.contents.length).toBe(12);
});
});
describe("createNotificationBubble", () => {
it("creates a simple notification", () => {
const bubble = createNotificationBubble("Hello world");
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("applies notification type styling", () => {
const successBubble = createNotificationBubble("Success!", { type: "success" });
const errorBubble = createNotificationBubble("Error!", { type: "error" });
expect(successBubble.body).toBeDefined();
expect(errorBubble.body).toBeDefined();
});
it("includes title when provided", () => {
const bubble = createNotificationBubble("Details here", {
title: "Alert Title",
});
expect(bubble.body).toBeDefined();
});
});
describe("createReceiptCard", () => {
it("creates a receipt with items", () => {
const card = createReceiptCard({
title: "Order Receipt",
items: [
{ name: "Item A", value: "$10" },
{ name: "Item B", value: "$20" },
],
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes total when provided", () => {
const card = createReceiptCard({
title: "Receipt",
items: [{ name: "Item", value: "$10" }],
total: { label: "Total", value: "$10" },
});
expect(card.body).toBeDefined();
});
it("includes footer when provided", () => {
const card = createReceiptCard({
title: "Receipt",
items: [{ name: "Item", value: "$10" }],
footer: "Thank you!",
});
expect(card.footer).toBeDefined();
});
});
describe("createMediaPlayerCard", () => {
it("creates a basic player card", () => {
const card = createMediaPlayerCard({
title: "Bohemian Rhapsody",
subtitle: "Queen",
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes album art when provided", () => {
const card = createMediaPlayerCard({
title: "Track Name",
imageUrl: "https://example.com/album.jpg",
});
expect(card.hero).toBeDefined();
expect((card.hero as { url: string }).url).toBe("https://example.com/album.jpg");
});
it("shows playing status", () => {
const card = createMediaPlayerCard({
title: "Track",
isPlaying: true,
});
expect(card.body).toBeDefined();
});
it("includes playback controls", () => {
const card = createMediaPlayerCard({
title: "Track",
controls: {
previous: { data: "action=prev" },
play: { data: "action=play" },
pause: { data: "action=pause" },
next: { data: "action=next" },
},
});
expect(card.footer).toBeDefined();
});
it("includes extra actions", () => {
const card = createMediaPlayerCard({
title: "Track",
extraActions: [
{ label: "Add to Playlist", data: "action=add_playlist" },
{ label: "Share", data: "action=share" },
],
});
expect(card.footer).toBeDefined();
});
});
describe("createDeviceControlCard", () => {
it("creates a device card with controls", () => {
const card = createDeviceControlCard({
deviceName: "Apple TV",
deviceType: "Streaming Box",
controls: [
{ label: "Play/Pause", data: "action=playpause" },
{ label: "Menu", data: "action=menu" },
],
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
expect(card.footer).toBeDefined();
});
it("shows device status", () => {
const card = createDeviceControlCard({
deviceName: "Apple TV",
status: "Playing",
controls: [{ label: "Pause", data: "action=pause" }],
});
expect(card.body).toBeDefined();
});
it("includes device image", () => {
const card = createDeviceControlCard({
deviceName: "Device",
imageUrl: "https://example.com/device.jpg",
controls: [],
});
expect(card.hero).toBeDefined();
});
it("limits controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
expect(card.footer).toBeDefined();
// Should have max 3 rows of 2 buttons
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
});
describe("createAppleTvRemoteCard", () => {
it("creates an Apple TV remote card with controls", () => {
const card = createAppleTvRemoteCard({
deviceName: "Apple TV",
status: "Playing",
actionData: {
up: "action=up",
down: "action=down",
left: "action=left",
right: "action=right",
select: "action=select",
menu: "action=menu",
home: "action=home",
play: "action=play",
pause: "action=pause",
volumeUp: "action=volume_up",
volumeDown: "action=volume_down",
mute: "action=mute",
},
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
});
describe("createEventCard", () => {
it("creates an event card with required fields", () => {
const card = createEventCard({
title: "Team Meeting",
date: "January 24, 2026",
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes time when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
time: "2:00 PM - 3:00 PM",
});
expect(card.body).toBeDefined();
});
it("includes location when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
location: "Conference Room A",
});
expect(card.body).toBeDefined();
});
it("includes description when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
description: "Discuss Q1 roadmap",
});
expect(card.body).toBeDefined();
});
it("includes all optional fields together", () => {
const card = createEventCard({
title: "Team Offsite",
date: "February 15, 2026",
time: "9:00 AM - 5:00 PM",
location: "Mountain View Office",
description: "Annual team building event",
});
expect(card.type).toBe("bubble");
expect(card.body).toBeDefined();
});
it("includes action when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
action: { type: "uri", label: "Join", uri: "https://meet.google.com/abc" },
});
expect(card.body).toBeDefined();
expect((card.body as { action?: unknown }).action).toBeDefined();
});
it("includes calendar name when provided", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
calendar: "Work Calendar",
});
expect(card.body).toBeDefined();
});
it("uses mega size for better readability", () => {
const card = createEventCard({
title: "Meeting",
date: "Jan 24",
});
expect(card.size).toBe("mega");
});
});
describe("createAgendaCard", () => {
it("creates an agenda card with title and events", () => {
const card = createAgendaCard({
title: "Today's Schedule",
events: [
{ title: "Team Meeting", time: "9:00 AM" },
{ title: "Lunch", time: "12:00 PM" },
],
});
expect(card.type).toBe("bubble");
expect(card.size).toBe("mega");
expect(card.body).toBeDefined();
});
it("limits events to 8", () => {
const manyEvents = Array.from({ length: 15 }, (_, i) => ({
title: `Event ${i + 1}`,
}));
const card = createAgendaCard({
title: "Many Events",
events: manyEvents,
});
expect(card.body).toBeDefined();
});
it("includes footer when provided", () => {
const card = createAgendaCard({
title: "Today",
events: [{ title: "Event" }],
footer: "Synced from Google Calendar",
});
expect(card.footer).toBeDefined();
});
it("shows event metadata (time, location, calendar)", () => {
const card = createAgendaCard({
title: "Schedule",
events: [
{
title: "Meeting",
time: "10:00 AM",
location: "Room A",
calendar: "Work",
},
],
});
expect(card.body).toBeDefined();
});
});
describe("toFlexMessage", () => {
it("wraps a container in a FlexMessage", () => {
const bubble = createInfoCard("Title", "Body");
const message = toFlexMessage("Alt text", bubble);
expect(message.type).toBe("flex");
expect(message.altText).toBe("Alt text");
expect(message.contents).toBe(bubble);
});
});

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@@ -0,0 +1,449 @@
import { describe, expect, it } from "vitest";
import {
extractMarkdownTables,
extractCodeBlocks,
extractLinks,
stripMarkdown,
processLineMessage,
convertTableToFlexBubble,
convertCodeBlockToFlexBubble,
hasMarkdownToConvert,
} from "./markdown-to-line.js";
describe("extractMarkdownTables", () => {
it("extracts a simple 2-column table", () => {
const text = `Here is a table:
| Name | Value |
|------|-------|
| foo | 123 |
| bar | 456 |
And some more text.`;
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Name", "Value"]);
expect(tables[0].rows).toEqual([
["foo", "123"],
["bar", "456"],
]);
expect(textWithoutTables).toContain("Here is a table:");
expect(textWithoutTables).toContain("And some more text.");
expect(textWithoutTables).not.toContain("|");
});
it("extracts a multi-column table", () => {
const text = `| Col A | Col B | Col C |
|-------|-------|-------|
| 1 | 2 | 3 |
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Col A", "Col B", "Col C"]);
expect(tables[0].rows).toHaveLength(2);
});
it("extracts multiple tables", () => {
const text = `Table 1:
| A | B |
|---|---|
| 1 | 2 |
Table 2:
| X | Y |
|---|---|
| 3 | 4 |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(2);
expect(tables[0].headers).toEqual(["A", "B"]);
expect(tables[1].headers).toEqual(["X", "Y"]);
});
it("handles tables with alignment markers", () => {
const text = `| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
});
it("returns empty when no tables present", () => {
const text = "Just some plain text without tables.";
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(0);
expect(textWithoutTables).toBe(text);
});
});
describe("extractCodeBlocks", () => {
it("extracts a code block with language", () => {
const text = `Here is some code:
\`\`\`javascript
const x = 1;
console.log(x);
\`\`\`
And more text.`;
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBe("javascript");
expect(codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
expect(textWithoutCode).toContain("Here is some code:");
expect(textWithoutCode).toContain("And more text.");
expect(textWithoutCode).not.toContain("```");
});
it("extracts a code block without language", () => {
const text = `\`\`\`
plain code
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBeUndefined();
expect(codeBlocks[0].code).toBe("plain code");
});
it("extracts multiple code blocks", () => {
const text = `\`\`\`python
print("hello")
\`\`\`
Some text
\`\`\`bash
echo "world"
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(2);
expect(codeBlocks[0].language).toBe("python");
expect(codeBlocks[1].language).toBe("bash");
});
it("returns empty when no code blocks present", () => {
const text = "No code here, just text.";
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(0);
expect(textWithoutCode).toBe(text);
});
});
describe("extractLinks", () => {
it("extracts markdown links", () => {
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
expect(textWithLinks).toBe("Check out Google and GitHub.");
});
it("handles text without links", () => {
const text = "No links here.";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(0);
expect(textWithLinks).toBe(text);
});
});
describe("stripMarkdown", () => {
it("strips bold markers", () => {
expect(stripMarkdown("This is **bold** text")).toBe("This is bold text");
expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text");
});
it("strips italic markers", () => {
expect(stripMarkdown("This is *italic* text")).toBe("This is italic text");
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
});
it("strips strikethrough markers", () => {
expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text");
});
it("strips headers", () => {
expect(stripMarkdown("# Heading 1")).toBe("Heading 1");
expect(stripMarkdown("## Heading 2")).toBe("Heading 2");
expect(stripMarkdown("### Heading 3")).toBe("Heading 3");
});
it("strips blockquotes", () => {
expect(stripMarkdown("> This is a quote")).toBe("This is a quote");
expect(stripMarkdown(">This is also a quote")).toBe("This is also a quote");
});
it("removes horizontal rules", () => {
expect(stripMarkdown("Above\n---\nBelow")).toBe("Above\n\nBelow");
expect(stripMarkdown("Above\n***\nBelow")).toBe("Above\n\nBelow");
});
it("strips inline code markers", () => {
expect(stripMarkdown("Use `const` keyword")).toBe("Use const keyword");
});
it("handles complex markdown", () => {
const input = `# Title
This is **bold** and *italic* text.
> A quote
Some ~~deleted~~ content.`;
const result = stripMarkdown(input);
expect(result).toContain("Title");
expect(result).toContain("This is bold and italic text.");
expect(result).toContain("A quote");
expect(result).toContain("Some deleted content.");
expect(result).not.toContain("#");
expect(result).not.toContain("**");
expect(result).not.toContain("~~");
expect(result).not.toContain(">");
});
});
describe("convertTableToFlexBubble", () => {
it("creates a receipt-style card for 2-column tables", () => {
const table = {
headers: ["Item", "Price"],
rows: [
["Apple", "$1"],
["Banana", "$2"],
],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("creates a multi-column layout for 3+ column tables", () => {
const table = {
headers: ["A", "B", "C"],
rows: [["1", "2", "3"]],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("replaces empty cells with placeholders", () => {
const table = {
headers: ["A", "B"],
rows: [["", ""]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
};
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
expect(rowsBox.contents[0].contents[0].text).toBe("-");
expect(rowsBox.contents[0].contents[1].text).toBe("-");
});
it("strips bold markers and applies weight for fully bold cells", () => {
const table = {
headers: ["**Name**", "Status"],
rows: [["**Alpha**", "OK"]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
};
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
expect(headerRow.contents[0].text).toBe("Name");
expect(headerRow.contents[0].weight).toBe("bold");
expect(dataRow.contents[0].text).toBe("Alpha");
expect(dataRow.contents[0].weight).toBe("bold");
});
});
describe("convertCodeBlockToFlexBubble", () => {
it("creates a code card with language label", () => {
const block = { language: "typescript", code: "const x = 1;" };
const bubble = convertCodeBlockToFlexBubble(block);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code (typescript)");
});
it("creates a code card without language", () => {
const block = { code: "plain code" };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code");
});
it("truncates very long code", () => {
const longCode = "x".repeat(3000);
const block = { code: longCode };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
const codeText = body.contents[1].contents[0].text;
expect(codeText.length).toBeLessThan(longCode.length);
expect(codeText).toContain("...");
});
});
describe("processLineMessage", () => {
it("processes text with tables", () => {
const text = `Here's the data:
| Key | Value |
|-----|-------|
| a | 1 |
Done.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.flexMessages[0].type).toBe("flex");
expect(result.text).toContain("Here's the data:");
expect(result.text).toContain("Done.");
expect(result.text).not.toContain("|");
});
it("processes text with code blocks", () => {
const text = `Check this code:
\`\`\`js
console.log("hi");
\`\`\`
That's it.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.text).toContain("Check this code:");
expect(result.text).toContain("That's it.");
expect(result.text).not.toContain("```");
});
it("processes text with markdown formatting", () => {
const text = "This is **bold** and *italic* text.";
const result = processLineMessage(text);
expect(result.text).toBe("This is bold and italic text.");
expect(result.flexMessages).toHaveLength(0);
});
it("handles mixed content", () => {
const text = `# Summary
Here's **important** info:
| Item | Count |
|------|-------|
| A | 5 |
\`\`\`python
print("done")
\`\`\`
> Note: Check the link [here](https://example.com).`;
const result = processLineMessage(text);
// Should have 2 flex messages (table + code)
expect(result.flexMessages).toHaveLength(2);
// Text should be cleaned
expect(result.text).toContain("Summary");
expect(result.text).toContain("important");
expect(result.text).toContain("Note: Check the link here.");
expect(result.text).not.toContain("#");
expect(result.text).not.toContain("**");
expect(result.text).not.toContain("|");
expect(result.text).not.toContain("```");
expect(result.text).not.toContain("[here]");
});
it("handles plain text unchanged", () => {
const text = "Just plain text with no markdown.";
const result = processLineMessage(text);
expect(result.text).toBe(text);
expect(result.flexMessages).toHaveLength(0);
});
});
describe("hasMarkdownToConvert", () => {
it("detects tables", () => {
const text = `| A | B |
|---|---|
| 1 | 2 |`;
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects code blocks", () => {
const text = "```js\ncode\n```";
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects bold", () => {
expect(hasMarkdownToConvert("**bold**")).toBe(true);
});
it("detects strikethrough", () => {
expect(hasMarkdownToConvert("~~deleted~~")).toBe(true);
});
it("detects headers", () => {
expect(hasMarkdownToConvert("# Title")).toBe(true);
});
it("detects blockquotes", () => {
expect(hasMarkdownToConvert("> quote")).toBe(true);
});
it("returns false for plain text", () => {
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
});
});

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More