Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { createAcpxPluginConfigSchema } from "./src/config.js";
import { createAcpxRuntimeService } from "./src/service.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "ACP runtime backend powered by the acpx CLI.",
configSchema: createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(
createAcpxRuntimeService({
pluginConfig: api.pluginConfig,
}),
);
},
};
export default plugin;

View File

@@ -0,0 +1,55 @@
{
"id": "acpx",
"name": "ACPX Runtime",
"description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"cwd": {
"type": "string"
},
"permissionMode": {
"type": "string",
"enum": ["approve-all", "approve-reads", "deny-all"]
},
"nonInteractivePermissions": {
"type": "string",
"enum": ["deny", "fail"]
},
"timeoutSeconds": {
"type": "number",
"minimum": 0.001
},
"queueOwnerTtlSeconds": {
"type": "number",
"minimum": 0
}
}
},
"uiHints": {
"cwd": {
"label": "Default Working Directory",
"help": "Default cwd for ACP session operations when not set per session."
},
"permissionMode": {
"label": "Permission Mode",
"help": "Default acpx permission policy for runtime prompts."
},
"nonInteractivePermissions": {
"label": "Non-Interactive Permission Policy",
"help": "acpx policy when interactive permission prompts are unavailable."
},
"timeoutSeconds": {
"label": "Prompt Timeout Seconds",
"help": "Optional acpx timeout for each runtime turn.",
"advanced": true
},
"queueOwnerTtlSeconds": {
"label": "Queue Owner TTL Seconds",
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
"advanced": true
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@openclaw/acpx",
"version": "2026.2.26",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "^0.1.13"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,209 @@
---
name: acp-router
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
user-invocable: false
---
# ACP Harness Router
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
Trigger this skill when the user asks OpenClaw to:
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
- continue existing harness work
- relay instructions to an external coding harness
- keep an external harness conversation in a thread-like conversation
## Mode selection
Choose one of these paths:
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
Use direct `acpx` when one of these is true:
- user explicitly asks for direct `acpx` driving
- ACP runtime/plugin path is unavailable or unhealthy
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
## AgentId mapping
Use these defaults when user names a harness directly:
- "pi" -> `agentId: "pi"`
- "claude" or "claude code" -> `agentId: "claude"`
- "codex" -> `agentId: "codex"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
These defaults match current acpx built-in aliases.
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
## OpenClaw ACP runtime path
Required behavior:
1. Use `sessions_spawn` with:
- `runtime: "acp"`
- `thread: true`
- `mode: "session"` (unless user explicitly wants one-shot)
2. Put requested work in `task` so the ACP session gets it immediately.
3. Set `agentId` explicitly unless ACP default agent is known.
4. Do not ask user to run slash commands or CLI when this path works directly.
Example:
User: "spawn a test codex session in thread and tell it to say hi"
Call:
```json
{
"task": "Say hi.",
"runtime": "acp",
"agentId": "codex",
"thread": true,
"mode": "session"
}
```
## Thread spawn recovery policy
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
1. Do not immediately ask the user to pick an alternate path.
2. First attempt automatic local repair:
- ensure plugin-local pinned acpx is installed in `extensions/acpx`
- verify `${ACPX_CMD} --version`
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
4. Retry ACP thread spawn once after repair.
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
When offering fallback, keep ACP first:
- Option 1: retry ACP spawn after showing exact failing step
- Option 2: direct acpx telephone-game flow
Do not default to subagent runtime for these requests.
## ACPX install and version policy (direct acpx path)
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
1. Prefer plugin-local binary, not global PATH:
- `./extensions/acpx/node_modules/.bin/acpx`
2. Resolve pinned version from extension dependency:
- `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
3. If binary is missing or version mismatched, install plugin-local pinned version:
- `cd extensions/acpx && npm install --omit=dev --no-save acpx@<pinnedVersion>`
4. Verify before use:
- `./extensions/acpx/node_modules/.bin/acpx --version`
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
Set and reuse:
```bash
ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
```
## Direct acpx path ("telephone game")
Use this path to drive harness sessions without `/acp` or subagent runtime.
### Rules
1. Use `exec` commands that call `${ACPX_CMD}`.
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
3. Prefer `--format quiet` for clean assistant text to relay back to user.
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
### Session naming
Use a deterministic name, for example:
- `oc-<harness>-<conversationId>`
Where `conversationId` is thread id when available, otherwise channel/conversation id.
### Command templates
Persistent session (create if missing, then prompt):
```bash
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
```
One-shot:
```bash
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
```
Cancel in-flight turn:
```bash
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
```
Close session:
```bash
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
```
### Harness aliases in acpx
- `pi`
- `claude`
- `codex`
- `opencode`
- `gemini`
### Built-in adapter commands in acpx
Defaults are:
- `pi -> npx pi-acp`
- `claude -> npx -y @zed-industries/claude-agent-acp`
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `gemini -> gemini`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
### Failure handling
- `acpx: command not found`:
- for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
- restart gateway after install and offer to run the restart automatically
- then retry once
- do not ask for install permission first unless policy explicitly requires it
- do not install global `acpx` unless explicitly requested
- adapter command missing (for example `claude-agent-acp` not found):
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
- then retry once before offering fallback
- if user wants binary-based overrides, install exactly the configured adapter binary
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
### Output relay
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ACPX_BUNDLED_BIN,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
} from "./config.js";
describe("acpx plugin config parsing", () => {
it("resolves a strict plugin-local acpx command", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
cwd: "/tmp/workspace",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
});
it("rejects command overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
command: "acpx-custom",
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: command");
});
it("rejects commandArgs overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
commandArgs: ["--foo"],
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: commandArgs");
});
it("schema rejects empty cwd", () => {
const schema = createAcpxPluginConfigSchema();
if (!schema.safeParse) {
throw new Error("acpx config schema missing safeParse");
}
const parsed = schema.safeParse({ cwd: " " });
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,196 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_PINNED_VERSION = "0.1.13";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`;
export type AcpxPluginConfig = {
cwd?: string;
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
};
export type ResolvedAcpxPluginConfig = {
command: string;
cwd: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
};
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
type ParseResult =
| { ok: true; value: AcpxPluginConfig | undefined }
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPermissionMode(value: string): value is AcpxPermissionMode {
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
}
function isNonInteractivePermissionPolicy(
value: string,
): value is AcpxNonInteractivePermissionPolicy {
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
}
function parseAcpxPluginConfig(value: unknown): ParseResult {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (!isRecord(value)) {
return { ok: false, message: "expected config object" };
}
const allowedKeys = new Set([
"cwd",
"permissionMode",
"nonInteractivePermissions",
"timeoutSeconds",
"queueOwnerTtlSeconds",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return { ok: false, message: `unknown config key: ${key}` };
}
}
const cwd = value.cwd;
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
return { ok: false, message: "cwd must be a non-empty string" };
}
const permissionMode = value.permissionMode;
if (
permissionMode !== undefined &&
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
) {
return {
ok: false,
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
};
}
const nonInteractivePermissions = value.nonInteractivePermissions;
if (
nonInteractivePermissions !== undefined &&
(typeof nonInteractivePermissions !== "string" ||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
) {
return {
ok: false,
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
) {
return { ok: false, message: "timeoutSeconds must be a positive number" };
}
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
if (
queueOwnerTtlSeconds !== undefined &&
(typeof queueOwnerTtlSeconds !== "number" ||
!Number.isFinite(queueOwnerTtlSeconds) ||
queueOwnerTtlSeconds < 0)
) {
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
}
return {
ok: true,
value: {
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
nonInteractivePermissions:
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
queueOwnerTtlSeconds:
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
},
};
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return {
safeParse(value: unknown):
| { success: true; data?: unknown }
| {
success: false;
error: { issues: Array<{ path: Array<string | number>; message: string }> };
} {
const parsed = parseAcpxPluginConfig(value);
if (parsed.ok) {
return { success: true, data: parsed.value };
}
return {
success: false,
error: {
issues: [{ path: [], message: parsed.message }],
},
};
},
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
cwd: { type: "string" },
permissionMode: {
type: "string",
enum: [...ACPX_PERMISSION_MODES],
},
nonInteractivePermissions: {
type: "string",
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
},
timeoutSeconds: { type: "number", minimum: 0.001 },
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
},
},
};
}
export function resolveAcpxPluginConfig(params: {
rawConfig: unknown;
workspaceDir?: string;
}): ResolvedAcpxPluginConfig {
const parsed = parseAcpxPluginConfig(params.rawConfig);
if (!parsed.ok) {
throw new Error(parsed.message);
}
const normalized = parsed.value ?? {};
const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
return {
command: ACPX_BUNDLED_BIN,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
timeoutSeconds: normalized.timeoutSeconds,
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
};
}

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
resolveSpawnFailureMock: vi.fn(() => null),
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./runtime-internals/process.js", () => ({
resolveSpawnFailure: resolveSpawnFailureMock,
spawnAndCollect: spawnAndCollectMock,
}));
import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
describe("acpx ensure", () => {
beforeEach(() => {
resolveSpawnFailureMock.mockReset();
resolveSpawnFailureMock.mockReturnValue(null);
spawnAndCollectMock.mockReset();
});
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
});
it("reports version mismatch", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toMatchObject({
ok: false,
reason: "version-mismatch",
expectedVersion: ACPX_PINNED_VERSION,
installedVersion: "0.0.9",
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
});
});
it("installs and verifies pinned acpx when precheck fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
await ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
});
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "",
stderr: "network down",
code: 1,
error: null,
});
await expect(
ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
}),
).rejects.toThrow("failed to install plugin-local acpx");
});
});

View File

@@ -0,0 +1,169 @@
import type { PluginLogger } from "openclaw/plugin-sdk";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js";
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
export type AcpxVersionCheckResult =
| {
ok: true;
version: string;
expectedVersion: string;
}
| {
ok: false;
reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
message: string;
expectedVersion: string;
installCommand: string;
installedVersion?: string;
};
function extractVersion(stdout: string, stderr: string): string | null {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(SEMVER_PATTERN);
return match?.[0] ?? null;
}
export async function checkPinnedAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
const result = await spawnAndCollect({
command: params.command,
args: ["--version"],
cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, cwd);
if (spawnFailure === "missing-command") {
return {
ok: false,
reason: "missing-command",
message: `acpx command not found at ${params.command}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
return {
ok: false,
reason: "execution-failed",
message: result.error.message,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if ((result.code ?? 0) !== 0) {
const stderr = result.stderr.trim();
return {
ok: false,
reason: "execution-failed",
message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
const installedVersion = extractVersion(result.stdout, result.stderr);
if (!installedVersion) {
return {
ok: false,
reason: "missing-version",
message: "acpx --version output did not include a parseable version",
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (installedVersion !== expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
installedVersion,
};
}
return {
ok: true,
version: installedVersion,
expectedVersion,
};
}
let pendingEnsure: Promise<void> | null = null;
export async function ensurePinnedAcpx(params: {
command: string;
logger?: PluginLogger;
pluginRoot?: string;
expectedVersion?: string;
}): Promise<void> {
if (pendingEnsure) {
return await pendingEnsure;
}
pendingEnsure = (async () => {
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const precheck = await checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
if (precheck.ok) {
return;
}
params.logger?.warn(
`acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
);
const install = await spawnAndCollect({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`],
cwd: pluginRoot,
});
if (install.error) {
const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
if (spawnFailure === "missing-command") {
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
}
throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
}
if ((install.code ?? 0) !== 0) {
const stderr = install.stderr.trim();
const stdout = install.stdout.trim();
const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
throw new Error(`failed to install plugin-local acpx: ${detail}`);
}
const postcheck = await checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
if (!postcheck.ok) {
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
}
params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
})();
try {
await pendingEnsure;
} finally {
pendingEnsure = null;
}
}

View File

@@ -0,0 +1,140 @@
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
if (asTrimmedString(value.type) !== "error") {
return null;
}
return {
message: asTrimmedString(value.message) || "acpx reported an error",
code: asOptionalString(value.code),
retryable: asOptionalBoolean(value.retryable),
};
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
events.push(parsed);
}
} catch {
// Ignore malformed lines; callers handle missing typed events via exit code.
}
}
return events;
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
type: "status",
text: trimmed,
};
}
if (!isRecord(parsed)) {
return null;
}
const type = asTrimmedString(parsed.type);
switch (type) {
case "text": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "output",
};
}
case "thought": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "thought",
};
}
case "tool_call": {
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
const status = asTrimmedString(parsed.status);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
};
}
case "client_operation": {
const method = asTrimmedString(parsed.method) || "operation";
const status = asTrimmedString(parsed.status);
const summary = asTrimmedString(parsed.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text };
}
case "plan": {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return null;
}
return { type: "status", text: `plan: ${content}` };
}
case "update": {
const update = asTrimmedString(parsed.update);
if (!update) {
return null;
}
return { type: "status", text: update };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(parsed.stopReason),
};
}
case "error": {
const message = asTrimmedString(parsed.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(parsed.code),
retryable: asOptionalBoolean(parsed.retryable),
};
}
default:
return null;
}
}

View File

@@ -0,0 +1,137 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
export type SpawnExit = {
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
};
type ResolvedSpawnCommand = {
command: string;
args: string[];
shell?: boolean;
};
function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
if (process.platform !== "win32") {
return { command: params.command, args: params.args };
}
const extension = path.extname(params.command).toLowerCase();
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return {
command: process.execPath,
args: [params.command, ...params.args],
};
}
if (extension === ".cmd" || extension === ".bat") {
return {
command: params.command,
args: params.args,
shell: true,
};
}
return {
command: params.command,
args: params.args,
};
}
export function spawnWithResolvedCommand(params: {
command: string;
args: string[];
cwd: string;
}): ChildProcessWithoutNullStreams {
const resolved = resolveSpawnCommand({
command: params.command,
args: params.args,
});
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
});
}
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
return await new Promise<SpawnExit>((resolve) => {
let settled = false;
const finish = (result: SpawnExit) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
child.once("error", (err) => {
finish({ code: null, signal: null, error: err });
});
child.once("close", (code, signal) => {
finish({ code, signal, error: null });
});
});
}
export async function spawnAndCollect(params: {
command: string;
args: string[];
cwd: string;
}): Promise<{
stdout: string;
stderr: string;
code: number | null;
error: Error | null;
}> {
const child = spawnWithResolvedCommand(params);
child.stdin.end();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
const exit = await waitForExit(child);
return {
stdout,
stderr,
code: exit.code,
error: exit.error,
};
}
export function resolveSpawnFailure(
err: unknown,
cwd: string,
): "missing-command" | "missing-cwd" | null {
if (!err || typeof err !== "object") {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
return null;
}
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
}
function directoryExists(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return existsSync(cwd);
} catch {
return false;
}
}

View File

@@ -0,0 +1,56 @@
import type { ResolvedAcpxPluginConfig } from "../config.js";
export type AcpxHandleState = {
name: string;
agent: string;
cwd: string;
mode: "persistent" | "oneshot";
acpxRecordId?: string;
backendSessionId?: string;
agentSessionId?: string;
};
export type AcpxJsonObject = Record<string, unknown>;
export type AcpxErrorEvent = {
message: string;
code?: string;
retryable?: boolean;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asOptionalString(value: unknown): string | undefined {
const text = asTrimmedString(value);
return text || undefined;
}
export function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
const match = sessionKey.match(/^agent:([^:]+):/i);
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
return candidate || fallbackAgent;
}
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
if (mode === "approve-all") {
return ["--approve-all"];
}
if (mode === "deny-all") {
return ["--deny-all"];
}
return ["--approve-reads"];
}

View File

@@ -0,0 +1,619 @@
import fs from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
const NOOP_LOGGER = {
info: (_message: string) => {},
warn: (_message: string) => {},
error: (_message: string) => {},
debug: (_message: string) => {},
};
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const logPath = process.env.MOCK_ACPX_LOG;
const writeLog = (entry) => {
if (!logPath) return;
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
};
if (args.includes("--version")) {
process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
process.exit(0);
}
if (args.includes("--help")) {
process.stdout.write("mock-acpx help\\n");
process.exit(0);
}
const commandIndex = args.findIndex(
(arg) =>
arg === "prompt" ||
arg === "cancel" ||
arg === "sessions" ||
arg === "set-mode" ||
arg === "set" ||
arg === "status",
);
const command = commandIndex >= 0 ? args[commandIndex] : "";
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
const readFlag = (flag) => {
const idx = args.indexOf(flag);
if (idx < 0) return "";
return String(args[idx + 1] || "");
};
const sessionFromOption = readFlag("--session");
const ensureName = readFlag("--name");
const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : "";
const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
process.stdout.write(JSON.stringify({
type: "session_ensured",
acpxRecordId: "rec-" + ensureName,
acpxSessionId: "sid-" + ensureName,
agentSessionId: "inner-" + ensureName,
name: ensureName,
created: true,
}) + "\n");
process.exit(0);
}
if (command === "cancel") {
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxSessionId: "sid-" + sessionFromOption,
cancelled: true,
}) + "\n");
process.exit(0);
}
if (command === "set-mode") {
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
process.stdout.write(JSON.stringify({
type: "mode_set",
acpxSessionId: "sid-" + sessionFromOption,
mode: setModeValue,
}) + "\n");
process.exit(0);
}
if (command === "set") {
writeLog({
kind: "set",
agent,
args,
sessionName: sessionFromOption,
key: setKey,
value: setValue,
});
process.stdout.write(JSON.stringify({
type: "config_set",
acpxSessionId: "sid-" + sessionFromOption,
key: setKey,
value: setValue,
}) + "\n");
process.exit(0);
}
if (command === "status") {
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
status: sessionFromOption ? "alive" : "no-session",
pid: 4242,
uptime: 120,
}) + "\n");
process.exit(0);
}
if (command === "sessions" && args[commandIndex + 1] === "close") {
writeLog({ kind: "close", agent, args, sessionName: closeName });
process.stdout.write(JSON.stringify({
type: "session_closed",
acpxRecordId: "rec-" + closeName,
acpxSessionId: "sid-" + closeName,
name: closeName,
}) + "\n");
process.exit(0);
}
if (command === "prompt") {
const stdinText = fs.readFileSync(0, "utf8");
writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
const acpxSessionId = "sid-" + sessionFromOption;
if (stdinText.includes("trigger-error")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "error",
code: "RUNTIME",
message: "mock failure",
}) + "\n");
process.exit(1);
}
if (stdinText.includes("split-spacing")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "text",
content: "alpha",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "text",
content: " beta",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: " gamma",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "thought",
content: "thinking",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "tool_call",
title: "run-tests",
status: "in_progress",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: "echo:" + stdinText.trim(),
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
writeLog({ kind: "unknown", args });
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId: "unknown",
seq: 0,
stream: "control",
type: "error",
code: "USAGE",
message: "unknown command",
}) + "\n");
process.exit(2);
`;
const tempDirs: string[] = [];
async function createMockRuntime(params?: {
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
queueOwnerTtlSeconds?: number;
}): Promise<{
runtime: AcpxRuntime;
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
const logPath = path.join(dir, "calls.log");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
process.env.MOCK_ACPX_LOG = logPath;
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
};
return {
runtime: new AcpxRuntime(config, {
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
logger: NOOP_LOGGER,
}),
logPath,
config,
};
}
async function readLogEntries(logPath: string): Promise<Array<Record<string, unknown>>> {
if (!fs.existsSync(logPath)) {
return [];
}
const raw = await readFile(logPath, "utf8");
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
afterEach(async () => {
delete process.env.MOCK_ACPX_LOG;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 10,
});
}
});
describe("AcpxRuntime", () => {
it("passes the shared ACP adapter contract suite", async () => {
const fixture = await createMockRuntime();
await runAcpRuntimeAdapterContract({
createRuntime: async () => fixture.runtime,
agentId: "codex",
successPrompt: "contract-pass",
errorPrompt: "trigger-error",
assertSuccessEvents: (events) => {
expect(events.some((event) => event.type === "done")).toBe(true);
},
assertErrorOutcome: ({ events, thrown }) => {
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
},
});
const logs = await readLogEntries(fixture.logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
});
it("ensures sessions and streams prompt events", async () => {
const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:123",
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "hello world",
mode: "prompt",
requestId: "req-test",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "text_delta",
text: "thinking",
stream: "thought",
});
expect(events).toContainEqual({
type: "tool_call",
text: "run-tests (in_progress)",
});
expect(events).toContainEqual({
type: "text_delta",
text: "echo:hello world",
stream: "output",
});
expect(events).toContainEqual({
type: "done",
stopReason: "end_turn",
});
const logs = await readLogEntries(logPath);
const ensure = logs.find((entry) => entry.kind === "ensure");
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(ensure).toBeDefined();
expect(prompt).toBeDefined();
expect(Array.isArray(prompt?.args)).toBe(true);
const promptArgs = (prompt?.args as string[]) ?? [];
expect(promptArgs).toContain("--ttl");
expect(promptArgs).toContain("180");
expect(promptArgs).toContain("--approve-all");
});
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:ttl-default",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "ttl-default",
mode: "prompt",
requestId: "req-ttl-default",
})) {
// drain
}
const logs = await readLogEntries(logPath);
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(prompt).toBeDefined();
const promptArgs = (prompt?.args as string[]) ?? [];
const ttlFlagIndex = promptArgs.indexOf("--ttl");
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
});
it("preserves leading spaces across streamed text deltas", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:space",
agent: "codex",
mode: "persistent",
});
const textDeltas: string[] = [];
for await (const event of runtime.runTurn({
handle,
text: "split-spacing",
mode: "prompt",
requestId: "req-space",
})) {
if (event.type === "text_delta" && event.stream === "output") {
textDeltas.push(event.text);
}
}
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
expect(textDeltas.join("")).toBe("alpha beta gamma");
});
it("maps acpx error events into ACP runtime error events", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:456",
agent: "codex",
mode: "persistent",
});
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "trigger-error",
mode: "prompt",
requestId: "req-err",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "error",
message: "mock failure",
code: "RUNTIME",
retryable: undefined,
});
});
it("supports cancel and close using encoded runtime handle state", async () => {
const { runtime, logPath, config } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:claude:acp:789",
agent: "claude",
mode: "persistent",
});
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.name).toBe("agent:claude:acp:789");
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
await secondRuntime.cancel({ handle, reason: "test" });
await secondRuntime.close({ handle, reason: "test" });
const logs = await readLogEntries(logPath);
const cancel = logs.find((entry) => entry.kind === "cancel");
const close = logs.find((entry) => entry.kind === "close");
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
expect(close?.sessionName).toBe("agent:claude:acp:789");
});
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:controls",
agent: "codex",
mode: "persistent",
});
const capabilities = runtime.getCapabilities();
expect(capabilities.controls).toContain("session/set_mode");
expect(capabilities.controls).toContain("session/set_config_option");
expect(capabilities.controls).toContain("session/status");
await runtime.setMode({
handle,
mode: "plan",
});
await runtime.setConfigOption({
handle,
key: "model",
value: "openai-codex/gpt-5.3-codex",
});
const status = await runtime.getStatus({ handle });
const ensuredSessionName = "agent:codex:acp:controls";
expect(status.summary).toContain("status=alive");
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.details?.status).toBe("alive");
expect(status.details?.pid).toBe(4242);
const logs = await readLogEntries(logPath);
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
});
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:aborted",
agent: "codex",
mode: "persistent",
});
const controller = new AbortController();
controller.abort();
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "should-not-run",
mode: "prompt",
requestId: "req-aborted",
signal: controller.signal,
})) {
events.push(event);
}
const logs = await readLogEntries(logPath);
expect(events).toEqual([]);
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
});
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
const { runtime } = await createMockRuntime();
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
await expect(
runtime.ensureSession({
sessionKey: "agent:codex:acp:missing-cwd",
agent: "codex",
mode: "persistent",
cwd: missingCwd,
}),
).rejects.toMatchObject({
code: "ACP_SESSION_INIT_FAILED",
message: expect.stringContaining("working directory does not exist"),
});
expect(runtime.isHealthy()).toBe(true);
});
it("marks runtime unhealthy when command is missing", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(false);
});
it("marks runtime healthy when command is available", async () => {
const { runtime } = await createMockRuntime();
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
});
it("returns doctor report for missing command", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
const report = await runtime.doctor();
expect(report.ok).toBe(false);
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
expect(report.installCommand).toContain("acpx");
});
});

View File

@@ -0,0 +1,578 @@
import { createInterface } from "node:readline";
import type {
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntime,
AcpRuntimeEnsureInput,
AcpRuntimeErrorCode,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
PluginLogger,
} from "openclaw/plugin-sdk";
import { AcpRuntimeError } from "openclaw/plugin-sdk";
import {
ACPX_LOCAL_INSTALL_COMMAND,
ACPX_PINNED_VERSION,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { checkPinnedAcpxVersion } from "./ensure.js";
import {
parseJsonLines,
parsePromptEventLine,
toAcpxErrorEvent,
} from "./runtime-internals/events.js";
import {
resolveSpawnFailure,
spawnAndCollect,
spawnWithResolvedCommand,
waitForExit,
} from "./runtime-internals/process.js";
import {
asOptionalString,
asTrimmedString,
buildPermissionArgs,
deriveAgentFromSessionKey,
isRecord,
type AcpxHandleState,
type AcpxJsonObject,
} from "./runtime-internals/shared.js";
export const ACPX_BACKEND_ID = "acpx";
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
const DEFAULT_AGENT_FALLBACK = "codex";
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
};
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
}
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
const trimmed = runtimeSessionName.trim();
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
return null;
}
const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
if (!encoded) {
return null;
}
try {
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return null;
}
const name = asTrimmedString(parsed.name);
const agent = asTrimmedString(parsed.agent);
const cwd = asTrimmedString(parsed.cwd);
const mode = asTrimmedString(parsed.mode);
const acpxRecordId = asOptionalString(parsed.acpxRecordId);
const backendSessionId = asOptionalString(parsed.backendSessionId);
const agentSessionId = asOptionalString(parsed.agentSessionId);
if (!name || !agent || !cwd) {
return null;
}
if (mode !== "persistent" && mode !== "oneshot") {
return null;
}
return {
name,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
} catch {
return null;
}
}
export class AcpxRuntime implements AcpRuntime {
private healthy = false;
private readonly logger?: PluginLogger;
private readonly queueOwnerTtlSeconds: number;
constructor(
private readonly config: ResolvedAcpxPluginConfig,
opts?: {
logger?: PluginLogger;
queueOwnerTtlSeconds?: number;
},
) {
this.logger = opts?.logger;
const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
this.queueOwnerTtlSeconds =
typeof requestedQueueOwnerTtlSeconds === "number" &&
Number.isFinite(requestedQueueOwnerTtlSeconds) &&
requestedQueueOwnerTtlSeconds >= 0
? requestedQueueOwnerTtlSeconds
: this.config.queueOwnerTtlSeconds;
}
isHealthy(): boolean {
return this.healthy;
}
async probeAvailability(): Promise<void> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
return;
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
this.healthy = result.error == null && (result.code ?? 0) === 0;
} catch {
this.healthy = false;
}
}
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
const sessionName = asTrimmedString(input.sessionKey);
if (!sessionName) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
const agent = asTrimmedString(input.agent);
if (!agent) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
}
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
const mode = input.mode;
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd,
command: [agent, "sessions", "ensure", "--name", sessionName],
}),
cwd,
fallbackCode: "ACP_SESSION_INIT_FAILED",
});
const ensuredEvent = events.find(
(event) =>
asOptionalString(event.agentSessionId) ||
asOptionalString(event.acpxSessionId) ||
asOptionalString(event.acpxRecordId),
);
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
const backendSessionId = ensuredEvent
? asOptionalString(ensuredEvent.acpxSessionId)
: undefined;
return {
sessionKey: input.sessionKey,
backend: ACPX_BACKEND_ID,
runtimeSessionName: encodeAcpxRuntimeHandleState({
name: sessionName,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
}),
cwd,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
}
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
const state = this.resolveHandleState(input.handle);
const args = this.buildPromptArgs({
agent: state.agent,
sessionName: state.name,
cwd: state.cwd,
});
const cancelOnAbort = async () => {
await this.cancel({
handle: input.handle,
reason: "abort-signal",
}).catch((err) => {
this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
});
};
const onAbort = () => {
void cancelOnAbort();
};
if (input.signal?.aborted) {
await cancelOnAbort();
return;
}
if (input.signal) {
input.signal.addEventListener("abort", onAbort, { once: true });
}
const child = spawnWithResolvedCommand({
command: this.config.command,
args,
cwd: state.cwd,
});
child.stdin.on("error", () => {
// Ignore EPIPE when the child exits before stdin flush completes.
});
child.stdin.end(input.text);
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
let sawDone = false;
let sawError = false;
const lines = createInterface({ input: child.stdout });
try {
for await (const line of lines) {
const parsed = parsePromptEventLine(line);
if (!parsed) {
continue;
}
if (parsed.type === "done") {
sawDone = true;
}
if (parsed.type === "error") {
sawError = true;
}
yield parsed;
}
const exit = await waitForExit(child);
if (exit.error) {
const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: exit.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
"ACP_TURN_FAILED",
`ACP runtime working directory does not exist: ${state.cwd}`,
{ cause: exit.error },
);
}
throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
}
if ((exit.code ?? 0) !== 0 && !sawError) {
yield {
type: "error",
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
};
return;
}
if (!sawDone && !sawError) {
yield { type: "done" };
}
} finally {
lines.close();
if (input.signal) {
input.signal.removeEventListener("abort", onAbort);
}
}
}
getCapabilities(): AcpRuntimeCapabilities {
return ACPX_CAPABILITIES;
}
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
const state = this.resolveHandleState(input.handle);
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "status", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
if (!detail) {
return {
summary: "acpx status unavailable",
};
}
const status = asTrimmedString(detail.status) || "unknown";
const acpxRecordId = asOptionalString(detail.acpxRecordId);
const acpxSessionId = asOptionalString(detail.acpxSessionId);
const agentSessionId = asOptionalString(detail.agentSessionId);
const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
const summary = [
`status=${status}`,
acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
pid != null ? `pid=${pid}` : null,
]
.filter(Boolean)
.join(" ");
return {
summary,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
details: detail,
};
}
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
const mode = asTrimmedString(input.mode);
if (!mode) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set-mode", mode, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async setConfigOption(input: {
handle: AcpRuntimeHandle;
key: string;
value: string;
}): Promise<void> {
const state = this.resolveHandleState(input.handle);
const key = asTrimmedString(input.key);
const value = asTrimmedString(input.value);
if (!key || !value) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set", key, value, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async doctor(): Promise<AcpRuntimeDoctorReport> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
const details = [
`expected=${versionCheck.expectedVersion}`,
versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
].filter((detail): detail is string => Boolean(detail));
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: versionCheck.message,
installCommand: versionCheck.installCommand,
details,
};
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `acpx command not found: ${this.config.command}`,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (spawnFailure === "missing-cwd") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
};
}
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.error.message,
details: [String(result.error)],
};
}
if ((result.code ?? 0) !== 0) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
};
}
this.healthy = true;
return {
ok: true,
message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
};
} catch (error) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: error instanceof Error ? error.message : String(error),
};
}
}
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "cancel", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "sessions", "close", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
if (decoded) {
return decoded;
}
const legacyName = asTrimmedString(handle.runtimeSessionName);
if (!legacyName) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
"Invalid acpx runtime handle: runtimeSessionName is missing.",
);
}
return {
name: legacyName,
agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
cwd: this.config.cwd,
mode: "persistent",
};
}
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
}
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
const args = [
"--format",
"json",
"--json-strict",
"--cwd",
params.cwd,
...buildPermissionArgs(this.config.permissionMode),
"--non-interactive-permissions",
this.config.nonInteractivePermissions,
];
if (this.config.timeoutSeconds) {
args.push("--timeout", String(this.config.timeoutSeconds));
}
args.push("--ttl", String(this.queueOwnerTtlSeconds));
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
return args;
}
private async runControlCommand(params: {
args: string[];
cwd: string;
fallbackCode: AcpRuntimeErrorCode;
ignoreNoSession?: boolean;
}): Promise<AcpxJsonObject[]> {
const result = await spawnAndCollect({
command: this.config.command,
args: params.args,
cwd: params.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: result.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
params.fallbackCode,
`ACP runtime working directory does not exist: ${params.cwd}`,
{ cause: result.error },
);
}
throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
}
const events = parseJsonLines(result.stdout);
const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
if (errorEvent) {
if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
return events;
}
throw new AcpRuntimeError(
params.fallbackCode,
errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
);
}
if ((result.code ?? 0) !== 0) {
throw new AcpRuntimeError(
params.fallbackCode,
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
);
}
return events;
}
}

View File

@@ -0,0 +1,173 @@
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
import {
__testing,
getAcpRuntimeBackend,
requireAcpRuntimeBackend,
} from "../../../src/acp/runtime/registry.js";
import { ACPX_BUNDLED_BIN } from "./config.js";
import { createAcpxRuntimeService } from "./service.js";
const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
ensurePinnedAcpxSpy: vi.fn(async () => {}),
}));
vi.mock("./ensure.js", () => ({
ensurePinnedAcpx: ensurePinnedAcpxSpy,
}));
type RuntimeStub = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
function createRuntimeStub(healthy: boolean): {
runtime: RuntimeStub;
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
isHealthySpy: ReturnType<typeof vi.fn>;
} {
const probeAvailabilitySpy = vi.fn(async () => {});
const isHealthySpy = vi.fn(() => healthy);
return {
runtime: {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
async probeAvailability() {
await probeAvailabilitySpy();
},
isHealthy() {
return isHealthySpy();
},
},
probeAvailabilitySpy,
isHealthySpy,
};
}
function createServiceContext(
overrides: Partial<OpenClawPluginServiceContext> = {},
): OpenClawPluginServiceContext {
return {
config: {},
workspaceDir: "/tmp/workspace",
stateDir: "/tmp/state",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
...overrides,
};
}
describe("createAcpxRuntimeService", () => {
beforeEach(() => {
__testing.resetAcpRuntimeBackendsForTests();
ensurePinnedAcpxSpy.mockReset();
ensurePinnedAcpxSpy.mockImplementation(async () => {});
});
it("registers and unregisters the acpx backend", async () => {
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
await service.start(context);
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
await vi.waitFor(() => {
expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
await service.stop?.(context);
expect(getAcpRuntimeBackend("acpx")).toBeNull();
});
it("marks backend unavailable when runtime health check fails", async () => {
const { runtime } = createRuntimeStub(false);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
await service.start(context);
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
try {
requireAcpRuntimeBackend("acpx");
throw new Error("expected ACP backend lookup to fail");
} catch (error) {
expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
}
});
it("passes queue-owner TTL from plugin config", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
pluginConfig: {
queueOwnerTtlSeconds: 0.25,
},
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.25,
pluginConfig: expect.objectContaining({
command: ACPX_BUNDLED_BIN,
}),
}),
);
});
it("uses a short default queue-owner TTL", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.1,
}),
);
});
it("does not block startup while acpx ensure runs", async () => {
const { runtime } = createRuntimeStub(true);
ensurePinnedAcpxSpy.mockImplementation(() => new Promise<void>(() => {}));
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
const startResult = await Promise.race([
Promise.resolve(service.start(context)).then(() => "started"),
new Promise<string>((resolve) => setTimeout(() => resolve("timed_out"), 100)),
]);
expect(startResult).toBe("started");
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
});
});

View File

@@ -0,0 +1,102 @@
import type {
AcpRuntime,
OpenClawPluginService,
OpenClawPluginServiceContext,
PluginLogger,
} from "openclaw/plugin-sdk";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
import {
ACPX_PINNED_VERSION,
resolveAcpxPluginConfig,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { ensurePinnedAcpx } from "./ensure.js";
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
queueOwnerTtlSeconds: number;
logger?: PluginLogger;
};
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
};
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime(params.pluginConfig, {
logger: params.logger,
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
});
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
let runtime: AcpxRuntimeLike | null = null;
let lifecycleRevision = 0;
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime,
healthy: () => runtime?.isHealthy() ?? false,
});
ctx.logger.info(
`acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
);
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
try {
await ensurePinnedAcpx({
command: pluginConfig.command,
logger: ctx.logger,
expectedVersion: ACPX_PINNED_VERSION,
});
if (currentRevision !== lifecycleRevision) {
return;
}
await runtime?.probeAvailability();
if (runtime?.isHealthy()) {
ctx.logger.info("acpx runtime backend ready");
} else {
ctx.logger.warn("acpx runtime backend probe failed after local install");
}
} catch (err) {
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
lifecycleRevision += 1;
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
runtime = null;
},
};
}

