Compare commits

..
132 changed files with 880 additions and 12975 deletions

No files matched your search

+24 -28
View File
@@ -32,21 +32,20 @@ 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
@@ -109,12 +108,6 @@ 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:
@@ -125,11 +118,16 @@ 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
@@ -214,12 +212,6 @@ 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:
@@ -230,11 +222,16 @@ 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
@@ -282,21 +279,20 @@ 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
+4 -3
View File
@@ -14,10 +14,11 @@ jobs:
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v3
with:
version: 10.23.0
run_install: false
version: 10
- name: Enable Corepack
run: corepack enable
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
+1 -8
View File
@@ -12,9 +12,7 @@ Docs: https://docs.clawd.bot
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
- 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.
@@ -28,11 +26,8 @@ Docs: https://docs.clawd.bot
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
### Fixes
- Gemini CLI OAuth: auto-detect client id from installed CLI and harden discovery paths. (#1773) Thanks @benostein.
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
- 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.
@@ -42,7 +37,6 @@ Docs: https://docs.clawd.bot
- 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.
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
- 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.
@@ -57,7 +51,6 @@ Docs: https://docs.clawd.bot
- 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)
- 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.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
-10
View File
@@ -40,13 +40,3 @@ 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!
@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
Task { await self.handleReceiveFailure(err) }
case let .success(msg):
Task {
await self.handle(msg)
await self.listen()
await self.handle(msg)
}
}
}
@@ -619,29 +619,6 @@ 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 {
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
]
do {
try await channel.send(method: "node.event", params: params)
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
}
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
])
}
do {
try await channel.send(method: "node.invoke.result", params: params)
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
}
@@ -0,0 +1,212 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private var queue: [URLSessionWebSocketTask.Message] = []
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
var state: URLSessionTask.State = .running
func resume() {}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
state = .canceling
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
guard case let .data(data) = message else { return }
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
switch frame.method {
case "connect":
enqueueResponse(id: frame.id, payload: helloOkPayload())
default:
enqueueResponse(id: frame.id, payload: ["ok": true])
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
try await withCheckedThrowingContinuation { cont in
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
cont.resume(returning: msg)
return
}
pendingContinuation = cont
lock.unlock()
}
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
completionHandler(.success(msg))
return
}
pendingHandler = completionHandler
lock.unlock()
}
func enqueue(_ message: URLSessionWebSocketTask.Message) {
lock.lock()
if let handler = pendingHandler {
pendingHandler = nil
lock.unlock()
handler(.success(message))
return
}
if let continuation = pendingContinuation {
pendingContinuation = nil
lock.unlock()
continuation.resume(returning: message)
return
}
queue.append(message)
lock.unlock()
}
private func enqueueResponse(id: String, payload: [String: Any]) {
let response = ResponseFrame(
type: "res",
id: id,
ok: true,
payload: ClawdbotProtocol.AnyCodable(payload),
error: nil)
guard let data = try? encoder.encode(response) else { return }
enqueue(.data(data))
}
private func helloOkPayload() -> [String: Any] {
[
"type": "hello.ok",
"protocol": 1,
"server": [:],
"features": [:],
"snapshot": [
"presence": [],
"health": [:],
"stateVersion": [
"presence": 0,
"health": 0,
],
"uptimeMs": 0,
],
"policy": [
"tickIntervalMs": 1000,
],
]
}
}
private final class FakeWebSocketSession: WebSocketSessioning {
let task: FakeWebSocketTask
init(task: FakeWebSocketTask) {
self.task = task
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: task)
}
}
private actor AsyncSignal {
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
private var stored: Result<Void, Error>?
func finish(_ result: Result<Void, Error>) {
if let continuation {
self.continuation = nil
continuation.resume(returning: result)
return
}
stored = result
}
func wait() async throws {
let result = await withCheckedContinuation { cont in
if let stored {
self.stored = nil
cont.resume(returning: stored)
return
}
continuation = cont
}
switch result {
case .success:
return
case let .failure(error):
throw error
}
}
}
private enum TestError: Error {
case timeout
}
struct GatewayChannelTests {
@Test
func listenRearmsBeforePushHandler() async throws {
let task = FakeWebSocketTask()
let session = FakeWebSocketSession(task: task)
let signal = AsyncSignal()
let url = URL(string: "ws://example.invalid")!
final class ChannelBox { var channel: GatewayChannelActor? }
let box = ChannelBox()
let channel = GatewayChannelActor(
url: url,
token: nil,
session: WebSocketSessionBox(session: session),
pushHandler: { push in
guard case let .event(evt) = push, evt.event == "test.event" else { return }
guard let channel = box.channel else { return }
let params: [String: ClawdbotKit.AnyCodable] = [
"event": ClawdbotKit.AnyCodable("test"),
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
]
do {
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
await signal.finish(.success(()))
} catch {
await signal.finish(.failure(error))
}
})
box.channel = channel
let challenge = EventFrame(
type: "event",
event: "connect.challenge",
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
seq: nil,
stateversion: nil)
let encoder = JSONEncoder()
task.enqueue(.data(try encoder.encode(challenge)))
try await channel.connect()
let event = EventFrame(
type: "event",
event: "test.event",
payload: ClawdbotProtocol.AnyCodable([:]),
seq: nil,
stateversion: nil)
task.enqueue(.data(try encoder.encode(event)))
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
try await signal.wait()
}
}
}
+2 -3
View File
@@ -2847,9 +2847,8 @@ 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 for the Control UI and skips
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`.
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
Related docs:
- [Control UI](/web/control-ui)
+1 -1
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** and skips device pairing (even on HTTPS). This is a security
to **token-only auth** on plain HTTP and skips device pairing. 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.
-41
View File
@@ -1,41 +0,0 @@
# 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).
+2 -2
View File
@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
}
```
This disables device identity + pairing for the Control UI (even on HTTPS). Use
only if you trust the network.
This disables device identity + pairing for the Control UI. Use only if you
trust the network.
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
+1 -12
View File
@@ -18,18 +18,7 @@ Restart the Gateway after enabling.
clawdbot models auth login --provider google-gemini-cli --set-default
```
## Requirements
If the Gemini CLI is installed, Clawdbot extracts the OAuth client ID automatically.
```bash
brew install gemini-cli
# or: npm install -g @google/gemini-cli
```
## Env vars (optional)
Override auto-detected credentials with:
## Env vars
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
@@ -1,205 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { join } from "node:path";
// Mock fs module before importing the module under test
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockRealpathSync = vi.fn();
const mockReaddirSync = vi.fn();
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
};
});
describe("extractGeminiCliCredentials", () => {
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
const FAKE_OAUTH2_CONTENT = `
const clientId = "${FAKE_CLIENT_ID}";
const clientSecret = "${FAKE_CLIENT_SECRET}";
`;
const FAKE_OAUTH2_ID_ONLY = `
const clientId = "${FAKE_CLIENT_ID}";
`;
let originalPath: string | undefined;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalPath = process.env.PATH;
});
afterEach(() => {
process.env.PATH = originalPath;
});
it("returns null when gemini binary is not in PATH", async () => {
process.env.PATH = "/nonexistent";
mockExistsSync.mockReturnValue(false);
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("extracts credentials from oauth2.js in known path", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_SECRET,
});
});
it("returns null when oauth2.js cannot be found", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath || p === fakeCliRoot);
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("returns null when oauth2.js lacks credentials", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return "// no credentials here";
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("caches credentials after first extraction", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
// First call
const result1 = extractGeminiCliCredentials();
expect(result1).not.toBeNull();
// Second call should use cache (readFileSync not called again)
const readCount = mockReadFileSync.mock.calls.length;
const result2 = extractGeminiCliCredentials();
expect(result2).toEqual(result1);
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
});
it("returns client id when secret is missing", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_ID_ONLY;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: undefined,
});
});
});
+8 -197
View File
@@ -1,7 +1,6 @@
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { delimiter, dirname, isAbsolute, join, normalize } from "node:path";
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
@@ -48,203 +47,15 @@ function resolveEnv(keys: string[]): string | undefined {
return undefined;
}
type GeminiCliClientConfig = { clientId: string; clientSecret?: string };
let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null;
/** @internal */
export function clearCredentialsCache(): void {
cachedGeminiCliCredentials = null;
}
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
export function extractGeminiCliCredentials(): GeminiCliClientConfig | null {
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
try {
const oauthPath = findGeminiCliOAuthPath();
if (!oauthPath) return null;
const content = readFileSync(oauthPath, "utf8");
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/i);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch?.[1] };
return cachedGeminiCliCredentials;
}
} catch {
// Gemini CLI not installed or extraction failed
}
return null;
}
function findGeminiCliOAuthPath(): string | null {
const geminiPath = findInPath("gemini");
if (!geminiPath) return null;
for (const root of getGeminiCliRoots(geminiPath)) {
const searchPaths = [
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"),
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"),
];
for (const p of searchPaths) {
if (existsSync(p)) return p;
}
if (shouldSearchRoot(root)) {
const found = findFile(root, "oauth2.js", 8);
if (found) return found;
}
}
return null;
}
function getGeminiCliRoots(geminiPath: string): string[] {
const roots = new Set<string>();
const resolved = safeRealpath(geminiPath);
for (const p of [geminiPath, resolved].filter(Boolean) as string[]) {
for (const root of extractGeminiCliRootsFromFile(p)) {
roots.add(root);
}
const fromPath = extractGeminiCliRootFromPath(p);
if (fromPath) roots.add(fromPath);
}
for (const globalRoot of getGlobalNodeModulesRoots()) {
const candidate = join(globalRoot, "@google", "gemini-cli");
if (existsSync(candidate)) roots.add(candidate);
}
return [...roots].filter((root) => existsSync(root));
}
function extractGeminiCliRootsFromFile(filePath: string): string[] {
const roots: string[] = [];
try {
const content = readFileSync(filePath, "utf8");
if (!content.includes("gemini-cli")) return roots;
const dir = dirname(filePath);
const regex = /["']([^"'\\r\\n]*@google[\\/]+gemini-cli[\\/]+dist[\\/]+index\\.js)["']/gi;
for (const match of content.matchAll(regex)) {
const resolved = resolveShimPath(dir, match[1]);
const root = extractGeminiCliRootFromPath(resolved);
if (root) roots.push(root);
}
} catch {
// ignore non-text files or missing paths
}
return roots;
}
function resolveShimPath(baseDir: string, rawPath: string): string {
let candidate = rawPath
.replace(/%~?dp0%/gi, baseDir)
.replace(/\$PSScriptRoot/gi, baseDir)
.replace(/__dirname/g, baseDir);
if (!isAbsolute(candidate)) {
candidate = join(baseDir, candidate);
}
return normalize(candidate);
}
function extractGeminiCliRootFromPath(pathValue: string): string | null {
const normalized = pathValue.replace(/\\/g, "/");
const marker = "/@google/gemini-cli/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
return normalize(`${normalized.slice(0, idx)}${marker.slice(0, -1)}`);
}
function safeRealpath(pathValue: string): string | null {
try {
return realpathSync(pathValue);
} catch {
return null;
}
}
function getGlobalNodeModulesRoots(): string[] {
const roots = new Set<string>();
const prefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX ?? process.env.PREFIX;
if (prefix) {
if (process.platform === "win32") {
roots.add(join(prefix, "node_modules"));
} else {
roots.add(join(prefix, "lib", "node_modules"));
}
}
if (process.platform === "win32") {
const appData = process.env.APPDATA;
if (appData) roots.add(join(appData, "npm", "node_modules"));
} else {
roots.add("/usr/local/lib/node_modules");
roots.add("/opt/homebrew/lib/node_modules");
}
const pnpmHome = process.env.PNPM_HOME;
if (pnpmHome) {
roots.add(join(pnpmHome, "global", "5", "node_modules"));
roots.add(join(pnpmHome, "global", "4", "node_modules"));
}
return [...roots].filter((root) => existsSync(root));
}
function shouldSearchRoot(root: string): boolean {
const normalized = root.replace(/\\/g, "/");
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) return false;
const segments = normalized.split("/").filter(Boolean);
return segments.length >= 3;
}
function findInPath(name: string): string | null {
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) return p;
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) return null;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isFile() && entry.name === name) return p;
if (entry.isDirectory() && !entry.name.startsWith(".")) {
const found = findFile(p, name, depth - 1);
if (found) return found;
}
}
} catch {}
return null;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
// 1. Check env vars first (user override)
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
);
}
// 2. Try to extract from installed Gemini CLI
const extracted = extractGeminiCliCredentials();
if (extracted?.clientId) {
return extracted;
}
// 3. No credentials available
throw new Error(
"Gemini CLI credentials not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
);
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
return { clientId, clientSecret };
}
function isWSL(): boolean {
-11
View File
@@ -1,11 +0,0 @@
{
"id": "line",
"channels": [
"line"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { linePlugin } from "./src/channel.js";
import { registerLineCardCommand } from "./src/card-command.js";
import { setLineRuntime } from "./src/runtime.js";
const plugin = {
id: "line",
name: "LINE",
description: "LINE Messaging API channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setLineRuntime(api.runtime);
api.registerChannel({ plugin: linePlugin });
registerLineCardCommand(api);
},
};
export default plugin;
-29
View File
@@ -1,29 +0,0 @@
{
"name": "@clawdbot/line",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "line",
"label": "LINE",
"selectionLabel": "LINE (Messaging API)",
"docsPath": "/channels/line",
"docsLabel": "line",
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
"order": 75,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
}
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}
-338
View File
@@ -1,338 +0,0 @@
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)}` };
}
},
});
}
@@ -1,96 +0,0 @@
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({});
});
});
@@ -1,308 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
type LineRuntimeMocks = {
pushMessageLine: ReturnType<typeof vi.fn>;
pushMessagesLine: ReturnType<typeof vi.fn>;
pushFlexMessage: ReturnType<typeof vi.fn>;
pushTemplateMessage: ReturnType<typeof vi.fn>;
pushLocationMessage: ReturnType<typeof vi.fn>;
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
createQuickReplyItems: ReturnType<typeof vi.fn>;
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
sendMessageLine: ReturnType<typeof vi.fn>;
chunkMarkdownText: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
messageId: "m-quick",
chatId: "c1",
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
const chunkMarkdownText = vi.fn((text: string) => [text]);
const resolveTextChunkLimit = vi.fn(() => 123);
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
const resolved = accountId ?? "default";
const lineConfig = (cfg.channels?.line ?? {}) as {
accounts?: Record<string, Record<string, unknown>>;
};
const accountConfig =
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
return {
accountId: resolved,
config: { ...lineConfig, ...accountConfig },
};
});
const runtime = {
channel: {
line: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
resolveLineAccount,
},
text: {
chunkMarkdownText,
resolveTextChunkLimit,
},
},
} as unknown as PluginRuntime;
return {
runtime,
mocks: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
chunkMarkdownText,
resolveLineAccount,
resolveTextChunkLimit,
},
};
}
describe("linePlugin outbound.sendPayload", () => {
it("sends flex message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Now playing:",
channelData: {
line: {
flexMessage: {
altText: "Now playing",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:group:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
verbose: false,
accountId: "default",
});
});
it("sends template message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Choose one:",
channelData: {
line: {
templateMessage: {
type: "confirm",
text: "Continue?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
verbose: false,
accountId: "default",
});
});
it("attaches quick replies when no text chunks are present", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
channelData: {
line: {
quickReplies: ["One", "Two"],
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:2",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:user:2",
[
{
type: "flex",
altText: "Card",
contents: { type: "bubble" },
quickReply: { items: ["One", "Two"] },
},
],
{ verbose: false, accountId: "default" },
);
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
});
it("sends media before quick-reply text so buttons stay visible", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Hello",
mediaUrl: "https://example.com/img.jpg",
channelData: {
line: {
quickReplies: ["One", "Two"],
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
verbose: false,
mediaUrl: "https://example.com/img.jpg",
accountId: "default",
});
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
"line:user:3",
"Hello",
["One", "Two"],
{ verbose: false, accountId: "default" },
);
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
expect(mediaOrder).toBeLessThan(quickReplyOrder);
});
it("uses configured text chunk limit for payloads", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
const payload = {
text: "Hello world",
channelData: {
line: {
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "primary",
cfg,
});
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
cfg,
"line",
"primary",
{ fallbackLimit: 5000 },
);
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
});
});
describe("linePlugin config.formatAllowFrom", () => {
it("strips line:user: prefixes without lowercasing", () => {
const formatted = linePlugin.config.formatAllowFrom({
allowFrom: ["line:user:UABC", "line:UDEF"],
});
expect(formatted).toEqual(["UABC", "UDEF"]);
});
});
describe("linePlugin groups.resolveRequireMention", () => {
it("uses account-level group settings when provided", () => {
const { runtime } = createRuntime();
setLineRuntime(runtime);
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: false },
},
accounts: {
primary: {
groups: {
"group-1": { requireMention: true },
},
},
},
},
},
} as ClawdbotConfig;
const requireMention = linePlugin.groups.resolveRequireMention({
cfg,
accountId: "primary",
groupId: "group-1",
});
expect(requireMention).toBe(true);
});
});
-773
View File
@@ -1,773 +0,0 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
type ChannelPlugin,
type ClawdbotConfig,
type LineConfig,
type LineChannelData,
type ResolvedLineAccount,
} from "clawdbot/plugin-sdk";
import { getLineRuntime } from "./runtime.js";
// LINE channel metadata
const meta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
};
function parseThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) return undefined;
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
const trimmed = threadId.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
idLabel: "lineUserId",
normalizeAllowEntry: (entry) => {
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
return entry.replace(/^line:(?:user:)?/i, "");
},
notifyApproval: async ({ cfg, id }) => {
const line = getLineRuntime().channel.line;
const account = line.resolveLineAccount({ cfg });
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
channelAccessToken: account.channelAccessToken,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
return {
...cfg,
channels: {
...cfg.channels,
line: rest,
},
};
}
const accounts = { ...lineConfig.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
// LINE sender IDs are case-sensitive; keep original casing.
return entry.replace(/^line:(?:user:)?/i, "");
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.line.accounts.${resolvedAccountId}.`
: "channels.line.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "clawdbot pairing approve line <code>",
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy =
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups;
if (!groups) return false;
const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false;
},
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) return null;
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) =>
getLineRuntime().channel.line.normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
name,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
name,
},
},
},
},
};
},
validateInput: ({ accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
}
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
return "LINE requires channelAccessToken or --token-file (or --use-env).";
}
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
return "LINE requires channelSecret or --secret-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
name?: string;
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.useEnv
? {}
: typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.useEnv
? {}
: typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
const quickReply = hasQuickReplies
? createQuickReplyItems(lineData.quickReplies!)
: undefined;
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) return;
for (let i = 0; i < messages.length; i += 5) {
const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(
cfg,
"line",
accountId ?? undefined,
{
fallbackLimit: 5000,
},
) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
lastResult = await sendFlex(
to,
lineData.flexMessage.altText,
lineData.flexMessage.contents,
{
verbose: false,
accountId: accountId ?? undefined,
},
);
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i]!, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) continue;
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (lastResult) return { channel: "line", ...lastResult };
return { channel: "line", messageId: "empty", chatId: to };
},
sendText: async ({ to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
// Process markdown: extract tables/code blocks, strip formatting
const processed = processLineMessage(text);
// Send cleaned text first (if non-empty)
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
// If text is empty after processing, still need a result
result = { messageId: "processed", chatId: to };
}
// Send flex messages for tables/code blocks
for (const flexMsg of processed.flexMessages) {
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
return { channel: "line", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const send = getLineRuntime().channel.line.sendMessageLine;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
});
return { channel: "line", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: ({ account }) => {
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
if (!account.channelAccessToken?.trim()) {
issues.push({
level: "error",
message: "LINE channel access token not configured",
});
}
if (!account.channelSecret?.trim()) {
issues.push({
level: "error",
message: "LINE channel secret not configured",
});
}
return issues;
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.channelAccessToken?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`;
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as ClawdbotConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if (
"channelAccessToken" in nextEntry ||
"channelSecret" in nextEntry ||
"tokenFile" in nextEntry ||
"secretFile" in nextEntry
) {
cleared = true;
delete nextEntry.channelAccessToken;
delete nextEntry.channelSecret;
delete nextEntry.tokenFile;
delete nextEntry.secretFile;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLine.accounts;
changed = true;
} else {
nextLine.accounts = accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### LINE Rich Messages",
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
"",
"**Quick Replies** (bottom button suggestions):",
" [[quick_replies: Option 1, Option 2, Option 3]]",
"",
"**Location** (map pin):",
" [[location: Place Name | Address | latitude | longitude]]",
"",
"**Confirm Dialog** (yes/no prompt):",
" [[confirm: Question text? | Yes Label | No Label]]",
"",
"**Button Menu** (title + text + buttons):",
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
"",
"**Media Player Card** (music status):",
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
" - Status: 'playing' or 'paused' (optional)",
"",
"**Event Card** (calendar events, meetings):",
" [[event: Event Title | Date | Time | Location | Description]]",
" - Time, Location, Description are optional",
"",
"**Agenda Card** (multiple events/schedule):",
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
"",
"**Device Control Card** (smart devices, TVs, etc.):",
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
"",
"**Apple TV Remote** (full D-pad + transport):",
" [[appletv_remote: Apple TV | Playing]]",
"",
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
"",
"When to use rich messages:",
"- Use [[quick_replies:...]] when offering 2-4 clear options",
"- Use [[confirm:...]] for yes/no decisions",
"- Use [[buttons:...]] for menus with actions/links",
"- Use [[location:...]] when sharing a place",
"- Use [[media_player:...]] when showing what's playing",
"- Use [[event:...]] for calendar event details",
"- Use [[agenda:...]] for a day's schedule or event list",
"- Use [[device:...]] for smart device status/controls",
"- Tables/code in your response auto-convert to visual cards",
],
},
};
-14
View File
@@ -1,14 +0,0 @@
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;
}
@@ -329,20 +329,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
const contentType =
"info" in content && content.info && "mimetype" in content.info
? (content.info as { mimetype?: string }).mimetype
: 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,
});
@@ -25,8 +25,10 @@ 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;
@@ -53,8 +55,7 @@ describe("downloadMatrixMedia", () => {
file,
});
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(decryptMedia).toHaveBeenCalled();
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
@@ -63,41 +64,4 @@ 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();
});
});
+9 -12
View File
@@ -40,7 +40,6 @@ 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;
@@ -51,13 +50,18 @@ async function fetchEncryptedMediaBuffer(params: {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(params.file);
if (decrypted.byteLength > params.maxBytes) {
// Download the encrypted content
const encryptedBuffer = await params.client.downloadContent(params.file.url);
if (encryptedBuffer.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 };
}
@@ -65,7 +69,6 @@ export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
@@ -74,12 +77,6 @@ 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
@@ -29,7 +29,6 @@ export type RoomMessageEventContent = MessageEventContent & {
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
@@ -1,97 +0,0 @@
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);
});
});
-114
View File
@@ -29,8 +29,6 @@ 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;
}
@@ -45,13 +43,6 @@ 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.
*/
@@ -59,12 +50,6 @@ 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;
@@ -163,10 +148,6 @@ export class MediaStreamHandler {
this.config.onTranscript?.(callSid, transcript);
});
sttSession.onSpeechStart(() => {
this.config.onSpeechStart?.(callSid);
});
const session: StreamSession = {
callId: callSid,
streamSid,
@@ -196,7 +177,6 @@ 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);
@@ -248,46 +228,6 @@ 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.
*/
@@ -302,65 +242,11 @@ 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);
}
}
/**
@@ -38,8 +38,6 @@ 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 */
@@ -93,7 +91,6 @@ 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,
@@ -246,7 +243,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
case "input_audio_buffer.speech_started":
console.log("[RealtimeSTT] Speech started");
this.pendingTranscript = "";
this.onSpeechStartCallback?.();
break;
case "error":
@@ -277,10 +273,6 @@ 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(() => {
+11 -29
View File
@@ -135,17 +135,6 @@ 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.
*/
@@ -515,7 +504,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 queue to serialize playback and prevent overlapping audio.
* Uses a jitter buffer to smooth out timing variations.
*/
private async playTtsViaStream(
text: string,
@@ -525,29 +514,22 @@ 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;
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);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
this.mediaStreamHandler.sendAudio(streamSid, chunk);
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
if (signal.aborted) break;
}
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
}
if (!signal.aborted) {
// Send a mark to track when audio finishes
handler.sendMark(streamSid, `tts-${Date.now()}`);
}
});
// Send a mark to track when audio finishes
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
}
/**
-10
View File
@@ -78,11 +78,6 @@ 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) {
@@ -114,11 +109,6 @@ 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}`);
},
-2
View File
@@ -42,7 +42,6 @@
"dist/signal/**",
"dist/slack/**",
"dist/telegram/**",
"dist/line/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
@@ -155,7 +154,6 @@
"@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
View File
@@ -34,9 +34,6 @@ 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
@@ -320,12 +317,6 @@ importers:
extensions/imessage: {}
extensions/line:
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/llm-task: {}
extensions/lobster: {}
@@ -1269,10 +1260,6 @@ 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==}
@@ -2660,9 +2647,6 @@ 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==}
@@ -6737,14 +6721,6 @@ 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
@@ -8322,10 +8298,6 @@ 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
+2 -3
View File
@@ -28,10 +28,9 @@ 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));
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
// Keep worker counts predictable for local runs and for CI on macOS.
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning",
+229 -116
View File
@@ -1,139 +1,252 @@
import { describe, it, expect, vi } from "vitest";
import {
parseModelRef,
resolveModelRefFromString,
resolveConfiguredModelRef,
buildModelAliasIndex,
normalizeProviderId,
modelKey,
} from "./model-selection.js";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import {
buildAllowedModelSet,
modelKey,
parseModelRef,
resolveAllowedModelRef,
resolveHooksGmailModel,
} from "./model-selection.js";
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");
});
});
const catalog = [
{
provider: "openai",
id: "gpt-4",
name: "GPT-4",
},
];
describe("parseModelRef", () => {
it("should parse full model refs", () => {
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
provider: "anthropic",
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();
});
});
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" },
},
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
};
},
} as ClawdbotConfig;
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"]);
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);
});
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(),
};
it("includes the default model when no allowlist is set", () => {
const cfg = {
agents: { defaults: {} },
} as ClawdbotConfig;
const resolved = resolveModelRefFromString({
raw: "fast",
defaultProvider: "openai",
aliasIndex: index,
});
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
expect(resolved?.alias).toBe("fast");
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
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" });
});
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);
});
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",
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 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();
const allowed = buildAllowedModelSet({
cfg,
catalog: [],
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
});
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(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",
});
});
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({
provider: "anthropic",
model: "claude-sonnet-4-1",
});
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
}
});
it("rejects disallowed models", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "GPT4" },
},
},
},
} 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",
});
expect(resolved).toEqual({
error: "model not allowed: anthropic/claude-sonnet-4-1",
});
});
});
+2 -12
View File
@@ -131,24 +131,14 @@ 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 };
}
+1 -1
View File
@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
inlineToolResultsAllowed: false,
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
});
log.debug(
+21 -7
View File
@@ -178,13 +178,6 @@ 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",
@@ -286,6 +279,27 @@ 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",
-2
View File
@@ -32,8 +32,6 @@ 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;
}
+2 -4
View File
@@ -15,12 +15,10 @@ 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;
@@ -38,6 +36,6 @@ export const handlePluginCommand: CommandHandler = async (
return {
shouldContinue: false,
reply: result,
reply: { text: result.text },
};
};
-38
View File
@@ -10,26 +10,11 @@ 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 () => {
@@ -158,29 +143,6 @@ 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 = {
@@ -138,38 +138,6 @@ 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,
+19 -3
View File
@@ -206,7 +206,6 @@ 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.
@@ -221,7 +220,6 @@ 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"}`);
@@ -270,6 +268,24 @@ 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({
@@ -281,7 +297,7 @@ export async function dispatchReplyFromConfig(params: {
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
await sendPayloadAsync(ttsPayload, context?.abortSignal);
} else {
dispatcher.sendBlockReply(ttsPayload);
}
@@ -1,377 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseLineDirectives, hasLineDirectives } from "./line-directives.js";
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
describe("hasLineDirectives", () => {
it("detects quick_replies directive", () => {
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
});
it("detects location directive", () => {
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
});
it("detects confirm directive", () => {
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
});
it("detects buttons directive", () => {
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
});
it("returns false for regular text", () => {
expect(hasLineDirectives("Just regular text")).toBe(false);
});
it("returns false for similar but invalid patterns", () => {
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
});
it("detects media_player directive", () => {
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
});
it("detects event directive", () => {
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
});
it("detects agenda directive", () => {
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
});
it("detects device directive", () => {
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
});
it("detects appletv_remote directive", () => {
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
});
});
describe("parseLineDirectives", () => {
describe("quick_replies", () => {
it("parses quick_replies and removes from text", () => {
const result = parseLineDirectives({
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
});
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
expect(result.text).toBe("Choose one:");
});
it("handles quick_replies in middle of text", () => {
const result = parseLineDirectives({
text: "Before [[quick_replies: A, B]] After",
});
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
expect(result.text).toBe("Before After");
});
it("merges with existing quickReplies", () => {
const result = parseLineDirectives({
text: "Text [[quick_replies: C, D]]",
channelData: { line: { quickReplies: ["A", "B"] } },
});
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
});
});
describe("location", () => {
it("parses location with all fields", () => {
const result = parseLineDirectives({
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
});
expect(getLineData(result).location).toEqual({
title: "Tokyo Station",
address: "Tokyo, Japan",
latitude: 35.6812,
longitude: 139.7671,
});
expect(result.text).toBe("Here's the location:");
});
it("ignores invalid coordinates", () => {
const result = parseLineDirectives({
text: "[[location: Place | Address | invalid | 139.7]]",
});
expect(getLineData(result).location).toBeUndefined();
});
it("does not override existing location", () => {
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
const result = parseLineDirectives({
text: "[[location: New | New Addr | 35.6 | 139.7]]",
channelData: { line: { location: existing } },
});
expect(getLineData(result).location).toEqual(existing);
});
});
describe("confirm", () => {
it("parses simple confirm", () => {
const result = parseLineDirectives({
text: "[[confirm: Delete this item? | Yes | No]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "confirm",
text: "Delete this item?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
altText: "Delete this item?",
});
// Text is undefined when directive consumes entire text
expect(result.text).toBeUndefined();
});
it("parses confirm with custom data", () => {
const result = parseLineDirectives({
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "confirm",
text: "Proceed?",
confirmLabel: "OK",
confirmData: "action=confirm",
cancelLabel: "Cancel",
cancelData: "action=cancel",
altText: "Proceed?",
});
});
});
describe("buttons", () => {
it("parses buttons with message actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "buttons",
title: "Menu",
text: "Select an option",
actions: [
{ type: "message", label: "Help", data: "/help" },
{ type: "message", label: "Status", data: "/status" },
],
altText: "Menu: Select an option",
});
});
it("parses buttons with uri actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.[0]).toEqual({
type: "uri",
label: "Site",
uri: "https://example.com",
});
}
});
it("parses buttons with postback actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.[0]).toEqual({
type: "postback",
label: "Select",
data: "action=select&id=1",
});
}
});
it("limits to 4 actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.length).toBe(4);
}
});
});
describe("media_player", () => {
it("parses media_player with all fields", () => {
const result = parseLineDirectives({
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
});
const flexMessage = getLineData(result).flexMessage as {
altText?: string;
contents?: { footer?: { contents?: unknown[] } };
};
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
expect(result.text).toBe("Now playing:");
});
it("parses media_player with minimal fields", () => {
const result = parseLineDirectives({
text: "[[media_player: Unknown Track]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
});
it("handles paused status", () => {
const result = parseLineDirectives({
text: "[[media_player: Song | Artist | Player | | paused]]",
});
const flexMessage = getLineData(result).flexMessage as {
contents?: { body: { contents: unknown[] } };
};
expect(flexMessage).toBeDefined();
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
expect(contents).toBeDefined();
});
});
describe("event", () => {
it("parses event with all fields", () => {
const result = parseLineDirectives({
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
});
it("parses event with minimal fields", () => {
const result = parseLineDirectives({
text: "[[event: Birthday Party | March 15]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
});
});
describe("agenda", () => {
it("parses agenda with multiple events", () => {
const result = parseLineDirectives({
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
});
it("parses agenda with events without times", () => {
const result = parseLineDirectives({
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
});
});
describe("device", () => {
it("parses device with controls", () => {
const result = parseLineDirectives({
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📱 TV: Playing");
});
it("parses device with minimal fields", () => {
const result = parseLineDirectives({
text: "[[device: Speaker]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📱 Speaker");
});
});
describe("appletv_remote", () => {
it("parses appletv_remote with status", () => {
const result = parseLineDirectives({
text: "[[appletv_remote: Apple TV | Playing]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toContain("Apple TV");
});
it("parses appletv_remote with minimal fields", () => {
const result = parseLineDirectives({
text: "[[appletv_remote: Apple TV]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
});
});
describe("combined directives", () => {
it("handles text with no directives", () => {
const result = parseLineDirectives({
text: "Just plain text here",
});
expect(result.text).toBe("Just plain text here");
expect(getLineData(result).quickReplies).toBeUndefined();
expect(getLineData(result).location).toBeUndefined();
expect(getLineData(result).templateMessage).toBeUndefined();
});
it("preserves other payload fields", () => {
const result = parseLineDirectives({
text: "Hello [[quick_replies: A, B]]",
mediaUrl: "https://example.com/image.jpg",
replyToId: "msg123",
});
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
expect(result.replyToId).toBe("msg123");
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
});
});
});
-336
View File
@@ -1,336 +0,0 @@
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,
);
}
@@ -1,22 +0,0 @@
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);
});
});
+5 -16
View File
@@ -6,7 +6,6 @@ import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
export type NormalizeReplyOptions = {
responsePrefix?: string;
@@ -22,16 +21,13 @@ 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 && !hasChannelData) return null;
if (!trimmed && !hasMedia) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia && !hasChannelData) return null;
if (!hasMedia) return null;
text = "";
}
if (text && !trimmed) {
@@ -43,21 +39,14 @@ export function normalizeReplyPayload(
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
if (stripped.shouldSkip && !hasMedia) return null;
text = stripped.text;
}
if (text) {
text = sanitizeUserFacingText(text);
}
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;
}
if (!text?.trim() && !hasMedia) return null;
// Resolve template variables in responsePrefix if context is provided
const effectivePrefix = opts.responsePrefixContext
@@ -73,5 +62,5 @@ export function normalizeReplyPayload(
text = `${effectivePrefix} ${text}`;
}
return { ...enrichedPayload, text };
return { ...payload, text };
}
+1 -2
View File
@@ -45,8 +45,7 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
payload.audioAsVoice ||
payload.channelData,
payload.audioAsVoice,
);
}
-18
View File
@@ -72,7 +72,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -380,23 +379,6 @@ 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([]);
+8 -11
View File
@@ -33,8 +33,6 @@ 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 = {
@@ -120,15 +118,14 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
abortSignal,
mirror:
params.mirror !== false && params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
mirror: params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
});
const last = results.at(-1);
-10
View File
@@ -30,7 +30,6 @@ 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";
@@ -474,14 +473,5 @@ 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");
}
-2
View File
@@ -52,6 +52,4 @@ 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>;
};
-1
View File
@@ -13,7 +13,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
-6
View File
@@ -1,5 +1,4 @@
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";
@@ -82,10 +81,6 @@ 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;
@@ -99,7 +94,6 @@ 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>;
-1
View File
@@ -134,7 +134,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
+7 -4
View File
@@ -34,13 +34,15 @@ 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: 0, block: 0, final: 1 } };
return { queuedFinal: true, counts: { tool: 1, 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();
@@ -51,7 +53,7 @@ beforeEach(() => {
});
describe("discord native commands", () => {
it("skips tool results for native slash commands", { timeout: 60_000 }, async () => {
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
const { ChannelType } = await import("@buape/carbon");
const { createDiscordNativeCommand } = await import("./monitor.js");
@@ -95,7 +97,8 @@ describe("discord native commands", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledTimes(1);
expect(followUp).toHaveBeenCalledTimes(0);
expect(reply.mock.calls[0]?.[0]?.content).toContain("final");
expect(followUp).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("tool");
expect(followUp.mock.calls[0]?.[0]?.content).toContain("final");
});
});
+2 -2
View File
@@ -230,6 +230,8 @@ 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,
@@ -237,8 +239,6 @@ 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, {
-1
View File
@@ -18,7 +18,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics,
@@ -40,7 +40,6 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -82,7 +81,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -49,7 +49,6 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -79,7 +78,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
-65
View File
@@ -2,7 +2,6 @@ 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,
@@ -287,70 +286,6 @@ 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 } =
-2
View File
@@ -21,7 +21,6 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -48,7 +47,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -75,7 +75,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -10,7 +10,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
@@ -21,6 +20,5 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
...merged,
gatewayHandlers: merged.gatewayHandlers ?? {},
httpHandlers: merged.httpHandlers ?? [],
httpRoutes: merged.httpRoutes ?? [],
};
};
-29
View File
@@ -56,35 +56,6 @@ 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
+2 -24
View File
@@ -16,30 +16,8 @@ export function createGatewayPluginRequestHandler(params: {
}): PluginHttpRequestHandler {
const { registry, log } = params;
return async (req, res) => {
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) {
if (registry.httpHandlers.length === 0) return false;
for (const entry of registry.httpHandlers) {
try {
const handled = await entry.handler(req, res);
if (handled) return true;
@@ -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 canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const canSkipDevice =
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
if (isControlUi && !allowInsecureControlUi) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
@@ -569,8 +569,7 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
const skipPairing = allowInsecureControlUi && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) {
if (device && devicePublicKey) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
deviceId: device.id,
-1
View File
@@ -138,7 +138,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
+2 -56
View File
@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { testState } from "./test-helpers.mocks.js";
installGatewayTestHooks({ scope: "suite" });
@@ -72,58 +70,6 @@ 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: [
@@ -277,12 +277,15 @@ describe("monitorIMessageProvider", () => {
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
});
it("prefixes final replies with responsePrefix", async () => {
it("prefixes tool and final replies with responsePrefix", async () => {
config = {
...config,
messages: { responsePrefix: "PFX" },
};
replyMock.mockResolvedValue({ text: "final reply" });
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
const run = monitorIMessageProvider();
await waitForSubscribe();
@@ -304,8 +307,9 @@ describe("monitorIMessageProvider", () => {
closeResolve?.();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
-40
View File
@@ -1,40 +0,0 @@
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"]);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
if (normalized.startsWith("win")) {
if (normalized.includes("win")) {
return ["cmd.exe", "/d", "/s", "/c", command];
}
return ["/bin/sh", "-lc", command];
-22
View File
@@ -311,28 +311,6 @@ 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 = {
+9 -34
View File
@@ -22,7 +22,7 @@ import {
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
import { normalizeOutboundPayloads } from "./payloads.js";
import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js";
@@ -69,7 +69,6 @@ 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>;
};
@@ -133,21 +132,6 @@ 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,
@@ -310,33 +294,24 @@ export async function deliverOutboundPayloads(params: {
})),
};
};
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads);
const normalizedPayloads = normalizeOutboundPayloads(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?.(payloadSummary);
if (handler.sendPayload && payload.channelData) {
results.push(await handler.sendPayload(payload));
continue;
}
if (payloadSummary.mediaUrls.length === 0) {
params.onPayload?.(payload);
if (payload.mediaUrls.length === 0) {
if (isSignalChannel) {
await sendSignalTextChunks(payloadSummary.text);
await sendSignalTextChunks(payload.text);
} else {
await sendTextChunks(payloadSummary.text);
await sendTextChunks(payload.text);
}
continue;
}
let first = true;
for (const url of payloadSummary.mediaUrls) {
for (const url of payload.mediaUrls) {
throwIfAborted(abortSignal);
const caption = first ? payloadSummary.text : "";
const caption = first ? payload.text : "";
first = false;
if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url));
@@ -346,7 +321,7 @@ export async function deliverOutboundPayloads(params: {
}
} catch (err) {
if (!params.bestEffort) throw err;
params.onError?.(err, payloadSummary);
params.onError?.(err, payload);
}
}
if (params.mirror && results.length > 0) {
+2 -17
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatOutboundPayloadLog,
normalizeOutboundPayloads,
normalizeOutboundPayloadsForJson,
} from "./payloads.js";
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
describe("normalizeOutboundPayloadsForJson", () => {
it("normalizes payloads with mediaUrl and mediaUrls", () => {
@@ -15,18 +11,16 @@ describe("normalizeOutboundPayloadsForJson", () => {
{ text: "multi", mediaUrls: ["https://x.test/1.png"] },
]),
).toEqual([
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
{ text: "hi", mediaUrl: null, mediaUrls: 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,
},
]);
});
@@ -43,20 +37,11 @@ describe("normalizeOutboundPayloadsForJson", () => {
text: "",
mediaUrl: null,
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
channelData: undefined,
},
]);
});
});
describe("normalizeOutboundPayloads", () => {
it("keeps channelData-only payloads", () => {
const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } };
const normalized = normalizeOutboundPayloads([{ channelData }]);
expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]);
});
});
describe("formatOutboundPayloadLog", () => {
it("trims trailing text and appends media lines", () => {
expect(
+5 -20
View File
@@ -5,14 +5,12 @@ 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[] {
@@ -60,23 +58,11 @@ export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): Rep
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
return normalizeReplyPayloadsForDelivery(payloads)
.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),
);
.map((payload) => ({
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
}))
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
}
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
@@ -84,7 +70,6 @@ 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
View File
@@ -1,199 +0,0 @@
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
View File
@@ -1,179 +0,0 @@
import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js";
import type {
LineConfig,
LineAccountConfig,
ResolvedLineAccount,
LineTokenSource,
} from "./types.js";
export const DEFAULT_ACCOUNT_ID = "default";
function readFileIfExists(filePath: string | undefined): string | undefined {
if (!filePath) return undefined;
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch {
return undefined;
}
}
function resolveToken(params: {
accountId: string;
baseConfig?: LineConfig;
accountConfig?: LineAccountConfig;
}): { token: string; tokenSource: LineTokenSource } {
const { accountId, baseConfig, accountConfig } = params;
// Check account-level config first
if (accountConfig?.channelAccessToken?.trim()) {
return { token: accountConfig.channelAccessToken.trim(), tokenSource: "config" };
}
// Check account-level token file
const accountFileToken = readFileIfExists(accountConfig?.tokenFile);
if (accountFileToken) {
return { token: accountFileToken, tokenSource: "file" };
}
// For default account, check base config and env
if (accountId === DEFAULT_ACCOUNT_ID) {
if (baseConfig?.channelAccessToken?.trim()) {
return { token: baseConfig.channelAccessToken.trim(), tokenSource: "config" };
}
const baseFileToken = readFileIfExists(baseConfig?.tokenFile);
if (baseFileToken) {
return { token: baseFileToken, tokenSource: "file" };
}
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim();
if (envToken) {
return { token: envToken, tokenSource: "env" };
}
}
return { token: "", tokenSource: "none" };
}
function resolveSecret(params: {
accountId: string;
baseConfig?: LineConfig;
accountConfig?: LineAccountConfig;
}): string {
const { accountId, baseConfig, accountConfig } = params;
// Check account-level config first
if (accountConfig?.channelSecret?.trim()) {
return accountConfig.channelSecret.trim();
}
// Check account-level secret file
const accountFileSecret = readFileIfExists(accountConfig?.secretFile);
if (accountFileSecret) {
return accountFileSecret;
}
// For default account, check base config and env
if (accountId === DEFAULT_ACCOUNT_ID) {
if (baseConfig?.channelSecret?.trim()) {
return baseConfig.channelSecret.trim();
}
const baseFileSecret = readFileIfExists(baseConfig?.secretFile);
if (baseFileSecret) {
return baseFileSecret;
}
const envSecret = process.env.LINE_CHANNEL_SECRET?.trim();
if (envSecret) {
return envSecret;
}
}
return "";
}
export function resolveLineAccount(params: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedLineAccount {
const { cfg, accountId = DEFAULT_ACCOUNT_ID } = params;
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const accountConfig = accountId !== DEFAULT_ACCOUNT_ID ? accounts?.[accountId] : undefined;
const { token, tokenSource } = resolveToken({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const secret = resolveSecret({
accountId,
baseConfig: lineConfig,
accountConfig,
});
const mergedConfig: LineConfig & LineAccountConfig = {
...lineConfig,
...accountConfig,
};
const enabled =
accountConfig?.enabled ??
(accountId === DEFAULT_ACCOUNT_ID ? (lineConfig?.enabled ?? true) : false);
const name =
accountConfig?.name ?? (accountId === DEFAULT_ACCOUNT_ID ? lineConfig?.name : undefined);
return {
accountId,
name,
enabled,
channelAccessToken: token,
channelSecret: secret,
tokenSource,
config: mergedConfig,
};
}
export function listLineAccountIds(cfg: ClawdbotConfig): string[] {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const ids = new Set<string>();
// Add default account if configured at base level
if (
lineConfig?.channelAccessToken?.trim() ||
lineConfig?.tokenFile ||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim()
) {
ids.add(DEFAULT_ACCOUNT_ID);
}
// Add named accounts
if (accounts) {
for (const id of Object.keys(accounts)) {
ids.add(id);
}
}
return Array.from(ids);
}
export function resolveDefaultLineAccountId(cfg: ClawdbotConfig): string {
const ids = listLineAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId: string | undefined): string {
const trimmed = accountId?.trim().toLowerCase();
if (!trimmed || trimmed === "default") {
return DEFAULT_ACCOUNT_ID;
}
return trimmed;
}
-202
View File
@@ -1,202 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
const createFlexMessage = (altText: string, contents: unknown) => ({
type: "flex" as const,
altText,
contents,
});
const createImageMessage = (url: string) => ({
type: "image" as const,
originalContentUrl: url,
previewImageUrl: url,
});
const createLocationMessage = (location: {
title: string;
address: string;
latitude: number;
longitude: number;
}) => ({
type: "location" as const,
...location,
});
describe("deliverLineAutoReply", () => {
it("uses reply token for text before sending rich messages", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string) => ({
type: "text" as const,
text,
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
};
const result = await deliverLineAutoReply({
payload: { text: "hello", channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: (text) => ({ text, flexMessages: [] }),
chunkMarkdownText: (text) => [text],
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], {
accountId: "acc",
});
expect(pushMessagesLine).toHaveBeenCalledTimes(1);
expect(pushMessagesLine).toHaveBeenCalledWith(
"line:user:1",
[createFlexMessage("Card", { type: "bubble" })],
{ accountId: "acc" },
);
expect(createQuickReplyItems).not.toHaveBeenCalled();
});
it("uses reply token for rich-only payloads", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string) => ({
type: "text" as const,
text,
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
const result = await deliverLineAutoReply({
payload: { channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: () => ({ text: "", flexMessages: [] }),
chunkMarkdownText: () => [],
sendLineReplyChunks: vi.fn(async () => ({ replyTokenUsed: false })),
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{
...createFlexMessage("Card", { type: "bubble" }),
quickReply: { items: ["A"] },
},
],
{ accountId: "acc" },
);
expect(pushMessagesLine).not.toHaveBeenCalled();
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
});
it("sends rich messages before quick-reply text so quick replies remain visible", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
quickReply: { items: ["A"] },
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" }));
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
quickReplies: ["A"],
};
await deliverLineAutoReply({
payload: { text: "hello", channelData: { line: lineData } },
lineData,
to: "line:user:1",
replyToken: "token",
replyTokenUsed: false,
accountId: "acc",
textLimit: 5000,
deps: {
buildTemplateMessageFromPayload: () => null,
processLineMessage: (text) => ({ text, flexMessages: [] }),
chunkMarkdownText: (text) => [text],
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
createQuickReplyItems,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
},
});
expect(pushMessagesLine).toHaveBeenCalledWith(
"line:user:1",
[createFlexMessage("Card", { type: "bubble" })],
{ accountId: "acc" },
);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{
type: "text",
text: "hello",
quickReply: { items: ["A"] },
},
],
{ accountId: "acc" },
);
const pushOrder = pushMessagesLine.mock.invocationCallOrder[0];
const replyOrder = replyMessageLine.mock.invocationCallOrder[0];
expect(pushOrder).toBeLessThan(replyOrder);
});
});
-180
View File
@@ -1,180 +0,0 @@
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
View File
@@ -1,48 +0,0 @@
export type NormalizedAllowFrom = {
entries: string[];
hasWildcard: boolean;
hasEntries: boolean;
};
function normalizeAllowEntry(value: string | number): string {
const trimmed = String(value).trim();
if (!trimmed) return "";
if (trimmed === "*") return "*";
return trimmed.replace(/^line:(?:user:)?/i, "");
}
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean);
const hasWildcard = entries.includes("*");
return {
entries,
hasWildcard,
hasEntries: entries.length > 0,
};
};
export const normalizeAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])];
return normalizeAllowFrom(combined);
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
};
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
}): boolean => {
const { allow, senderId } = params;
if (!allow.hasEntries) return false;
if (allow.hasWildcard) return true;
if (!senderId) return false;
return allow.entries.includes(senderId);
};
-173
View File
@@ -1,173 +0,0 @@
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
View File
@@ -1,337 +0,0 @@
import type {
WebhookEvent,
MessageEvent,
FollowEvent,
UnfollowEvent,
JoinEvent,
LeaveEvent,
PostbackEvent,
EventSource,
} from "@line/bot-sdk";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildLineMessageContext,
buildLinePostbackContext,
type LineInboundContext,
} from "./bot-message-context.js";
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { downloadLineMedia } from "./download.js";
import { pushMessageLine, replyMessageLine } from "./send.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
interface MediaRef {
path: string;
contentType?: string;
}
export interface LineHandlerContext {
cfg: ClawdbotConfig;
account: ResolvedLineAccount;
runtime: RuntimeEnv;
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
}
type LineSourceInfo = {
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
};
function getSourceInfo(source: EventSource): LineSourceInfo {
const userId =
source.type === "user"
? source.userId
: source.type === "group"
? source.userId
: source.type === "room"
? source.userId
: undefined;
const groupId = source.type === "group" ? source.groupId : undefined;
const roomId = source.type === "room" ? source.roomId : undefined;
const isGroup = source.type === "group" || source.type === "room";
return { userId, groupId, roomId, isGroup };
}
function resolveLineGroupConfig(params: {
config: ResolvedLineAccount["config"];
groupId?: string;
roomId?: string;
}): LineGroupConfig | undefined {
const groups = params.config.groups ?? {};
if (params.groupId) {
return groups[params.groupId] ?? groups[`group:${params.groupId}`] ?? groups["*"];
}
if (params.roomId) {
return groups[params.roomId] ?? groups[`room:${params.roomId}`] ?? groups["*"];
}
return groups["*"];
}
async function sendLinePairingReply(params: {
senderId: string;
replyToken?: string;
context: LineHandlerContext;
}): Promise<void> {
const { senderId, replyToken, context } = params;
const { code, created } = await upsertChannelPairingRequest({
channel: "line",
id: senderId,
});
if (!created) return;
logVerbose(`line pairing request sender=${senderId}`);
const idLabel = (() => {
try {
return resolvePairingIdLabel("line");
} catch {
return "lineUserId";
}
})();
const text = buildPairingReply({
channel: "line",
idLine: `Your ${idLabel}: ${senderId}`,
code,
});
try {
if (replyToken) {
await replyMessageLine(replyToken, [{ type: "text", text }], {
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
return;
}
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
try {
await pushMessageLine(`line:${senderId}`, text, {
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
}
async function shouldProcessLineEvent(
event: MessageEvent | PostbackEvent,
context: LineHandlerContext,
): Promise<boolean> {
const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getSourceInfo(event.source);
const senderId = userId ?? "";
const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []);
const effectiveDmAllow = normalizeAllowFromWithStore({
allowFrom: account.config.allowFrom,
storeAllowFrom,
});
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const groupAllowOverride = groupConfig?.allowFrom;
const fallbackGroupAllowFrom = account.config.allowFrom?.length
? account.config.allowFrom
: undefined;
const groupAllowFrom = firstDefined(
groupAllowOverride,
account.config.groupAllowFrom,
fallbackGroupAllowFrom,
);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowFrom,
storeAllowFrom,
});
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return false;
}
if (typeof groupAllowOverride !== "undefined") {
if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return false;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return false;
}
}
if (groupPolicy === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)");
return false;
}
if (groupPolicy === "allowlist") {
if (!senderId) {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return false;
}
if (!effectiveGroupAllow.hasEntries) {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return false;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return false;
}
}
return true;
}
if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)");
return false;
}
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
if (!dmAllowed) {
if (dmPolicy === "pairing") {
if (!senderId) {
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
return false;
}
await sendLinePairingReply({
senderId,
replyToken: "replyToken" in event ? event.replyToken : undefined,
context,
});
} else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
}
return false;
}
return true;
}
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message;
if (!(await shouldProcessLineEvent(event, context))) return;
// Download media if applicable
const allMedia: MediaRef[] = [];
if (message.type === "image" || message.type === "video" || message.type === "audio") {
try {
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
allMedia.push({
path: media.path,
contentType: media.contentType,
});
} catch (err) {
const errMsg = String(err);
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
logVerbose(`line: media exceeds size limit for message ${message.id}`);
// Continue without media
} else {
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
}
}
}
const messageContext = await buildLineMessageContext({
event,
allMedia,
cfg,
account,
});
if (!messageContext) {
logVerbose("line: skipping empty message");
return;
}
await processMessage(messageContext);
}
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
const userId = event.source.type === "user" ? event.source.userId : undefined;
logVerbose(`line: user ${userId ?? "unknown"} followed`);
// Could implement welcome message here
}
async function handleUnfollowEvent(
event: UnfollowEvent,
_context: LineHandlerContext,
): Promise<void> {
const userId = event.source.type === "user" ? event.source.userId : undefined;
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
}
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
const groupId = event.source.type === "group" ? event.source.groupId : undefined;
const roomId = event.source.type === "room" ? event.source.roomId : undefined;
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handlePostbackEvent(
event: PostbackEvent,
context: LineHandlerContext,
): Promise<void> {
const data = event.postback.data;
logVerbose(`line: received postback: ${data}`);
if (!(await shouldProcessLineEvent(event, context))) return;
const postbackContext = await buildLinePostbackContext({
event,
cfg: context.cfg,
account: context.account,
});
if (!postbackContext) return;
await context.processMessage(postbackContext);
}
export async function handleLineWebhookEvents(
events: WebhookEvent[],
context: LineHandlerContext,
): Promise<void> {
for (const event of events) {
try {
switch (event.type) {
case "message":
await handleMessageEvent(event, context);
break;
case "follow":
await handleFollowEvent(event, context);
break;
case "unfollow":
await handleUnfollowEvent(event, context);
break;
case "join":
await handleJoinEvent(event, context);
break;
case "leave":
await handleLeaveEvent(event, context);
break;
case "postback":
await handlePostbackEvent(event, context);
break;
default:
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
}
} catch (err) {
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
}
}
}
-82
View File
@@ -1,82 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
import type { ClawdbotConfig } from "../config/config.js";
import type { ResolvedLineAccount } from "./types.js";
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
describe("buildLineMessageContext", () => {
let tmpDir: string;
let storePath: string;
let cfg: ClawdbotConfig;
const account: ResolvedLineAccount = {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {},
};
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-line-context-"));
storePath = path.join(tmpDir, "sessions.json");
cfg = { session: { store: storePath } };
});
afterEach(async () => {
await fs.rm(tmpDir, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50,
});
});
it("routes group message replies to the group id", async () => {
const event = {
type: "message",
message: { id: "1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-1" },
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
});
expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1");
expect(context.ctxPayload.To).toBe("line:group:group-1");
});
it("routes group postback replies to the group id", async () => {
const event = {
type: "postback",
postback: { data: "action=select" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-2", userId: "user-2" },
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
} as PostbackEvent;
const context = await buildLinePostbackContext({
event,
cfg,
account,
});
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
expect(context?.ctxPayload.To).toBe("line:group:group-2");
});
});
-465
View File
@@ -1,465 +0,0 @@
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
View File
@@ -1,82 +0,0 @@
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
View File
@@ -1,53 +0,0 @@
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
View File
@@ -1,120 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { messagingApi } from "@line/bot-sdk";
import { logVerbose } from "../globals.js";
interface DownloadResult {
path: string;
contentType?: string;
size: number;
}
export async function downloadLineMedia(
messageId: string,
channelAccessToken: string,
maxBytes = 10 * 1024 * 1024,
): Promise<DownloadResult> {
const client = new messagingApi.MessagingApiBlobClient({
channelAccessToken,
});
const response = await client.getMessageContent(messageId);
// response is a Readable stream
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of response as AsyncIterable<Buffer>) {
totalSize += chunk.length;
if (totalSize > maxBytes) {
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Determine content type from magic bytes
const contentType = detectContentType(buffer);
const ext = getExtensionForContentType(contentType);
// Write to temp file
const tempDir = os.tmpdir();
const fileName = `line-media-${messageId}-${Date.now()}${ext}`;
const filePath = path.join(tempDir, fileName);
await fs.promises.writeFile(filePath, buffer);
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
return {
path: filePath,
contentType,
size: buffer.length,
};
}
function detectContentType(buffer: Buffer): string {
// Check magic bytes
if (buffer.length >= 2) {
// JPEG
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
// PNG
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return "image/png";
}
// GIF
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return "image/gif";
}
// WebP
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return "image/webp";
}
// MP4
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "video/mp4";
}
// M4A/AAC
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x00) {
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "audio/mp4";
}
}
}
return "application/octet-stream";
}
function getExtensionForContentType(contentType: string): string {
switch (contentType) {
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "video/mp4":
return ".mp4";
case "audio/mp4":
return ".m4a";
case "audio/mpeg":
return ".mp3";
default:
return ".bin";
}
}
-499
View File
@@ -1,499 +0,0 @@
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);
});
});
File diff suppressed because it is too large. Load diff
-45
View File
@@ -1,45 +0,0 @@
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
View File
@@ -1,155 +0,0 @@
export {
createLineBot,
createLineWebhookCallback,
type LineBot,
type LineBotOptions,
} from "./bot.js";
export {
monitorLineProvider,
getLineRuntimeState,
type MonitorLineProviderOptions,
type LineProviderMonitor,
} from "./monitor.js";
export {
sendMessageLine,
pushMessageLine,
pushMessagesLine,
replyMessageLine,
createImageMessage,
createLocationMessage,
createFlexMessage,
createQuickReplyItems,
createTextMessageWithQuickReplies,
showLoadingAnimation,
getUserProfile,
getUserDisplayName,
pushImageMessage,
pushLocationMessage,
pushFlexMessage,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
} from "./send.js";
export {
startLineWebhook,
createLineWebhookMiddleware,
type LineWebhookOptions,
type StartLineWebhookOptions,
} from "./webhook.js";
export {
handleLineHttpRequest,
registerLineHttpHandler,
normalizeLineWebhookPath,
} from "./http-registry.js";
export {
resolveLineAccount,
listLineAccountIds,
resolveDefaultLineAccountId,
normalizeAccountId,
DEFAULT_ACCOUNT_ID,
} from "./accounts.js";
export { probeLineBot } from "./probe.js";
export { downloadLineMedia } from "./download.js";
export { LineConfigSchema, type LineConfigSchemaType } from "./config-schema.js";
export { buildLineMessageContext } from "./bot-message-context.js";
export { handleLineWebhookEvents, type LineHandlerContext } from "./bot-handlers.js";
// Flex Message templates
export {
createInfoCard,
createListCard,
createImageCard,
createActionCard,
createCarousel,
createNotificationBubble,
createReceiptCard,
createEventCard,
createMediaPlayerCard,
createAppleTvRemoteCard,
createDeviceControlCard,
toFlexMessage,
type ListItem,
type CardAction,
type FlexContainer,
type FlexBubble,
type FlexCarousel,
} from "./flex-templates.js";
// Markdown to LINE conversion
export {
processLineMessage,
hasMarkdownToConvert,
stripMarkdown,
extractMarkdownTables,
extractCodeBlocks,
extractLinks,
convertTableToFlexBubble,
convertCodeBlockToFlexBubble,
convertLinksToFlexBubble,
type ProcessedLineMessage,
type MarkdownTable,
type CodeBlock,
type MarkdownLink,
} from "./markdown-to-line.js";
// Rich Menu operations
export {
createRichMenu,
uploadRichMenuImage,
setDefaultRichMenu,
cancelDefaultRichMenu,
getDefaultRichMenuId,
linkRichMenuToUser,
linkRichMenuToUsers,
unlinkRichMenuFromUser,
unlinkRichMenuFromUsers,
getRichMenuIdOfUser,
getRichMenuList,
getRichMenu,
deleteRichMenu,
createRichMenuAlias,
deleteRichMenuAlias,
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
type CreateRichMenuParams,
type RichMenuSize,
type RichMenuAreaRequest,
} from "./rich-menu.js";
// Template messages (Button, Confirm, Carousel)
export {
createConfirmTemplate,
createButtonTemplate,
createTemplateCarousel,
createCarouselColumn,
createImageCarousel,
createImageCarouselColumn,
createYesNoConfirm,
createButtonMenu,
createLinkMenu,
createProductCarousel,
messageAction as templateMessageAction,
uriAction as templateUriAction,
postbackAction as templatePostbackAction,
datetimePickerAction as templateDatetimePickerAction,
type TemplateMessage,
type ConfirmTemplate,
type ButtonsTemplate,
type CarouselTemplate,
type CarouselColumn,
} from "./template-messages.js";
export type {
LineConfig,
LineAccountConfig,
LineGroupConfig,
ResolvedLineAccount,
LineTokenSource,
LineMessageType,
LineWebhookContext,
LineSendResult,
LineProbeResult,
} from "./types.js";
-449
View File
@@ -1,449 +0,0 @@
import { describe, expect, it } from "vitest";
import {
extractMarkdownTables,
extractCodeBlocks,
extractLinks,
stripMarkdown,
processLineMessage,
convertTableToFlexBubble,
convertCodeBlockToFlexBubble,
hasMarkdownToConvert,
} from "./markdown-to-line.js";
describe("extractMarkdownTables", () => {
it("extracts a simple 2-column table", () => {
const text = `Here is a table:
| Name | Value |
|------|-------|
| foo | 123 |
| bar | 456 |
And some more text.`;
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Name", "Value"]);
expect(tables[0].rows).toEqual([
["foo", "123"],
["bar", "456"],
]);
expect(textWithoutTables).toContain("Here is a table:");
expect(textWithoutTables).toContain("And some more text.");
expect(textWithoutTables).not.toContain("|");
});
it("extracts a multi-column table", () => {
const text = `| Col A | Col B | Col C |
|-------|-------|-------|
| 1 | 2 | 3 |
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Col A", "Col B", "Col C"]);
expect(tables[0].rows).toHaveLength(2);
});
it("extracts multiple tables", () => {
const text = `Table 1:
| A | B |
|---|---|
| 1 | 2 |
Table 2:
| X | Y |
|---|---|
| 3 | 4 |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(2);
expect(tables[0].headers).toEqual(["A", "B"]);
expect(tables[1].headers).toEqual(["X", "Y"]);
});
it("handles tables with alignment markers", () => {
const text = `| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |`;
const { tables } = extractMarkdownTables(text);
expect(tables).toHaveLength(1);
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
});
it("returns empty when no tables present", () => {
const text = "Just some plain text without tables.";
const { tables, textWithoutTables } = extractMarkdownTables(text);
expect(tables).toHaveLength(0);
expect(textWithoutTables).toBe(text);
});
});
describe("extractCodeBlocks", () => {
it("extracts a code block with language", () => {
const text = `Here is some code:
\`\`\`javascript
const x = 1;
console.log(x);
\`\`\`
And more text.`;
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBe("javascript");
expect(codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
expect(textWithoutCode).toContain("Here is some code:");
expect(textWithoutCode).toContain("And more text.");
expect(textWithoutCode).not.toContain("```");
});
it("extracts a code block without language", () => {
const text = `\`\`\`
plain code
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBeUndefined();
expect(codeBlocks[0].code).toBe("plain code");
});
it("extracts multiple code blocks", () => {
const text = `\`\`\`python
print("hello")
\`\`\`
Some text
\`\`\`bash
echo "world"
\`\`\``;
const { codeBlocks } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(2);
expect(codeBlocks[0].language).toBe("python");
expect(codeBlocks[1].language).toBe("bash");
});
it("returns empty when no code blocks present", () => {
const text = "No code here, just text.";
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
expect(codeBlocks).toHaveLength(0);
expect(textWithoutCode).toBe(text);
});
});
describe("extractLinks", () => {
it("extracts markdown links", () => {
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
expect(textWithLinks).toBe("Check out Google and GitHub.");
});
it("handles text without links", () => {
const text = "No links here.";
const { links, textWithLinks } = extractLinks(text);
expect(links).toHaveLength(0);
expect(textWithLinks).toBe(text);
});
});
describe("stripMarkdown", () => {
it("strips bold markers", () => {
expect(stripMarkdown("This is **bold** text")).toBe("This is bold text");
expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text");
});
it("strips italic markers", () => {
expect(stripMarkdown("This is *italic* text")).toBe("This is italic text");
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
});
it("strips strikethrough markers", () => {
expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text");
});
it("strips headers", () => {
expect(stripMarkdown("# Heading 1")).toBe("Heading 1");
expect(stripMarkdown("## Heading 2")).toBe("Heading 2");
expect(stripMarkdown("### Heading 3")).toBe("Heading 3");
});
it("strips blockquotes", () => {
expect(stripMarkdown("> This is a quote")).toBe("This is a quote");
expect(stripMarkdown(">This is also a quote")).toBe("This is also a quote");
});
it("removes horizontal rules", () => {
expect(stripMarkdown("Above\n---\nBelow")).toBe("Above\n\nBelow");
expect(stripMarkdown("Above\n***\nBelow")).toBe("Above\n\nBelow");
});
it("strips inline code markers", () => {
expect(stripMarkdown("Use `const` keyword")).toBe("Use const keyword");
});
it("handles complex markdown", () => {
const input = `# Title
This is **bold** and *italic* text.
> A quote
Some ~~deleted~~ content.`;
const result = stripMarkdown(input);
expect(result).toContain("Title");
expect(result).toContain("This is bold and italic text.");
expect(result).toContain("A quote");
expect(result).toContain("Some deleted content.");
expect(result).not.toContain("#");
expect(result).not.toContain("**");
expect(result).not.toContain("~~");
expect(result).not.toContain(">");
});
});
describe("convertTableToFlexBubble", () => {
it("creates a receipt-style card for 2-column tables", () => {
const table = {
headers: ["Item", "Price"],
rows: [
["Apple", "$1"],
["Banana", "$2"],
],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("creates a multi-column layout for 3+ column tables", () => {
const table = {
headers: ["A", "B", "C"],
rows: [["1", "2", "3"]],
};
const bubble = convertTableToFlexBubble(table);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
});
it("replaces empty cells with placeholders", () => {
const table = {
headers: ["A", "B"],
rows: [["", ""]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
};
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
expect(rowsBox.contents[0].contents[0].text).toBe("-");
expect(rowsBox.contents[0].contents[1].text).toBe("-");
});
it("strips bold markers and applies weight for fully bold cells", () => {
const table = {
headers: ["**Name**", "Status"],
rows: [["**Alpha**", "OK"]],
};
const bubble = convertTableToFlexBubble(table);
const body = bubble.body as {
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
};
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
expect(headerRow.contents[0].text).toBe("Name");
expect(headerRow.contents[0].weight).toBe("bold");
expect(dataRow.contents[0].text).toBe("Alpha");
expect(dataRow.contents[0].weight).toBe("bold");
});
});
describe("convertCodeBlockToFlexBubble", () => {
it("creates a code card with language label", () => {
const block = { language: "typescript", code: "const x = 1;" };
const bubble = convertCodeBlockToFlexBubble(block);
expect(bubble.type).toBe("bubble");
expect(bubble.body).toBeDefined();
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code (typescript)");
});
it("creates a code card without language", () => {
const block = { code: "plain code" };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ text: string }> };
expect(body.contents[0].text).toBe("Code");
});
it("truncates very long code", () => {
const longCode = "x".repeat(3000);
const block = { code: longCode };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
const codeText = body.contents[1].contents[0].text;
expect(codeText.length).toBeLessThan(longCode.length);
expect(codeText).toContain("...");
});
});
describe("processLineMessage", () => {
it("processes text with tables", () => {
const text = `Here's the data:
| Key | Value |
|-----|-------|
| a | 1 |
Done.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.flexMessages[0].type).toBe("flex");
expect(result.text).toContain("Here's the data:");
expect(result.text).toContain("Done.");
expect(result.text).not.toContain("|");
});
it("processes text with code blocks", () => {
const text = `Check this code:
\`\`\`js
console.log("hi");
\`\`\`
That's it.`;
const result = processLineMessage(text);
expect(result.flexMessages).toHaveLength(1);
expect(result.text).toContain("Check this code:");
expect(result.text).toContain("That's it.");
expect(result.text).not.toContain("```");
});
it("processes text with markdown formatting", () => {
const text = "This is **bold** and *italic* text.";
const result = processLineMessage(text);
expect(result.text).toBe("This is bold and italic text.");
expect(result.flexMessages).toHaveLength(0);
});
it("handles mixed content", () => {
const text = `# Summary
Here's **important** info:
| Item | Count |
|------|-------|
| A | 5 |
\`\`\`python
print("done")
\`\`\`
> Note: Check the link [here](https://example.com).`;
const result = processLineMessage(text);
// Should have 2 flex messages (table + code)
expect(result.flexMessages).toHaveLength(2);
// Text should be cleaned
expect(result.text).toContain("Summary");
expect(result.text).toContain("important");
expect(result.text).toContain("Note: Check the link here.");
expect(result.text).not.toContain("#");
expect(result.text).not.toContain("**");
expect(result.text).not.toContain("|");
expect(result.text).not.toContain("```");
expect(result.text).not.toContain("[here]");
});
it("handles plain text unchanged", () => {
const text = "Just plain text with no markdown.";
const result = processLineMessage(text);
expect(result.text).toBe(text);
expect(result.flexMessages).toHaveLength(0);
});
});
describe("hasMarkdownToConvert", () => {
it("detects tables", () => {
const text = `| A | B |
|---|---|
| 1 | 2 |`;
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects code blocks", () => {
const text = "```js\ncode\n```";
expect(hasMarkdownToConvert(text)).toBe(true);
});
it("detects bold", () => {
expect(hasMarkdownToConvert("**bold**")).toBe(true);
});
it("detects strikethrough", () => {
expect(hasMarkdownToConvert("~~deleted~~")).toBe(true);
});
it("detects headers", () => {
expect(hasMarkdownToConvert("# Title")).toBe(true);
});
it("detects blockquotes", () => {
expect(hasMarkdownToConvert("> quote")).toBe(true);
});
it("returns false for plain text", () => {
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
});
});
-433
View File
@@ -1,433 +0,0 @@
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
View File
@@ -1,376 +0,0 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { IncomingMessage, ServerResponse } from "node:http";
import crypto from "node:crypto";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLineBot } from "./bot.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import {
replyMessageLine,
showLoadingAnimation,
getUserDisplayName,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushTextMessageWithQuickReplies,
pushMessageLine,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
} from "./send.js";
import { buildTemplateMessageFromPayload } from "./template-messages.js";
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { processLineMessage } from "./markdown-to-line.js";
import { sendLineReplyChunks } from "./reply-chunks.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
export interface MonitorLineProviderOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
config: ClawdbotConfig;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
webhookUrl?: string;
webhookPath?: string;
}
export interface LineProviderMonitor {
account: ResolvedLineAccount;
handleWebhook: (body: WebhookRequestBody) => Promise<void>;
stop: () => void;
}
// Track runtime state in memory (simplified version)
const runtimeState = new Map<
string,
{
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
}
>();
function recordChannelRuntimeState(params: {
channel: string;
accountId: string;
state: Partial<{
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
lastInboundAt: number | null;
lastOutboundAt: number | null;
}>;
}): void {
const key = `${params.channel}:${params.accountId}`;
const existing = runtimeState.get(key) ?? {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
runtimeState.set(key, { ...existing, ...params.state });
}
export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
function validateLineSignature(body: string, signature: string, channelSecret: string): boolean {
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
return hash === signature;
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
function startLineLoadingKeepalive(params: {
userId: string;
accountId?: string;
intervalMs?: number;
loadingSeconds?: number;
}): () => void {
const intervalMs = params.intervalMs ?? 18_000;
const loadingSeconds = params.loadingSeconds ?? 20;
let stopped = false;
const trigger = () => {
if (stopped) return;
void showLoadingAnimation(params.userId, {
accountId: params.accountId,
loadingSeconds,
}).catch(() => {});
};
trigger();
const timer = setInterval(trigger, intervalMs);
return () => {
if (stopped) return;
stopped = true;
clearInterval(timer);
};
}
export async function monitorLineProvider(
opts: MonitorLineProviderOptions,
): Promise<LineProviderMonitor> {
const {
channelAccessToken,
channelSecret,
accountId,
config,
runtime,
abortSignal,
webhookPath,
} = opts;
const resolvedAccountId = accountId ?? "default";
// Record starting state
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: true,
lastStartAt: Date.now(),
},
});
// Create the bot
const bot = createLineBot({
channelAccessToken,
channelSecret,
accountId,
runtime,
config,
onMessage: async (ctx) => {
if (!ctx) return;
const { ctxPayload, replyToken, route } = ctx;
// Record inbound activity
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastInboundAt: Date.now(),
},
});
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
// Fetch display name for logging (non-blocking)
const displayNamePromise = ctx.userId
? getUserDisplayName(ctx.userId, { accountId: ctx.accountId })
: Promise.resolve(ctxPayload.From);
// Show loading animation while processing (non-blocking, best-effort)
const stopLoading = shouldShowLoading
? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId })
: null;
const displayName = await displayNamePromise;
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
// Dispatch to auto-reply system for AI response
try {
const textLimit = 5000; // LINE max message length
let replyTokenUsed = false; // Track if we've used the one-time reply token
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId).responsePrefix,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
// Show loading animation before each delivery (non-blocking)
if (ctx.userId && !ctx.isGroup) {
void showLoadingAnimation(ctx.userId, { accountId: ctx.accountId }).catch(() => {});
}
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
payload,
lineData,
to: ctxPayload.From,
replyToken,
replyTokenUsed,
accountId: ctx.accountId,
textLimit,
deps: {
buildTemplateMessageFromPayload,
processLineMessage,
chunkMarkdownText,
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
onReplyError: (replyErr) => {
logVerbose(
`line: reply token failed, falling back to push: ${String(replyErr)}`,
);
},
},
});
replyTokenUsed = nextReplyTokenUsed;
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastOutboundAt: Date.now(),
},
});
},
onError: (err, info) => {
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {},
});
if (!queuedFinal) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
}
} catch (err) {
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
// Send error message to user
if (replyToken) {
try {
await replyMessageLine(
replyToken,
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
{ accountId: ctx.accountId },
);
} catch (replyErr) {
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
}
}
} finally {
stopLoading?.();
}
},
});
// Register HTTP webhook handler
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
const unregisterHttp = registerPluginHttpRoute({
path: normalizedPath,
pluginId: "line",
accountId: resolvedAccountId,
log: (msg) => logVerbose(msg),
handler: async (req: IncomingMessage, res: ServerResponse) => {
// Handle GET requests for webhook verification
if (req.method === "GET") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("OK");
return;
}
// Only accept POST requests
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "GET, POST");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
try {
const rawBody = await readRequestBody(req);
const signature = req.headers["x-line-signature"];
// Validate signature
if (!signature || typeof signature !== "string") {
logVerbose("line: webhook missing X-Line-Signature header");
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
return;
}
if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid signature" }));
return;
}
// Parse and process the webhook body
const body = JSON.parse(rawBody) as WebhookRequestBody;
// Respond immediately with 200 to avoid LINE timeout
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
// Process events asynchronously
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await bot.handleWebhook(body).catch((err) => {
runtime.error?.(danger(`line webhook handler failed: ${String(err)}`));
});
}
} catch (err) {
runtime.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
},
});
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
// Handle abort signal
const stopHandler = () => {
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
running: false,
lastStopAt: Date.now(),
},
});
};
abortSignal?.addEventListener("abort", stopHandler);
return {
account: bot.account,
handleWebhook: bot.handleWebhook,
stop: () => {
stopHandler();
abortSignal?.removeEventListener("abort", stopHandler);
},
};
}
-51
View File
@@ -1,51 +0,0 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return { getBotInfo: getBotInfoMock };
});
return { getBotInfoMock, MessagingApiClientMock };
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
let probeLineBot: typeof import("./probe.js").probeLineBot;
afterEach(() => {
vi.useRealTimers();
getBotInfoMock.mockReset();
});
describe("probeLineBot", () => {
beforeAll(async () => {
({ probeLineBot } = await import("./probe.js"));
});
it("returns timeout when bot info stalls", async () => {
vi.useFakeTimers();
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
const probePromise = probeLineBot("token", 10);
await vi.advanceTimersByTimeAsync(20);
const result = await probePromise;
expect(result.ok).toBe(false);
expect(result.error).toBe("timeout");
});
it("returns bot info when available", async () => {
getBotInfoMock.mockResolvedValue({
displayName: "Clawdbot",
userId: "U123",
basicId: "@clawdbot",
pictureUrl: "https://example.com/bot.png",
});
const result = await probeLineBot("token", 50);
expect(result.ok).toBe(true);
expect(result.bot?.userId).toBe("U123");
});
});
-43
View File
@@ -1,43 +0,0 @@
import { messagingApi } from "@line/bot-sdk";
import type { LineProbeResult } from "./types.js";
export async function probeLineBot(
channelAccessToken: string,
timeoutMs = 5000,
): Promise<LineProbeResult> {
if (!channelAccessToken?.trim()) {
return { ok: false, error: "Channel access token not configured" };
}
const client = new messagingApi.MessagingApiClient({
channelAccessToken: channelAccessToken.trim(),
});
try {
const profile = await withTimeout(client.getBotInfo(), timeoutMs);
return {
ok: true,
bot: {
displayName: profile.displayName,
userId: profile.userId,
basicId: profile.basicId,
pictureUrl: profile.pictureUrl,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: message };
}
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
if (!timeoutMs || timeoutMs <= 0) return promise;
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
-115
View File
@@ -1,115 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { sendLineReplyChunks } from "./reply-chunks.js";
describe("sendLineReplyChunks", () => {
it("uses reply token for all chunks when possible", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
}));
const result = await sendLineReplyChunks({
to: "line:group:1",
chunks: ["one", "two", "three"],
quickReplies: ["A", "B"],
replyToken: "token",
replyTokenUsed: false,
accountId: "default",
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("three", ["A", "B"]);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{ type: "text", text: "one" },
{ type: "text", text: "two" },
{ type: "text", text: "three" },
],
{ accountId: "default" },
);
expect(pushMessageLine).not.toHaveBeenCalled();
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
it("attaches quick replies to a single reply chunk", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
quickReply: { items: [] },
}));
const result = await sendLineReplyChunks({
to: "line:user:1",
chunks: ["only"],
quickReplies: ["A"],
replyToken: "token",
replyTokenUsed: false,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(createTextMessageWithQuickReplies).toHaveBeenCalledWith("only", ["A"]);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(pushMessageLine).not.toHaveBeenCalled();
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
it("replies with up to five chunks before pushing the rest", async () => {
const replyMessageLine = vi.fn(async () => ({}));
const pushMessageLine = vi.fn(async () => ({}));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({}));
const createTextMessageWithQuickReplies = vi.fn((text: string, _quickReplies: string[]) => ({
type: "text" as const,
text,
}));
const chunks = ["1", "2", "3", "4", "5", "6", "7"];
const result = await sendLineReplyChunks({
to: "line:group:1",
chunks,
quickReplies: ["A"],
replyToken: "token",
replyTokenUsed: false,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createTextMessageWithQuickReplies,
});
expect(result.replyTokenUsed).toBe(true);
expect(replyMessageLine).toHaveBeenCalledTimes(1);
expect(replyMessageLine).toHaveBeenCalledWith(
"token",
[
{ type: "text", text: "1" },
{ type: "text", text: "2" },
{ type: "text", text: "3" },
{ type: "text", text: "4" },
{ type: "text", text: "5" },
],
{ accountId: undefined },
);
expect(pushMessageLine).toHaveBeenCalledTimes(1);
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { accountId: undefined });
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1);
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], {
accountId: undefined,
});
expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled();
});
});
-101
View File
@@ -1,101 +0,0 @@
import type { messagingApi } from "@line/bot-sdk";
export type LineReplyMessage = messagingApi.TextMessage;
export type SendLineReplyChunksParams = {
to: string;
chunks: string[];
quickReplies?: string[];
replyToken?: string | null;
replyTokenUsed?: boolean;
accountId?: string;
replyMessageLine: (
replyToken: string,
messages: messagingApi.Message[],
opts?: { accountId?: string },
) => Promise<unknown>;
pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise<unknown>;
pushTextMessageWithQuickReplies: (
to: string,
text: string,
quickReplies: string[],
opts?: { accountId?: string },
) => Promise<unknown>;
createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage;
onReplyError?: (err: unknown) => void;
};
export async function sendLineReplyChunks(
params: SendLineReplyChunksParams,
): Promise<{ replyTokenUsed: boolean }> {
const hasQuickReplies = Boolean(params.quickReplies?.length);
let replyTokenUsed = Boolean(params.replyTokenUsed);
if (params.chunks.length === 0) {
return { replyTokenUsed };
}
if (params.replyToken && !replyTokenUsed) {
try {
const replyBatch = params.chunks.slice(0, 5);
const remaining = params.chunks.slice(replyBatch.length);
const replyMessages: LineReplyMessage[] = replyBatch.map((chunk) => ({
type: "text",
text: chunk,
}));
if (hasQuickReplies && remaining.length === 0 && replyMessages.length > 0) {
const lastIndex = replyMessages.length - 1;
replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(
replyBatch[lastIndex]!,
params.quickReplies!,
);
}
await params.replyMessageLine(params.replyToken, replyMessages, {
accountId: params.accountId,
});
replyTokenUsed = true;
for (let i = 0; i < remaining.length; i += 1) {
const isLastChunk = i === remaining.length - 1;
if (isLastChunk && hasQuickReplies) {
await params.pushTextMessageWithQuickReplies(
params.to,
remaining[i]!,
params.quickReplies!,
{ accountId: params.accountId },
);
} else {
await params.pushMessageLine(params.to, remaining[i]!, {
accountId: params.accountId,
});
}
}
return { replyTokenUsed };
} catch (err) {
params.onReplyError?.(err);
replyTokenUsed = true;
}
}
for (let i = 0; i < params.chunks.length; i += 1) {
const isLastChunk = i === params.chunks.length - 1;
if (isLastChunk && hasQuickReplies) {
await params.pushTextMessageWithQuickReplies(
params.to,
params.chunks[i]!,
params.quickReplies!,
{ accountId: params.accountId },
);
} else {
await params.pushMessageLine(params.to, params.chunks[i]!, {
accountId: params.accountId,
});
}
}
return { replyTokenUsed };
}
-247
View File
@@ -1,247 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
} from "./rich-menu.js";
describe("messageAction", () => {
it("creates a message action", () => {
const action = messageAction("Help", "/help");
expect(action.type).toBe("message");
expect(action.label).toBe("Help");
expect((action as { text: string }).text).toBe("/help");
});
it("uses label as text when text not provided", () => {
const action = messageAction("Click");
expect((action as { text: string }).text).toBe("Click");
});
it("truncates label to 20 characters", () => {
const action = messageAction("This is a very long label text");
expect(action.label.length).toBe(20);
expect(action.label).toBe("This is a very long ");
});
});
describe("uriAction", () => {
it("creates a URI action", () => {
const action = uriAction("Open", "https://example.com");
expect(action.type).toBe("uri");
expect(action.label).toBe("Open");
expect((action as { uri: string }).uri).toBe("https://example.com");
});
it("truncates label to 20 characters", () => {
const action = uriAction("Click here to visit our website", "https://example.com");
expect(action.label.length).toBe(20);
});
});
describe("postbackAction", () => {
it("creates a postback action", () => {
const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
expect(action.type).toBe("postback");
expect(action.label).toBe("Select");
expect((action as { data: string }).data).toBe("action=select&item=1");
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
});
it("truncates data to 300 characters", () => {
const longData = "x".repeat(400);
const action = postbackAction("Test", longData);
expect((action as { data: string }).data.length).toBe(300);
});
it("truncates displayText to 300 characters", () => {
const longText = "y".repeat(400);
const action = postbackAction("Test", "data", longText);
expect((action as { displayText: string }).displayText?.length).toBe(300);
});
it("omits displayText when not provided", () => {
const action = postbackAction("Test", "data");
expect((action as { displayText?: string }).displayText).toBeUndefined();
});
});
describe("datetimePickerAction", () => {
it("creates a date picker action", () => {
const action = datetimePickerAction("Pick date", "date_picked", "date");
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe("Pick date");
expect((action as { mode: string }).mode).toBe("date");
expect((action as { data: string }).data).toBe("date_picked");
});
it("creates a time picker action", () => {
const action = datetimePickerAction("Pick time", "time_picked", "time");
expect((action as { mode: string }).mode).toBe("time");
});
it("creates a datetime picker action", () => {
const action = datetimePickerAction("Pick datetime", "datetime_picked", "datetime");
expect((action as { mode: string }).mode).toBe("datetime");
});
it("includes initial/min/max when provided", () => {
const action = datetimePickerAction("Pick", "data", "date", {
initial: "2024-06-15",
min: "2024-01-01",
max: "2024-12-31",
});
expect((action as { initial: string }).initial).toBe("2024-06-15");
expect((action as { min: string }).min).toBe("2024-01-01");
expect((action as { max: string }).max).toBe("2024-12-31");
});
});
describe("createGridLayout", () => {
it("creates a 2x3 grid layout for tall menu", () => {
const actions = [
messageAction("A1"),
messageAction("A2"),
messageAction("A3"),
messageAction("A4"),
messageAction("A5"),
messageAction("A6"),
] as [
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
];
const areas = createGridLayout(1686, actions);
expect(areas.length).toBe(6);
// Check first row positions
expect(areas[0].bounds.x).toBe(0);
expect(areas[0].bounds.y).toBe(0);
expect(areas[1].bounds.x).toBe(833);
expect(areas[1].bounds.y).toBe(0);
expect(areas[2].bounds.x).toBe(1666);
expect(areas[2].bounds.y).toBe(0);
// Check second row positions
expect(areas[3].bounds.y).toBe(843);
expect(areas[4].bounds.y).toBe(843);
expect(areas[5].bounds.y).toBe(843);
});
it("creates a 2x3 grid layout for short menu", () => {
const actions = [
messageAction("A1"),
messageAction("A2"),
messageAction("A3"),
messageAction("A4"),
messageAction("A5"),
messageAction("A6"),
] as [
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
];
const areas = createGridLayout(843, actions);
expect(areas.length).toBe(6);
// Row height should be half of 843
expect(areas[0].bounds.height).toBe(421);
expect(areas[3].bounds.y).toBe(421);
});
it("assigns correct actions to areas", () => {
const actions = [
messageAction("Help", "/help"),
messageAction("Status", "/status"),
messageAction("Settings", "/settings"),
messageAction("About", "/about"),
messageAction("Feedback", "/feedback"),
messageAction("Contact", "/contact"),
] as [
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
ReturnType<typeof messageAction>,
];
const areas = createGridLayout(843, actions);
expect((areas[0].action as { text: string }).text).toBe("/help");
expect((areas[1].action as { text: string }).text).toBe("/status");
expect((areas[2].action as { text: string }).text).toBe("/settings");
expect((areas[3].action as { text: string }).text).toBe("/about");
expect((areas[4].action as { text: string }).text).toBe("/feedback");
expect((areas[5].action as { text: string }).text).toBe("/contact");
});
});
describe("createDefaultMenuConfig", () => {
it("creates a valid default menu configuration", () => {
const config = createDefaultMenuConfig();
expect(config.size.width).toBe(2500);
expect(config.size.height).toBe(843);
expect(config.selected).toBe(false);
expect(config.name).toBe("Default Menu");
expect(config.chatBarText).toBe("Menu");
expect(config.areas.length).toBe(6);
});
it("has valid area bounds", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.bounds.x).toBeGreaterThanOrEqual(0);
expect(area.bounds.y).toBeGreaterThanOrEqual(0);
expect(area.bounds.width).toBeGreaterThan(0);
expect(area.bounds.height).toBeGreaterThan(0);
expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
}
});
it("has message actions for all areas", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.action.type).toBe("message");
}
});
it("has expected default commands", () => {
const config = createDefaultMenuConfig();
const commands = config.areas.map((a) => (a.action as { text: string }).text);
expect(commands).toContain("/help");
expect(commands).toContain("/status");
expect(commands).toContain("/settings");
});
});
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more