View File

@@ -0,0 +1,45 @@
# BlueBubbles extension (developer reference)
This directory contains the **BlueBubbles external channel plugin** for OpenClaw.
If youre looking for **how to use BlueBubbles as an agent/tool user**, see:
- `skills/bluebubbles/SKILL.md`
## Layout
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
## Internal helpers (use these, not raw API calls)
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
## Webhooks
- BlueBubbles posts JSON to the gateway HTTP server.
- Normalize sender/chat IDs defensively (payloads vary by version).
- Skip messages marked as from self.
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers.
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
## Config (core)
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
## Message tool notes
- **Reactions:** the `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`.
Example:
`action=react target=+15551234567 messageId=ABC123 emoji=❤️`

View File

@@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
import { setBlueBubblesRuntime } from "./src/runtime.js";
const plugin = {
id: "bluebubbles",
name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};
export default plugin;

View File

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

View File

@@ -0,0 +1,33 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.26",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",
"selectionLabel": "BlueBubbles (macOS app)",
"detailLabel": "BlueBubbles",
"docsPath": "/channels/bluebubbles",
"docsLabel": "bluebubbles",
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
"aliases": [
"bb"
],
"preferOver": [
"imessage"
],
"systemImage": "bubble.left.and.text.bubble.right",
"order": 75
},
"install": {
"npmSpec": "@openclaw/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,35 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
cfg?: OpenClawConfig;
};
export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
baseUrl: string;
password: string;
accountId: string;
allowPrivateNetwork: boolean;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return {
baseUrl,
password,
accountId: account.accountId,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
};
}

View File

@@ -0,0 +1,88 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
accountId: string;
enabled: boolean;
name?: string;
config: BlueBubblesAccountConfig;
configured: boolean;
baseUrl?: string;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean);
}
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): BlueBubblesAccountConfig | undefined {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
}
function mergeBlueBubblesAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): BlueBubblesAccountConfig {
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
accounts?: unknown;
};
const { accounts: _ignored, ...rest } = base;
const account = resolveAccountConfig(cfg, accountId) ?? {};
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
return { ...rest, ...account, chunkMode };
}
export function resolveBlueBubblesAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const serverUrl = merged.serverUrl?.trim();
const password = merged.password?.trim();
const configured = Boolean(serverUrl && password);
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
name: merged.name?.trim() || undefined,
config: merged,
configured,
baseUrl,
};
}
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,692 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./reactions.js", () => ({
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
vi.mock("./chat.js", () => ({
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
}));
vi.mock("./monitor.js", () => ({
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
}));
vi.mock("./probe.js", () => ({
isMacOS26OrHigher: vi.fn().mockReturnValue(false),
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
describe("bluebubblesMessageActions", () => {
const listActions = bluebubblesMessageActions.listActions!;
const supportsAction = bluebubblesMessageActions.supportsAction!;
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
const handleAction = bluebubblesMessageActions.handleAction!;
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
handleAction({ channel: "bluebubbles", ...ctx });
const blueBubblesConfig = (): OpenClawConfig => ({
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
});
const runReactAction = async (params: Record<string, unknown>) => {
return await callHandleAction({
action: "react",
params,
cfg: blueBubblesConfig(),
accountId: null,
});
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
describe("listActions", () => {
it("returns empty array when account is not enabled", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: false } },
};
const actions = listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns empty array when account is not configured", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: true } },
};
const actions = listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns react action when enabled and configured", () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const actions = listActions({ cfg });
expect(actions).toContain("react");
});
it("excludes react action when reactions are gated off", () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
actions: { reactions: false },
},
},
};
const actions = listActions({ cfg });
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
expect(actions).toContain("unsend");
});
it("hides private-api actions when private API is disabled", () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const actions = listActions({ cfg });
expect(actions).toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");
expect(actions).not.toContain("sendWithEffect");
expect(actions).not.toContain("edit");
expect(actions).not.toContain("unsend");
expect(actions).not.toContain("renameGroup");
expect(actions).not.toContain("setGroupIcon");
expect(actions).not.toContain("addParticipant");
expect(actions).not.toContain("removeParticipant");
expect(actions).not.toContain("leaveGroup");
});
});
describe("supportsAction", () => {
it("returns true for react action", () => {
expect(supportsAction({ action: "react" })).toBe(true);
});
it("returns true for all supported actions", () => {
expect(supportsAction({ action: "edit" })).toBe(true);
expect(supportsAction({ action: "unsend" })).toBe(true);
expect(supportsAction({ action: "reply" })).toBe(true);
expect(supportsAction({ action: "sendWithEffect" })).toBe(true);
expect(supportsAction({ action: "renameGroup" })).toBe(true);
expect(supportsAction({ action: "setGroupIcon" })).toBe(true);
expect(supportsAction({ action: "addParticipant" })).toBe(true);
expect(supportsAction({ action: "removeParticipant" })).toBe(true);
expect(supportsAction({ action: "leaveGroup" })).toBe(true);
expect(supportsAction({ action: "sendAttachment" })).toBe(true);
});
it("returns false for unsupported actions", () => {
expect(supportsAction({ action: "delete" as never })).toBe(false);
expect(supportsAction({ action: "unknown" as never })).toBe(false);
});
});
describe("extractToolSend", () => {
it("extracts send params from sendMessage action", () => {
const result = extractToolSend({
args: {
action: "sendMessage",
to: "+15551234567",
accountId: "test-account",
},
});
expect(result).toEqual({
to: "+15551234567",
accountId: "test-account",
});
});
it("returns null for non-sendMessage action", () => {
const result = extractToolSend({
args: { action: "react", to: "+15551234567" },
});
expect(result).toBeNull();
});
it("returns null when to is missing", () => {
const result = extractToolSend({
args: { action: "sendMessage" },
});
expect(result).toBeNull();
});
});
describe("handleAction", () => {
it("throws for unsupported actions", async () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "unknownAction" as never,
params: {},
cfg,
accountId: null,
}),
).rejects.toThrow("is not supported");
});
it("throws when emoji is missing for react action", async () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "react",
params: { messageId: "msg-123" },
cfg,
accountId: null,
}),
).rejects.toThrow(/emoji/i);
});
it("throws a private-api error for private-only actions when disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "react",
params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
cfg,
accountId: null,
}),
).rejects.toThrow("requires Private API");
});
it("throws when messageId is missing", async () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "react",
params: { emoji: "❤️" },
cfg,
accountId: null,
}),
).rejects.toThrow("messageId");
});
it("throws when chatGuid cannot be resolved", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "react",
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
cfg,
accountId: null,
}),
).rejects.toThrow("chatGuid not found");
});
it("sends reaction successfully with chatGuid", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-123",
emoji: "❤️",
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, added: "❤️" },
});
});
it("sends reaction removal successfully", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
remove: true,
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, removed: true },
});
});
it("resolves chatGuid from to parameter", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await callHandleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
to: "+15559876543",
},
cfg,
accountId: null,
});
expect(resolveChatGuidForTarget).toHaveBeenCalled();
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15559876543",
}),
);
});
it("passes partIndex when provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await callHandleAction({
action: "react",
params: {
emoji: "😂",
messageId: "msg-789",
chatGuid: "iMessage;-;chat-guid",
partIndex: 2,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
partIndex: 2,
}),
);
});
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await callHandleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
},
cfg,
accountId: null,
toolContext: {
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
},
});
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
}),
);
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15550001111",
}),
);
});
it("resolves short messageId before reacting", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "1",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
});
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
messageGuid: "resolved-uuid",
}),
);
});
it("propagates short-id errors from the resolver", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
throw new Error("short id expired");
});
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "999",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
}),
).rejects.toThrow("short id expired");
});
it("accepts message param for edit action", async () => {
const { editBlueBubblesMessage } = await import("./chat.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await callHandleAction({
action: "edit",
params: { messageId: "msg-123", message: "updated" },
cfg,
accountId: null,
});
expect(editBlueBubblesMessage).toHaveBeenCalledWith(
"msg-123",
"updated",
expect.objectContaining({ cfg, accountId: undefined }),
);
});
it("accepts message/target aliases for sendWithEffect", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await callHandleAction({
action: "sendWithEffect",
params: {
message: "peekaboo",
target: "+15551234567",
effect: "invisible ink",
},
cfg,
accountId: null,
});
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"+15551234567",
"peekaboo",
expect.objectContaining({ effectId: "invisible ink" }),
);
expect(result).toMatchObject({
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
});
});
it("passes asVoice through sendAttachment", async () => {
const { sendBlueBubblesAttachment } = await import("./attachments.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const base64Buffer = Buffer.from("voice").toString("base64");
await callHandleAction({
action: "sendAttachment",
params: {
to: "+15551234567",
filename: "voice.mp3",
buffer: base64Buffer,
contentType: "audio/mpeg",
asVoice: true,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
expect.objectContaining({
filename: "voice.mp3",
contentType: "audio/mpeg",
asVoice: true,
}),
);
});
it("throws when buffer is missing for setGroupIcon", async () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
callHandleAction({
action: "setGroupIcon",
params: { chatGuid: "iMessage;-;chat-guid" },
cfg,
accountId: null,
}),
).rejects.toThrow(/requires an image/i);
});
it("sets group icon successfully with chatGuid and buffer", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
// Base64 encode a simple test buffer
const testBuffer = Buffer.from("fake-image-data");
const base64Buffer = testBuffer.toString("base64");
const result = await callHandleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
filename: "group-icon.png",
contentType: "image/png",
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"group-icon.png",
expect.objectContaining({ contentType: "image/png" }),
);
expect(result).toMatchObject({
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
});
});
it("uses default filename when not provided for setGroupIcon", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const base64Buffer = Buffer.from("test").toString("base64");
await callHandleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"icon.png",
expect.anything(),
);
});
});
});

View File

@@ -0,0 +1,460 @@
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
createActionGate,
extractToolSend,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import {
editBlueBubblesMessage,
unsendBlueBubblesMessage,
renameBlueBubblesChat,
setGroupIconBlueBubbles,
addBlueBubblesParticipant,
removeBlueBubblesParticipant,
leaveBlueBubblesChat,
} from "./chat.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_identifier") {
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
const raw = params[key];
if (typeof raw === "boolean") {
return raw;
}
if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") {
return true;
}
if (trimmed === "false") {
return false;
}
}
return undefined;
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg });
if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
const macOS26 = isMacOS26OrHigher(account.accountId);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) {
continue;
}
if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
continue;
}
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
continue;
}
if (gate(spec.gate)) {
actions.add(action);
}
}
return Array.from(actions);
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const account = resolveBlueBubblesAccount({
cfg: cfg,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const opts = { cfg: cfg, accountId: accountId ?? undefined };
const assertPrivateApiEnabled = () => {
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
throw new Error(
`BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
);
}
};
// Helper to resolve chatGuid from various params or session context
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) {
return chatGuid.trim();
}
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
// Fall back to session context if no explicit target provided
const contextTarget = toolContext?.currentChannelId?.trim();
const target = chatIdentifier?.trim()
? ({
kind: "chat_identifier",
chatIdentifier: chatIdentifier.trim(),
} as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: contextTarget
? mapTarget(contextTarget)
: null;
if (!target) {
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
}
if (!baseUrl || !password) {
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
}
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
}
return resolved;
};
// Handle react action
if (action === "react") {
assertPrivateApiEnabled();
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error(
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
);
}
const rawMessageId = readStringParam(params, "messageId");
if (!rawMessageId) {
throw new Error(
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid();
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts,
});
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
}
// Handle edit action
if (action === "edit") {
assertPrivateApiEnabled();
// Edit is not supported on macOS 26+
if (isMacOS26OrHigher(accountId ?? undefined)) {
throw new Error(
"BlueBubbles edit is not supported on macOS 26 or higher. " +
"Apple removed the ability to edit iMessages in this version.",
);
}
const rawMessageId = readStringParam(params, "messageId");
const newText =
readStringParam(params, "text") ??
readStringParam(params, "newText") ??
readStringParam(params, "message");
if (!rawMessageId || !newText) {
const missing: string[] = [];
if (!rawMessageId) {
missing.push("messageId (the message ID to edit)");
}
if (!newText) {
missing.push("text (the new message content)");
}
throw new Error(
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
await editBlueBubblesMessage(messageId, newText, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
});
return jsonResult({ ok: true, edited: rawMessageId });
}
// Handle unsend action
if (action === "unsend") {
assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId");
if (!rawMessageId) {
throw new Error(
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
"Use action=unsend with messageId=<message_id>.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, unsent: rawMessageId });
}
// Handle reply action
if (action === "reply") {
assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!rawMessageId || !text || !to) {
const missing: string[] = [];
if (!rawMessageId) {
missing.push("messageId (the message ID to reply to)");
}
if (!text) {
missing.push("text or message (the reply message content)");
}
if (!to) {
missing.push("to or target (the chat target)");
}
throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, {
...opts,
replyToMessageGuid: messageId,
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
}
// Handle sendWithEffect action
if (action === "sendWithEffect") {
assertPrivateApiEnabled();
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) {
missing.push("text or message (the message content)");
}
if (!to) {
missing.push("to or target (the chat target)");
}
if (!effectId) {
missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
);
}
throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
);
}
const result = await sendMessageBlueBubbles(to, text, {
...opts,
effectId,
});
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
}
// Handle renameGroup action
if (action === "renameGroup") {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
}
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
}
// Handle setGroupIcon action
if (action === "setGroupIcon") {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer");
const filename =
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
if (!base64Buffer) {
throw new Error(
"BlueBubbles setGroupIcon requires an image. " +
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
);
}
// Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts,
contentType: contentType ?? undefined,
});
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
}
// Handle addParticipant action
if (action === "addParticipant") {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
}
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
}
// Handle removeParticipant action
if (action === "removeParticipant") {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
}
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
}
// Handle leaveGroup action
if (action === "leaveGroup") {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
await leaveBlueBubblesChat(resolvedChatGuid, opts);
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action
if (action === "sendAttachment") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer");
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
let buffer: Uint8Array;
if (base64Buffer) {
// Decode base64 to buffer
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
);
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
}
const result = await sendBlueBubblesAttachment({
to,
buffer,
filename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
asVoice: asVoice ?? undefined,
opts,
});
return jsonResult({ ok: true, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -0,0 +1,499 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatus,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
const fetchRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url);
if (!res.ok) {
const text = await res.text().catch(() => "unknown");
throw new Error(
`Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
code?: string;
};
error.code = "max_bytes";
throw error;
}
return {
buffer,
contentType: res.headers.get("content-type") ?? undefined,
fileName: undefined,
};
},
);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
const runtimeStub = {
channel: {
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
},
},
} as unknown as PluginRuntime;
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
fetchRemoteMediaMock.mockClear();
mockFetch.mockReset();
setBlueBubblesRuntime(runtimeStub);
});
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
const largeBuffer = new Uint8Array(params.bufferBytes);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
}),
).rejects.toThrow("too large");
}
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when guid is empty string", async () => {
const attachment: BlueBubblesAttachment = { guid: " " };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when serverUrl is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("downloads attachment successfully", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-123" };
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(result.buffer).toEqual(mockBuffer);
expect(result.contentType).toBe("image/png");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/attachment/att-123/download"),
expect.objectContaining({ method: "GET" }),
);
});
it("includes password in URL query", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-456" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "my-secret-password",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret-password");
});
it("encodes guid in URL", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("Attachment not found"),
});
const attachment: BlueBubblesAttachment = { guid: "att-missing" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Attachment not found");
});
it("throws when attachment exceeds max bytes", async () => {
await expectAttachmentTooLarge({
bufferBytes: 10 * 1024 * 1024,
maxBytes: 5 * 1024 * 1024,
});
});
it("uses default max bytes when not specified", async () => {
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
});
it("uses attachment mimeType as fallback when response has no content-type", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-789",
mimeType: "video/mp4",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("video/mp4");
});
it("prefers response content-type over attachment mimeType", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/webp" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-xyz",
mimeType: "image/png",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("image/webp");
});
it("resolves credentials from config when opts not provided", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-config" };
const result = await downloadBlueBubblesAttachment(attachment, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-password",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
expect(calledUrl).toContain("password=config-password");
expect(result.buffer).toEqual(new Uint8Array([1]));
});
it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
await downloadBlueBubblesAttachment(attachment, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test",
allowPrivateNetwork: true,
},
},
},
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
});
it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] });
});
it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://192.168.1.5:1234",
password: "test",
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] });
});
});
describe("sendBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
fetchRemoteMediaMock.mockClear();
setBlueBubblesRuntime(runtimeStub);
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
mockBlueBubblesPrivateApiStatus(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function decodeBody(body: Uint8Array) {
return Buffer.from(body).toString("utf8");
}
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice.mp3",
contentType: "audio/mpeg",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('name="isAudioMessage"');
expect(bodyText).toContain("true");
expect(bodyText).toContain('filename="voice.mp3"');
});
it("normalizes mp3 filenames for voice memos", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice",
contentType: "audio/mpeg",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('filename="voice.mp3"');
expect(bodyText).toContain('name="voice.mp3"');
});
it("throws when asVoice is true but media is not audio", async () => {
await expect(
sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "image.png",
contentType: "image/png",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
}),
).rejects.toThrow("voice messages require audio");
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
await expect(
sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice.wav",
contentType: "audio/wav",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
}),
).rejects.toThrow("require mp3 or caf");
expect(mockFetch).not.toHaveBeenCalled();
});
it("sanitizes filenames before sending", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "../evil.mp3",
contentType: "audio/mpeg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('filename="evil.mp3"');
expect(bodyText).toContain('name="evil.mp3"');
});
it("downgrades attachment reply threading when private API is disabled", async () => {
mockBlueBubblesPrivateApiStatusOnce(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-123",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).not.toContain('name="method"');
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({
...runtimeStub,
log: runtimeLog,
} as unknown as PluginRuntime);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-unknown",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
});

View File

@@ -0,0 +1,282 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesAttachment,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesAttachmentOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
};
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
function sanitizeFilename(input: string | undefined, fallback: string): string {
const trimmed = input?.trim() ?? "";
const base = trimmed ? path.basename(trimmed) : "";
const name = base || fallback;
// Strip characters that could enable multipart header injection (CWE-93)
return name.replace(/[\r\n"\\]/g, "_");
}
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
const currentExt = path.extname(filename);
if (currentExt.toLowerCase() === extension) {
return filename;
}
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
return `${base || fallbackBase}${extension}`;
}
function resolveVoiceInfo(filename: string, contentType?: string) {
const normalizedType = contentType?.trim().toLowerCase();
const extension = path.extname(filename).toLowerCase();
const isMp3 =
extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
const isCaf =
extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
return { isAudio, isMp3, isCaf };
}
function resolveAccount(params: BlueBubblesAttachmentOpts) {
return resolveBlueBubblesServerAccount(params);
}
function safeExtractHostname(url: string): string | undefined {
try {
const hostname = new URL(url).hostname.trim();
return hostname || undefined;
} catch {
return undefined;
}
}
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const code = (error as { code?: unknown }).code;
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
? code
: undefined;
}
export async function downloadBlueBubblesAttachment(
attachment: BlueBubblesAttachment,
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
): Promise<{ buffer: Uint8Array; contentType?: string }> {
const guid = attachment.guid?.trim();
if (!guid) {
throw new Error("BlueBubbles attachment guid is required");
}
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
password,
});
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
const trustedHostname = safeExtractHostname(baseUrl);
try {
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
url,
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
maxBytes,
ssrfPolicy: allowPrivateNetwork
? { allowPrivateNetwork: true }
: trustedHostname
? { allowedHostnames: [trustedHostname] }
: undefined,
fetchImpl: async (input, init) =>
await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET" },
opts.timeoutMs,
),
});
return {
buffer: new Uint8Array(fetched.buffer),
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
};
} catch (error) {
if (readMediaFetchErrorCode(error) === "max_bytes") {
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
}
const text = error instanceof Error ? error.message : String(error);
throw new Error(`BlueBubbles attachment download failed: ${text}`);
}
}
export type SendBlueBubblesAttachmentResult = {
messageId: string;
};
/**
* Send an attachment via BlueBubbles API.
* Supports sending media files (images, videos, audio, documents) to a chat.
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
*/
export async function sendBlueBubblesAttachment(params: {
to: string;
buffer: Uint8Array;
filename: string;
contentType?: string;
caption?: string;
replyToMessageGuid?: string;
replyToPartIndex?: number;
asVoice?: boolean;
opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> {
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
let { buffer, filename, contentType } = params;
const wantsVoice = asVoice === true;
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
filename = sanitizeFilename(filename, fallbackName);
contentType = contentType?.trim() || undefined;
const { baseUrl, password, accountId } = resolveAccount(opts);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
const isAudioMessage = wantsVoice;
if (isAudioMessage) {
const voiceInfo = resolveVoiceInfo(filename, contentType);
if (!voiceInfo.isAudio) {
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
}
if (voiceInfo.isMp3) {
filename = ensureExtension(filename, ".mp3", fallbackName);
contentType = contentType ?? "audio/mpeg";
} else if (voiceInfo.isCaf) {
filename = ensureExtension(filename, ".caf", fallbackName);
contentType = contentType ?? "audio/x-caf";
} else {
throw new Error(
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
);
}
}
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
throw new Error(
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/attachment",
password,
});
// Build FormData with the attachment
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Helper to add a form field
const addField = (name: string, value: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(encoder.encode(`${value}\r\n`));
};
// Helper to add a file field
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`),
);
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
parts.push(fileBuffer);
parts.push(encoder.encode("\r\n"));
};
// Add required fields
addFile("attachment", buffer, filename, contentType);
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
if (privateApiEnabled) {
addField("method", "private-api");
}
// Add isAudioMessage flag for voice memos
if (isAudioMessage) {
addField("isAudioMessage", "true");
}
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo && privateApiEnabled) {
addField("selectedMessageGuid", trimmedReplyTo);
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
} else if (trimmedReplyTo && privateApiStatus === null) {
warnBlueBubbles(
"Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
);
}
// Add optional caption
if (caption) {
addField("message", caption);
addField("text", caption);
addField("caption", caption);
}
// Close the multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
const res = await postMultipartFormData({
url,
boundary,
parts,
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(
`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
);
}
const responseBody = await res.text();
if (!responseBody) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(responseBody) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}

View File

@@ -0,0 +1,414 @@
import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef,
}),
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
onboarding: blueBubblesOnboardingAdapter,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
.map((entry) => normalizeBlueBubblesHandle(entry)),
},
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("bluebubbles"),
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") {
return [];
}
return [
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
];
},
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) {
return true;
}
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) {
return handle;
}
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "")
.replace(/^chat_guid:/i, "")
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) {
return handle;
}
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) {
return null;
}
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) {
return cleanDisplay;
}
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) {
return cleanTarget;
}
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "bluebubbles",
accountId,
name,
}),
validateInput: ({ input }) => {
if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password.";
}
if (!input.httpUrl) {
return "BlueBubbles requires --http-url.";
}
if (!input.password) {
return "BlueBubbles requires --password.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "bluebubbles",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "bluebubbles",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
} as OpenClawConfig;
}
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
},
},
} as OpenClawConfig;
},
},
pairing: {
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg,
});
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async (ctx) => {
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: resolvedCaption ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectBlueBubblesStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running,
connected: probeOk ?? running,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config);
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
webhookPath,
});
},
},
};

View File

@@ -0,0 +1,632 @@
import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import {
addBlueBubblesParticipant,
editBlueBubblesMessage,
leaveBlueBubblesChat,
markBlueBubblesChatRead,
removeBlueBubblesParticipant,
renameBlueBubblesChat,
sendBlueBubblesTyping,
setGroupIconBlueBubbles,
unsendBlueBubblesMessage,
} from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("chat", () => {
function mockOkTextResponse() {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
}
async function expectCalledUrlIncludesPassword(params: {
password: string;
invoke: () => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke();
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(`password=${params.password}`);
}
async function expectCalledUrlUsesConfigCredentials(params: {
serverHost: string;
password: string;
invoke: (cfg: {
channels: { bluebubbles: { serverUrl: string; password: string } };
}) => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke({
channels: {
bluebubbles: {
serverUrl: `http://${params.serverHost}`,
password: params.password,
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(params.serverHost);
expect(calledUrl).toContain(`password=${params.password}`);
}
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty or whitespace", async () => {
for (const chatGuid of ["", " "]) {
await markBlueBubblesChatRead(chatGuid, {
serverUrl: "http://localhost:1234",
password: "test",
});
}
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when required credentials are missing", async () => {
await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
"serverUrl is required",
);
await expect(
markBlueBubblesChatRead("chat-guid", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("marks chat as read successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
expect.objectContaining({ method: "POST" }),
);
});
it("does not send read receipt when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("includes password in URL query", async () => {
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("Chat not found"),
});
await expect(
markBlueBubblesChatRead("missing-chat", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("read failed (404): Chat not found");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead(" chat-with-spaces ", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
markBlueBubblesChatRead("chat-123", {
cfg,
}),
});
});
});
describe("sendBlueBubblesTyping", () => {
it("does nothing when chatGuid is empty or whitespace", async () => {
for (const chatGuid of ["", " "]) {
await sendBlueBubblesTyping(chatGuid, true, {
serverUrl: "http://localhost:1234",
password: "test",
});
}
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when required credentials are missing", async () => {
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
"serverUrl is required",
);
await expect(
sendBlueBubblesTyping("chat-guid", true, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("does not send typing when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("uses POST for start and DELETE for stop", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][0]).toContain(
"/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
);
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
expect(mockFetch.mock.calls[1][0]).toContain(
"/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
);
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "typing-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=typing-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("typing failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping(" trimmed-chat ", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
});
it("encodes special characters in chatGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://typing-server:8888",
password: "typing-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("typing-server:8888");
expect(calledUrl).toContain("password=typing-pass");
});
});
describe("editBlueBubblesMessage", () => {
it("throws when required args are missing", async () => {
await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText");
});
it("sends edit request with default payload values", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await editBlueBubblesMessage(" message-guid ", " updated text ", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/message-guid/edit"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).toEqual({
editedMessage: "updated text",
backwardsCompatibilityMessage: "Edited to: updated text",
partIndex: 0,
});
});
it("supports custom part index and backwards compatibility message", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await editBlueBubblesMessage("message-guid", "new text", {
serverUrl: "http://localhost:1234",
password: "test-password",
partIndex: 3,
backwardsCompatMessage: "custom-backwards-message",
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 422,
text: () => Promise.resolve("Unprocessable"),
});
await expect(
editBlueBubblesMessage("message-guid", "new text", {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("edit failed (422): Unprocessable");
});
});
describe("unsendBlueBubblesMessage", () => {
it("throws when messageGuid is missing", async () => {
await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
});
it("sends unsend request with default part index", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await unsendBlueBubblesMessage(" msg-123 ", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/msg-123/unsend"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(0);
});
it("uses custom part index", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await unsendBlueBubblesMessage("msg-123", {
serverUrl: "http://localhost:1234",
password: "test-password",
partIndex: 2,
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(2);
});
});
describe("group chat mutation actions", () => {
it("renames chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/chat-guid"),
expect.objectContaining({ method: "PUT" }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.displayName).toBe("New Group Name");
});
it("adds and removes participant using matching endpoint", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await addBlueBubblesParticipant("chat-guid", "+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(addBody.address).toBe("+15551234567");
expect(removeBody.address).toBe("+15551234567");
});
it("leaves chat without JSON body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await leaveBlueBubblesChat("chat-guid", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/chat-guid/leave"),
expect.objectContaining({ method: "POST" }),
);
expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
});
});
describe("setGroupIconBlueBubbles", () => {
it("throws when chatGuid is empty", async () => {
await expect(
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid");
});
it("throws when buffer is empty", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("image buffer");
});
it("throws when required credentials are missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
).rejects.toThrow("serverUrl is required");
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("throws when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires Private API");
expect(mockFetch).not.toHaveBeenCalled();
});
it("sets group icon successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
serverUrl: "http://localhost:1234",
password: "test-password",
contentType: "image/png",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": expect.stringContaining("multipart/form-data"),
}),
}),
);
});
it("includes password in URL query", async () => {
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("setGroupIcon failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg,
}),
});
});
it("includes filename in multipart body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
serverUrl: "http://localhost:1234",
password: "test",
contentType: "image/jpeg",
});
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
const bodyString = new TextDecoder().decode(body);
expect(bodyString).toContain('filename="custom-icon.jpg"');
expect(bodyString).toContain("image/jpeg");
});
});
});

View File

@@ -0,0 +1,329 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
};
function resolveAccount(params: BlueBubblesChatOpts) {
return resolveBlueBubblesServerAccount(params);
}
function assertPrivateApiEnabled(accountId: string, feature: string): void {
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
throw new Error(
`BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
);
}
}
function resolvePartIndex(partIndex: number | undefined): number {
return typeof partIndex === "number" ? partIndex : 0;
}
async function sendPrivateApiJsonRequest(params: {
opts: BlueBubblesChatOpts;
feature: string;
action: string;
path: string;
method: "POST" | "PUT" | "DELETE";
payload?: unknown;
}): Promise<void> {
const { baseUrl, password, accountId } = resolveAccount(params.opts);
assertPrivateApiEnabled(accountId, params.feature);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: params.path,
password,
});
const request: RequestInit = { method: params.method };
if (params.payload !== undefined) {
request.headers = { "Content-Type": "application/json" };
request.body = JSON.stringify(params.payload);
}
const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
}
}
export async function sendBlueBubblesTyping(
chatGuid: string,
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: typing ? "POST" : "DELETE" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Edit a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function editBlueBubblesMessage(
messageGuid: string,
newText: string,
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles edit requires messageGuid");
}
const trimmedText = newText.trim();
if (!trimmedText) {
throw new Error("BlueBubbles edit requires newText");
}
await sendPrivateApiJsonRequest({
opts,
feature: "edit",
action: "edit",
method: "POST",
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
payload: {
editedMessage: trimmedText,
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
partIndex: resolvePartIndex(opts.partIndex),
},
});
}
/**
* Unsend (retract) a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function unsendBlueBubblesMessage(
messageGuid: string,
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles unsend requires messageGuid");
}
await sendPrivateApiJsonRequest({
opts,
feature: "unsend",
action: "unsend",
method: "POST",
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
payload: { partIndex: resolvePartIndex(opts.partIndex) },
});
}
/**
* Rename a group chat via BlueBubbles API.
*/
export async function renameBlueBubblesChat(
chatGuid: string,
displayName: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles rename requires chatGuid");
}
await sendPrivateApiJsonRequest({
opts,
feature: "renameGroup",
action: "rename",
method: "PUT",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
payload: { displayName },
});
}
/**
* Add a participant to a group chat via BlueBubbles API.
*/
export async function addBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles addParticipant requires chatGuid");
}
const trimmedAddress = address.trim();
if (!trimmedAddress) {
throw new Error("BlueBubbles addParticipant requires address");
}
await sendPrivateApiJsonRequest({
opts,
feature: "addParticipant",
action: "addParticipant",
method: "POST",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
payload: { address: trimmedAddress },
});
}
/**
* Remove a participant from a group chat via BlueBubbles API.
*/
export async function removeBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles removeParticipant requires chatGuid");
}
const trimmedAddress = address.trim();
if (!trimmedAddress) {
throw new Error("BlueBubbles removeParticipant requires address");
}
await sendPrivateApiJsonRequest({
opts,
feature: "removeParticipant",
action: "removeParticipant",
method: "DELETE",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
payload: { address: trimmedAddress },
});
}
/**
* Leave a group chat via BlueBubbles API.
*/
export async function leaveBlueBubblesChat(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles leaveChat requires chatGuid");
}
await sendPrivateApiJsonRequest({
opts,
feature: "leaveGroup",
action: "leaveChat",
method: "POST",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
});
}
/**
* Set a group chat's icon/photo via BlueBubbles API.
* Requires Private API to be enabled.
*/
export async function setGroupIconBlueBubbles(
chatGuid: string,
buffer: Uint8Array,
filename: string,
opts: BlueBubblesChatOpts & { contentType?: string } = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) {
throw new Error("BlueBubbles setGroupIcon requires chatGuid");
}
if (!buffer || buffer.length === 0) {
throw new Error("BlueBubbles setGroupIcon requires image buffer");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "setGroupIcon");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
password,
});
// Build multipart form-data
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Sanitize filename to prevent multipart header injection (CWE-93)
const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png";
// Add file field named "icon" as per API spec
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`),
);
parts.push(
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
);
parts.push(buffer);
parts.push(encoder.encode("\r\n"));
// Close multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
const res = await postMultipartFormData({
url,
boundary,
parts,
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
});
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
}
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { BlueBubblesConfigSchema } from "./config-schema.js";
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret",
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().default(true),
edit: z.boolean().default(true),
unsend: z.boolean().default(true),
reply: z.boolean().default(true),
sendWithEffect: z.boolean().default(true),
renameGroup: z.boolean().default(true),
setGroupIcon: z.boolean().default(true),
addParticipant: z.boolean().default(true),
removeParticipant: z.boolean().default(true),
leaveGroup: z.boolean().default(true),
sendAttachment: z.boolean().default(true),
})
.optional();
const bluebubblesGroupConfigSchema = z.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
});
const bluebubblesAccountSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
mediaLocalRoots: z.array(z.string()).optional(),
sendReadReceipts: z.boolean().optional(),
allowPrivateNetwork: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
})
.superRefine((value, ctx) => {
const serverUrl = value.serverUrl?.trim() ?? "";
const password = value.password?.trim() ?? "";
if (serverUrl && !password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["password"],
message: "password is required when serverUrl is configured",
});
}
});
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
actions: bluebubblesActionSchema,
});

View File

@@ -0,0 +1,177 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
messageId?: string;
};
export type BlueBubblesHistoryFetchResult = {
entries: BlueBubblesHistoryEntry[];
/**
* True when at least one API path returned a recognized response shape.
* False means all attempts failed or returned unusable data.
*/
resolved: boolean;
};
export type BlueBubblesMessageData = {
guid?: string;
text?: string;
handle_id?: string;
is_from_me?: boolean;
date_created?: number;
date_delivered?: number;
associated_message_guid?: string;
sender?: {
address?: string;
display_name?: string;
};
};
export type BlueBubblesChatOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
};
function resolveAccount(params: BlueBubblesChatOpts) {
return resolveBlueBubblesServerAccount(params);
}
const MAX_HISTORY_FETCH_LIMIT = 100;
const HISTORY_SCAN_MULTIPLIER = 8;
const MAX_HISTORY_SCAN_MESSAGES = 500;
const MAX_HISTORY_BODY_CHARS = 2_000;
function clampHistoryLimit(limit: number): number {
if (!Number.isFinite(limit)) {
return 0;
}
const normalized = Math.floor(limit);
if (normalized <= 0) {
return 0;
}
return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
}
function truncateHistoryBody(text: string): string {
if (text.length <= MAX_HISTORY_BODY_CHARS) {
return text;
}
return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
}
/**
* Fetch message history from BlueBubbles API for a specific chat.
* This provides the initial backfill for both group chats and DMs.
*/
export async function fetchBlueBubblesHistory(
chatIdentifier: string,
limit: number,
opts: BlueBubblesChatOpts = {},
): Promise<BlueBubblesHistoryFetchResult> {
const effectiveLimit = clampHistoryLimit(limit);
if (!chatIdentifier.trim() || effectiveLimit <= 0) {
return { entries: [], resolved: true };
}
let baseUrl: string;
let password: string;
try {
({ baseUrl, password } = resolveAccount(opts));
} catch {
return { entries: [], resolved: false };
}
// Try different common API patterns for fetching messages
const possiblePaths = [
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
`/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
];
for (const path of possiblePaths) {
try {
const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
opts.timeoutMs ?? 10000,
);
if (!res.ok) {
continue; // Try next path
}
const data = await res.json().catch(() => null);
if (!data) {
continue;
}
// Handle different response structures
let messages: unknown[] = [];
if (Array.isArray(data)) {
messages = data;
} else if (data.data && Array.isArray(data.data)) {
messages = data.data;
} else if (data.messages && Array.isArray(data.messages)) {
messages = data.messages;
} else {
continue;
}
const historyEntries: BlueBubblesHistoryEntry[] = [];
const maxScannedMessages = Math.min(
Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
MAX_HISTORY_SCAN_MESSAGES,
);
for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
const item = messages[i];
const msg = item as BlueBubblesMessageData;
// Skip messages without text content
const text = msg.text?.trim();
if (!text) {
continue;
}
const sender = msg.is_from_me
? "me"
: msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
const timestamp = msg.date_created || msg.date_delivered;
historyEntries.push({
sender,
body: truncateHistoryBody(text),
timestamp,
messageId: msg.guid,
});
}
// Sort by timestamp (oldest first for context)
historyEntries.sort((a, b) => {
const aTime = a.timestamp || 0;
const bTime = b.timestamp || 0;
return aTime - bTime;
});
return {
entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
resolved: true,
};
} catch (error) {
// Continue to next path
continue;
}
}
// If none of the API paths worked, return empty history
return { entries: [], resolved: false };
}

View File

@@ -0,0 +1,256 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendBlueBubblesMedia } from "./media-send.js";
import { setBlueBubblesRuntime } from "./runtime.js";
const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn());
const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn((id: string) => id));
vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: sendBlueBubblesAttachmentMock,
}));
vi.mock("./send.js", () => ({
sendMessageBlueBubbles: sendMessageBlueBubblesMock,
}));
vi.mock("./monitor.js", () => ({
resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
}));
type RuntimeMocks = {
detectMime: ReturnType<typeof vi.fn>;
fetchRemoteMedia: ReturnType<typeof vi.fn>;
};
let runtimeMocks: RuntimeMocks;
const tempDirs: string[] = [];
function createMockRuntime(): { runtime: PluginRuntime; mocks: RuntimeMocks } {
const detectMime = vi.fn().mockResolvedValue("text/plain");
const fetchRemoteMedia = vi.fn().mockResolvedValue({
buffer: new Uint8Array([1, 2, 3]),
contentType: "image/png",
fileName: "remote.png",
});
return {
runtime: {
version: "1.0.0",
media: {
detectMime,
},
channel: {
media: {
fetchRemoteMedia,
},
},
} as unknown as PluginRuntime,
mocks: { detectMime, fetchRemoteMedia },
};
}
function createConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
channels: {
bluebubbles: {
...overrides,
},
},
} as unknown as OpenClawConfig;
}
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bb-media-"));
tempDirs.push(dir);
return dir;
}
beforeEach(() => {
const runtime = createMockRuntime();
runtimeMocks = runtime.mocks;
setBlueBubblesRuntime(runtime.runtime);
sendBlueBubblesAttachmentMock.mockReset();
sendBlueBubblesAttachmentMock.mockResolvedValue({ messageId: "msg-1" });
sendMessageBlueBubblesMock.mockReset();
sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "msg-caption" });
resolveBlueBubblesMessageIdMock.mockClear();
});
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await fs.rm(dir, { recursive: true, force: true });
}
});
describe("sendBlueBubblesMedia local-path hardening", () => {
it("rejects local paths when mediaLocalRoots is not configured", async () => {
await expect(
sendBlueBubblesMedia({
cfg: createConfig(),
to: "chat:123",
mediaPath: "/etc/passwd",
}),
).rejects.toThrow(/mediaLocalRoots/i);
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
});
it("rejects local paths outside configured mediaLocalRoots", async () => {
const allowedRoot = await makeTempDir();
const outsideDir = await makeTempDir();
const outsideFile = path.join(outsideDir, "outside.txt");
await fs.writeFile(outsideFile, "not allowed", "utf8");
await expect(
sendBlueBubblesMedia({
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
to: "chat:123",
mediaPath: outsideFile,
}),
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
});
it("allows local paths that are explicitly configured", async () => {
const allowedRoot = await makeTempDir();
const allowedFile = path.join(allowedRoot, "allowed.txt");
await fs.writeFile(allowedFile, "allowed", "utf8");
const result = await sendBlueBubblesMedia({
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
to: "chat:123",
mediaPath: allowedFile,
});
expect(result).toEqual({ messageId: "msg-1" });
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
filename: "allowed.txt",
contentType: "text/plain",
}),
);
expect(runtimeMocks.detectMime).toHaveBeenCalled();
});
it("allows file:// media paths and file:// local roots", async () => {
const allowedRoot = await makeTempDir();
const allowedFile = path.join(allowedRoot, "allowed.txt");
await fs.writeFile(allowedFile, "allowed", "utf8");
const result = await sendBlueBubblesMedia({
cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
to: "chat:123",
mediaPath: pathToFileURL(allowedFile).toString(),
});
expect(result).toEqual({ messageId: "msg-1" });
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
filename: "allowed.txt",
}),
);
});
it("uses account-specific mediaLocalRoots over top-level roots", async () => {
const baseRoot = await makeTempDir();
const accountRoot = await makeTempDir();
const baseFile = path.join(baseRoot, "base.txt");
const accountFile = path.join(accountRoot, "account.txt");
await fs.writeFile(baseFile, "base", "utf8");
await fs.writeFile(accountFile, "account", "utf8");
const cfg = createConfig({
mediaLocalRoots: [baseRoot],
accounts: {
work: {
mediaLocalRoots: [accountRoot],
},
},
});
await expect(
sendBlueBubblesMedia({
cfg,
to: "chat:123",
accountId: "work",
mediaPath: baseFile,
}),
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
const result = await sendBlueBubblesMedia({
cfg,
to: "chat:123",
accountId: "work",
mediaPath: accountFile,
});
expect(result).toEqual({ messageId: "msg-1" });
});
it("rejects symlink escapes under an allowed root", async () => {
const allowedRoot = await makeTempDir();
const outsideDir = await makeTempDir();
const outsideFile = path.join(outsideDir, "secret.txt");
const linkPath = path.join(allowedRoot, "link.txt");
await fs.writeFile(outsideFile, "secret", "utf8");
try {
await fs.symlink(outsideFile, linkPath);
} catch {
// Some environments disallow symlink creation; skip without failing the suite.
return;
}
await expect(
sendBlueBubblesMedia({
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
to: "chat:123",
mediaPath: linkPath,
}),
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
});
it("rejects relative mediaLocalRoots entries", async () => {
const allowedRoot = await makeTempDir();
const allowedFile = path.join(allowedRoot, "allowed.txt");
const relativeRoot = path.relative(process.cwd(), allowedRoot);
await fs.writeFile(allowedFile, "allowed", "utf8");
await expect(
sendBlueBubblesMedia({
cfg: createConfig({ mediaLocalRoots: [relativeRoot] }),
to: "chat:123",
mediaPath: allowedFile,
}),
).rejects.toThrow(/must be absolute paths/i);
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
});
it("keeps remote URL flow unchanged", async () => {
await sendBlueBubblesMedia({
cfg: createConfig(),
to: "chat:123",
mediaUrl: "https://example.com/file.png",
});
expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({ url: "https://example.com/file.png" }),
);
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,317 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";
const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024;
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
if (typeof maxBytes !== "number" || maxBytes <= 0) {
return;
}
if (sizeBytes <= maxBytes) {
return;
}
const maxLabel = (maxBytes / MB).toFixed(0);
const sizeLabel = (sizeBytes / MB).toFixed(2);
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
}
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) {
return source;
}
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function expandHomePath(input: string): string {
if (input === "~") {
return os.homedir();
}
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function resolveConfiguredPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Empty mediaLocalRoots entry is not allowed");
}
if (trimmed.startsWith("file://")) {
let parsed: string;
try {
parsed = fileURLToPath(trimmed);
} catch {
throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
}
if (!path.isAbsolute(parsed)) {
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
}
return parsed;
}
const resolved = expandHomePath(trimmed);
if (!path.isAbsolute(resolved)) {
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
}
return resolved;
}
function isPathInsideRoot(candidate: string, root: string): boolean {
const normalizedCandidate = path.normalize(candidate);
const normalizedRoot = path.normalize(root);
const rootWithSep = normalizedRoot.endsWith(path.sep)
? normalizedRoot
: normalizedRoot + path.sep;
if (process.platform === "win32") {
const candidateLower = normalizedCandidate.toLowerCase();
const rootLower = normalizedRoot.toLowerCase();
const rootWithSepLower = rootWithSep.toLowerCase();
return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
}
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
}
function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
const account = resolveBlueBubblesAccount({
cfg: params.cfg,
accountId: params.accountId,
});
return (account.config.mediaLocalRoots ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
async function assertLocalMediaPathAllowed(params: {
localPath: string;
localRoots: string[];
accountId?: string;
}): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> {
if (params.localRoots.length === 0) {
throw new Error(
`Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${
params.accountId
? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots`
: ""
} to explicitly allow local file directories.`,
);
}
const resolvedLocalPath = path.resolve(params.localPath);
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
for (const rootEntry of params.localRoots) {
const resolvedRootInput = resolveConfiguredPath(rootEntry);
const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
if (
relativeToRoot.startsWith("..") ||
path.isAbsolute(relativeToRoot) ||
relativeToRoot === ""
) {
continue;
}
let rootReal: string;
try {
rootReal = await fs.realpath(resolvedRootInput);
} catch {
rootReal = path.resolve(resolvedRootInput);
}
const candidatePath = path.resolve(rootReal, relativeToRoot);
if (!isPathInsideRoot(candidatePath, rootReal)) {
continue;
}
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
handle = await fs.open(candidatePath, openFlags);
const realPath = await fs.realpath(candidatePath);
if (!isPathInsideRoot(realPath, rootReal)) {
continue;
}
const stat = await handle.stat();
if (!stat.isFile()) {
continue;
}
const realStat = await fs.stat(realPath);
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
continue;
}
const data = await handle.readFile();
return { data, realPath, sizeBytes: stat.size };
} catch {
// Try next configured root.
continue;
} finally {
if (handle) {
await handle.close().catch(() => {});
}
}
}
throw new Error(
`Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`,
);
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) {
return undefined;
}
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export async function sendBlueBubblesMedia(params: {
cfg: OpenClawConfig;
to: string;
mediaUrl?: string;
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
replyToId?: string | null;
accountId?: string;
asVoice?: boolean;
}) {
const {
cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption,
replyToId,
accountId,
asVoice,
} = params;
const core = getBlueBubblesRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) {
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
buffer = mediaBuffer;
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({
url: source,
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
});
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = expandHomePath(resolveLocalMediaPath(source));
const localFile = await assertLocalMediaPathAllowed({
localPath,
localRoots: mediaLocalRoots,
accountId,
});
if (typeof maxBytes === "number" && maxBytes > 0) {
assertMediaWithinLimit(localFile.sizeBytes, maxBytes);
}
const data = localFile.data;
assertMediaWithinLimit(data.byteLength, maxBytes);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localFile.realPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localFile.realPath);
}
}
}
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
: undefined;
const attachmentResult = await sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
replyToMessageGuid,
asVoice,
opts: {
cfg,
accountId,
},
});
const trimmedCaption = caption?.trim();
if (trimmedCaption) {
await sendMessageBlueBubbles(to, trimmedCaption, {
cfg,
accountId,
replyToMessageGuid,
});
}
return attachmentResult;
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
describe("normalizeWebhookMessage", () => {
it("falls back to DM chatGuid handle when sender handle is missing", () => {
const result = normalizeWebhookMessage({
type: "new-message",
data: {
guid: "msg-1",
text: "hello",
isGroup: false,
isFromMe: false,
handle: null,
chatGuid: "iMessage;-;+15551234567",
},
});
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
});
it("does not infer sender from group chatGuid when sender handle is missing", () => {
const result = normalizeWebhookMessage({
type: "new-message",
data: {
guid: "msg-1",
text: "hello group",
isGroup: true,
isFromMe: false,
handle: null,
chatGuid: "iMessage;+;chat123456",
},
});
expect(result).toBeNull();
});
it("accepts array-wrapped payload data", () => {
const result = normalizeWebhookMessage({
type: "new-message",
data: [
{
guid: "msg-1",
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
},
],
});
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
});
});
describe("normalizeWebhookReaction", () => {
it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
const result = normalizeWebhookReaction({
type: "updated-message",
data: {
guid: "msg-2",
associatedMessageGuid: "p:0/msg-1",
associatedMessageType: 2000,
isGroup: false,
isFromMe: false,
handle: null,
chatGuid: "iMessage;-;+15551234567",
},
});
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
expect(result?.messageId).toBe("p:0/msg-1");
expect(result?.action).toBe("added");
});
});

View File

@@ -0,0 +1,825 @@
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) {
return undefined;
}
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
const raw = message["attachments"];
if (!Array.isArray(raw)) {
return [];
}
const out: BlueBubblesAttachment[] = [];
for (const entry of raw) {
const record = asRecord(entry);
if (!record) {
continue;
}
out.push({
guid: readString(record, "guid"),
uti: readString(record, "uti"),
mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
height: readNumberLike(record, "height"),
width: readNumberLike(record, "width"),
originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
});
}
return out;
}
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
if (attachments.length === 0) {
return "";
}
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
const tag = allImages
? "<media:image>"
: allVideos
? "<media:video>"
: allAudio
? "<media:audio>"
: "<media:attachment>";
const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
const suffix = attachments.length === 1 ? label : `${label}s`;
return `${tag} (${attachments.length} ${suffix})`;
}
export function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
if (attachmentPlaceholder) {
return attachmentPlaceholder;
}
if (message.balloonBundleId) {
return "<media:sticker>";
}
return "";
}
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
export function formatReplyTag(message: {
replyToId?: string;
replyToShortId?: string;
}): string | null {
// Prefer short ID
const rawId = message.replyToShortId || message.replyToId;
if (!rawId) {
return null;
}
return `[[reply_to:${rawId}]]`;
}
function extractReplyMetadata(message: Record<string, unknown>): {
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
} {
const replyRaw =
message["replyTo"] ??
message["reply_to"] ??
message["replyToMessage"] ??
message["reply_to_message"] ??
message["repliedMessage"] ??
message["quotedMessage"] ??
message["associatedMessage"] ??
message["reply"];
const replyRecord = asRecord(replyRaw);
const replyHandle =
asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
const replySenderRaw =
readString(replyHandle, "address") ??
readString(replyHandle, "handle") ??
readString(replyHandle, "id") ??
readString(replyRecord, "senderId") ??
readString(replyRecord, "sender") ??
readString(replyRecord, "from");
const normalizedSender = replySenderRaw
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
: undefined;
const replyToBody =
readString(replyRecord, "text") ??
readString(replyRecord, "body") ??
readString(replyRecord, "message") ??
readString(replyRecord, "subject") ??
undefined;
const directReplyId =
readString(message, "replyToMessageGuid") ??
readString(message, "replyToGuid") ??
readString(message, "replyGuid") ??
readString(message, "selectedMessageGuid") ??
readString(message, "selectedMessageId") ??
readString(message, "replyToMessageId") ??
readString(message, "replyId") ??
readString(replyRecord, "guid") ??
readString(replyRecord, "id") ??
readString(replyRecord, "messageId");
const associatedType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId");
const isReactionAssociation =
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
const messageGuid = readString(message, "guid");
const fallbackReplyId =
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
? threadOriginatorGuid
: undefined;
return {
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
replyToBody: replyToBody?.trim() || undefined,
replyToSender: normalizedSender || undefined,
};
}
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
const chats = message["chats"];
if (!Array.isArray(chats) || chats.length === 0) {
return null;
}
const first = chats[0];
return asRecord(first);
}
function extractSenderInfo(message: Record<string, unknown>): {
senderId: string;
senderName?: string;
} {
const handleValue = message.handle ?? message.sender;
const handle =
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
const senderId =
readString(handle, "address") ??
readString(handle, "handle") ??
readString(handle, "id") ??
readString(message, "senderId") ??
readString(message, "sender") ??
readString(message, "from") ??
"";
const senderName =
readString(handle, "displayName") ??
readString(handle, "name") ??
readString(message, "senderName") ??
undefined;
return { senderId, senderName };
}
function extractChatContext(message: Record<string, unknown>): {
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
chatName?: string;
isGroup: boolean;
participants: unknown[];
} {
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
const chatFromList = readFirstChatRecord(message);
const chatGuid =
readString(message, "chatGuid") ??
readString(message, "chat_guid") ??
readString(chat, "chatGuid") ??
readString(chat, "chat_guid") ??
readString(chat, "guid") ??
readString(chatFromList, "chatGuid") ??
readString(chatFromList, "chat_guid") ??
readString(chatFromList, "guid");
const chatIdentifier =
readString(message, "chatIdentifier") ??
readString(message, "chat_identifier") ??
readString(chat, "chatIdentifier") ??
readString(chat, "chat_identifier") ??
readString(chat, "identifier") ??
readString(chatFromList, "chatIdentifier") ??
readString(chatFromList, "chat_identifier") ??
readString(chatFromList, "identifier") ??
extractChatIdentifierFromChatGuid(chatGuid);
const chatId =
readNumberLike(message, "chatId") ??
readNumberLike(message, "chat_id") ??
readNumberLike(chat, "chatId") ??
readNumberLike(chat, "chat_id") ??
readNumberLike(chat, "id") ??
readNumberLike(chatFromList, "chatId") ??
readNumberLike(chatFromList, "chat_id") ??
readNumberLike(chatFromList, "id");
const chatName =
readString(message, "chatName") ??
readString(chat, "displayName") ??
readString(chat, "name") ??
readString(chatFromList, "displayName") ??
readString(chatFromList, "name") ??
undefined;
const chatParticipants = chat ? chat["participants"] : undefined;
const messageParticipants = message["participants"];
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
const participants = Array.isArray(chatParticipants)
? chatParticipants
: Array.isArray(messageParticipants)
? messageParticipants
: Array.isArray(chatsParticipants)
? chatsParticipants
: [];
const participantsCount = participants.length;
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
const explicitIsGroup =
readBoolean(message, "isGroup") ??
readBoolean(message, "is_group") ??
readBoolean(chat, "isGroup") ??
readBoolean(message, "group");
const isGroup =
typeof groupFromChatGuid === "boolean"
? groupFromChatGuid
: (explicitIsGroup ?? participantsCount > 2);
return {
chatGuid,
chatIdentifier,
chatId,
chatName,
isGroup,
participants,
};
}
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
if (typeof entry === "string" || typeof entry === "number") {
const raw = String(entry).trim();
if (!raw) {
return null;
}
const normalized = normalizeBlueBubblesHandle(raw) || raw;
return normalized ? { id: normalized } : null;
}
const record = asRecord(entry);
if (!record) {
return null;
}
const nestedHandle =
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
const idRaw =
readString(record, "address") ??
readString(record, "handle") ??
readString(record, "id") ??
readString(record, "phoneNumber") ??
readString(record, "phone_number") ??
readString(record, "email") ??
readString(nestedHandle, "address") ??
readString(nestedHandle, "handle") ??
readString(nestedHandle, "id");
const nameRaw =
readString(record, "displayName") ??
readString(record, "name") ??
readString(record, "title") ??
readString(nestedHandle, "displayName") ??
readString(nestedHandle, "name");
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
if (!normalizedId) {
return null;
}
const name = nameRaw?.trim() || undefined;
return { id: normalizedId, name };
}
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
if (!Array.isArray(raw) || raw.length === 0) {
return [];
}
const seen = new Set<string>();
const output: BlueBubblesParticipant[] = [];
for (const entry of raw) {
const normalized = normalizeParticipantEntry(entry);
if (!normalized?.id) {
continue;
}
const key = normalized.id.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
export function formatGroupMembers(params: {
participants?: BlueBubblesParticipant[];
fallback?: BlueBubblesParticipant;
}): string | undefined {
const seen = new Set<string>();
const ordered: BlueBubblesParticipant[] = [];
for (const entry of params.participants ?? []) {
if (!entry?.id) {
continue;
}
const key = entry.id.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
ordered.push(entry);
}
if (ordered.length === 0 && params.fallback?.id) {
ordered.push(params.fallback);
}
if (ordered.length === 0) {
return undefined;
}
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
}
export function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
const guid = chatGuid?.trim();
if (!guid) {
return undefined;
}
const parts = guid.split(";");
if (parts.length >= 3) {
if (parts[1] === "+") {
return true;
}
if (parts[1] === "-") {
return false;
}
}
if (guid.includes(";+;")) {
return true;
}
if (guid.includes(";-;")) {
return false;
}
return undefined;
}
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
const guid = chatGuid?.trim();
if (!guid) {
return undefined;
}
const parts = guid.split(";");
if (parts.length < 3) {
return undefined;
}
const identifier = parts[2]?.trim();
return identifier || undefined;
}
export function formatGroupAllowlistEntry(params: {
chatGuid?: string;
chatId?: number;
chatIdentifier?: string;
}): string | null {
const guid = params.chatGuid?.trim();
if (guid) {
return `chat_guid:${guid}`;
}
const chatId = params.chatId;
if (typeof chatId === "number" && Number.isFinite(chatId)) {
return `chat_id:${chatId}`;
}
const identifier = params.chatIdentifier?.trim();
if (identifier) {
return `chat_identifier:${identifier}`;
}
return null;
}
export type BlueBubblesParticipant = {
id: string;
name?: string;
};
export type NormalizedWebhookMessage = {
text: string;
senderId: string;
senderName?: string;
messageId?: string;
timestamp?: number;
isGroup: boolean;
chatId?: number;
chatGuid?: string;
chatIdentifier?: string;
chatName?: string;
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
associatedMessageGuid?: string;
associatedMessageType?: number;
associatedMessageEmoji?: string;
isTapback?: boolean;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
};
export type NormalizedWebhookReaction = {
action: "added" | "removed";
emoji: string;
senderId: string;
senderName?: string;
messageId: string;
timestamp?: number;
isGroup: boolean;
chatId?: number;
chatGuid?: string;
chatIdentifier?: string;
chatName?: string;
fromMe?: boolean;
};
const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
[2000, { emoji: "❤️", action: "added" }],
[2001, { emoji: "👍", action: "added" }],
[2002, { emoji: "👎", action: "added" }],
[2003, { emoji: "😂", action: "added" }],
[2004, { emoji: "‼️", action: "added" }],
[2005, { emoji: "❓", action: "added" }],
[3000, { emoji: "❤️", action: "removed" }],
[3001, { emoji: "👍", action: "removed" }],
[3002, { emoji: "👎", action: "removed" }],
[3003, { emoji: "😂", action: "removed" }],
[3004, { emoji: "‼️", action: "removed" }],
[3005, { emoji: "❓", action: "removed" }],
]);
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
["loved", { emoji: "❤️", action: "added" }],
["liked", { emoji: "👍", action: "added" }],
["disliked", { emoji: "👎", action: "added" }],
["laughed at", { emoji: "😂", action: "added" }],
["emphasized", { emoji: "‼️", action: "added" }],
["questioned", { emoji: "❓", action: "added" }],
// Removal patterns (e.g., "Removed a heart from")
["removed a heart from", { emoji: "❤️", action: "removed" }],
["removed a like from", { emoji: "👍", action: "removed" }],
["removed a dislike from", { emoji: "👎", action: "removed" }],
["removed a laugh from", { emoji: "😂", action: "removed" }],
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
["removed a question from", { emoji: "❓", action: "removed" }],
]);
const TAPBACK_EMOJI_REGEX =
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
function extractFirstEmoji(text: string): string | null {
const match = text.match(TAPBACK_EMOJI_REGEX);
return match ? match[0] : null;
}
function extractQuotedTapbackText(text: string): string | null {
const match = text.match(/["]([^"]+)["]/s);
return match ? match[1] : null;
}
function isTapbackAssociatedType(type: number | undefined): boolean {
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
}
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
if (typeof type !== "number" || !Number.isFinite(type)) {
return undefined;
}
if (type >= 3000 && type < 4000) {
return "removed";
}
if (type >= 2000 && type < 3000) {
return "added";
}
return undefined;
}
export function resolveTapbackContext(message: NormalizedWebhookMessage): {
emojiHint?: string;
actionHint?: "added" | "removed";
replyToId?: string;
} | null {
const associatedType = message.associatedMessageType;
const hasTapbackType = isTapbackAssociatedType(associatedType);
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
if (!hasTapbackType && !hasTapbackMarker) {
return null;
}
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint =
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
return { emojiHint, actionHint, replyToId };
}
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
export function parseTapbackText(params: {
text: string;
emojiHint?: string;
actionHint?: "added" | "removed";
requireQuoted?: boolean;
}): {
emoji: string;
action: "added" | "removed";
quotedText: string;
} | null {
const trimmed = params.text.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) {
return null;
}
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
if (lower.startsWith(pattern)) {
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
const afterPattern = trimmed.slice(pattern.length).trim();
if (params.requireQuoted) {
const strictMatch = afterPattern.match(/^["](.+)["]$/s);
if (!strictMatch) {
return null;
}
return { emoji, action, quotedText: strictMatch[1] };
}
const quotedText =
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
return { emoji, action, quotedText };
}
}
if (lower.startsWith("reacted")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) {
return null;
}
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) {
return null;
}
const fallback = trimmed.slice("reacted".length).trim();
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
}
if (lower.startsWith("removed")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) {
return null;
}
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) {
return null;
}
const fallback = trimmed.slice("removed".length).trim();
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
}
return null;
}
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
const parseRecord = (value: unknown): Record<string, unknown> | null => {
const record = asRecord(value);
if (record) {
return record;
}
if (Array.isArray(value)) {
for (const entry of value) {
const parsedEntry = parseRecord(entry);
if (parsedEntry) {
return parsedEntry;
}
}
return null;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
try {
return parseRecord(JSON.parse(trimmed));
} catch {
return null;
}
};
const dataRaw = payload.data ?? payload.payload ?? payload.event;
const data = parseRecord(dataRaw);
const messageRaw = payload.message ?? data?.message ?? data;
const message = parseRecord(messageRaw);
if (message) {
return message;
}
return null;
}
export function normalizeWebhookMessage(
payload: Record<string, unknown>,
): NormalizedWebhookMessage | null {
const message = extractMessagePayload(payload);
if (!message) {
return null;
}
const text =
readString(message, "text") ??
readString(message, "body") ??
readString(message, "subject") ??
"";
const { senderId, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
extractChatContext(message);
const normalizedParticipants = normalizeParticipantList(participants);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const messageId =
readString(message, "guid") ??
readString(message, "id") ??
readString(message, "messageId") ??
undefined;
const balloonBundleId = readString(message, "balloonBundleId");
const associatedMessageGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId") ??
undefined;
const associatedMessageType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedMessageEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji") ??
undefined;
const isTapback =
readBoolean(message, "isTapback") ??
readBoolean(message, "is_tapback") ??
readBoolean(message, "tapback") ??
undefined;
const timestampRaw =
readNumber(message, "date") ??
readNumber(message, "dateCreated") ??
readNumber(message, "timestamp");
const timestamp =
typeof timestampRaw === "number"
? timestampRaw > 1_000_000_000_000
? timestampRaw
: timestampRaw * 1000
: undefined;
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
const senderFallbackFromChatGuid =
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
}
const replyMetadata = extractReplyMetadata(message);
return {
text,
senderId: normalizedSender,
senderName,
messageId,
timestamp,
isGroup,
chatId,
chatGuid,
chatIdentifier,
chatName,
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
associatedMessageGuid,
associatedMessageType,
associatedMessageEmoji,
isTapback,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
replyToSender: replyMetadata.replyToSender,
};
}
export function normalizeWebhookReaction(
payload: Record<string, unknown>,
): NormalizedWebhookReaction | null {
const message = extractMessagePayload(payload);
if (!message) {
return null;
}
const associatedGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId");
const associatedType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
if (!associatedGuid || associatedType === undefined) {
return null;
}
const mapping = REACTION_TYPE_MAP.get(associatedType);
const associatedEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji");
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
const { senderId, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const timestampRaw =
readNumberLike(message, "date") ??
readNumberLike(message, "dateCreated") ??
readNumberLike(message, "timestamp");
const timestamp =
typeof timestampRaw === "number"
? timestampRaw > 1_000_000_000_000
? timestampRaw
: timestampRaw * 1000
: undefined;
const senderFallbackFromChatGuid =
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
}
return {
action,
emoji,
senderId: normalizedSender,
senderName,
messageId: associatedGuid,
timestamp,
isGroup,
chatId,
chatGuid,
chatIdentifier,
chatName,
fromMe,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
const REPLY_CACHE_MAX = 2000;
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
type BlueBubblesReplyCacheEntry = {
accountId: string;
messageId: string;
shortId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
senderLabel?: string;
body?: string;
timestamp: number;
};
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function generateShortId(): string {
blueBubblesShortIdCounter += 1;
return String(blueBubblesShortIdCounter);
}
export function rememberBlueBubblesReplyCache(
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
): BlueBubblesReplyCacheEntry {
const messageId = entry.messageId.trim();
if (!messageId) {
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this GUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
blueBubblesShortIdToUuid.set(shortId, messageId);
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
// Opportunistic prune.
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
if (value.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(key);
// Clean up short ID mappings for expired entries
if (value.shortId) {
blueBubblesShortIdToUuid.delete(value.shortId);
blueBubblesUuidToShortId.delete(key);
}
continue;
}
break;
}
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
if (!oldest) {
break;
}
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
blueBubblesReplyCacheByMessageId.delete(oldest);
// Clean up short ID mappings for evicted entries
if (oldEntry?.shortId) {
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
blueBubblesUuidToShortId.delete(oldest);
}
}
return fullEntry;
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
* Returns the input unchanged if it's already a GUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
opts?: { requireKnownShortId?: boolean },
): string {
const trimmed = shortOrUuid.trim();
if (!trimmed) {
return trimmed;
}
// If it looks like a short ID (numeric), try to resolve it
if (/^\d+$/.test(trimmed)) {
const uuid = blueBubblesShortIdToUuid.get(trimmed);
if (uuid) {
return uuid;
}
if (opts?.requireKnownShortId) {
throw new Error(
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
);
}
}
// Return as-is (either already a UUID or not found)
return trimmed;
}
/**
* Resets the short ID state. Only use in tests.
* @internal
*/
export function _resetBlueBubblesShortIdState(): void {
blueBubblesShortIdToUuid.clear();
blueBubblesUuidToShortId.clear();
blueBubblesReplyCacheByMessageId.clear();
blueBubblesShortIdCounter = 0;
}
/**
* Gets the short ID for a message GUID, if one exists.
*/
export function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
}
export function resolveReplyContextFromCache(params: {
accountId: string;
replyToId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
}): BlueBubblesReplyCacheEntry | null {
const replyToId = params.replyToId.trim();
if (!replyToId) {
return null;
}
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
if (!cached) {
return null;
}
if (cached.accountId !== params.accountId) {
return null;
}
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
if (cached.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(replyToId);
return null;
}
const chatGuid = trimOrUndefined(params.chatGuid);
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
// Avoid cross-chat collisions if we have identifiers.
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
return null;
}
if (
!chatGuid &&
chatIdentifier &&
cachedChatIdentifier &&
chatIdentifier !== cachedChatIdentifier
) {
return null;
}
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
return null;
}
return cached;
}

View File

@@ -0,0 +1,41 @@
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import type { BlueBubblesAccountConfig } from "./types.js";
export { normalizeWebhookPath };
export type BlueBubblesRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type BlueBubblesMonitorOptions = {
account: ResolvedBlueBubblesAccount;
config: OpenClawConfig;
runtime: BlueBubblesRuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
webhookPath?: string;
};
export type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
export type WebhookTarget = {
account: ResolvedBlueBubblesAccount;
config: OpenClawConfig;
runtime: BlueBubblesRuntimeEnv;
core: BlueBubblesCoreRuntime;
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = config?.webhookPath?.trim();
if (raw) {
return normalizeWebhookPath(raw);
}
return DEFAULT_WEBHOOK_PATH;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
requestBodyErrorToText,
resolveSingleWebhookTarget,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
type NormalizedWebhookMessage,
} from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
resolveBlueBubblesMessageId,
} from "./monitor-reply-cache.js";
import {
DEFAULT_WEBHOOK_PATH,
normalizeWebhookPath,
resolveWebhookPathFromConfig,
type BlueBubblesCoreRuntime,
type BlueBubblesMonitorOptions,
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import { getBlueBubblesRuntime } from "./runtime.js";
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
/**
* Default debounce window for inbound message coalescing (ms).
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
/**
* Combines multiple debounced messages into a single message for processing.
* Used when multiple webhook events arrive within the debounce window.
*/
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
if (entries.length === 0) {
throw new Error("Cannot combine empty entries");
}
if (entries.length === 1) {
return entries[0].message;
}
// Use the first message as the base (typically the text message)
const first = entries[0].message;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
const text = entry.message.text.trim();
if (!text) {
continue;
}
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) {
continue;
}
seenTexts.add(normalizedText);
textParts.push(text);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
// Use the latest timestamp
const timestamps = entries
.map((e) => e.message.timestamp)
.filter((t): t is number => typeof t === "number");
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
// Collect all message IDs for reference
const messageIds = entries
.map((e) => e.message.messageId)
.filter((id): id is string => Boolean(id));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
timestamp: latestTimestamp,
// Use first message's ID as primary (for reply reference), but we've coalesced others
messageId: messageIds[0] ?? first.messageId,
// Preserve reply context if present
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
balloonBundleId: undefined,
};
}
const webhookTargets = new Map<string, WebhookTarget[]>();
type BlueBubblesDebouncer = {
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
};
/**
* Maps webhook targets to their inbound debouncers.
* Each target gets its own debouncer keyed by a unique identifier.
*/
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
function resolveBlueBubblesDebounceMs(
config: OpenClawConfig,
core: BlueBubblesCoreRuntime,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) {
return DEFAULT_INBOUND_DEBOUNCE_MS;
}
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
/**
* Creates or retrieves a debouncer for a webhook target.
*/
function getOrCreateDebouncer(target: WebhookTarget) {
const existing = targetDebouncers.get(target);
if (existing) {
return existing;
}
const { account, config, runtime, core } = target;
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
// same message (e.g., text-only then text+attachment).
//
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
// messageId than the originating text. When present, key by associatedMessageGuid
// to keep text + balloon coalescing working.
const balloonBundleId = msg.balloonBundleId?.trim();
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
if (balloonBundleId && associatedMessageGuid) {
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
}
const messageId = msg.messageId?.trim();
if (messageId) {
return `bluebubbles:${account.accountId}:msg:${messageId}`;
}
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) {
return false;
}
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) {
return false;
}
// Debounce all other messages to coalesce rapid-fire webhook events
// (e.g., text+image arriving as separate webhooks for the same messageId)
return true;
},
onFlush: async (entries) => {
if (entries.length === 0) {
return;
}
// Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target;
if (entries.length === 1) {
// Single message - process normally
await processMessage(entries[0].message, flushTarget);
return;
}
// Multiple messages - combine and process
const combined = combineDebounceEntries(entries);
if (core.logging.shouldLogVerbose()) {
const count = entries.length;
const preview = combined.text.slice(0, 50);
runtime.log?.(
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
);
}
await processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
}
/**
* Removes a debouncer for a target (called during unregistration).
*/
function removeDebouncer(target: WebhookTarget): void {
targetDebouncers.delete(target);
}
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const registered = registerWebhookTarget(webhookTargets, target);
return () => {
registered.unregister();
// Clean up debouncer when target is unregistered
removeDebouncer(registered.target);
};
}
type ReadBlueBubblesWebhookBodyResult =
| { ok: true; value: unknown }
| { ok: false; statusCode: number; error: string };
function parseBlueBubblesWebhookPayload(
rawBody: string,
): { ok: true; value: unknown } | { ok: false; error: string } {
const trimmed = rawBody.trim();
if (!trimmed) {
return { ok: false, error: "empty payload" };
}
try {
return { ok: true, value: JSON.parse(trimmed) as unknown };
} catch {
const params = new URLSearchParams(rawBody);
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
if (!payload) {
return { ok: false, error: "invalid json" };
}
try {
return { ok: true, value: JSON.parse(payload) as unknown };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
async function readBlueBubblesWebhookBody(
req: IncomingMessage,
maxBytes: number,
): Promise<ReadBlueBubblesWebhookBodyResult> {
try {
const rawBody = await readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: 30_000,
});
const parsed = parseBlueBubblesWebhookPayload(rawBody);
if (!parsed.ok) {
return { ok: false, statusCode: 400, error: parsed.error };
}
return parsed;
} catch (error) {
if (isRequestBodyLimitError(error)) {
return {
ok: false,
statusCode: error.statusCode,
error: requestBodyErrorToText(error.code),
};
}
return {
ok: false,
statusCode: 400,
error: error instanceof Error ? error.message : String(error),
};
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function maskSecret(value: string): string {
if (value.length <= 6) {
return "***";
}
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function normalizeAuthToken(raw: string): string {
const value = raw.trim();
if (!value) {
return "";
}
if (value.toLowerCase().startsWith("bearer ")) {
return value.slice("bearer ".length).trim();
}
return value;
}
function safeEqualSecret(aRaw: string, bRaw: string): boolean {
const a = normalizeAuthToken(aRaw);
const b = normalizeAuthToken(bRaw);
if (!a || !b) {
return false;
}
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
if (bufA.length !== bufB.length) {
return false;
}
return timingSafeEqual(bufA, bufB);
}
export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.statusCode;
res.end(body.error ?? "invalid payload");
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
return true;
}
const payload = asRecord(body.value) ?? {};
const firstTarget = targets[0];
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
);
}
const eventTypeRaw = payload.type;
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
const allowedEventTypes = new Set([
"new-message",
"updated-message",
"message-reaction",
"reaction",
]);
if (eventType && !allowedEventTypes.has(eventType)) {
res.statusCode = 200;
res.end("ok");
if (firstTarget) {
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
}
return true;
}
const reaction = normalizeWebhookReaction(payload);
if (
(eventType === "updated-message" ||
eventType === "message-reaction" ||
eventType === "reaction") &&
!reaction
) {
res.statusCode = 200;
res.end("ok");
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook ignored ${eventType || "event"} without reaction`,
);
}
return true;
}
const message = reaction ? null : normalizeWebhookMessage(payload);
if (!message && !reaction) {
res.statusCode = 400;
res.end("invalid payload");
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
return true;
}
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
const headerToken =
req.headers["x-guid"] ??
req.headers["x-password"] ??
req.headers["x-bluebubbles-guid"] ??
req.headers["authorization"];
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
const token = target.account.config.password?.trim() ?? "";
return safeEqualSecret(guid, token);
});
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
console.warn(
`[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
);
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
return true;
}
const target = matchedTarget.target;
target.statusSink?.({ lastInboundAt: Date.now() });
if (reaction) {
processReaction(reaction, target).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
);
});
} else if (message) {
// Route messages through debouncer to coalesce rapid-fire events
// (e.g., text message + URL balloon arriving as separate webhooks)
const debouncer = getOrCreateDebouncer(target);
debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
});
}
res.statusCode = 200;
res.end("ok");
if (reaction) {
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
);
}
} else if (message) {
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
);
}
}
return true;
}
export async function monitorBlueBubblesProvider(
options: BlueBubblesMonitorOptions,
): Promise<void> {
const { account, config, runtime, abortSignal, statusSink } = options;
const core = getBlueBubblesRuntime();
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
// Fetch and cache server info (for macOS version detection in action gating)
const serverInfo = await fetchBlueBubblesServerInfo({
baseUrl: account.baseUrl,
password: account.config.password,
accountId: account.accountId,
timeoutMs: 5000,
}).catch(() => null);
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
}
if (typeof serverInfo?.private_api === "boolean") {
runtime.log?.(
`[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
);
}
const unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime,
core,
path,
statusSink,
});
return await new Promise((resolve) => {
const stop = () => {
unregister();
resolve();
};
if (abortSignal?.aborted) {
stop();
return;
}
abortSignal?.addEventListener("abort", stop, { once: true });
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
});
}
export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };

View File

@@ -0,0 +1,32 @@
import { blueBubblesFetchWithTimeout } from "./types.js";
export function concatUint8Arrays(parts: Uint8Array[]): Uint8Array {
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
const body = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
return body;
}
export async function postMultipartFormData(params: {
url: string;
boundary: string;
parts: Uint8Array[];
timeoutMs: number;
}): Promise<Response> {
const body = Buffer.from(concatUint8Arrays(params.parts));
return await blueBubblesFetchWithTimeout(
params.url,
{
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${params.boundary}`,
},
body,
},
params.timeoutMs,
);
}

View File

@@ -0,0 +1,340 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
OpenClawConfig,
DmPolicy,
WizardPrompter,
} from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
normalizeAccountId,
promptAccountId,
} from "openclaw/plugin-sdk";
import {
listBlueBubblesAccountIds,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
const channel = "bluebubbles" as const;
function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setBlueBubblesAllowFrom(
cfg: OpenClawConfig,
accountId: string,
allowFrom: string[],
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
accounts: {
...cfg.channels?.bluebubbles?.accounts,
[accountId]: {
...cfg.channels?.bluebubbles?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseBlueBubblesAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptBlueBubblesAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultBlueBubblesAccountId(params.cfg);
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist BlueBubbles DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:iMessage;-;+15555550123",
"Multiple entries: comma- or newline-separated.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles allowlist",
);
const entry = await params.prompter.text({
message: "BlueBubbles allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const parts = parseBlueBubblesAllowFromInput(raw);
for (const part of parts) {
if (part === "*") {
continue;
}
const parsed = parseBlueBubblesAllowTarget(part);
if (parsed.kind === "handle" && !parsed.handle) {
return `Invalid entry: ${part}`;
}
}
return undefined;
},
});
const parts = parseBlueBubblesAllowFromInput(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "BlueBubbles",
channel,
policyKey: "channels.bluebubbles.dmPolicy",
allowFromKey: "channels.bluebubbles.allowFrom",
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
promptAllowFrom: promptBlueBubblesAllowFrom,
};
export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
return account.configured;
});
return {
channel,
configured,
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
quickstartScore: configured ? 1 : 0,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
let accountId = blueBubblesOverride
? normalizeAccountId(blueBubblesOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !blueBubblesOverride) {
accountId = await promptAccountId({
cfg,
prompter,
label: "BlueBubbles",
currentId: accountId,
listAccountIds: listBlueBubblesAccountIds,
defaultAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
const validateServerUrlInput = (value: unknown): string | undefined => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
};
const promptServerUrl = async (initialValue?: string): Promise<string> => {
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
initialValue,
validate: validateServerUrlInput,
});
return String(entered).trim();
};
// Prompt for server URL
let serverUrl = resolvedAccount.config.serverUrl?.trim();
if (!serverUrl) {
await prompter.note(
[
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
"Find this in the BlueBubbles Server app under Connection.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles server URL",
);
serverUrl = await promptServerUrl();
} else {
const keepUrl = await prompter.confirm({
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
initialValue: true,
});
if (!keepUrl) {
serverUrl = await promptServerUrl(serverUrl);
}
}
// Prompt for password
let password = resolvedAccount.config.password?.trim();
if (!password) {
await prompter.note(
[
"Enter the BlueBubbles server password.",
"Find this in the BlueBubbles Server app under Settings.",
].join("\n"),
"BlueBubbles password",
);
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
password = String(entered).trim();
} else {
const keepPassword = await prompter.confirm({
message: "BlueBubbles password already set. Keep it?",
initialValue: true,
});
if (!keepPassword) {
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
password = String(entered).trim();
}
}
// Prompt for webhook path (optional)
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
const wantsWebhook = await prompter.confirm({
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
});
let webhookPath = "/bluebubbles-webhook";
if (wantsWebhook) {
const entered = await prompter.text({
message: "Webhook path",
placeholder: "/bluebubbles-webhook",
initialValue: existingWebhookPath || "/bluebubbles-webhook",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!trimmed.startsWith("/")) {
return "Path must start with /";
}
return undefined;
},
});
webhookPath = String(entered).trim();
}
// Apply config
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
serverUrl,
password,
webhookPath,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
serverUrl,
password,
webhookPath,
},
},
},
},
};
}
await prompter.note(
[
"Configure the webhook URL in BlueBubbles Server:",
"1. Open BlueBubbles Server → Settings → Webhooks",
"2. Add your OpenClaw gateway URL + webhook path",
" Example: https://your-gateway-host:3000/bluebubbles-webhook",
"3. Enable the webhook and save",
"",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles next steps",
);
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
},
}),
};

View File

@@ -0,0 +1,163 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
export type BlueBubblesProbe = BaseProbeResult & {
status?: number | null;
};
export type BlueBubblesServerInfo = {
os_version?: string;
server_version?: string;
private_api?: boolean;
helper_connected?: boolean;
proxy_service?: string;
detected_icloud?: string;
computer_id?: string;
};
/** Cache server info by account ID to avoid repeated API calls.
* Size-capped to prevent unbounded growth (#4948). */
const MAX_SERVER_INFO_CACHE_SIZE = 64;
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
return accountId?.trim() || "default";
}
/**
* Fetch server info from BlueBubbles API and cache it.
* Returns cached result if available and not expired.
*/
export async function fetchBlueBubblesServerInfo(params: {
baseUrl?: string | null;
password?: string | null;
accountId?: string;
timeoutMs?: number;
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl || !password) {
return null;
}
const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
if (!res.ok) {
return null;
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
// Evict oldest entries if cache exceeds max size
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
const oldest = serverInfoCache.keys().next().value;
if (oldest !== undefined) {
serverInfoCache.delete(oldest);
}
}
}
return data ?? null;
} catch {
return null;
}
}
/**
* Get cached server info synchronously (for use in listActions).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
const cacheKey = buildCacheKey(accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
return null;
}
/**
* Read cached private API capability for a BlueBubbles account.
* Returns null when capability is unknown (for example, before first probe).
*/
export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info || typeof info.private_api !== "boolean") {
return null;
}
return info.private_api;
}
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
return status === true;
}
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/
export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) {
return null;
}
const match = /^(\d+)/.exec(version.trim());
return match ? Number.parseInt(match[1], 10) : null;
}
/**
* Check if the cached server info indicates macOS 26 or higher.
* Returns false if no cached info is available (fail open for action listing).
*/
export function isMacOS26OrHigher(accountId?: string): boolean {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) {
return false;
}
const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26;
}
/** Clear the server info cache (for testing) */
export function clearServerInfoCache(): void {
serverInfoCache.clear();
}
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;
timeoutMs?: number;
}): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl) {
return { ok: false, error: "serverUrl not configured" };
}
if (!password) {
return { ok: false, error: "password not configured" };
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status };
} catch (err) {
return {
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -0,0 +1,372 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendBlueBubblesReaction } from "./reactions.js";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
const mockFetch = vi.fn();
describe("reactions", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("sendBlueBubblesReaction", () => {
async function expectRemovedReaction(emoji: string) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji,
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
}
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("chatGuid");
});
it("throws when messageGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("messageGuid");
});
it("throws when emoji is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("emoji or name");
});
it("throws when serverUrl is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {},
}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
},
}),
).rejects.toThrow("password is required");
});
it("throws for unsupported reaction type", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "unsupported",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("Unsupported BlueBubbles reaction");
});
describe("reaction type normalization", () => {
const testCases = [
{ input: "love", expected: "love" },
{ input: "like", expected: "like" },
{ input: "dislike", expected: "dislike" },
{ input: "laugh", expected: "laugh" },
{ input: "emphasize", expected: "emphasize" },
{ input: "question", expected: "question" },
{ input: "heart", expected: "love" },
{ input: "thumbs_up", expected: "like" },
{ input: "thumbs-down", expected: "dislike" },
{ input: "thumbs_down", expected: "dislike" },
{ input: "haha", expected: "laugh" },
{ input: "lol", expected: "laugh" },
{ input: "emphasis", expected: "emphasize" },
{ input: "exclaim", expected: "emphasize" },
{ input: "❤️", expected: "love" },
{ input: "❤", expected: "love" },
{ input: "♥️", expected: "love" },
{ input: "😍", expected: "love" },
{ input: "👍", expected: "like" },
{ input: "👎", expected: "dislike" },
{ input: "😂", expected: "laugh" },
{ input: "🤣", expected: "laugh" },
{ input: "😆", expected: "laugh" },
{ input: "‼️", expected: "emphasize" },
{ input: "‼", expected: "emphasize" },
{ input: "❗", expected: "emphasize" },
{ input: "❓", expected: "question" },
{ input: "❔", expected: "question" },
{ input: "LOVE", expected: "love" },
{ input: "Like", expected: "like" },
];
for (const { input, expected } of testCases) {
it(`normalizes "${input}" to "${expected}"`, async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: input,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe(expected);
});
}
});
it("sends reaction successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-uuid-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/react"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
expect(body.reaction).toBe("love");
expect(body.partIndex).toBe(0);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "my-react-password",
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-react-password");
});
it("sends reaction removal with dash prefix", async () => {
await expectRemovedReaction("love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
await expectRemovedReaction("-love");
});
it("uses custom partIndex when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "laugh",
partIndex: 3,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve("Invalid reaction type"),
});
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("reaction failed (400): Invalid reaction type");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "emphasize",
opts: {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://react-server:7777",
password: "react-pass",
},
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("react-server:7777");
expect(calledUrl).toContain("password=react-pass");
});
it("trims chatGuid and messageGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: " chat-with-spaces ",
messageGuid: " msg-with-spaces ",
emoji: "question",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("chat-with-spaces");
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
});
describe("reaction removal aliases", () => {
it("handles emoji-based removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "👍",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-like");
});
it("handles text alias removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "haha",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-laugh");
});
});
});
});

View File

@@ -0,0 +1,182 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesReactionOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
};
const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]);
const REACTION_ALIASES = new Map<string, string>([
// General
["heart", "love"],
["love", "love"],
["❤", "love"],
["❤️", "love"],
["red_heart", "love"],
["thumbs_up", "like"],
["thumbsup", "like"],
["thumbs-up", "like"],
["thumbsup", "like"],
["like", "like"],
["thumb", "like"],
["ok", "like"],
["thumbs_down", "dislike"],
["thumbsdown", "dislike"],
["thumbs-down", "dislike"],
["dislike", "dislike"],
["boo", "dislike"],
["no", "dislike"],
// Laugh
["haha", "laugh"],
["lol", "laugh"],
["lmao", "laugh"],
["rofl", "laugh"],
["😂", "laugh"],
["🤣", "laugh"],
["xd", "laugh"],
["laugh", "laugh"],
// Emphasize / exclaim
["emphasis", "emphasize"],
["emphasize", "emphasize"],
["exclaim", "emphasize"],
["!!", "emphasize"],
["‼", "emphasize"],
["‼️", "emphasize"],
["❗", "emphasize"],
["important", "emphasize"],
["bang", "emphasize"],
// Question
["question", "question"],
["?", "question"],
["❓", "question"],
["❔", "question"],
["ask", "question"],
// Apple/Messages names
["loved", "love"],
["liked", "like"],
["disliked", "dislike"],
["laughed", "laugh"],
["emphasized", "emphasize"],
["questioned", "question"],
// Colloquial / informal
["fire", "love"],
["🔥", "love"],
["wow", "emphasize"],
["!", "emphasize"],
// Edge: generic emoji name forms
["heart_eyes", "love"],
["smile", "laugh"],
["smiley", "laugh"],
["happy", "laugh"],
["joy", "laugh"],
]);
const REACTION_EMOJIS = new Map<string, string>([
// Love
["❤️", "love"],
["❤", "love"],
["♥️", "love"],
["♥", "love"],
["😍", "love"],
["💕", "love"],
// Like
["👍", "like"],
["👌", "like"],
// Dislike
["👎", "dislike"],
["🙅", "dislike"],
// Laugh
["😂", "laugh"],
["🤣", "laugh"],
["😆", "laugh"],
["😁", "laugh"],
["😹", "laugh"],
// Emphasize
["‼️", "emphasize"],
["‼", "emphasize"],
["!!", "emphasize"],
["❗", "emphasize"],
["❕", "emphasize"],
["!", "emphasize"],
// Question
["❓", "question"],
["❔", "question"],
["?", "question"],
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
return resolveBlueBubblesServerAccount(params);
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim();
if (!trimmed) {
throw new Error("BlueBubbles reaction requires an emoji or name.");
}
let raw = trimmed.toLowerCase();
if (raw.startsWith("-")) {
raw = raw.slice(1);
}
const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) {
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
}
return remove ? `-${mapped}` : mapped;
}
export async function sendBlueBubblesReaction(params: {
chatGuid: string;
messageGuid: string;
emoji: string;
remove?: boolean;
partIndex?: number;
opts?: BlueBubblesReactionOpts;
}): Promise<void> {
const chatGuid = params.chatGuid.trim();
const messageGuid = params.messageGuid.trim();
if (!chatGuid) {
throw new Error("BlueBubbles reaction requires chatGuid.");
}
if (!messageGuid) {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {});
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
throw new Error(
"BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
);
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/react",
password,
});
const payload = {
chatGuid,
selectedMessageGuid: messageGuid,
reaction,
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.opts?.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
}
}

View File

@@ -0,0 +1,12 @@
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return String(input);
}

View File

@@ -0,0 +1,34 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
type LegacyRuntimeLogShape = { log?: (message: string) => void };
export function setBlueBubblesRuntime(next: PluginRuntime): void {
runtime = next;
}
export function clearBlueBubblesRuntime(): void {
runtime = null;
}
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
return runtime;
}
export function getBlueBubblesRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("BlueBubbles runtime not initialized");
}
return runtime;
}
export function warnBlueBubbles(message: string): void {
const formatted = `[bluebubbles] ${message}`;
// Backward-compatible with tests/legacy injections that pass { log }.
const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
if (typeof log === "function") {
log(formatted);
return;
}
console.warn(formatted);
}

View File

@@ -0,0 +1,53 @@
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
export function extractBlueBubblesMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return "unknown";
}
const record = payload as Record<string, unknown>;
const data =
record.data && typeof record.data === "object"
? (record.data as Record<string, unknown>)
: null;
const candidates = [
record.messageId,
record.messageGuid,
record.message_guid,
record.guid,
record.id,
data?.messageId,
data?.messageGuid,
data?.message_guid,
data?.message_id,
data?.guid,
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
}
return "unknown";
}

View File

@@ -0,0 +1,760 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesSendTarget } from "./types.js";
const mockFetch = vi.fn();
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock,
});
function mockResolvedHandleTarget(
guid: string = "iMessage;-;+15551234567",
address: string = "+15551234567",
) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid,
participants: [{ address }],
},
],
}),
});
}
function mockSendResponse(body: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(body)),
});
}
function mockNewChatSendResponse(guid: string) {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid },
}),
),
});
}
describe("send", () => {
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
return await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
};
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
chatGuid: "iMessage;-;+15551234567",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
expect(mockFetch).not.toHaveBeenCalled();
});
it("queries chats to resolve chat_id target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat456");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/query"),
expect.objectContaining({ method: "POST" }),
);
});
it("queries chats to resolve chat_identifier target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
identifier: "chat123@group.imessage",
guid: "iMessage;-;chat123",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat123@group.imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat123");
});
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;chat660250192681427962",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;+;chat660250192681427962");
});
it("resolves handle target by matching participant", async () => {
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when handle only exists in group chat (not DM)", async () => {
// This is the critical fix: if a phone number only exists as a participant in a group chat
// (no direct DM chat), we should NOT send to that group. Return null instead.
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-the-council",
participants: [
{ address: "+12622102921" },
{ address: "+15550001111" },
{ address: "+15550002222" },
],
},
],
}),
})
// Empty second page to stop pagination
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+12622102921",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
// Should return null, NOT the group chat GUID
expect(result).toBeNull();
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("handles API error gracefully", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("paginates through chats to find match", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: Array(500)
.fill(null)
.map((_, i) => ({
id: i,
guid: `chat-${i}`,
participants: [],
})),
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [{ id: 555, guid: "found-chat", participants: [] }],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("found-chat");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("normalizes handle addresses for matching", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;test@example.com",
participants: [{ address: "Test@Example.COM" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "test@example.com",
service: "auto",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;test@example.com");
});
it("extracts guid from various response formats", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
chatGuid: "format1-guid",
id: 100,
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("format1-guid");
});
});
describe("sendMessageBlueBubbles", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("throws when text is empty", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when text is whitespace only", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", " ", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when text becomes empty after markdown stripping", async () => {
// Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown
await expect(
sendMessageBlueBubbles("+15551234567", "***", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("empty after markdown removal");
});
it("throws when serverUrl is missing", async () => {
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
await expect(
sendMessageBlueBubbles("chat_id:999", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid not found");
});
it("sends message successfully", async () => {
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-uuid-123");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
expect(sendCall[0]).toContain("/api/v1/message/text");
const body = JSON.parse(sendCall[1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.message).toBe("Hello world!");
expect(body.method).toBeUndefined();
});
it("strips markdown formatting from outbound messages", async () => {
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
const result = await sendMessageBlueBubbles(
"+15551234567",
"**Bold** and *italic* with `code`\n## Header",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("msg-uuid-stripped");
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
// Markdown should be stripped: no asterisks, backticks, or hashes
expect(body.message).toBe("Bold and italic with code\nHeader");
});
it("strips markdown when creating a new chat", async () => {
mockNewChatSendResponse("new-msg-stripped");
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-stripped");
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
// Markdown should be stripped
expect(body.message).toBe("Welcome to the chat!");
});
it("creates a new chat when handle target is missing", async () => {
mockNewChatSendResponse("new-msg-guid");
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
expect(body.addresses).toEqual(["+15550009999"]);
expect(body.message).toBe("Hello new chat");
});
it("throws when creating a new chat requires Private API", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
sendMessageBlueBubbles("+15550008888", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Private API must be enabled");
});
it("uses private-api when reply metadata is present", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
replyToPartIndex: 1,
});
expect(result.messageId).toBe("msg-uuid-124");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.selectedMessageGuid).toBe("reply-guid-123");
expect(body.partIndex).toBe(1);
});
it("downgrades threaded reply to plain send when private API is disabled", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
replyToPartIndex: 1,
});
expect(result.messageId).toBe("msg-uuid-plain");
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
expect(body.selectedMessageGuid).toBeUndefined();
expect(body.partIndex).toBeUndefined();
});
it("normalizes effect names and uses private-api for effects", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
effectId: "invisible ink",
});
expect(result.messageId).toBe("msg-uuid-125");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
});
it("warns and downgrades private-api features when status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-unknown" } });
try {
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
effectId: "invisible ink",
});
expect(result.messageId).toBe("msg-uuid-unknown");
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
expect(warnSpy).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
expect(body.selectedMessageGuid).toBeUndefined();
expect(body.partIndex).toBeUndefined();
expect(body.effectId).toBeUndefined();
} finally {
clearBlueBubblesRuntime();
warnSpy.mockRestore();
}
});
it("sends message with chat_guid target directly", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageId: "direct-msg-123" },
}),
),
});
const result = await sendMessageBlueBubbles(
"chat_guid:iMessage;-;direct-chat",
"Direct message",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("direct-msg-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("handles send failure", async () => {
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("send failed (500)");
});
it("handles empty response body", async () => {
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("handles invalid JSON response body", async () => {
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("extracts messageId from various response formats", async () => {
mockResolvedHandleTarget();
mockSendResponse({ id: "numeric-id-456" });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("numeric-id-456");
});
it("extracts messageGuid from response payload", async () => {
mockResolvedHandleTarget();
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-guid-789");
});
it("resolves credentials from config", async () => {
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-pass",
},
},
},
});
expect(result.messageId).toBe("msg-123");
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
});
it("includes tempGuid in request payload", async () => {
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg" } });
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.tempGuid).toBeDefined();
expect(typeof body.tempGuid).toBe("string");
expect(body.tempGuid.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,472 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
/** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string;
/** Part index for reply (default: 0) */
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
/** Maps short effect names to full Apple effect IDs */
const EFFECT_MAP: Record<string, string> = {
// Bubble effects
slam: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
heart: "com.apple.messages.effect.CKHeartEffect",
hearts: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
};
function resolveEffectId(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim().toLowerCase();
if (EFFECT_MAP[trimmed]) {
return EFFECT_MAP[trimmed];
}
const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) {
return EFFECT_MAP[normalized];
}
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) {
return EFFECT_MAP[compact];
}
return raw;
}
type PrivateApiDecision = {
canUsePrivateApi: boolean;
throwEffectDisabledError: boolean;
warningMessage?: string;
};
function resolvePrivateApiDecision(params: {
privateApiStatus: boolean | null;
wantsReplyThread: boolean;
wantsEffect: boolean;
}): PrivateApiDecision {
const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi =
needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
if (!needsPrivateApi || privateApiStatus !== null) {
return { canUsePrivateApi, throwEffectDisabledError };
}
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
return {
canUsePrivateApi,
throwEffectDisabledError,
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
};
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
const candidates = [
chat.chatGuid,
chat.guid,
chat.chat_guid,
chat.identifier,
chat.chatIdentifier,
chat.chat_identifier,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
}
function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return candidate;
}
}
return null;
}
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) {
return null;
}
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) {
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
out.push(entry);
continue;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const candidate =
(typeof record.address === "string" && record.address) ||
(typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier);
if (candidate) {
out.push(candidate);
}
}
}
return out;
}
async function queryChats(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
offset: number;
limit: number;
}): Promise<BlueBubblesChatRecord[]> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/query",
password: params.password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: params.limit,
offset: params.offset,
with: ["participants"],
}),
},
params.timeoutMs,
);
if (!res.ok) {
return [];
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
}
export async function resolveChatGuidForTarget(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
target: BlueBubblesSendTarget;
}): Promise<string | null> {
if (params.target.kind === "chat_guid") {
return params.target.chatGuid;
}
const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
const targetChatIdentifier =
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
let participantMatch: string | null = null;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
password: params.password,
timeoutMs: params.timeoutMs,
offset,
limit,
});
if (chats.length === 0) {
break;
}
for (const chat of chats) {
if (targetChatId != null) {
const chatId = extractChatId(chat);
if (chatId != null && chatId === targetChatId) {
return extractChatGuid(chat);
}
}
if (targetChatIdentifier) {
const guid = extractChatGuid(chat);
if (guid) {
// Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) {
return guid;
}
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
return guid;
}
}
const identifier =
typeof chat.identifier === "string"
? chat.identifier
: typeof chat.chatIdentifier === "string"
? chat.chatIdentifier
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) {
return guid ?? extractChatGuid(chat);
}
}
if (normalizedHandle) {
const guid = extractChatGuid(chat);
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
if (directHandle && directHandle === normalizedHandle) {
return guid;
}
if (!participantMatch && guid) {
// Only consider DM chats (`;-;` separator) as participant matches.
// Group chats (`;+;` separator) should never match when searching by handle/phone.
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
}
}
}
return participantMatch;
}
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
tempGuid: `temp-${crypto.randomUUID()}`,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (
res.status === 400 ||
res.status === 403 ||
errorText.toLowerCase().includes("private api")
) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
opts: BlueBubblesSendOpts = {},
): Promise<BlueBubblesSendResult> {
const trimmedText = text ?? "";
if (!trimmedText.trim()) {
throw new Error("BlueBubbles send requires text");
}
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
const strippedText = stripMarkdown(trimmedText);
if (!strippedText.trim()) {
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const account = resolveBlueBubblesAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
});
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = opts.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: strippedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
});
if (privateApiDecision.throwEffectDisabledError) {
throw new Error(
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
}
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/text",
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import {
isAllowedBlueBubblesSender,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
parseBlueBubblesAllowTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
// DM format: service;-;handle
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
// Email handles
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
// Group format: service;+;groupId
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View File

@@ -0,0 +1,370 @@
import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk";
export type BlueBubblesService = "imessage" | "sms" | "auto";
export type BlueBubblesTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; to: string; service: BlueBubblesService };
export type BlueBubblesAllowTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parts = trimmed.split(";");
if (parts.length !== 3) {
return null;
}
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) {
return null;
}
if (separator !== "+" && separator !== "-") {
return null;
}
return `${service};${separator};${identifier}`;
}
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) {
return trimmed;
}
return trimmed.slice("bluebubbles:".length).trim();
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return false;
}
if (/^chat\d+$/i.test(trimmed)) {
return true;
}
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
}
function parseGroupTarget(params: {
trimmed: string;
lower: string;
requireValue: boolean;
}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null {
if (!params.lower.startsWith("group:")) {
return null;
}
const value = stripPrefix(params.trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
if (params.requireValue) {
throw new Error("group target is required");
}
return null;
}
function parseRawChatIdentifierTarget(
trimmed: string,
): { kind: "chat_identifier"; chatIdentifier: string } | null {
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
return null;
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) {
return normalizeBlueBubblesHandle(trimmed.slice(9));
}
if (lowered.startsWith("sms:")) {
return normalizeBlueBubblesHandle(trimmed.slice(4));
}
if (lowered.startsWith("auto:")) {
return normalizeBlueBubblesHandle(trimmed.slice(5));
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
return trimmed.replace(/\s+/g, "");
}
/**
* Extracts the handle from a chat_guid if it's a DM (1:1 chat).
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
* Group chat format: "service;+;groupId" (has "+" instead of "-")
*/
export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = parts[2]?.trim();
if (handle) {
return normalizeBlueBubblesHandle(handle);
}
}
return null;
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) {
return undefined;
}
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_id") {
return `chat_id:${parsed.chatId}`;
}
if (parsed.kind === "chat_guid") {
// For DM chat_guids, normalize to just the handle for easier comparison.
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) {
return handle;
}
// For group chats or unrecognized formats, keep the full chat_guid
return `chat_guid:${parsed.chatGuid}`;
}
if (parsed.kind === "chat_identifier") {
return `chat_identifier:${parsed.chatIdentifier}`;
}
const handle = normalizeBlueBubblesHandle(parsed.to);
if (!handle) {
return undefined;
}
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch {
return trimmed;
}
}
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) {
return false;
}
if (parseRawChatGuid(candidate)) {
return true;
}
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) {
return true;
}
if (looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (candidate.includes("@")) {
return true;
}
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) {
throw new Error("BlueBubbles target is required");
}
const lower = trimmed.toLowerCase();
const servicePrefixed = resolveServicePrefixedTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
isChatTarget: (remainderLower) =>
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
remainderLower.startsWith("group:"),
parseTarget: parseBlueBubblesTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatTargetPrefixesOrThrow({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true });
if (groupTarget) {
return groupTarget;
}
const rawChatGuid = parseRawChatGuid(trimmed);
if (rawChatGuid) {
return { kind: "chat_guid", chatGuid: rawChatGuid };
}
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = trimmed.toLowerCase();
const servicePrefixed = resolveServicePrefixedAllowTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false });
if (groupTarget) {
return groupTarget;
}
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
}
export function isAllowedBlueBubblesSender(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeBlueBubblesHandle,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
}
export function formatBlueBubblesChatTarget(params: {
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): string {
if (params.chatId && Number.isFinite(params.chatId)) {
return `chat_id:${params.chatId}`;
}
const guid = params.chatGuid?.trim();
if (guid) {
return `chat_guid:${guid}`;
}
const identifier = params.chatIdentifier?.trim();
if (identifier) {
return `chat_identifier:${identifier}`;
}
return "";
}

View File

@@ -0,0 +1,79 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true,
disabled: false,
unknown: null,
} as const;
type BlueBubblesPrivateApiStatusMock = {
mockReturnValue: (value: boolean | null) => unknown;
mockReturnValueOnce: (value: boolean | null) => unknown;
};
export function mockBlueBubblesPrivateApiStatus(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
value: boolean | null,
) {
mock.mockReturnValue(value);
}
export function mockBlueBubblesPrivateApiStatusOnce(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
value: boolean | null,
) {
mock.mockReturnValueOnce(value);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const config = params.cfg?.channels?.bluebubbles ?? {};
return {
accountId: params.accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
};
}
type BlueBubblesProbeMockModule = {
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
};
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
getCachedBlueBubblesPrivateApiStatus: vi
.fn()
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true),
};
}
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {
vi.unstubAllGlobals();
});
}

View File

@@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./probe.js", async () => {
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
return createBlueBubblesProbeMockModule();
});

View File

@@ -0,0 +1,135 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */
requireMention?: boolean;
/** Optional tool policy overrides for this group. */
tools?: { allow?: string[]; deny?: string[] };
};
export type BlueBubblesAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this BlueBubbles account. Default: true. */
enabled?: boolean;
/** Base URL for the BlueBubbles API. */
serverUrl?: string;
/** Password for BlueBubbles API authentication. */
password?: string;
/** Webhook path for the gateway HTTP server. */
webhookPath?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders. */
groupAllowFrom?: Array<string | number>;
/** Group message handling policy. */
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, unknown>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: Record<string, unknown>;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/**
* Explicit allowlist of local directory roots permitted for outbound media paths.
* Local paths are rejected unless they resolve under one of these roots.
*/
mediaLocalRoots?: string[];
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
allowPrivateNetwork?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
};
export type BlueBubblesActionConfig = {
reactions?: boolean;
edit?: boolean;
unsend?: boolean;
reply?: boolean;
sendWithEffect?: boolean;
renameGroup?: boolean;
addParticipant?: boolean;
removeParticipant?: boolean;
leaveGroup?: boolean;
sendAttachment?: boolean;
};
export type BlueBubblesConfig = {
/** Optional per-account BlueBubbles configuration (multi-account). */
accounts?: Record<string, BlueBubblesAccountConfig>;
/** Per-action tool gating (default: true for all). */
actions?: BlueBubblesActionConfig;
} & BlueBubblesAccountConfig;
export type BlueBubblesSendTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" };
export type BlueBubblesAttachment = {
guid?: string;
uti?: string;
mimeType?: string;
transferName?: string;
totalBytes?: number;
height?: number;
width?: number;
originalROWID?: number;
};
const DEFAULT_TIMEOUT_MS = 10_000;
export function normalizeBlueBubblesServerUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("BlueBubbles serverUrl is required");
}
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
return withScheme.replace(/\/+$/, "");
}
export function buildBlueBubblesApiUrl(params: {
baseUrl: string;
path: string;
password?: string;
}): string {
const normalized = normalizeBlueBubblesServerUrl(params.baseUrl);
const url = new URL(params.path, `${normalized}/`);
if (params.password) {
url.searchParams.set("password", params.password);
}
return url.toString();
}
export async function blueBubblesFetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs = DEFAULT_TIMEOUT_MS,
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,24 @@
# Copilot Proxy (OpenClaw plugin)
Provider plugin for the **Copilot Proxy** VS Code extension.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
openclaw plugins enable copilot-proxy
```
Restart the Gateway after enabling.
## Authenticate
```bash
openclaw models auth login --provider copilot-proxy --set-default
```
## Notes
- Copilot Proxy must be running in VS Code.
- Base URL must include `/v1`.

View File

@@ -0,0 +1,154 @@
import {
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_MODEL_IDS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5-mini",
"claude-opus-4.6",
"claude-opus-4.5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3-pro",
"gemini-3-flash",
"grok-code-fast-1",
] as const;
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return DEFAULT_BASE_URL;
}
let normalized = trimmed;
while (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
if (!normalized.endsWith("/v1")) {
normalized = `${normalized}/v1`;
}
return normalized;
}
function validateBaseUrl(value: string): string | undefined {
const normalized = normalizeBaseUrl(value);
try {
new URL(normalized);
} catch {
return "Enter a valid URL";
}
return undefined;
}
function parseModelIds(input: string): string[] {
const parsed = input
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
return Array.from(new Set(parsed));
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
api: "openai-completions" as const,
reasoning: false,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: "copilot-proxy",
label: "Copilot Proxy",
docsPath: "/providers/models",
auth: [
{
id: "local",
label: "Local proxy",
hint: "Configure base URL + models for the Copilot Proxy server",
kind: "custom",
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const baseUrlInput = await ctx.prompter.text({
message: "Copilot Proxy base URL",
initialValue: DEFAULT_BASE_URL,
validate: validateBaseUrl,
});
const modelInput = await ctx.prompter.text({
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value: string) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
const modelIds = parseModelIds(modelInput);
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
return {
profiles: [
{
profileId: "copilot-proxy:local",
credential: {
type: "token",
provider: "copilot-proxy",
token: DEFAULT_API_KEY,
},
},
],
configPatch: {
models: {
providers: {
"copilot-proxy": {
baseUrl,
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
),
},
},
},
defaultModel: defaultModelRef,
notes: [
"Start the Copilot Proxy VS Code extension before using these models.",
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
],
};
},
},
],
});
},
};
export default copilotProxyPlugin;

View File

@@ -0,0 +1,9 @@
{
"id": "copilot-proxy",
"providers": ["copilot-proxy"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.26",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,529 @@
import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import {
approveDevicePairing,
listDevicePairing,
resolveGatewayBindUrl,
runPluginCommandWithTimeout,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk";
import qrcode from "qrcode-terminal";
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
const DEFAULT_GATEWAY_PORT = 18789;
type DevicePairPluginConfig = {
publicUrl?: string;
};
type SetupPayload = {
url: string;
token?: string;
password?: string;
};
type ResolveUrlResult = {
url?: string;
source?: string;
error?: string;
};
type ResolveAuthResult = {
token?: string;
password?: string;
label?: string;
error?: string;
};
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = raw.trim();
if (!candidate) {
return null;
}
const parsedUrl = parseNormalizedGatewayUrl(candidate);
if (parsedUrl) {
return parsedUrl;
}
const hostPort = candidate.split("/", 1)[0]?.trim() ?? "";
return hostPort ? `${schemeFallback}://${hostPort}` : null;
}
function parseNormalizedGatewayUrl(raw: string): string | null {
try {
const parsed = new URL(raw);
const scheme = parsed.protocol.slice(0, -1);
const normalizedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme;
if (!(normalizedScheme === "ws" || normalizedScheme === "wss")) {
return null;
}
if (!parsed.hostname) {
return null;
}
return `${normalizedScheme}://${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
return null;
}
}
function parsePositiveInteger(raw: string | undefined): number | null {
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function resolveGatewayPort(cfg: OpenClawPluginApi["config"]): number {
const envPort =
parsePositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim()) ??
parsePositiveInteger(process.env.CLAWDBOT_GATEWAY_PORT?.trim());
if (envPort) {
return envPort;
}
const configPort = cfg.gateway?.port;
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
return configPort;
}
return DEFAULT_GATEWAY_PORT;
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },
): "ws" | "wss" {
if (opts?.forceSecure) {
return "wss";
}
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function isPrivateIPv4(address: string): boolean {
const parts = address.split(".");
if (parts.length != 4) {
return false;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return false;
}
const [a, b] = octets;
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
return false;
}
function isTailnetIPv4(address: string): boolean {
const parts = address.split(".");
if (parts.length !== 4) {
return false;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
// Check for IPv4 (string "IPv4" on Node 18+, number 4 on older)
const isIpv4 = family === "IPv4" || String(family) === "4";
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (predicate(address)) {
return address;
}
}
}
return null;
}
function pickLanIPv4(): string | null {
return pickMatchingIPv4(isPrivateIPv4);
}
function pickTailnetIPv4(): string | null {
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(): Promise<string | null> {
return await resolveTailnetHostWithRunner((argv, opts) =>
runPluginCommandWithTimeout({
argv,
timeoutMs: opts.timeoutMs,
}),
);
}
function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
const mode = cfg.gateway?.auth?.mode;
const token =
pickFirstDefined([
process.env.OPENCLAW_GATEWAY_TOKEN,
process.env.CLAWDBOT_GATEWAY_TOKEN,
cfg.gateway?.auth?.token,
]) ?? undefined;
const password =
pickFirstDefined([
process.env.OPENCLAW_GATEWAY_PASSWORD,
process.env.CLAWDBOT_GATEWAY_PASSWORD,
cfg.gateway?.auth?.password,
]) ?? undefined;
if (mode === "token" || mode === "password") {
return resolveRequiredAuth(mode, { token, password });
}
if (token) {
return { token, label: "token" };
}
if (password) {
return { password, label: "password" };
}
return { error: "Gateway auth is not configured (no token or password)." };
}
function pickFirstDefined(candidates: Array<string | undefined>): string | null {
for (const value of candidates) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return null;
}
function resolveRequiredAuth(
mode: "token" | "password",
values: { token?: string; password?: string },
): ResolveAuthResult {
if (mode === "token") {
return values.token
? { token: values.token, label: "token" }
: { error: "Gateway auth is set to token, but no token is configured." };
}
return values.password
? { password: values.password, label: "password" }
: { error: "Gateway auth is set to password, but no password is configured." };
}
async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const cfg = api.config;
const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig;
const scheme = resolveScheme(cfg);
const port = resolveGatewayPort(cfg);
if (typeof pluginCfg.publicUrl === "string" && pluginCfg.publicUrl.trim()) {
const url = normalizeUrl(pluginCfg.publicUrl, scheme);
if (url) {
return { url, source: "plugins.entries.device-pair.config.publicUrl" };
}
return { error: "Configured publicUrl is invalid." };
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost();
if (!host) {
return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
}
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
const remoteUrl = cfg.gateway?.remote?.url;
if (typeof remoteUrl === "string" && remoteUrl.trim()) {
const url = normalizeUrl(remoteUrl, scheme);
if (url) {
return { url, source: "gateway.remote.url" };
}
}
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: pickTailnetIPv4,
pickLanHost: pickLanIPv4,
});
if (bindResult) {
return bindResult;
}
return {
error:
"Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.",
};
}
function encodeSetupCode(payload: SetupPayload): string {
const json = JSON.stringify(payload);
const base64 = Buffer.from(json, "utf8").toString("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code below and tap Connect",
"3) Back here, run /pair approve",
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
].join("\n");
}
function formatSetupInstructions(): string {
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code from my next message and tap Connect",
"3) Back here, run /pair approve",
].join("\n");
}
type PendingPairingRequest = {
requestId: string;
deviceId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
ts?: number;
};
function formatPendingRequests(pending: PendingPairingRequest[]): string {
if (pending.length === 0) {
return "No pending device pairing requests.";
}
const lines: string[] = ["Pending device pairing requests:"];
for (const req of pending) {
const label = req.displayName?.trim() || req.deviceId;
const platform = req.platform?.trim();
const ip = req.remoteIp?.trim();
const parts = [
`- ${req.requestId}`,
label ? `name=${label}` : null,
platform ? `platform=${platform}` : null,
ip ? `ip=${ip}` : null,
].filter(Boolean);
lines.push(parts.join(" · "));
}
return lines.join("\n");
}
export default function register(api: OpenClawPluginApi) {
api.registerCommand({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
}`,
);
if (action === "status" || action === "pending") {
const list = await listDevicePairing();
return { text: formatPendingRequests(list.pending) };
}
if (action === "approve") {
const requested = tokens[1]?.trim();
const list = await listDevicePairing();
if (list.pending.length === 0) {
return { text: "No pending device pairing requests." };
}
let pending: (typeof list.pending)[number] | undefined;
if (requested) {
if (requested.toLowerCase() === "latest") {
pending = [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0];
} else {
pending = list.pending.find((entry) => entry.requestId === requested);
}
} else if (list.pending.length === 1) {
pending = list.pending[0];
} else {
return {
text:
`${formatPendingRequests(list.pending)}\n\n` +
"Multiple pending requests found. Approve one explicitly:\n" +
"/pair approve <requestId>\n" +
"Or approve the most recent:\n" +
"/pair approve latest",
};
}
if (!pending) {
return { text: "Pairing request not found." };
}
const approved = await approveDevicePairing(pending.requestId);
if (!approved) {
return { text: "Pairing request not found." };
}
const label = approved.device.displayName?.trim() || approved.device.deviceId;
const platform = approved.device.platform?.trim();
const platformLabel = platform ? ` (${platform})` : "";
return { text: `✅ Paired ${label}${platformLabel}.` };
}
const auth = resolveAuth(api.config);
if (auth.error) {
return { text: `Error: ${auth.error}` };
}
const urlResult = await resolveGatewayUrl(api);
if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
}
const payload: SetupPayload = {
url: urlResult.url,
token: auth.token,
password: auth.password,
};
if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
const authLabel = auth.label ?? "auth";
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (channel === "telegram" && target) {
try {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (send) {
await send(
target,
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
"\n",
),
{
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
},
);
return {
text: [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, come back here and run `/pair approve` to complete pairing.",
].join("\n"),
};
}
} catch (err) {
api.logger.warn?.(
`device-pair: telegram QR send failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
// Render based on channel capability
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
const infoLines = [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, run `/pair approve` to complete pairing.",
];
// WebUI + CLI/TUI: ASCII QR
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
};
}
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";
if (channel === "telegram" && target) {
try {
const runtimeKeys = Object.keys(api.runtime ?? {});
const channelKeys = Object.keys(api.runtime?.channel ?? {});
api.logger.debug?.(
`device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${
channelKeys.join(",") || "none"
}`,
);
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (!send) {
throw new Error(
`telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join(
",",
)})`,
);
}
await send(target, formatSetupInstructions(), {
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(
`device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${
ctx.messageThreadId ?? "none"
}`,
);
return { text: encodeSetupCode(payload) };
} catch (err) {
api.logger.warn?.(
`device-pair: telegram split send failed, falling back to single message (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
return {
text: formatSetupReply(payload, authLabel),
};
},
});
}

View File

@@ -0,0 +1,20 @@
{
"id": "device-pair",
"name": "Device Pairing",
"description": "Generate setup codes and approve device pairing requests.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"publicUrl": {
"type": "string"
}
}
},
"uiHints": {
"publicUrl": {
"label": "Gateway URL",
"help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https)."
}
}
}

View File

@@ -0,0 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { createDiagnosticsOtelService } from "./src/service.js";
const plugin = {
id: "diagnostics-otel",
name: "Diagnostics OpenTelemetry",
description: "Export diagnostics events to OpenTelemetry",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(createDiagnosticsOtelService());
},
};
export default plugin;

View File

@@ -0,0 +1,8 @@
{
"id": "diagnostics-otel",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.26",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.212.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
"@opentelemetry/resources": "^2.5.1",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.5.1",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-trace-base": "^2.5.1",
"@opentelemetry/semantic-conventions": "^1.39.0"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,367 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const registerLogTransportMock = vi.hoisted(() => vi.fn());
const telemetryState = vi.hoisted(() => {
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
const tracer = {
startSpan: vi.fn((_name: string, _opts?: unknown) => ({
end: vi.fn(),
setStatus: vi.fn(),
})),
};
const meter = {
createCounter: vi.fn((name: string) => {
const counter = { add: vi.fn() };
counters.set(name, counter);
return counter;
}),
createHistogram: vi.fn((name: string) => {
const histogram = { record: vi.fn() };
histograms.set(name, histogram);
return histogram;
}),
};
return { counters, histograms, tracer, meter };
});
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const logEmit = vi.hoisted(() => vi.fn());
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const traceExporterCtor = vi.hoisted(() => vi.fn());
vi.mock("@opentelemetry/api", () => ({
metrics: {
getMeter: () => telemetryState.meter,
},
trace: {
getTracer: () => telemetryState.tracer,
},
SpanStatusCode: {
ERROR: 2,
},
}));
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
start = sdkStart;
shutdown = sdkShutdown;
},
}));
vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({
OTLPMetricExporter: class {},
}));
vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({
OTLPTraceExporter: class {
constructor(options?: unknown) {
traceExporterCtor(options);
}
},
}));
vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({
OTLPLogExporter: class {},
}));
vi.mock("@opentelemetry/sdk-logs", () => ({
BatchLogRecordProcessor: class {},
LoggerProvider: class {
getLogger = vi.fn(() => ({
emit: logEmit,
}));
shutdown = logShutdown;
},
}));
vi.mock("@opentelemetry/sdk-metrics", () => ({
PeriodicExportingMetricReader: class {},
}));
vi.mock("@opentelemetry/sdk-trace-base", () => ({
ParentBasedSampler: class {},
TraceIdRatioBasedSampler: class {},
}));
vi.mock("@opentelemetry/resources", () => ({
resourceFromAttributes: vi.fn((attrs: Record<string, unknown>) => attrs),
Resource: class {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(_value?: unknown) {}
},
}));
vi.mock("@opentelemetry/semantic-conventions", () => ({
ATTR_SERVICE_NAME: "service.name",
}));
vi.mock("openclaw/plugin-sdk", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk")>("openclaw/plugin-sdk");
return {
...actual,
registerLogTransport: registerLogTransportMock,
};
});
import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
import { createDiagnosticsOtelService } from "./service.js";
const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test";
const OTEL_TEST_ENDPOINT = "http://otel-collector:4318";
const OTEL_TEST_PROTOCOL = "http/protobuf";
function createLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
type OtelContextFlags = {
traces?: boolean;
metrics?: boolean;
logs?: boolean;
};
function createOtelContext(
endpoint: string,
{ traces = false, metrics = false, logs = false }: OtelContextFlags = {},
): OpenClawPluginServiceContext {
return {
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint,
protocol: OTEL_TEST_PROTOCOL,
traces,
metrics,
logs,
},
},
},
logger: createLogger(),
stateDir: OTEL_TEST_STATE_DIR,
};
}
function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
return createOtelContext(endpoint, { traces: true });
}
type RegisteredLogTransport = (logObj: Record<string, unknown>) => void;
function setupRegisteredTransports() {
const registeredTransports: RegisteredLogTransport[] = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
return { registeredTransports, stopTransport };
}
async function emitAndCaptureLog(logObj: Record<string, unknown>) {
const { registeredTransports } = setupRegisteredTransports();
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
await service.start(ctx);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.(logObj);
expect(logEmit).toHaveBeenCalled();
const emitCall = logEmit.mock.calls[0]?.[0];
await service.stop?.(ctx);
return emitCall;
}
describe("diagnostics-otel service", () => {
beforeEach(() => {
telemetryState.counters.clear();
telemetryState.histograms.clear();
telemetryState.tracer.startSpan.mockClear();
telemetryState.meter.createCounter.mockClear();
telemetryState.meter.createHistogram.mockClear();
sdkStart.mockClear();
sdkShutdown.mockClear();
logEmit.mockClear();
logShutdown.mockClear();
traceExporterCtor.mockClear();
registerLogTransportMock.mockReset();
});
test("records message-flow metrics and spans", async () => {
const { registeredTransports } = setupRegisteredTransports();
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true });
await service.start(ctx);
emitDiagnosticEvent({
type: "webhook.received",
channel: "telegram",
updateType: "telegram-post",
});
emitDiagnosticEvent({
type: "webhook.processed",
channel: "telegram",
updateType: "telegram-post",
durationMs: 120,
});
emitDiagnosticEvent({
type: "message.queued",
channel: "telegram",
source: "telegram",
queueDepth: 2,
});
emitDiagnosticEvent({
type: "message.processed",
channel: "telegram",
outcome: "completed",
durationMs: 55,
});
emitDiagnosticEvent({
type: "queue.lane.dequeue",
lane: "main",
queueSize: 3,
waitMs: 10,
});
emitDiagnosticEvent({
type: "session.stuck",
state: "processing",
ageMs: 125_000,
});
emitDiagnosticEvent({
type: "run.attempt",
runId: "run-1",
attempt: 2,
});
expect(telemetryState.counters.get("openclaw.webhook.received")?.add).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.webhook.duration_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.message.queued")?.add).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.message.processed")?.add).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.message.duration_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.histograms.get("openclaw.queue.wait_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.session.stuck")?.add).toHaveBeenCalled();
expect(
telemetryState.histograms.get("openclaw.session.stuck_age_ms")?.record,
).toHaveBeenCalled();
expect(telemetryState.counters.get("openclaw.run.attempt")?.add).toHaveBeenCalled();
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
expect(spanNames).toContain("openclaw.webhook.processed");
expect(spanNames).toContain("openclaw.message.processed");
expect(spanNames).toContain("openclaw.session.stuck");
expect(registerLogTransportMock).toHaveBeenCalledTimes(1);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.({
0: '{"subsystem":"diagnostic"}',
1: "hello",
_meta: { logLevelName: "INFO", date: new Date() },
});
expect(logEmit).toHaveBeenCalled();
await service.stop?.(ctx);
});
test("appends signal path when endpoint contains non-signal /v1 segment", async () => {
const service = createDiagnosticsOtelService();
const ctx = createTraceOnlyContext("https://www.comet.com/opik/api/v1/private/otel");
await service.start(ctx);
const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
expect(options?.url).toBe("https://www.comet.com/opik/api/v1/private/otel/v1/traces");
await service.stop?.(ctx);
});
test("keeps already signal-qualified endpoint unchanged", async () => {
const service = createDiagnosticsOtelService();
const ctx = createTraceOnlyContext("https://collector.example.com/v1/traces");
await service.start(ctx);
const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
expect(options?.url).toBe("https://collector.example.com/v1/traces");
await service.stop?.(ctx);
});
test("keeps signal-qualified endpoint unchanged when it has query params", async () => {
const service = createDiagnosticsOtelService();
const ctx = createTraceOnlyContext("https://collector.example.com/v1/traces?timeout=30s");
await service.start(ctx);
const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
expect(options?.url).toBe("https://collector.example.com/v1/traces?timeout=30s");
await service.stop?.(ctx);
});
test("keeps signal-qualified endpoint unchanged when signal path casing differs", async () => {
const service = createDiagnosticsOtelService();
const ctx = createTraceOnlyContext("https://collector.example.com/v1/Traces");
await service.start(ctx);
const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
expect(options?.url).toBe("https://collector.example.com/v1/Traces");
await service.stop?.(ctx);
});
test("redacts sensitive data from log messages before export", async () => {
const emitCall = await emitAndCaptureLog({
0: "Using API key sk-1234567890abcdef1234567890abcdef",
_meta: { logLevelName: "INFO", date: new Date() },
});
expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef");
expect(emitCall?.body).toContain("sk-123");
expect(emitCall?.body).toContain("…");
});
test("redacts sensitive data from log attributes before export", async () => {
const emitCall = await emitAndCaptureLog({
0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}',
1: "auth configured",
_meta: { logLevelName: "DEBUG", date: new Date() },
});
const tokenAttr = emitCall?.attributes?.["openclaw.token"];
expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456");
if (typeof tokenAttr === "string") {
expect(tokenAttr).toContain("…");
}
});
test("redacts sensitive reason in session.state metric attributes", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true });
await service.start(ctx);
emitDiagnosticEvent({
type: "session.state",
state: "waiting",
reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456",
});
const sessionCounter = telemetryState.counters.get("openclaw.session.state");
expect(sessionCounter?.add).toHaveBeenCalledWith(
1,
expect.objectContaining({
"openclaw.reason": expect.stringContaining("…"),
}),
);
const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(typeof attrs?.["openclaw.reason"]).toBe("string");
expect(String(attrs?.["openclaw.reason"])).not.toContain(
"ghp_abcdefghijklmnopqrstuvwxyz123456",
);
await service.stop?.(ctx);
});
});

View File

@@ -0,0 +1,678 @@
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
import type { SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk";
const DEFAULT_SERVICE_NAME = "openclaw";
function normalizeEndpoint(endpoint?: string): string | undefined {
const trimmed = endpoint?.trim();
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
}
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
if (!endpoint) {
return undefined;
}
const endpointWithoutQueryOrFragment = endpoint.split(/[?#]/, 1)[0] ?? endpoint;
if (/\/v1\/(?:traces|metrics|logs)$/i.test(endpointWithoutQueryOrFragment)) {
return endpoint;
}
return `${endpoint}/${path}`;
}
function resolveSampleRate(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
if (value < 0 || value > 1) {
return undefined;
}
return value;
}
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.stack ?? err.message;
}
if (typeof err === "string") {
return err;
}
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
function redactOtelAttributes(attributes: Record<string, string | number | boolean>) {
const redactedAttributes: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(attributes)) {
redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value;
}
return redactedAttributes;
}
export function createDiagnosticsOtelService(): OpenClawPluginService {
let sdk: NodeSDK | null = null;
let logProvider: LoggerProvider | null = null;
let stopLogTransport: (() => void) | null = null;
let unsubscribe: (() => void) | null = null;
return {
id: "diagnostics-otel",
async start(ctx) {
const cfg = ctx.config.diagnostics;
const otel = cfg?.otel;
if (!cfg?.enabled || !otel?.enabled) {
return;
}
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
if (protocol !== "http/protobuf") {
ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`);
return;
}
const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
const headers = otel.headers ?? undefined;
const serviceName =
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
const sampleRate = resolveSampleRate(otel.sampleRate);
const tracesEnabled = otel.traces !== false;
const metricsEnabled = otel.metrics !== false;
const logsEnabled = otel.logs === true;
if (!tracesEnabled && !metricsEnabled && !logsEnabled) {
return;
}
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
});
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
const logUrl = resolveOtelUrl(endpoint, "v1/logs");
const traceExporter = tracesEnabled
? new OTLPTraceExporter({
...(traceUrl ? { url: traceUrl } : {}),
...(headers ? { headers } : {}),
})
: undefined;
const metricExporter = metricsEnabled
? new OTLPMetricExporter({
...(metricUrl ? { url: metricUrl } : {}),
...(headers ? { headers } : {}),
})
: undefined;
const metricReader = metricExporter
? new PeriodicExportingMetricReader({
exporter: metricExporter,
...(typeof otel.flushIntervalMs === "number"
? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) }
: {}),
})
: undefined;
if (tracesEnabled || metricsEnabled) {
sdk = new NodeSDK({
resource,
...(traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(sampleRate !== undefined
? {
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(sampleRate),
}),
}
: {}),
});
try {
await sdk.start();
} catch (err) {
ctx.logger.error(`diagnostics-otel: failed to start SDK: ${formatError(err)}`);
throw err;
}
}
const logSeverityMap: Record<string, SeverityNumber> = {
TRACE: 1 as SeverityNumber,
DEBUG: 5 as SeverityNumber,
INFO: 9 as SeverityNumber,
WARN: 13 as SeverityNumber,
ERROR: 17 as SeverityNumber,
FATAL: 21 as SeverityNumber,
};
const meter = metrics.getMeter("openclaw");
const tracer = trace.getTracer("openclaw");
const tokensCounter = meter.createCounter("openclaw.tokens", {
unit: "1",
description: "Token usage by type",
});
const costCounter = meter.createCounter("openclaw.cost.usd", {
unit: "1",
description: "Estimated model cost (USD)",
});
const durationHistogram = meter.createHistogram("openclaw.run.duration_ms", {
unit: "ms",
description: "Agent run duration",
});
const contextHistogram = meter.createHistogram("openclaw.context.tokens", {
unit: "1",
description: "Context window size and usage",
});
const webhookReceivedCounter = meter.createCounter("openclaw.webhook.received", {
unit: "1",
description: "Webhook requests received",
});
const webhookErrorCounter = meter.createCounter("openclaw.webhook.error", {
unit: "1",
description: "Webhook processing errors",
});
const webhookDurationHistogram = meter.createHistogram("openclaw.webhook.duration_ms", {
unit: "ms",
description: "Webhook processing duration",
});
const messageQueuedCounter = meter.createCounter("openclaw.message.queued", {
unit: "1",
description: "Messages queued for processing",
});
const messageProcessedCounter = meter.createCounter("openclaw.message.processed", {
unit: "1",
description: "Messages processed by outcome",
});
const messageDurationHistogram = meter.createHistogram("openclaw.message.duration_ms", {
unit: "ms",
description: "Message processing duration",
});
const queueDepthHistogram = meter.createHistogram("openclaw.queue.depth", {
unit: "1",
description: "Queue depth on enqueue/dequeue",
});
const queueWaitHistogram = meter.createHistogram("openclaw.queue.wait_ms", {
unit: "ms",
description: "Queue wait time before execution",
});
const laneEnqueueCounter = meter.createCounter("openclaw.queue.lane.enqueue", {
unit: "1",
description: "Command queue lane enqueue events",
});
const laneDequeueCounter = meter.createCounter("openclaw.queue.lane.dequeue", {
unit: "1",
description: "Command queue lane dequeue events",
});
const sessionStateCounter = meter.createCounter("openclaw.session.state", {
unit: "1",
description: "Session state transitions",
});
const sessionStuckCounter = meter.createCounter("openclaw.session.stuck", {
unit: "1",
description: "Sessions stuck in processing",
});
const sessionStuckAgeHistogram = meter.createHistogram("openclaw.session.stuck_age_ms", {
unit: "ms",
description: "Age of stuck sessions",
});
const runAttemptCounter = meter.createCounter("openclaw.run.attempt", {
unit: "1",
description: "Run attempts",
});
if (logsEnabled) {
const logExporter = new OTLPLogExporter({
...(logUrl ? { url: logUrl } : {}),
...(headers ? { headers } : {}),
});
const logProcessor = new BatchLogRecordProcessor(
logExporter,
typeof otel.flushIntervalMs === "number"
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
: {},
);
logProvider = new LoggerProvider({
resource,
processors: [logProcessor],
});
const otelLogger = logProvider.getLogger("openclaw");
stopLogTransport = registerLogTransport((logObj) => {
try {
const safeStringify = (value: unknown) => {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const meta = (logObj as Record<string, unknown>)._meta as
| {
logLevelName?: string;
date?: Date;
name?: string;
parentNames?: string[];
path?: {
filePath?: string;
fileLine?: string;
fileColumn?: string;
filePathWithLine?: string;
method?: string;
};
}
| undefined;
const logLevelName = meta?.logLevelName ?? "INFO";
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key))
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
.map(([, value]) => value);
let bindings: Record<string, unknown> | undefined;
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
try {
const parsed = JSON.parse(numericArgs[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
bindings = parsed as Record<string, unknown>;
numericArgs.shift();
}
} catch {
// ignore malformed json bindings
}
}
let message = "";
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
message = String(numericArgs.pop());
} else if (numericArgs.length === 1) {
message = safeStringify(numericArgs[0]);
numericArgs.length = 0;
}
if (!message) {
message = "log";
}
const attributes: Record<string, string | number | boolean> = {
"openclaw.log.level": logLevelName,
};
if (meta?.name) {
attributes["openclaw.logger"] = meta.name;
}
if (meta?.parentNames?.length) {
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
}
if (bindings) {
for (const [key, value] of Object.entries(bindings)) {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
attributes[`openclaw.${key}`] = value;
} else if (value != null) {
attributes[`openclaw.${key}`] = safeStringify(value);
}
}
}
if (numericArgs.length > 0) {
attributes["openclaw.log.args"] = safeStringify(numericArgs);
}
if (meta?.path?.filePath) {
attributes["code.filepath"] = meta.path.filePath;
}
if (meta?.path?.fileLine) {
attributes["code.lineno"] = Number(meta.path.fileLine);
}
if (meta?.path?.method) {
attributes["code.function"] = meta.path.method;
}
if (meta?.path?.filePathWithLine) {
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
}
// OTLP can leave the host boundary, so redact string fields before export.
otelLogger.emit({
body: redactSensitiveText(message),
severityText: logLevelName,
severityNumber,
attributes: redactOtelAttributes(attributes),
timestamp: meta?.date ?? new Date(),
});
} catch (err) {
ctx.logger.error(`diagnostics-otel: log transport failed: ${formatError(err)}`);
}
});
}
const spanWithDuration = (
name: string,
attributes: Record<string, string | number>,
durationMs?: number,
) => {
const startTime =
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
const span = tracer.startSpan(name, {
attributes,
...(startTime ? { startTime } : {}),
});
return span;
};
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.provider": evt.provider ?? "unknown",
"openclaw.model": evt.model ?? "unknown",
};
const usage = evt.usage;
if (usage.input) {
tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
}
if (usage.output) {
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
}
if (usage.cacheRead) {
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
}
if (usage.cacheWrite) {
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
}
if (usage.promptTokens) {
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
}
if (usage.total) {
tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
}
if (evt.costUsd) {
costCounter.add(evt.costUsd, attrs);
}
if (evt.durationMs) {
durationHistogram.record(evt.durationMs, attrs);
}
if (evt.context?.limit) {
contextHistogram.record(evt.context.limit, {
...attrs,
"openclaw.context": "limit",
});
}
if (evt.context?.used) {
contextHistogram.record(evt.context.used, {
...attrs,
"openclaw.context": "used",
});
}
if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = {
...attrs,
"openclaw.sessionKey": evt.sessionKey ?? "",
"openclaw.sessionId": evt.sessionId ?? "",
"openclaw.tokens.input": usage.input ?? 0,
"openclaw.tokens.output": usage.output ?? 0,
"openclaw.tokens.cache_read": usage.cacheRead ?? 0,
"openclaw.tokens.cache_write": usage.cacheWrite ?? 0,
"openclaw.tokens.total": usage.total ?? 0,
};
const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookReceived = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.received" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.webhook": evt.updateType ?? "unknown",
};
webhookReceivedCounter.add(1, attrs);
};
const recordWebhookProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.processed" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.webhook": evt.updateType ?? "unknown",
};
if (typeof evt.durationMs === "number") {
webhookDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookError = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.error" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.webhook": evt.updateType ?? "unknown",
};
webhookErrorCounter.add(1, attrs);
if (!tracesEnabled) {
return;
}
const redactedError = redactSensitiveText(evt.error);
const spanAttrs: Record<string, string | number> = {
...attrs,
"openclaw.error": redactedError,
};
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
const span = tracer.startSpan("openclaw.webhook.error", {
attributes: spanAttrs,
});
span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError });
span.end();
};
const recordMessageQueued = (
evt: Extract<DiagnosticEventPayload, { type: "message.queued" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.source": evt.source ?? "unknown",
};
messageQueuedCounter.add(1, attrs);
if (typeof evt.queueDepth === "number") {
queueDepthHistogram.record(evt.queueDepth, attrs);
}
};
const addSessionIdentityAttrs = (
spanAttrs: Record<string, string | number>,
evt: { sessionKey?: string; sessionId?: string },
) => {
if (evt.sessionKey) {
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
}
if (evt.sessionId) {
spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
};
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
) => {
const attrs = {
"openclaw.channel": evt.channel ?? "unknown",
"openclaw.outcome": evt.outcome ?? "unknown",
};
messageProcessedCounter.add(1, attrs);
if (typeof evt.durationMs === "number") {
messageDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs };
addSessionIdentityAttrs(spanAttrs, evt);
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
if (evt.messageId !== undefined) {
spanAttrs["openclaw.messageId"] = String(evt.messageId);
}
if (evt.reason) {
spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason);
}
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
if (evt.outcome === "error" && evt.error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) });
}
span.end();
};
const recordLaneEnqueue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
) => {
const attrs = { "openclaw.lane": evt.lane };
laneEnqueueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
};
const recordLaneDequeue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
) => {
const attrs = { "openclaw.lane": evt.lane };
laneDequeueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
if (typeof evt.waitMs === "number") {
queueWaitHistogram.record(evt.waitMs, attrs);
}
};
const recordSessionState = (
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
) => {
const attrs: Record<string, string> = { "openclaw.state": evt.state };
if (evt.reason) {
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
}
sessionStateCounter.add(1, attrs);
};
const recordSessionStuck = (
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
) => {
const attrs: Record<string, string> = { "openclaw.state": evt.state };
sessionStuckCounter.add(1, attrs);
if (typeof evt.ageMs === "number") {
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
}
if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs };
addSessionIdentityAttrs(spanAttrs, evt);
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["openclaw.ageMs"] = evt.ageMs;
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
span.end();
};
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt });
};
const recordHeartbeat = (
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
) => {
queueDepthHistogram.record(evt.queued, { "openclaw.channel": "heartbeat" });
};
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
try {
switch (evt.type) {
case "model.usage":
recordModelUsage(evt);
return;
case "webhook.received":
recordWebhookReceived(evt);
return;
case "webhook.processed":
recordWebhookProcessed(evt);
return;
case "webhook.error":
recordWebhookError(evt);
return;
case "message.queued":
recordMessageQueued(evt);
return;
case "message.processed":
recordMessageProcessed(evt);
return;
case "queue.lane.enqueue":
recordLaneEnqueue(evt);
return;
case "queue.lane.dequeue":
recordLaneDequeue(evt);
return;
case "session.state":
recordSessionState(evt);
return;
case "session.stuck":
recordSessionStuck(evt);
return;
case "run.attempt":
recordRunAttempt(evt);
return;
case "diagnostic.heartbeat":
recordHeartbeat(evt);
return;
}
} catch (err) {
ctx.logger.error(
`diagnostics-otel: event handler failed (${evt.type}): ${formatError(err)}`,
);
}
});
if (logsEnabled) {
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)");
}
},
async stop() {
unsubscribe?.();
unsubscribe = null;
stopLogTransport?.();
stopLogTransport = null;
if (logProvider) {
await logProvider.shutdown().catch(() => undefined);
logProvider = null;
}
if (sdk) {
await sdk.shutdown().catch(() => undefined);
sdk = null;
}
},
} satisfies OpenClawPluginService;
}

View File

@@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { discordPlugin } from "./src/channel.js";
import { setDiscordRuntime } from "./src/runtime.js";
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
const plugin = {
id: "discord",
name: "Discord",
description: "Discord channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setDiscordRuntime(api.runtime);
api.registerChannel({ plugin: discordPlugin });
registerDiscordSubagentHooks(api);
},
};
export default plugin;

View File

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

View File

@@ -0,0 +1,11 @@
{
"name": "@openclaw/discord",
"version": "2026.2.26",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,36 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { discordPlugin } from "./channel.js";
import { setDiscordRuntime } from "./runtime.js";
describe("discordPlugin outbound", () => {
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
setDiscordRuntime({
channel: {
discord: {
sendMessageDiscord,
},
},
} as unknown as PluginRuntime);
const result = await discordPlugin.outbound!.sendMedia!({
cfg: {} as OpenClawConfig,
to: "channel:123",
text: "hi",
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],
accountId: "work",
});
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hi",
expect.objectContaining({
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp/agent-root"],
}),
);
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
});
});

View File

@@ -0,0 +1,451 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
collectDiscordAuditChannelIds,
collectDiscordStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
discordOnboardingAdapter,
DiscordConfigSchema,
formatPairingApproveHint,
getChatChannelMeta,
listDiscordAccountIds,
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
looksLikeDiscordTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeDiscordMessagingTarget,
normalizeDiscordOutboundTarget,
PAIRING_APPROVED_MESSAGE,
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedDiscordAccount,
} from "openclaw/plugin-sdk";
import { getDiscordRuntime } from "./runtime.js";
const meta = getChatChannelMeta("discord");
const discordMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
extractToolSend: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
const ma = getDiscordRuntime().channel.discord.messageActions;
if (!ma?.handleAction) {
throw new Error("Discord message actions not available");
}
return ma.handleAction(ctx);
},
};
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...meta,
},
onboarding: discordOnboardingAdapter,
pairing: {
idLabel: "discordUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
notifyApproval: async ({ id }) => {
await getDiscordRuntime().channel.discord.sendMessageDiscord(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
);
},
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "discord",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "discord",
accountId,
clearBaseFields: ["token", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]);
const allowFromPath = useAccountPath
? `channels.discord.accounts.${resolvedAccountId}.dm.`
: "channels.discord.dm.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("discord"),
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.discord !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const channelAllowlistConfigured = guildsConfigured;
if (groupPolicy === "open") {
if (channelAllowlistConfigured) {
warnings.push(
`- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
);
} else {
warnings.push(
`- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
);
}
}
return warnings;
},
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
resolveToolPolicy: resolveDiscordGroupToolPolicy,
},
mentions: {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
},
agentPrompt: {
messageToolHints: () => [
"- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
"- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
],
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
},
directory: {
self: async () => null,
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
listPeersLive: async (params) =>
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
listGroupsLive: async (params) =>
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
},
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const token = account.token?.trim();
if (!token) {
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Discord token",
}));
}
if (kind === "group") {
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.channelId ?? entry.guildId,
name:
entry.channelName ??
entry.guildName ??
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
note: entry.note,
}));
}
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
},
},
actions: discordMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "discord",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "DISCORD_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token) {
return "Discord requires token (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "discord",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "discord",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[accountId]: {
...next.channels?.discord?.accounts?.[accountId],
enabled: true,
...(input.token ? { token: input.token } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
silent,
}) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId, silent }) =>
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
}),
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectDiscordStatusIssues,
buildChannelSummary: ({ snapshot }) =>
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
probeAccount: async ({ account, timeoutMs }) =>
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,
accountId: account.accountId,
});
if (!channelIds.length && unresolvedChannels === 0) {
return undefined;
}
const botToken = account.token?.trim();
if (!botToken) {
return {
ok: unresolvedChannels === 0,
checkedChannels: 0,
unresolvedChannels,
channels: [],
elapsedMs: 0,
};
}
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
token: botToken,
accountId: account.accountId,
channelIds,
timeoutMs,
});
return { ...audit, unresolvedChannels };
},
buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
const configured = Boolean(account.token?.trim());
const app = runtime?.application ?? (probe as { application?: unknown })?.application;
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
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,
application: app ?? undefined,
bot: bot ?? undefined,
probe,
audit,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
let discordBotLabel = "";
try {
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
includeApplication: true,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) {
discordBotLabel = ` (@${username})`;
}
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
application: probe.application,
});
const messageContent = probe.application?.intents?.messageContent;
if (messageContent === "disabled") {
ctx.log?.warn(
`[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
);
} else if (messageContent === "limited") {
ctx.log?.info(
`[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
);
}
} catch (err) {
if (getDiscordRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
token,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
historyLimit: account.config.historyLimit,
});
},
},
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setDiscordRuntime(next: PluginRuntime) {
runtime = next;
}
export function getDiscordRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Discord runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,388 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordSubagentHooks } from "./subagent-hooks.js";
type ThreadBindingRecord = {
accountId: string;
threadId: string;
};
type MockResolvedDiscordAccount = {
accountId: string;
config: {
threadBindings?: {
enabled?: boolean;
spawnSubagentSessions?: boolean;
};
};
};
const hookMocks = vi.hoisted(() => ({
resolveDiscordAccount: vi.fn(
(params?: { accountId?: string }): MockResolvedDiscordAccount => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
},
},
}),
),
autoBindSpawnedDiscordSubagent: vi.fn(
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
}));
vi.mock("openclaw/plugin-sdk", () => ({
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
}));
function registerHandlersForTest(
config: Record<string, unknown> = {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
},
},
},
},
) {
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const api = {
config,
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
handlers.set(hookName, handler);
},
} as unknown as OpenClawPluginApi;
registerDiscordSubagentHooks(api);
return handlers;
}
function getRequiredHandler(
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
hookName: string,
): (event: unknown, ctx: unknown) => unknown {
const handler = handlers.get(hookName);
if (!handler) {
throw new Error(`expected ${hookName} hook handler`);
}
return handler;
}
function createSpawnEvent(overrides?: {
childSessionKey?: string;
agentId?: string;
label?: string;
mode?: string;
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string;
};
threadRequested?: boolean;
}): {
childSessionKey: string;
agentId: string;
label: string;
mode: string;
requester: {
channel: string;
accountId: string;
to: string;
threadId?: string;
};
threadRequested: boolean;
} {
const base = {
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
};
return {
...base,
...overrides,
requester: {
...base.requester,
...(overrides?.requester ?? {}),
},
};
}
function createSpawnEventWithoutThread() {
return createSpawnEvent({
label: "",
requester: { threadId: undefined },
});
}
async function runSubagentSpawning(
config?: Record<string, unknown>,
event = createSpawnEventWithoutThread(),
) {
const handlers = registerHandlersForTest(config);
const handler = getRequiredHandler(handlers, "subagent_spawning");
return await handler(event, {});
}
async function expectSubagentSpawningError(params?: {
config?: Record<string, unknown>;
errorContains?: string;
event?: ReturnType<typeof createSpawnEvent>;
}) {
const result = await runSubagentSpawning(params?.config, params?.event);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
if (params?.errorContains) {
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain(params.errorContains);
}
}
describe("discord subagent hook handlers", () => {
beforeEach(() => {
hookMocks.resolveDiscordAccount.mockClear();
hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
},
},
}));
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
});
it("registers subagent hooks", () => {
const handlers = registerHandlersForTest();
expect(handlers.has("subagent_spawning")).toBe(true);
expect(handlers.has("subagent_delivery_target")).toBe(true);
expect(handlers.has("subagent_spawned")).toBe(false);
expect(handlers.has("subagent_ended")).toBe(true);
});
it("binds thread routing on subagent_spawning", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_spawning");
const result = await handler(createSpawnEvent(), {});
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
accountId: "work",
channel: "discord",
to: "channel:123",
threadId: "456",
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
boundBy: "system",
});
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("returns error when thread-bound subagent spawn is disabled", async () => {
await expectSubagentSpawningError({
config: {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: false,
},
},
},
},
errorContains: "spawnSubagentSessions=true",
});
});
it("returns error when global thread bindings are disabled", async () => {
await expectSubagentSpawningError({
config: {
session: {
threadBindings: {
enabled: false,
},
},
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
},
},
},
},
errorContains: "threadBindings.enabled=true",
});
});
it("allows account-level threadBindings.enabled to override global disable", async () => {
const result = await runSubagentSpawning({
session: {
threadBindings: {
enabled: false,
},
},
channels: {
discord: {
accounts: {
work: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
},
},
},
},
},
});
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
await expectSubagentSpawningError({
config: {
channels: {
discord: {
threadBindings: {},
},
},
},
});
});
it("no-ops when thread binding is requested on non-discord channel", async () => {
const result = await runSubagentSpawning(
undefined,
createSpawnEvent({
requester: {
channel: "signal",
accountId: "",
to: "+123",
threadId: undefined,
},
}),
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("returns error when thread bind fails", async () => {
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
const result = await runSubagentSpawning();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toMatch(/unable to create or bind/i);
});
it("unbinds thread routing on subagent_ended", () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_ended");
handler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
accountId: "work",
},
{},
);
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "work",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
});
});
it("resolves delivery target from matching bound thread", () => {
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
{ accountId: "work", threadId: "777" },
]);
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
const result = handler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "777",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{},
);
expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "work",
targetKind: "subagent",
});
expect(result).toEqual({
origin: {
channel: "discord",
accountId: "work",
to: "channel:777",
threadId: "777",
},
});
});
it("keeps original routing when delivery target is ambiguous", () => {
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
{ accountId: "work", threadId: "777" },
{ accountId: "work", threadId: "888" },
]);
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
const result = handler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{},
);
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,152 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import {
autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey,
resolveDiscordAccount,
unbindThreadBindingsBySessionKey,
} from "openclaw/plugin-sdk";
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
export function registerDiscordSubagentHooks(api: OpenClawPluginApi) {
const resolveThreadBindingFlags = (accountId?: string) => {
const account = resolveDiscordAccount({
cfg: api.config,
accountId,
});
const baseThreadBindings = api.config.channels?.discord?.threadBindings;
const accountThreadBindings =
api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
return {
enabled:
accountThreadBindings?.enabled ??
baseThreadBindings?.enabled ??
api.config.session?.threadBindings?.enabled ??
true,
spawnSubagentSessions:
accountThreadBindings?.spawnSubagentSessions ??
baseThreadBindings?.spawnSubagentSessions ??
false,
};
};
api.on("subagent_spawning", async (event) => {
if (!event.threadRequested) {
return;
}
const channel = event.requester?.channel?.trim().toLowerCase();
if (channel !== "discord") {
// Ignore non-Discord channels so channel-specific plugins can handle
// their own thread/session provisioning without Discord blocking them.
return;
}
const threadBindingFlags = resolveThreadBindingFlags(event.requester?.accountId);
if (!threadBindingFlags.enabled) {
return {
status: "error" as const,
error:
"Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
};
}
if (!threadBindingFlags.spawnSubagentSessions) {
return {
status: "error" as const,
error:
"Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).",
};
}
try {
const binding = await autoBindSpawnedDiscordSubagent({
accountId: event.requester?.accountId,
channel: event.requester?.channel,
to: event.requester?.to,
threadId: event.requester?.threadId,
childSessionKey: event.childSessionKey,
agentId: event.agentId,
label: event.label,
boundBy: "system",
});
if (!binding) {
return {
status: "error" as const,
error:
"Unable to create or bind a Discord thread for this subagent session. Session mode is unavailable for this target.",
};
}
return { status: "ok" as const, threadBindingReady: true };
} catch (err) {
return {
status: "error" as const,
error: `Discord thread bind failed: ${summarizeError(err)}`,
};
}
});
api.on("subagent_ended", (event) => {
unbindThreadBindingsBySessionKey({
targetSessionKey: event.targetSessionKey,
accountId: event.accountId,
targetKind: event.targetKind,
reason: event.reason,
sendFarewell: event.sendFarewell,
});
});
api.on("subagent_delivery_target", (event) => {
if (!event.expectsCompletionMessage) {
return;
}
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
if (requesterChannel !== "discord") {
return;
}
const requesterAccountId = event.requesterOrigin?.accountId?.trim();
const requesterThreadId =
event.requesterOrigin?.threadId != null && event.requesterOrigin.threadId !== ""
? String(event.requesterOrigin.threadId).trim()
: "";
const bindings = listThreadBindingsBySessionKey({
targetSessionKey: event.childSessionKey,
...(requesterAccountId ? { accountId: requesterAccountId } : {}),
targetKind: "subagent",
});
if (bindings.length === 0) {
return;
}
let binding: (typeof bindings)[number] | undefined;
if (requesterThreadId) {
binding = bindings.find((entry) => {
if (entry.threadId !== requesterThreadId) {
return false;
}
if (requesterAccountId && entry.accountId !== requesterAccountId) {
return false;
}
return true;
});
}
if (!binding && bindings.length === 1) {
binding = bindings[0];
}
if (!binding) {
return;
}
return {
origin: {
channel: "discord",
accountId: binding.accountId,
to: `channel:${binding.threadId}`,
threadId: binding.threadId,
},
};
});
}

View File

@@ -0,0 +1,63 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { registerFeishuBitableTools } from "./src/bitable.js";
import { feishuPlugin } from "./src/channel.js";
import { registerFeishuDocTools } from "./src/docx.js";
import { registerFeishuDriveTools } from "./src/drive.js";
import { registerFeishuPermTools } from "./src/perm.js";
import { setFeishuRuntime } from "./src/runtime.js";
import { registerFeishuWikiTools } from "./src/wiki.js";
export { monitorFeishuProvider } from "./src/monitor.js";
export {
sendMessageFeishu,
sendCardFeishu,
updateCardFeishu,
editMessageFeishu,
getMessageFeishu,
} from "./src/send.js";
export {
uploadImageFeishu,
uploadFileFeishu,
sendImageFeishu,
sendFileFeishu,
sendMediaFeishu,
} from "./src/media.js";
export { probeFeishu } from "./src/probe.js";
export {
addReactionFeishu,
removeReactionFeishu,
listReactionsFeishu,
FeishuEmoji,
} from "./src/reactions.js";
export {
extractMentionTargets,
extractMessageBody,
isMentionForwardRequest,
formatMentionForText,
formatMentionForCard,
formatMentionAllForText,
formatMentionAllForCard,
buildMentionedMessage,
buildMentionedCardContent,
type MentionTarget,
} from "./src/mention.js";
export { feishuPlugin } from "./src/channel.js";
const plugin = {
id: "feishu",
name: "Feishu",
description: "Feishu/Lark channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin });
registerFeishuDocTools(api);
registerFeishuWikiTools(api);
registerFeishuDriveTools(api);
registerFeishuPermTools(api);
registerFeishuBitableTools(api);
},
};
export default plugin;

View File

@@ -0,0 +1,10 @@
{
"id": "feishu",
"channels": ["feishu"],
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,34 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.26",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.59.0",
"@sinclair/typebox": "0.34.48",
"zod": "^4.3.6"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "feishu",
"label": "Feishu",
"selectionLabel": "Feishu/Lark (飞书)",
"docsPath": "/channels/feishu",
"docsLabel": "feishu",
"blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.",
"aliases": [
"lark"
],
"order": 35,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/feishu",
"localPath": "extensions/feishu",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,105 @@
---
name: feishu-doc
description: |
Feishu document read/write operations. Activate when user mentions Feishu docs, cloud docs, or docx links.
---
# Feishu Document Tool
Single tool `feishu_doc` with action parameter for all document operations.
## Token Extraction
From URL `https://xxx.feishu.cn/docx/ABC123def``doc_token` = `ABC123def`
## Actions
### Read Document
```json
{ "action": "read", "doc_token": "ABC123def" }
```
Returns: title, plain text content, block statistics. Check `hint` field - if present, structured content (tables, images) exists that requires `list_blocks`.
### Write Document (Replace All)
```json
{ "action": "write", "doc_token": "ABC123def", "content": "# Title\n\nMarkdown content..." }
```
Replaces entire document with markdown content. Supports: headings, lists, code blocks, quotes, links, images (`![](url)` auto-uploaded), bold/italic/strikethrough.
**Limitation:** Markdown tables are NOT supported.
### Append Content
```json
{ "action": "append", "doc_token": "ABC123def", "content": "Additional content" }
```
Appends markdown to end of document.
### Create Document
```json
{ "action": "create", "title": "New Document" }
```
With folder:
```json
{ "action": "create", "title": "New Document", "folder_token": "fldcnXXX" }
```
### List Blocks
```json
{ "action": "list_blocks", "doc_token": "ABC123def" }
```
Returns full block data including tables, images. Use this to read structured content.
### Get Single Block
```json
{ "action": "get_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" }
```
### Update Block Text
```json
{
"action": "update_block",
"doc_token": "ABC123def",
"block_id": "doxcnXXX",
"content": "New text"
}
```
### Delete Block
```json
{ "action": "delete_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" }
```
## Reading Workflow
1. Start with `action: "read"` - get plain text + statistics
2. Check `block_types` in response for Table, Image, Code, etc.
3. If structured content exists, use `action: "list_blocks"` for full data
## Configuration
```yaml
channels:
feishu:
tools:
doc: true # default: true
```
**Note:** `feishu_wiki` depends on this tool - wiki page content is read/written via `feishu_doc`.
## Permissions
Required: `docx:document`, `docx:document:readonly`, `docx:document.block:convert`, `drive:drive`

View File

@@ -0,0 +1,103 @@
# Feishu Block Types Reference
Complete reference for Feishu document block types. Use with `feishu_doc_list_blocks`, `feishu_doc_update_block`, and `feishu_doc_delete_block`.
## Block Type Table
| block_type | Name | Description | Editable |
| ---------- | --------------- | ------------------------------ | -------- |
| 1 | Page | Document root (contains title) | No |
| 2 | Text | Plain text paragraph | Yes |
| 3 | Heading1 | H1 heading | Yes |
| 4 | Heading2 | H2 heading | Yes |
| 5 | Heading3 | H3 heading | Yes |
| 6 | Heading4 | H4 heading | Yes |
| 7 | Heading5 | H5 heading | Yes |
| 8 | Heading6 | H6 heading | Yes |
| 9 | Heading7 | H7 heading | Yes |
| 10 | Heading8 | H8 heading | Yes |
| 11 | Heading9 | H9 heading | Yes |
| 12 | Bullet | Unordered list item | Yes |
| 13 | Ordered | Ordered list item | Yes |
| 14 | Code | Code block | Yes |
| 15 | Quote | Blockquote | Yes |
| 16 | Equation | LaTeX equation | Partial |
| 17 | Todo | Checkbox / task item | Yes |
| 18 | Bitable | Multi-dimensional table | No |
| 19 | Callout | Highlight block | Yes |
| 20 | ChatCard | Chat card embed | No |
| 21 | Diagram | Diagram embed | No |
| 22 | Divider | Horizontal rule | No |
| 23 | File | File attachment | No |
| 24 | Grid | Grid layout container | No |
| 25 | GridColumn | Grid column | No |
| 26 | Iframe | Embedded iframe | No |
| 27 | Image | Image | Partial |
| 28 | ISV | Third-party widget | No |
| 29 | MindnoteBlock | Mindmap embed | No |
| 30 | Sheet | Spreadsheet embed | No |
| 31 | Table | Table | Partial |
| 32 | TableCell | Table cell | Yes |
| 33 | View | View embed | No |
| 34 | Undefined | Unknown type | No |
| 35 | QuoteContainer | Quote container | No |
| 36 | Task | Lark Tasks integration | No |
| 37 | OKR | OKR integration | No |
| 38 | OKRObjective | OKR objective | No |
| 39 | OKRKeyResult | OKR key result | No |
| 40 | OKRProgress | OKR progress | No |
| 41 | AddOns | Add-ons block | No |
| 42 | JiraIssue | Jira issue embed | No |
| 43 | WikiCatalog | Wiki catalog | No |
| 44 | Board | Board embed | No |
| 45 | Agenda | Agenda block | No |
| 46 | AgendaItem | Agenda item | No |
| 47 | AgendaItemTitle | Agenda item title | No |
| 48 | SyncedBlock | Synced block reference | No |
## Editing Guidelines
### Text-based blocks (2-17, 19)
Update text content using `feishu_doc_update_block`:
```json
{
"doc_token": "ABC123",
"block_id": "block_xxx",
"content": "New text content"
}
```
### Image blocks (27)
Images cannot be updated directly via `update_block`. Use `feishu_doc_write` or `feishu_doc_append` with markdown to add new images.
### Table blocks (31)
**Important:** Table blocks CANNOT be created via the `documentBlockChildren.create` API (error 1770029). This affects `feishu_doc_write` and `feishu_doc_append` - markdown tables will be skipped with a warning.
Tables can only be read (via `list_blocks`) and individual cells (type 32) can be updated, but new tables cannot be inserted programmatically via markdown.
### Container blocks (24, 25, 35)
Grid and QuoteContainer are layout containers. Edit their child blocks instead.
## Common Patterns
### Replace specific paragraph
1. `feishu_doc_list_blocks` - find the block_id
2. `feishu_doc_update_block` - update its content
### Insert content at specific location
Currently, the API only supports appending to document end. For insertion at specific positions, consider:
1. Read existing content
2. Delete affected blocks
3. Rewrite with new content in desired order
### Delete multiple blocks
Blocks must be deleted one at a time. Delete child blocks before parent containers.

View File

@@ -0,0 +1,97 @@
---
name: feishu-drive
description: |
Feishu cloud storage file management. Activate when user mentions cloud space, folders, drive.
---
# Feishu Drive Tool
Single tool `feishu_drive` for cloud storage operations.
## Token Extraction
From URL `https://xxx.feishu.cn/drive/folder/ABC123``folder_token` = `ABC123`
## Actions
### List Folder Contents
```json
{ "action": "list" }
```
Root directory (no folder_token).
```json
{ "action": "list", "folder_token": "fldcnXXX" }
```
Returns: files with token, name, type, url, timestamps.
### Get File Info
```json
{ "action": "info", "file_token": "ABC123", "type": "docx" }
```
Searches for the file in the root directory. Note: file must be in root or use `list` to browse folders first.
`type`: `doc`, `docx`, `sheet`, `bitable`, `folder`, `file`, `mindnote`, `shortcut`
### Create Folder
```json
{ "action": "create_folder", "name": "New Folder" }
```
In parent folder:
```json
{ "action": "create_folder", "name": "New Folder", "folder_token": "fldcnXXX" }
```
### Move File
```json
{ "action": "move", "file_token": "ABC123", "type": "docx", "folder_token": "fldcnXXX" }
```
### Delete File
```json
{ "action": "delete", "file_token": "ABC123", "type": "docx" }
```
## File Types
| Type | Description |
| ---------- | ----------------------- |
| `doc` | Old format document |
| `docx` | New format document |
| `sheet` | Spreadsheet |
| `bitable` | Multi-dimensional table |
| `folder` | Folder |
| `file` | Uploaded file |
| `mindnote` | Mind map |
| `shortcut` | Shortcut |
## Configuration
```yaml
channels:
feishu:
tools:
drive: true # default: true
```
## Permissions
- `drive:drive` - Full access (create, move, delete)
- `drive:drive:readonly` - Read only (list, info)
## Known Limitations
- **Bots have no root folder**: Feishu bots use `tenant_access_token` and don't have their own "My Space". The root folder concept only exists for user accounts. This means:
- `create_folder` without `folder_token` will fail (400 error)
- Bot can only access files/folders that have been **shared with it**
- **Workaround**: User must first create a folder manually and share it with the bot, then bot can create subfolders inside it

View File

@@ -0,0 +1,119 @@
---
name: feishu-perm
description: |
Feishu permission management for documents and files. Activate when user mentions sharing, permissions, collaborators.
---
# Feishu Permission Tool
Single tool `feishu_perm` for managing file/document permissions.
## Actions
### List Collaborators
```json
{ "action": "list", "token": "ABC123", "type": "docx" }
```
Returns: members with member_type, member_id, perm, name.
### Add Collaborator
```json
{
"action": "add",
"token": "ABC123",
"type": "docx",
"member_type": "email",
"member_id": "user@example.com",
"perm": "edit"
}
```
### Remove Collaborator
```json
{
"action": "remove",
"token": "ABC123",
"type": "docx",
"member_type": "email",
"member_id": "user@example.com"
}
```
## Token Types
| Type | Description |
| ---------- | ----------------------- |
| `doc` | Old format document |
| `docx` | New format document |
| `sheet` | Spreadsheet |
| `bitable` | Multi-dimensional table |
| `folder` | Folder |
| `file` | Uploaded file |
| `wiki` | Wiki node |
| `mindnote` | Mind map |
## Member Types
| Type | Description |
| ------------------ | ------------------ |
| `email` | Email address |
| `openid` | User open_id |
| `userid` | User user_id |
| `unionid` | User union_id |
| `openchat` | Group chat open_id |
| `opendepartmentid` | Department open_id |
## Permission Levels
| Perm | Description |
| ------------- | ------------------------------------ |
| `view` | View only |
| `edit` | Can edit |
| `full_access` | Full access (can manage permissions) |
## Examples
Share document with email:
```json
{
"action": "add",
"token": "doxcnXXX",
"type": "docx",
"member_type": "email",
"member_id": "alice@company.com",
"perm": "edit"
}
```
Share folder with group:
```json
{
"action": "add",
"token": "fldcnXXX",
"type": "folder",
"member_type": "openchat",
"member_id": "oc_xxx",
"perm": "view"
}
```
## Configuration
```yaml
channels:
feishu:
tools:
perm: true # default: false (disabled)
```
**Note:** This tool is disabled by default because permission management is a sensitive operation. Enable explicitly if needed.
## Permissions
Required: `drive:permission`

View File

@@ -0,0 +1,111 @@
---
name: feishu-wiki
description: |
Feishu knowledge base navigation. Activate when user mentions knowledge base, wiki, or wiki links.
---
# Feishu Wiki Tool
Single tool `feishu_wiki` for knowledge base operations.
## Token Extraction
From URL `https://xxx.feishu.cn/wiki/ABC123def``token` = `ABC123def`
## Actions
### List Knowledge Spaces
```json
{ "action": "spaces" }
```
Returns all accessible wiki spaces.
### List Nodes
```json
{ "action": "nodes", "space_id": "7xxx" }
```
With parent:
```json
{ "action": "nodes", "space_id": "7xxx", "parent_node_token": "wikcnXXX" }
```
### Get Node Details
```json
{ "action": "get", "token": "ABC123def" }
```
Returns: `node_token`, `obj_token`, `obj_type`, etc. Use `obj_token` with `feishu_doc` to read/write the document.
### Create Node
```json
{ "action": "create", "space_id": "7xxx", "title": "New Page" }
```
With type and parent:
```json
{
"action": "create",
"space_id": "7xxx",
"title": "Sheet",
"obj_type": "sheet",
"parent_node_token": "wikcnXXX"
}
```
`obj_type`: `docx` (default), `sheet`, `bitable`, `mindnote`, `file`, `doc`, `slides`
### Move Node
```json
{ "action": "move", "space_id": "7xxx", "node_token": "wikcnXXX" }
```
To different location:
```json
{
"action": "move",
"space_id": "7xxx",
"node_token": "wikcnXXX",
"target_space_id": "7yyy",
"target_parent_token": "wikcnYYY"
}
```
### Rename Node
```json
{ "action": "rename", "space_id": "7xxx", "node_token": "wikcnXXX", "title": "New Title" }
```
## Wiki-Doc Workflow
To edit a wiki page:
1. Get node: `{ "action": "get", "token": "wiki_token" }` → returns `obj_token`
2. Read doc: `feishu_doc { "action": "read", "doc_token": "obj_token" }`
3. Write doc: `feishu_doc { "action": "write", "doc_token": "obj_token", "content": "..." }`
## Configuration
```yaml
channels:
feishu:
tools:
wiki: true # default: true
doc: true # required - wiki content uses feishu_doc
```
**Dependency:** This tool requires `feishu_doc` to be enabled. Wiki pages are documents - use `feishu_wiki` to navigate, then `feishu_doc` to read/edit content.
## Permissions
Required: `wiki:wiki` or `wiki:wiki:readonly`

View File

@@ -0,0 +1,144 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
FeishuConfig,
FeishuAccountConfig,
FeishuDomain,
ResolvedFeishuAccount,
} from "./types.js";
/**
* List all configured account IDs from the accounts field.
*/
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean);
}
/**
* List all Feishu account IDs.
* If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
*/
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Backward compatibility: no accounts configured, use default
return [DEFAULT_ACCOUNT_ID];
}
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
/**
* Resolve the default account ID.
*/
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
const ids = listFeishuAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* Get the raw account-specific config.
*/
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): FeishuAccountConfig | undefined {
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId];
}
/**
* Merge top-level config with account-specific config.
* Account-specific fields override top-level fields.
*/
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
// Extract base config (exclude accounts field to avoid recursion)
const { accounts: _ignored, ...base } = feishuCfg ?? {};
// Get account-specific overrides
const account = resolveAccountConfig(cfg, accountId) ?? {};
// Merge: account config overrides base config
return { ...base, ...account } as FeishuConfig;
}
/**
* Resolve Feishu credentials from a config.
*/
export function resolveFeishuCredentials(cfg?: FeishuConfig): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null {
const appId = cfg?.appId?.trim();
const appSecret = cfg?.appSecret?.trim();
if (!appId || !appSecret) {
return null;
}
return {
appId,
appSecret,
encryptKey: cfg?.encryptKey?.trim() || undefined,
verificationToken: cfg?.verificationToken?.trim() || undefined,
domain: cfg?.domain ?? "feishu",
};
}
/**
* Resolve a complete Feishu account with merged config.
*/
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const accountId = normalizeAccountId(params.accountId);
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
// Base enabled state (top-level)
const baseEnabled = feishuCfg?.enabled !== false;
// Merge configs
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
// Account-level enabled state
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
// Resolve credentials from merged config
const creds = resolveFeishuCredentials(merged);
return {
accountId,
enabled,
configured: Boolean(creds),
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
appId: creds?.appId,
appSecret: creds?.appSecret,
encryptKey: creds?.encryptKey,
verificationToken: creds?.verificationToken,
domain: creds?.domain ?? "feishu",
config: merged,
};
}
/**
* List all enabled and configured accounts.
*/
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
return listFeishuAccountIds(cfg)
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}

View File

@@ -0,0 +1,713 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient } from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/** Field type ID to human-readable name */
const FIELD_TYPE_NAMES: Record<number, string> = {
1: "Text",
2: "Number",
3: "SingleSelect",
4: "MultiSelect",
5: "DateTime",
7: "Checkbox",
11: "User",
13: "Phone",
15: "URL",
17: "Attachment",
18: "SingleLink",
19: "Lookup",
20: "Formula",
21: "DuplexLink",
22: "Location",
23: "GroupChat",
1001: "CreatedTime",
1002: "ModifiedTime",
1003: "CreatedUser",
1004: "ModifiedUser",
1005: "AutoNumber",
};
// ============ Core Functions ============
/** Parse bitable URL and extract tokens */
function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki: boolean } | null {
try {
const u = new URL(url);
const tableId = u.searchParams.get("table") ?? undefined;
// Wiki format: /wiki/XXXXX?table=YYY
const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/);
if (wikiMatch) {
return { token: wikiMatch[1], tableId, isWiki: true };
}
// Base format: /base/XXXXX?table=YYY
const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/);
if (baseMatch) {
return { token: baseMatch[1], tableId, isWiki: false };
}
return null;
} catch {
return null;
}
}
/** Get app_token from wiki node_token */
async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise<string> {
const res = await client.wiki.space.getNode({
params: { token: nodeToken },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const node = res.data?.node;
if (!node) {
throw new Error("Node not found");
}
if (node.obj_type !== "bitable") {
throw new Error(`Node is not a bitable (type: ${node.obj_type})`);
}
return node.obj_token!;
}
/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */
async function getBitableMeta(client: Lark.Client, url: string) {
const parsed = parseBitableUrl(url);
if (!parsed) {
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
}
let appToken: string;
if (parsed.isWiki) {
appToken = await getAppTokenFromWiki(client, parsed.token);
} else {
appToken = parsed.token;
}
// Get bitable app info
const res = await client.bitable.app.get({
path: { app_token: appToken },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
// List tables if no table_id specified
let tables: { table_id: string; name: string }[] = [];
if (!parsed.tableId) {
const tablesRes = await client.bitable.appTable.list({
path: { app_token: appToken },
});
if (tablesRes.code === 0) {
tables = (tablesRes.data?.items ?? []).map((t) => ({
table_id: t.table_id!,
name: t.name!,
}));
}
}
return {
app_token: appToken,
table_id: parsed.tableId,
name: res.data?.app?.name,
url_type: parsed.isWiki ? "wiki" : "base",
...(tables.length > 0 && { tables }),
hint: parsed.tableId
? `Use app_token="${appToken}" and table_id="${parsed.tableId}" for other bitable tools`
: `Use app_token="${appToken}" for other bitable tools. Select a table_id from the tables list.`,
};
}
async function listFields(client: Lark.Client, appToken: string, tableId: string) {
const res = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const fields = res.data?.items ?? [];
return {
fields: fields.map((f) => ({
field_id: f.field_id,
field_name: f.field_name,
type: f.type,
type_name: FIELD_TYPE_NAMES[f.type ?? 0] || `type_${f.type}`,
is_primary: f.is_primary,
...(f.property && { property: f.property }),
})),
total: fields.length,
};
}
async function listRecords(
client: Lark.Client,
appToken: string,
tableId: string,
pageSize?: number,
pageToken?: string,
) {
const res = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: {
page_size: pageSize ?? 100,
...(pageToken && { page_token: pageToken }),
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
records: res.data?.items ?? [],
has_more: res.data?.has_more ?? false,
page_token: res.data?.page_token,
total: res.data?.total,
};
}
async function getRecord(client: Lark.Client, appToken: string, tableId: string, recordId: string) {
const res = await client.bitable.appTableRecord.get({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
record: res.data?.record,
};
}
async function createRecord(
client: Lark.Client,
appToken: string,
tableId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.create({
path: { app_token: appToken, table_id: tableId },
// oxlint-disable-next-line typescript/no-explicit-any
data: { fields: fields as any },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
record: res.data?.record,
};
}
/** Logger interface for cleanup operations */
type CleanupLogger = {
debug: (msg: string) => void;
warn: (msg: string) => void;
};
/** Default field types created for new Bitable tables (to be cleaned up) */
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
/** Clean up default placeholder rows and fields in a newly created Bitable table */
async function cleanupNewBitable(
client: Lark.Client,
appToken: string,
tableId: string,
tableName: string,
logger: CleanupLogger,
): Promise<{ cleanedRows: number; cleanedFields: number }> {
let cleanedRows = 0;
let cleanedFields = 0;
// Step 1: Clean up default fields
const fieldsRes = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
if (fieldsRes.code === 0 && fieldsRes.data?.items) {
// Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
if (primaryField?.field_id) {
try {
const newFieldName = tableName.length <= 20 ? tableName : "Name";
await client.bitable.appTableField.update({
path: {
app_token: appToken,
table_id: tableId,
field_id: primaryField.field_id,
},
data: {
field_name: newFieldName,
type: 1,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to rename primary field: ${err}`);
}
}
// Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
const defaultFieldsToDelete = fieldsRes.data.items.filter(
(f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
);
for (const field of defaultFieldsToDelete) {
if (field.field_id) {
try {
await client.bitable.appTableField.delete({
path: {
app_token: appToken,
table_id: tableId,
field_id: field.field_id,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
}
}
}
}
// Step 2: Delete empty placeholder rows (batch when possible)
const recordsRes = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: { page_size: 100 },
});
if (recordsRes.code === 0 && recordsRes.data?.items) {
const emptyRecordIds = recordsRes.data.items
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
.map((r) => r.record_id)
.filter((id): id is string => Boolean(id));
if (emptyRecordIds.length > 0) {
try {
await client.bitable.appTableRecord.batchDelete({
path: { app_token: appToken, table_id: tableId },
data: { records: emptyRecordIds },
});
cleanedRows = emptyRecordIds.length;
} catch {
// Fallback: delete one by one if batch API is unavailable
for (const recordId of emptyRecordIds) {
try {
await client.bitable.appTableRecord.delete({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
cleanedRows++;
} catch (err) {
logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
}
}
}
}
}
return { cleanedRows, cleanedFields };
}
async function createApp(
client: Lark.Client,
name: string,
folderToken?: string,
logger?: CleanupLogger,
) {
const res = await client.bitable.app.create({
data: {
name,
...(folderToken && { folder_token: folderToken }),
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const appToken = res.data?.app?.app_token;
if (!appToken) {
throw new Error("Failed to create Bitable: no app_token returned");
}
const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
let tableId: string | undefined;
let cleanedRows = 0;
let cleanedFields = 0;
try {
const tablesRes = await client.bitable.appTable.list({
path: { app_token: appToken },
});
if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
tableId = tablesRes.data.items[0].table_id ?? undefined;
if (tableId) {
const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
cleanedRows = cleanup.cleanedRows;
cleanedFields = cleanup.cleanedFields;
}
}
} catch (err) {
log.debug(`Cleanup failed (non-critical): ${err}`);
}
return {
app_token: appToken,
table_id: tableId,
name: res.data?.app?.name,
url: res.data?.app?.url,
cleaned_placeholder_rows: cleanedRows,
cleaned_default_fields: cleanedFields,
hint: tableId
? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
: "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
};
}
async function createField(
client: Lark.Client,
appToken: string,
tableId: string,
fieldName: string,
fieldType: number,
property?: Record<string, unknown>,
) {
const res = await client.bitable.appTableField.create({
path: { app_token: appToken, table_id: tableId },
data: {
field_name: fieldName,
type: fieldType,
...(property && { property }),
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
field_id: res.data?.field?.field_id,
field_name: res.data?.field?.field_name,
type: res.data?.field?.type,
type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}`,
};
}
async function updateRecord(
client: Lark.Client,
appToken: string,
tableId: string,
recordId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.update({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
// oxlint-disable-next-line typescript/no-explicit-any
data: { fields: fields as any },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
record: res.data?.record,
};
}
// ============ Schemas ============
const GetMetaSchema = Type.Object({
url: Type.String({
description: "Bitable URL. Supports both formats: /base/XXX?table=YYY or /wiki/XXX?table=YYY",
}),
});
const ListFieldsSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
});
const ListRecordsSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
page_size: Type.Optional(
Type.Number({
description: "Number of records per page (1-500, default 100)",
minimum: 1,
maximum: 500,
}),
),
page_token: Type.Optional(
Type.String({ description: "Pagination token from previous response" }),
),
});
const GetRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
record_id: Type.String({ description: "Record ID to retrieve" }),
});
const CreateRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
fields: Type.Record(Type.String(), Type.Any(), {
description:
"Field values keyed by field name. Format by type: Text='string', Number=123, SingleSelect='Option', MultiSelect=['A','B'], DateTime=timestamp_ms, User=[{id:'ou_xxx'}], URL={text:'Display',link:'https://...'}",
}),
});
const CreateAppSchema = Type.Object({
name: Type.String({
description: "Name for the new Bitable application",
}),
folder_token: Type.Optional(
Type.String({
description: "Optional folder token to place the Bitable in a specific folder",
}),
),
});
const CreateFieldSchema = Type.Object({
app_token: Type.String({
description:
"Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
field_name: Type.String({ description: "Name for the new field" }),
field_type: Type.Number({
description:
"Field type ID: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=SingleLink, 19=Lookup, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber",
minimum: 1,
}),
property: Type.Optional(
Type.Record(Type.String(), Type.Any(), {
description: "Field-specific properties (e.g., options for SingleSelect, format for Number)",
}),
),
});
const UpdateRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
record_id: Type.String({ description: "Record ID to update" }),
fields: Type.Record(Type.String(), Type.Any(), {
description: "Field values to update (same format as create_record)",
}),
});
// ============ Tool Registration ============
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;
}
type AccountAwareParams = { accountId?: string };
const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const registerBitableTool = <TParams extends AccountAwareParams>(params: {
name: string;
label: string;
description: string;
parameters: unknown;
execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
}) => {
api.registerTool(
(ctx) => ({
name: params.name,
label: params.label,
description: params.description,
parameters: params.parameters,
async execute(_toolCallId, rawParams) {
try {
return json(
await params.execute({
params: rawParams as TParams,
defaultAccountId: ctx.agentAccountId,
}),
);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
}),
{ name: params.name },
);
};
registerBitableTool<{ url: string; accountId?: string }>({
name: "feishu_bitable_get_meta",
label: "Feishu Bitable Get Meta",
description:
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
parameters: GetMetaSchema,
async execute({ params, defaultAccountId }) {
return getBitableMeta(getClient(params, defaultAccountId), params.url);
},
});
registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({
name: "feishu_bitable_list_fields",
label: "Feishu Bitable List Fields",
description: "List all fields (columns) in a Bitable table with their types and properties",
parameters: ListFieldsSchema,
async execute({ params, defaultAccountId }) {
return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
accountId?: string;
}>({
name: "feishu_bitable_list_records",
label: "Feishu Bitable List Records",
description: "List records (rows) from a Bitable table with pagination support",
parameters: ListRecordsSchema,
async execute({ params, defaultAccountId }) {
return listRecords(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.page_size,
params.page_token,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
accountId?: string;
}>({
name: "feishu_bitable_get_record",
label: "Feishu Bitable Get Record",
description: "Get a single record by ID from a Bitable table",
parameters: GetRecordSchema,
async execute({ params, defaultAccountId }) {
return getRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute({ params, defaultAccountId }) {
return createRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.fields,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute({ params, defaultAccountId }) {
return updateRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
params.fields,
);
},
});
registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({
name: "feishu_bitable_create_app",
label: "Feishu Bitable Create App",
description: "Create a new Bitable (multidimensional table) application",
parameters: CreateAppSchema,
async execute({ params, defaultAccountId }) {
return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, {
debug: (msg) => api.logger.debug?.(msg),
warn: (msg) => api.logger.warn?.(msg),
});
},
});
registerBitableTool<{
app_token: string;
table_id: string;
field_name: string;
field_type: number;
property?: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_field",
label: "Feishu Bitable Create Field",
description: "Create a new field (column) in a Bitable table",
parameters: CreateFieldSchema,
async execute({ params, defaultAccountId }) {
return createField(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.field_name,
params.field_type,
params.property,
);
},
});
api.logger.info?.("feishu_bitable: Registered bitable tools");
}

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { parseFeishuMessageEvent } from "./bot.js";
// Helper to build a minimal FeishuMessageEvent for testing
function makeEvent(
chatType: "p2p" | "group",
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
text = "hello",
) {
return {
sender: {
sender_id: { user_id: "u1", open_id: "ou_sender" },
},
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text }),
mentions,
},
};
}
function makePostEvent(content: unknown) {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: JSON.stringify(content),
mentions: [],
},
};
}
describe("parseFeishuMessageEvent mentionedBot", () => {
const BOT_OPEN_ID = "ou_bot_123";
it("returns mentionedBot=false when there are no mentions", () => {
const event = makeEvent("group", []);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=true when bot is mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false when only other users are mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, undefined);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, "");
expect(ctx.mentionedBot).toBe(false);
});
it("treats mention.name regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
"@NotBot hello",
);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.content).toBe("@NotBot hello");
});
it("treats mention.key regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
"hello world",
);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.content).toBe("hello world");
});
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
const BOT_OPEN_ID = "ou_bot_123";
const event = makePostEvent({
content: [
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
[{ tag: "text", text: "What does this document say" }],
],
});
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false for post message with no at", () => {
const event = makePostEvent({
content: [[{ tag: "text", text: "hello" }]],
});
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false for post message with at for another user", () => {
const event = makePostEvent({
content: [
[{ tag: "at", user_id: "ou_other", user_name: "other" }],
[{ tag: "text", text: "hello" }],
],
});
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
type Mentions = FeishuMessageEvent["message"]["mentions"];
describe("stripBotMention", () => {
it("returns original text when mentions are missing", () => {
expect(stripBotMention("hello world", undefined)).toBe("hello world");
});
it("strips mention name and key for normal mentions", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
});
it("treats mention.name regex metacharacters as literal text", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
});
it("treats mention.key regex metacharacters as literal text", () => {
const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention("hello world", mentions)).toBe("hello world");
});
it("trims once after all mention replacements", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
});
it("strips multiple mentions in one pass", () => {
const mentions: Mentions = [
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
{ key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
];
expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
});
});

View File

@@ -0,0 +1,543 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuMessageEvent } from "./bot.js";
import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
fileName: "clip.mp4",
}),
mockCreateFeishuClient: vi.fn(),
}));
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
}));
vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv;
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
await handleFeishuMessage({
cfg: params.cfg,
event: params.event,
runtime: createRuntimeEnv(),
});
}
describe("buildFeishuAgentBody", () => {
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
const body = buildFeishuAgentBody({
ctx: {
content: "hello world",
senderName: "Sender Name",
senderOpenId: "ou-sender",
messageId: "msg-42",
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
},
quotedContent: "previous message",
permissionErrorForAgent: {
code: 99991672,
message: "permission denied",
grantUrl: "https://open.feishu.cn/app/cli_test",
},
});
expect(body).toBe(
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
);
});
});
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher = vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
const mockBuildPairingReply = vi.fn(() => "Pairing response");
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
contentType: "video/mp4",
});
beforeEach(() => {
vi.clearAllMocks();
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
},
},
});
setFeishuRuntime({
system: {
enqueueSystemEvent: vi.fn(),
},
channel: {
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:feishu:dm:ou-attacker",
matchedBy: "default",
})),
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher: mockWithReplyDispatcher,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
media: {
saveMediaBuffer: mockSaveMediaBuffer,
},
pairing: {
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
buildPairingReply: mockBuildPairingReply,
},
},
media: {
detectMime: vi.fn(async () => "application/octet-stream"),
},
} as unknown as PluginRuntime);
});
it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["ou-admin"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-auth-bypass-regression",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
CommandAuthorized: false,
SenderId: "ou-attacker",
Surface: "feishu",
}),
);
});
it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-read-store-non-command",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello there" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadAllowFromStore).toHaveBeenCalledWith({
channel: "feishu",
accountId: "default",
});
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-unapproved",
},
},
message: {
message_id: "msg-pairing-flow",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
channel: "feishu",
accountId: "default",
id: "ou-unapproved",
meta: { name: undefined },
});
expect(mockBuildPairingReply).toHaveBeenCalledWith({
channel: "feishu",
idLine: "Your Feishu user id: ou-unapproved",
code: "ABCDEFGH",
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou-unapproved",
accountId: "default",
}),
);
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("computes group command authorization from group allowFrom", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-group-command-auth",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: false, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ChatType: "group",
CommandAuthorized: false,
SenderId: "ou-attacker",
}),
);
});
it("falls back to top-level allowFrom for group command authorization", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
allowFrom: ["ou-admin"],
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-admin",
},
},
message: {
message_id: "msg-group-command-fallback",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: true }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ChatType: "group",
CommandAuthorized: true,
SenderId: "ou-admin",
}),
);
});
it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-sender",
},
},
message: {
message_id: "msg-video-inbound",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "video",
content: JSON.stringify({
file_key: "file_video_payload",
image_key: "img_thumb_payload",
file_name: "clip.mp4",
}),
},
};
await dispatchMessage({ cfg, event });
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "msg-video-inbound",
fileKey: "file_video_payload",
type: "file",
}),
);
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
expect.any(Number),
"clip.mp4",
);
});
it("includes message_id in BodyForAgent on its own line", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-msgid",
},
},
message: {
message_id: "msg-message-id-line",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello",
}),
);
});
it("dispatches once and appends permission notice to the main agent body", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockRejectedValue({
response: {
data: {
code: 99991672,
msg: "permission denied https://open.feishu.cn/app/cli_test",
},
},
}),
},
},
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
appId: "cli_test",
appSecret: "sec_test",
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-perm",
},
},
message: {
message_id: "msg-perm-1",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello group" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining(
"Permission grant URL: https://open.feishu.cn/app/cli_test",
),
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining("ou-perm: hello group"),
}),
);
});
});

View File

@@ -0,0 +1,979 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import {
buildAgentMediaPayload,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
createScopedPairingAccess,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
recordPendingHistoryEntryIfEnabled,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { tryRecordMessagePersistent } from "./dedup.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import {
escapeRegExp,
extractMentionTargets,
extractMessageBody,
isMentionForwardRequest,
} from "./mention.js";
import {
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code: number;
message: string;
grantUrl?: string;
};
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
msg?: string;
error?: { permission_violations?: Array<{ uri?: string }> };
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
const grantUrl = urlMatch?.[0];
return {
code: feishuErr.code,
message: msg,
grantUrl,
};
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by open_id to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
type SenderNameResult = {
name?: string;
permissionError?: PermissionError;
};
async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderOpenId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderOpenId, log } = params;
if (!account.configured) return {};
if (!senderOpenId) return {};
const cached = senderNameCache.get(senderOpenId);
const now = Date.now();
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(account);
// contact/v3/users/:user_id?user_id_type=open_id
const res: any = await client.contact.user.get({
path: { user_id: senderOpenId },
params: { user_id_type: "open_id" },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
// Check if this is a permission error
const permErr = extractPermissionError(err);
if (permErr) {
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
// Best-effort. Don't fail message handling if name lookup fails.
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
return {};
}
}
export type FeishuMessageEvent = {
sender: {
sender_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
sender_type?: string;
tenant_key?: string;
};
message: {
message_id: string;
root_id?: string;
parent_id?: string;
chat_id: string;
chat_type: "p2p" | "group";
message_type: string;
content: string;
mentions?: Array<{
key: string;
id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
name: string;
tenant_key?: string;
}>;
};
};
export type FeishuBotAddedEvent = {
chat_id: string;
operator_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
external: boolean;
operator_tenant_key?: string;
};
function parseMessageContent(content: string, messageType: string): string {
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "post") {
// Extract text content from rich text post
const { textContent } = parsePostContent(content);
return textContent;
}
return content;
} catch {
return content;
}
}
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
if (!botOpenId) return false;
const mentions = event.message.mentions ?? [];
if (mentions.length > 0) {
return mentions.some((m) => m.id.open_id === botOpenId);
}
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
if (event.message.message_type === "post") {
const { mentionedOpenIds } = parsePostContent(event.message.content);
return mentionedOpenIds.some((id) => id === botOpenId);
}
return false;
}
export function stripBotMention(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
): string {
if (!mentions || mentions.length === 0) return text;
let result = text;
for (const mention of mentions) {
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
}
return result.trim();
}
/**
* Parse media keys from message content based on message type.
*/
function parseMediaKeys(
content: string,
messageType: string,
): {
imageKey?: string;
fileKey?: string;
fileName?: string;
} {
try {
const parsed = JSON.parse(content);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey };
case "file":
return { fileKey, fileName: parsed.file_name };
case "audio":
return { fileKey };
case "video":
// Video has both file_key (video) and image_key (thumbnail)
return { fileKey, imageKey };
case "sticker":
return { fileKey };
default:
return {};
}
} catch {
return {};
}
}
/**
* Parse post (rich text) content and extract embedded image keys.
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
*/
function parsePostContent(content: string): {
textContent: string;
imageKeys: string[];
mentionedOpenIds: string[];
} {
try {
const parsed = JSON.parse(content);
const title = parsed.title || "";
const contentBlocks = parsed.content || [];
let textContent = title ? `${title}\n\n` : "";
const imageKeys: string[] = [];
const mentionedOpenIds: string[] = [];
for (const paragraph of contentBlocks) {
if (Array.isArray(paragraph)) {
for (const element of paragraph) {
if (element.tag === "text") {
textContent += element.text || "";
} else if (element.tag === "a") {
// Link: show text or href
textContent += element.text || element.href || "";
} else if (element.tag === "at") {
// Mention: @username
textContent += `@${element.user_name || element.user_id || ""}`;
if (element.user_id) {
mentionedOpenIds.push(element.user_id);
}
} else if (element.tag === "img" && element.image_key) {
// Embedded image
const imageKey = normalizeFeishuExternalKey(element.image_key);
if (imageKey) {
imageKeys.push(imageKey);
}
}
}
textContent += "\n";
}
}
return {
textContent: textContent.trim() || "[Rich text message]",
imageKeys,
mentionedOpenIds,
};
} catch {
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
}
}
/**
* Infer placeholder text based on message type.
*/
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message, downloading and saving to disk.
* Similar to Discord's resolveMediaList().
*/
async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
// Only process media message types (including post for embedded images)
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images
if (messageType === "post") {
const { imageKeys } = parsePostContent(content);
if (imageKeys.length === 0) {
return [];
}
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
for (const imageKey of imageKeys) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
return out;
}
// Handle other media types
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
let buffer: Buffer;
let contentType: string | undefined;
let fileName: string | undefined;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
if (!fileKey) {
return [];
}
const resourceType = messageType === "image" ? "image" : "file";
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
accountId,
});
buffer = result.buffer;
contentType = result.contentType;
fileName = result.fileName || mediaKeys.fileName;
// Detect mime type if not provided
if (!contentType) {
contentType = await core.media.detectMime({ buffer });
}
// Save to disk using core's saveMediaBuffer
const saved = await core.channel.media.saveMediaBuffer(
buffer,
contentType,
"inbound",
maxBytes,
fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}
/**
* Build media payload for inbound context.
* Similar to Discord's buildDiscordMediaPayload().
*/
export function parseFeishuMessageEvent(
event: FeishuMessageEvent,
botOpenId?: string,
): FeishuMessageContext {
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
const mentionedBot = checkBotMentioned(event, botOpenId);
const content = stripBotMention(rawContent, event.message.mentions);
const ctx: FeishuMessageContext = {
chatId: event.message.chat_id,
messageId: event.message.message_id,
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
senderOpenId: event.sender.sender_id.open_id || "",
chatType: event.message.chat_type,
mentionedBot,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
content,
contentType: event.message.message_type,
};
// Detect mention forward request: message mentions bot + at least one other user
if (isMentionForwardRequest(event, botOpenId)) {
const mentionTargets = extractMentionTargets(event, botOpenId);
if (mentionTargets.length > 0) {
ctx.mentionTargets = mentionTargets;
// Extract message body (remove all @ placeholders)
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
}
}
return ctx;
}
export function buildFeishuAgentBody(params: {
ctx: Pick<
FeishuMessageContext,
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
}): string {
const { ctx, quotedContent, permissionErrorForAgent } = params;
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// DMs already have per-sender sessions, but this label still improves attribution.
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
if (permissionErrorForAgent) {
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
}
return messageBody;
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
}): Promise<void> {
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
// Resolve account with merged config
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Dedup check: skip if this message was already processed (memory + disk).
const messageId = event.message.message_id;
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
log(`feishu: skipping duplicate message ${messageId}`);
return;
}
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const senderResult = await resolveFeishuSenderName({
account,
senderOpenId: ctx.senderOpenId,
log,
});
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent: PermissionError | undefined;
if (senderResult.permissionError) {
const appKey = account.appId ?? "default";
const now = Date.now();
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
permissionErrorNotifiedAt.set(appKey, now);
permissionErrorForAgent = senderResult.permissionError;
}
}
log(
`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`,
);
// Log mention targets if detected
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
}
const historyLimit = Math.max(
0,
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupConfig = isGroup
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
: undefined;
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
if (isGroup) {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "feishu",
accountId: account.accountId,
log,
});
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed = isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
});
if (!groupAllowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
return;
}
// Additional sender-level allowlist check if group has specific allowFrom config
const senderAllowFrom = groupConfig?.allowFrom ?? [];
if (senderAllowFrom.length > 0) {
const senderAllowed = isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: senderAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
});
if (!senderAllowed) {
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
return;
}
}
const { requireMention } = resolveFeishuReplyPolicy({
isDirectMessage: false,
globalConfig: feishuCfg,
groupConfig,
});
if (requireMention && !ctx.mentionedBot) {
log(
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
);
if (chatHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: ctx.chatId,
limit: historyLimit,
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: Date.now(),
messageId: ctx.messageId,
},
});
}
return;
}
} else {
}
try {
const core = getFeishuRuntime();
const pairing = createScopedPairingAccess({
core,
channel: "feishu",
accountId: account.accountId,
});
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
ctx.content,
cfg,
);
const storeAllowFrom =
!isGroup &&
dmPolicy !== "allowlist" &&
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
? await pairing.readAllowFromStore().catch(() => [])
: [];
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const dmAllowed = resolveFeishuAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
}).allowed;
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: ctx.senderOpenId,
meta: { name: ctx.senderName },
});
if (created) {
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
try {
await sendMessageFeishu({
cfg,
to: `user:${ctx.senderOpenId}`,
text: core.channel.pairing.buildPairingReply({
channel: "feishu",
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
code,
}),
accountId: account.accountId,
});
} catch (err) {
log(
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
);
}
}
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
const commandAllowFrom = isGroup
? (groupConfig?.allowFrom ?? configAllowFrom)
: effectiveDmAllowFrom;
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
allowFrom: commandAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
}).allowed;
const commandAuthorized = shouldComputeCommandAuthorized
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
// Resolve peer ID for session routing
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
// get a separate session from the main group chat.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
let topicSessionMode: "enabled" | "disabled" = "disabled";
if (isGroup && ctx.rootId) {
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
if (topicSessionMode === "enabled") {
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
}
}
let route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
// Add parentPeer for binding inheritance in topic mode
parentPeer:
isGroup && ctx.rootId && topicSessionMode === "enabled"
? {
kind: "group",
id: ctx.chatId,
}
: null,
});
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg;
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtime = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
log: (msg) => log(msg),
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "direct", id: ctx.senderOpenId },
});
log(
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
);
}
}
}
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
});
// Resolve media from message
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
const mediaList = await resolveFeishuMediaList({
cfg,
messageId: ctx.messageId,
messageType: event.message.message_type,
content: event.message.content,
maxBytes: mediaMaxBytes,
log,
accountId: account.accountId,
});
const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
quotedContent,
permissionErrorForAgent,
});
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
if (permissionErrorForAgent) {
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
}
const body = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: messageBody,
});
let combinedBody = body;
const historyKey = isGroup ? ctx.chatId : undefined;
if (isGroup && historyKey && chatHistories) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
// Preserve speaker identity in group history as well.
from: `${ctx.chatId}:${entry.sender}`,
timestamp: entry.timestamp,
body: entry.body,
envelope: envelopeOptions,
}),
});
}
const inboundHistory =
isGroup && historyKey && historyLimit > 0 && chatHistories
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: ctx.senderName ?? ctx.senderOpenId,
SenderId: ctx.senderOpenId,
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
});
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
);
} catch (err) {
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
}
}

View File

@@ -0,0 +1,48 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
},
},
},
},
} as OpenClawConfig;
const account = feishuPlugin.config.resolveAccount(cfg, "main");
probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
const result = await feishuPlugin.status?.probeAccount?.({
account,
timeoutMs: 1_000,
cfg,
});
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
expect(probeFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main",
appId: "cli_main",
appSecret: "secret_main",
}),
);
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});

View File

@@ -0,0 +1,359 @@
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
import {
buildBaseChannelStatusSummary,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
import {
listFeishuDirectoryPeers,
listFeishuDirectoryGroups,
listFeishuDirectoryPeersLive,
listFeishuDirectoryGroupsLive,
} from "./directory.js";
import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
const meta: ChannelMeta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu/Lark (飞书)",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "飞书/Lark enterprise messaging.",
aliases: ["lark"],
order: 70,
};
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
...meta,
},
pairing: {
idLabel: "feishuUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
notifyApproval: async ({ cfg, id }) => {
await sendMessageFeishu({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "channel"],
polls: false,
threads: true,
media: true,
reactions: true,
edit: true,
reply: true,
},
agentPrompt: {
messageToolHints: () => [
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
"- Feishu supports interactive cards for rich messages.",
],
},
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
appId: { type: "string" },
appSecret: { type: "string" },
encryptKey: { type: "string" },
verificationToken: { type: "string" },
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: { type: "string" },
encryptKey: { type: "string" },
verificationToken: { type: "string" },
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
// For default account, set top-level enabled
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled,
},
},
};
}
// For named accounts, set enabled in accounts[accountId]
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
// Delete entire feishu config
const next = { ...cfg } as ClawdbotConfig;
const nextChannels = { ...cfg.channels };
delete (nextChannels as Record<string, unknown>).feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
}
// Delete specific account from accounts
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const accounts = { ...feishuCfg?.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
domain: account.domain,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
},
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") return [];
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled: true,
},
},
},
},
};
},
},
onboarding: feishuOnboardingAdapter,
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
targetResolver: {
looksLikeId: looksLikeFeishuId,
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeers({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroups: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryGroups({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listPeersLive: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeersLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryGroupsLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
},
outbound: feishuOutbound,
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) => ({
...buildBaseChannelStatusSummary(snapshot),
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
domain: account.domain,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const { monitorFeishuProvider } = await import("./monitor.js");
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
const port = account.config?.webhookPort ?? null;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(
`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`,
);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
},
},
};

View File

@@ -0,0 +1,118 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
// Multi-account client cache
const clientCache = new Map<
string,
{
client: Lark.Client;
config: { appId: string; appSecret: string; domain?: FeishuDomain };
}
>();
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
if (domain === "lark") {
return Lark.Domain.Lark;
}
if (domain === "feishu" || !domain) {
return Lark.Domain.Feishu;
}
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
}
/**
* Credentials needed to create a Feishu client.
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
*/
export type FeishuClientCredentials = {
accountId?: string;
appId?: string;
appSecret?: string;
domain?: FeishuDomain;
};
/**
* Create or get a cached Feishu client for an account.
* Accepts any object with appId, appSecret, and optional domain/accountId.
*/
export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
const { accountId = "default", appId, appSecret, domain } = creds;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
// Check cache
const cached = clientCache.get(accountId);
if (
cached &&
cached.config.appId === appId &&
cached.config.appSecret === appSecret &&
cached.config.domain === domain
) {
return cached.client;
}
// Create new client
const client = new Lark.Client({
appId,
appSecret,
appType: Lark.AppType.SelfBuild,
domain: resolveDomain(domain),
});
// Cache it
clientCache.set(accountId, {
client,
config: { appId, appSecret, domain },
});
return client;
}
/**
* Create a Feishu WebSocket client for an account.
* Note: WSClient is not cached since each call creates a new connection.
*/
export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
const { accountId, appId, appSecret, domain } = account;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
return new Lark.WSClient({
appId,
appSecret,
domain: resolveDomain(domain),
loggerLevel: Lark.LoggerLevel.info,
});
}
/**
* Create an event dispatcher for an account.
*/
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
return new Lark.EventDispatcher({
encryptKey: account.encryptKey,
verificationToken: account.verificationToken,
});
}
/**
* Get a cached client for an account (if exists).
*/
export function getFeishuClient(accountId: string): Lark.Client | null {
return clientCache.get(accountId)?.client ?? null;
}
/**
* Clear client cache for a specific account or all accounts.
*/
export function clearClientCache(accountId?: string): void {
if (accountId) {
clientCache.delete(accountId);
} else {
clientCache.clear();
}
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
describe("FeishuConfigSchema webhook validation", () => {
it("applies top-level defaults", () => {
const result = FeishuConfigSchema.parse({});
expect(result.domain).toBe("feishu");
expect(result.connectionMode).toBe("websocket");
expect(result.webhookPath).toBe("/feishu/events");
expect(result.dmPolicy).toBe("pairing");
expect(result.groupPolicy).toBe("allowlist");
expect(result.requireMention).toBe(true);
});
it("does not force top-level policy defaults into account config", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {},
},
});
expect(result.accounts?.main?.dmPolicy).toBeUndefined();
expect(result.accounts?.main?.groupPolicy).toBeUndefined();
expect(result.accounts?.main?.requireMention).toBeUndefined();
});
it("rejects top-level webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
appId: "cli_top",
appSecret: "secret_top",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
).toBe(true);
}
});
it("accepts top-level webhook mode with verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: "token_top",
appId: "cli_top",
appSecret: "secret_top",
});
expect(result.success).toBe(true);
});
it("rejects account webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.path.join(".") === "accounts.main.verificationToken",
),
).toBe(true);
}
});
it("accepts account webhook mode inheriting top-level verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
verificationToken: "token_top",
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,226 @@
import { z } from "zod";
export { z };
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
const FeishuDomainSchema = z.union([
z.enum(["feishu", "lark"]),
z.string().url().startsWith("https://"),
]);
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
const DmConfigSchema = z
.object({
enabled: z.boolean().optional(),
systemPrompt: z.string().optional(),
})
.strict()
.optional();
const MarkdownConfigSchema = z
.object({
mode: z.enum(["native", "escape", "strip"]).optional(),
tableMode: z.enum(["native", "ascii", "simple"]).optional(),
})
.strict()
.optional();
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
// for incremental text display with a "Thinking..." placeholder
const StreamingModeSchema = z.boolean().optional();
const BlockStreamingCoalesceSchema = z
.object({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().positive().optional(),
maxDelayMs: z.number().int().positive().optional(),
})
.strict()
.optional();
const ChannelHeartbeatVisibilitySchema = z
.object({
visibility: z.enum(["visible", "hidden"]).optional(),
intervalMs: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Dynamic agent creation configuration.
* When enabled, a new agent is created for each unique DM user.
*/
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
maxAgents: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Feishu tools configuration.
* Controls which tool categories are enabled.
*
* Dependencies:
* - wiki requires doc (wiki content is edited via doc tools)
* - perm can work independently but is typically used with drive
*/
const FeishuToolsConfigSchema = z
.object({
doc: z.boolean().optional(), // Document operations (default: true)
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
drive: z.boolean().optional(), // Cloud storage operations (default: true)
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
})
.strict()
.optional();
/**
* Topic session isolation mode for group chats.
* - "disabled" (default): All messages in a group share one session
* - "enabled": Messages in different topics get separate sessions
*
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
* for messages within a topic thread, allowing isolated conversations.
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
export const FeishuGroupSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topicSessionMode: TopicSessionModeSchema,
})
.strict();
const FeishuSharedConfigShape = {
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
};
/**
* Per-account configuration.
* All fields are optional - missing fields inherit from top-level config.
*/
export const FeishuAccountConfigSchema = z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(), // Display name for this account
appId: z.string().optional(),
appSecret: z.string().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
webhookPath: z.string().optional(),
...FeishuSharedConfigShape,
})
.strict();
export const FeishuConfigSchema = z
.object({
enabled: z.boolean().optional(),
// Top-level credentials (backward compatible for single-account mode)
appId: z.string().optional(),
appSecret: z.string().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
...FeishuSharedConfigShape,
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
requireMention: z.boolean().optional().default(true),
topicSessionMode: TopicSessionModeSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Multi-account configuration
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
})
.strict()
.superRefine((value, ctx) => {
const defaultConnectionMode = value.connectionMode ?? "websocket";
const defaultVerificationToken = value.verificationToken?.trim();
if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["verificationToken"],
message:
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
});
}
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
if (!account) {
continue;
}
const accountConnectionMode = account.connectionMode ?? defaultConnectionMode;
if (accountConnectionMode !== "webhook") {
continue;
}
const accountVerificationToken =
account.verificationToken?.trim() || defaultVerificationToken;
if (!accountVerificationToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accounts", accountId, "verificationToken"],
message:
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
"a verificationToken (account-level or top-level)",
});
}
}
if (value.dmPolicy === "open") {
const allowFrom = value.allowFrom ?? [];
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
if (!hasWildcard) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
});
}
}
});

View File

@@ -0,0 +1,54 @@
import os from "node:os";
import path from "node:path";
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
const MEMORY_MAX_SIZE = 1_000;
const FILE_MAX_ENTRIES = 10_000;
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (stateOverride) {
return stateOverride;
}
if (env.VITEST || env.NODE_ENV === "test") {
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
}
return path.join(os.homedir(), ".openclaw");
}
function resolveNamespaceFilePath(namespace: string): string {
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
}
const persistentDedupe = createPersistentDedupe({
ttlMs: DEDUP_TTL_MS,
memoryMaxSize: MEMORY_MAX_SIZE,
fileMaxEntries: FILE_MAX_ENTRIES,
resolveFilePath: resolveNamespaceFilePath,
});
/**
* Synchronous dedup — memory only.
* Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
*/
export function tryRecordMessage(messageId: string): boolean {
return !memoryDedupe.check(messageId);
}
export async function tryRecordMessagePersistent(
messageId: string,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
return persistentDedupe.checkAndRecord(messageId, {
namespace,
onDiskError: (error) => {
log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
},
});
}

View File

@@ -0,0 +1,177 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { normalizeFeishuTarget } from "./targets.js";
export type FeishuDirectoryPeer = {
kind: "user";
id: string;
name?: string;
};
export type FeishuDirectoryGroup = {
kind: "group";
id: string;
name?: string;
};
export async function listFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of feishuCfg?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) {
ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "user" as const, id }));
}
export async function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
const trimmed = groupId.trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "group" as const, id }));
}
export async function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryPeers(params);
}
try {
const client = createFeishuClient(account);
const peers: FeishuDirectoryPeer[] = [];
const limit = params.limit ?? 50;
const response = await client.contact.user.list({
params: {
page_size: Math.min(limit, 50),
},
});
if (response.code === 0 && response.data?.items) {
for (const user of response.data.items) {
if (user.open_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = user.name || "";
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
peers.push({
kind: "user",
id: user.open_id,
name: name || undefined,
});
}
}
if (peers.length >= limit) {
break;
}
}
}
return peers;
} catch {
return listFeishuDirectoryPeers(params);
}
}
export async function listFeishuDirectoryGroupsLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryGroups(params);
}
try {
const client = createFeishuClient(account);
const groups: FeishuDirectoryGroup[] = [];
const limit = params.limit ?? 50;
const response = await client.im.chat.list({
params: {
page_size: Math.min(limit, 100),
},
});
if (response.code === 0 && response.data?.items) {
for (const chat of response.data.items) {
if (chat.chat_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = chat.name || "";
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
groups.push({
kind: "group",
id: chat.chat_id,
name: name || undefined,
});
}
}
if (groups.length >= limit) {
break;
}
}
}
return groups;
} catch {
return listFeishuDirectoryGroups(params);
}
}

View File

@@ -0,0 +1,47 @@
import { Type, type Static } from "@sinclair/typebox";
export const FeishuDocSchema = Type.Union([
Type.Object({
action: Type.Literal("read"),
doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
}),
Type.Object({
action: Type.Literal("write"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({
description: "Markdown content to write (replaces entire document content)",
}),
}),
Type.Object({
action: Type.Literal("append"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to append to end of document" }),
}),
Type.Object({
action: Type.Literal("create"),
title: Type.String({ description: "Document title" }),
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
}),
Type.Object({
action: Type.Literal("list_blocks"),
doc_token: Type.String({ description: "Document token" }),
}),
Type.Object({
action: Type.Literal("get_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
}),
Type.Object({
action: Type.Literal("update_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
content: Type.String({ description: "New text content" }),
}),
Type.Object({
action: Type.Literal("delete_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID" }),
}),
]);
export type FeishuDocParams = Static<typeof FeishuDocSchema>;

View File

@@ -0,0 +1,76 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, test, vi } from "vitest";
import { registerFeishuDocTools } from "./docx.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
}));
vi.mock("./client.js", () => {
return {
createFeishuClient: (creds: { appId?: string } | undefined) => createFeishuClientMock(creds),
};
});
// Patch SDK import so tool execution can run without network concerns.
vi.mock("@larksuiteoapi/node-sdk", () => {
return {
default: {},
};
});
describe("feishu_doc account selection", () => {
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" });
await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" });
await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" });
expect(createFeishuClientMock).toHaveBeenCalledTimes(2);
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b");
});
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
await docTool.execute("call-override", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
},
},
}),
}));
import { registerFeishuDocTools } from "./docx.js";
describe("feishu_doc image fetch hardening", () => {
const convertMock = vi.hoisted(() => vi.fn());
const blockListMock = vi.hoisted(() => vi.fn());
const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
const driveUploadAllMock = vi.hoisted(() => vi.fn());
const blockPatchMock = vi.hoisted(() => vi.fn());
const scopeListMock = vi.hoisted(() => vi.fn());
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
docx: {
document: {
convert: convertMock,
},
documentBlock: {
list: blockListMock,
patch: blockPatchMock,
},
documentBlockChildren: {
create: blockChildrenCreateMock,
},
},
drive: {
media: {
uploadAll: driveUploadAllMock,
},
},
application: {
scope: {
list: scopeListMock,
},
},
});
convertMock.mockResolvedValue({
code: 0,
data: {
blocks: [{ block_type: 27 }],
first_level_block_ids: [],
},
});
blockListMock.mockResolvedValue({
code: 0,
data: {
items: [],
},
});
blockChildrenCreateMock.mockResolvedValue({
code: 0,
data: {
children: [{ block_type: 27, block_id: "img_block_1" }],
},
});
driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
blockPatchMock.mockResolvedValue({ code: 0 });
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
});
it("skips image upload when markdown image URL is blocked", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchRemoteMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const result = await feishuDocTool.execute("tool-call", {
action: "write",
doc_token: "doc_1",
content: "![x](https://x.test/image.png)",
});
expect(fetchRemoteMediaMock).toHaveBeenCalled();
expect(driveUploadAllMock).not.toHaveBeenCalled();
expect(blockPatchMock).not.toHaveBeenCalled();
expect(result.details.images_processed).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -0,0 +1,566 @@
import { Readable } from "stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccount,
} from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/** Extract image URLs from markdown content */
function extractImageUrls(markdown: string): string[] {
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
const urls: string[] = [];
let match;
while ((match = regex.exec(markdown)) !== null) {
const url = match[1].trim();
if (url.startsWith("http://") || url.startsWith("https://")) {
urls.push(url);
}
}
return urls;
}
const BLOCK_TYPE_NAMES: Record<number, string> = {
1: "Page",
2: "Text",
3: "Heading1",
4: "Heading2",
5: "Heading3",
12: "Bullet",
13: "Ordered",
14: "Code",
15: "Quote",
17: "Todo",
18: "Bitable",
21: "Diagram",
22: "Divider",
23: "File",
27: "Image",
30: "Sheet",
31: "Table",
32: "TableCell",
};
// Block types that cannot be created via documentBlockChildren.create API
const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
const skipped: string[] = [];
const cleaned = blocks
.filter((block) => {
if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
skipped.push(typeName);
return false;
}
return true;
})
.map((block) => {
if (block.block_type === 31 && block.table?.merge_info) {
const { merge_info: _merge_info, ...tableRest } = block.table;
return { ...block, table: tableRest };
}
return block;
});
return { cleaned, skipped };
}
// ============ Core Functions ============
async function convertMarkdown(client: Lark.Client, markdown: string) {
const res = await client.docx.document.convert({
data: { content_type: "markdown", content: markdown },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
blocks: res.data?.blocks ?? [],
firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
};
}
function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
const sortedIds = new Set(firstLevelIds);
const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
return [...sorted, ...remaining];
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function insertBlocks(
client: Lark.Client,
docToken: string,
blocks: any[],
parentBlockId?: string,
): Promise<{ children: any[]; skipped: string[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
const blockId = parentBlockId ?? docToken;
if (cleaned.length === 0) {
return { children: [], skipped };
}
const res = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: blockId },
data: { children: cleaned },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { children: res.data?.children ?? [], skipped };
}
async function clearDocumentContent(client: Lark.Client, docToken: string) {
const existing = await client.docx.documentBlock.list({
path: { document_id: docToken },
});
if (existing.code !== 0) {
throw new Error(existing.msg);
}
const childIds =
existing.data?.items
?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
.map((b) => b.block_id) ?? [];
if (childIds.length > 0) {
const res = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: docToken },
data: { start_index: 0, end_index: childIds.length },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
}
return childIds.length;
}
async function uploadImageToDocx(
client: Lark.Client,
blockId: string,
imageBuffer: Buffer,
fileName: string,
): Promise<string> {
const res = await client.drive.media.uploadAll({
data: {
file_name: fileName,
parent_type: "docx_image",
parent_node: blockId,
size: imageBuffer.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
file: Readable.from(imageBuffer) as any,
},
});
const fileToken = res?.file_token;
if (!fileToken) {
throw new Error("Image upload failed: no file_token returned");
}
return fileToken;
}
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
return fetched.buffer;
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function processImages(
client: Lark.Client,
docToken: string,
markdown: string,
insertedBlocks: any[],
maxBytes: number,
): Promise<number> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const imageUrls = extractImageUrls(markdown);
if (imageUrls.length === 0) {
return 0;
}
const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
let processed = 0;
for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
const url = imageUrls[i];
const blockId = imageBlocks[i].block_id;
try {
const buffer = await downloadImage(url, maxBytes);
const urlPath = new URL(url).pathname;
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
replace_image: { token: fileToken },
},
});
processed++;
} catch (err) {
console.error(`Failed to process image ${url}:`, err);
}
}
return processed;
}
// ============ Actions ============
const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
async function readDoc(client: Lark.Client, docToken: string) {
const [contentRes, infoRes, blocksRes] = await Promise.all([
client.docx.document.rawContent({ path: { document_id: docToken } }),
client.docx.document.get({ path: { document_id: docToken } }),
client.docx.documentBlock.list({ path: { document_id: docToken } }),
]);
if (contentRes.code !== 0) {
throw new Error(contentRes.msg);
}
const blocks = blocksRes.data?.items ?? [];
const blockCounts: Record<string, number> = {};
const structuredTypes: string[] = [];
for (const b of blocks) {
const type = b.block_type ?? 0;
const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
blockCounts[name] = (blockCounts[name] || 0) + 1;
if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
structuredTypes.push(name);
}
}
let hint: string | undefined;
if (structuredTypes.length > 0) {
hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
}
return {
title: infoRes.data?.document?.title,
content: contentRes.data?.content,
revision_id: infoRes.data?.document?.revision_id,
block_count: blocks.length,
block_types: blockCounts,
...(hint && { hint }),
};
}
async function createDoc(client: Lark.Client, title: string, folderToken?: string) {
const res = await client.docx.document.create({
data: { title, folder_token: folderToken },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const doc = res.data?.document;
return {
document_id: doc?.document_id,
title: doc?.title,
url: `https://feishu.cn/docx/${doc?.document_id}`,
};
}
async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
const deleted = await clearDocumentContent(client, docToken);
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
return {
success: true,
blocks_deleted: deleted,
blocks_added: inserted.length,
images_processed: imagesProcessed,
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
async function appendDoc(
client: Lark.Client,
docToken: string,
markdown: string,
maxBytes: number,
) {
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
return {
success: true,
blocks_added: inserted.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
block_ids: inserted.map((b: any) => b.block_id),
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
async function updateBlock(
client: Lark.Client,
docToken: string,
blockId: string,
content: string,
) {
const blockInfo = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (blockInfo.code !== 0) {
throw new Error(blockInfo.msg);
}
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
update_text_elements: {
elements: [{ text_run: { content } }],
},
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block_id: blockId };
}
async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) {
const blockInfo = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (blockInfo.code !== 0) {
throw new Error(blockInfo.msg);
}
const parentId = blockInfo.data?.block?.parent_id ?? docToken;
const children = await client.docx.documentBlockChildren.get({
path: { document_id: docToken, block_id: parentId },
});
if (children.code !== 0) {
throw new Error(children.msg);
}
const items = children.data?.items ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
const index = items.findIndex((item: any) => item.block_id === blockId);
if (index === -1) {
throw new Error("Block not found");
}
const res = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: parentId },
data: { start_index: index, end_index: index + 1 },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, deleted_block_id: blockId };
}
async function listBlocks(client: Lark.Client, docToken: string) {
const res = await client.docx.documentBlock.list({
path: { document_id: docToken },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
blocks: res.data?.items ?? [],
};
}
async function getBlock(client: Lark.Client, docToken: string, blockId: string) {
const res = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
block: res.data?.block,
};
}
async function listAppScopes(client: Lark.Client) {
const res = await client.application.scope.list({});
if (res.code !== 0) {
throw new Error(res.msg);
}
const scopes = res.data?.scopes ?? [];
const granted = scopes.filter((s) => s.grant_status === 1);
const pending = scopes.filter((s) => s.grant_status !== 1);
return {
granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
summary: `${granted.length} granted, ${pending.length} pending`,
};
}
// ============ Tool Registration ============
export function registerFeishuDocTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
return;
}
// Check if any account is configured
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
return;
}
// Register if enabled on any account; account routing is resolved per execution.
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
const registered: string[] = [];
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const getMediaMaxBytes = (
params: { accountId?: string } | undefined,
defaultAccountId?: string,
) =>
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
?.mediaMaxMb ?? 30) *
1024 *
1024;
// Main document tool with action-based dispatch
if (toolsCfg.doc) {
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocExecuteParams;
try {
const client = getClient(p, defaultAccountId);
switch (p.action) {
case "read":
return json(await readDoc(client, p.doc_token));
case "write":
return json(
await writeDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "append":
return json(
await appendDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
default: {
const exhaustiveCheck: never = p;
return json({ error: `Unknown action: ${String(exhaustiveCheck)}` });
}
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
};
},
{ name: "feishu_doc" },
);
registered.push("feishu_doc");
}
// Keep feishu_app_scopes as independent tool
if (toolsCfg.scopes) {
api.registerTool(
(ctx) => ({
name: "feishu_app_scopes",
label: "Feishu App Scopes",
description:
"List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
parameters: Type.Object({}),
async execute() {
try {
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
}),
{ name: "feishu_app_scopes" },
);
registered.push("feishu_app_scopes");
}
if (registered.length > 0) {
api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
}
}

View File

@@ -0,0 +1,46 @@
import { Type, type Static } from "@sinclair/typebox";
const FileType = Type.Union([
Type.Literal("doc"),
Type.Literal("docx"),
Type.Literal("sheet"),
Type.Literal("bitable"),
Type.Literal("folder"),
Type.Literal("file"),
Type.Literal("mindnote"),
Type.Literal("shortcut"),
]);
export const FeishuDriveSchema = Type.Union([
Type.Object({
action: Type.Literal("list"),
folder_token: Type.Optional(
Type.String({ description: "Folder token (optional, omit for root directory)" }),
),
}),
Type.Object({
action: Type.Literal("info"),
file_token: Type.String({ description: "File or folder token" }),
type: FileType,
}),
Type.Object({
action: Type.Literal("create_folder"),
name: Type.String({ description: "Folder name" }),
folder_token: Type.Optional(
Type.String({ description: "Parent folder token (optional, omit for root)" }),
),
}),
Type.Object({
action: Type.Literal("move"),
file_token: Type.String({ description: "File token to move" }),
type: FileType,
folder_token: Type.String({ description: "Target folder token" }),
}),
Type.Object({
action: Type.Literal("delete"),
file_token: Type.String({ description: "File token to delete" }),
type: FileType,
}),
]);
export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;

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