Compare commits

..

3 Commits

472 changed files with 8651 additions and 24142 deletions

View File

@@ -6,7 +6,15 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { showPagedSelectList } from "./ui/paged-select";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileInfo {
status: string;
@@ -100,17 +108,87 @@ export default function (pi: ExtensionAPI) {
}
};
const items = files.map((file) => ({
value: file,
label: `${file.status} ${file.file}`,
}));
await showPagedSelectList({
ctx,
title: " Select file to diff",
items,
onSelect: (item) => {
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
// Build select items with colored status
const items: SelectItem[] = files.map((f) => {
let statusColor: string;
switch (f.status) {
case "M":
statusColor = theme.fg("warning", f.status);
break;
case "A":
statusColor = theme.fg("success", f.status);
break;
case "D":
statusColor = theme.fg("error", f.status);
break;
case "?":
statusColor = theme.fg("muted", f.status);
break;
default:
statusColor = theme.fg("dim", f.status);
}
return {
value: f,
label: `${statusColor} ${f.file}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileInfo);
},
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
},
});

View File

@@ -6,7 +6,15 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { showPagedSelectList } from "./ui/paged-select";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileEntry {
path: string;
@@ -105,29 +113,81 @@ export default function (pi: ExtensionAPI) {
}
};
const items = files.map((file) => {
const ops: string[] = [];
if (file.operations.has("read")) {
ops.push("R");
}
if (file.operations.has("write")) {
ops.push("W");
}
if (file.operations.has("edit")) {
ops.push("E");
}
return {
value: file,
label: `${ops.join("")} ${file.path}`,
};
});
await showPagedSelectList({
ctx,
title: " Select file to open",
items,
onSelect: (item) => {
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
// Build select items with colored operations
const items: SelectItem[] = files.map((f) => {
const ops: string[] = [];
if (f.operations.has("read")) {
ops.push(theme.fg("muted", "R"));
}
if (f.operations.has("write")) {
ops.push(theme.fg("success", "W"));
}
if (f.operations.has("edit")) {
ops.push(theme.fg("warning", "E"));
}
const opsLabel = ops.join("");
return {
value: f,
label: `${opsLabel} ${f.path}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileEntry);
},
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
},
});

View File

@@ -1,82 +0,0 @@
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
type CustomUiContext = {
ui: {
custom: <T>(
render: (
tui: { requestRender: () => void },
theme: {
fg: (tone: string, text: string) => string;
bold: (text: string) => string;
},
kb: unknown,
done: () => void,
) => {
render: (width: number) => string;
invalidate: () => void;
handleInput: (data: string) => void;
},
) => Promise<T>;
};
};
export async function showPagedSelectList(params: {
ctx: CustomUiContext;
title: string;
items: SelectItem[];
onSelect: (item: SelectItem) => void;
}): Promise<void> {
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
const visibleRows = Math.min(params.items.length, 15);
let currentIndex = 0;
const selectList = new SelectList(params.items, visibleRows, {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => text,
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});
selectList.onSelect = (item) => params.onSelect(item);
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = params.items.indexOf(item);
};
container.addChild(selectList);
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (width) => container.render(width),
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.left)) {
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
}

View File

@@ -6,8 +6,6 @@ Docs: https://docs.openclaw.ai
### Changes
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
@@ -17,7 +15,6 @@ Docs: https://docs.openclaw.ai
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
@@ -39,60 +36,17 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
### Fixes
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
@@ -105,17 +59,13 @@ Docs: https://docs.openclaw.ai
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
- Browser/Config schema: accept `browser.extraArgs` in config validation and reject invalid non-array/non-string values with explicit schema regressions coverage. (#29882) Thanks @gushiaoke.
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
@@ -124,7 +74,6 @@ Docs: https://docs.openclaw.ai
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
@@ -162,7 +111,6 @@ Docs: https://docs.openclaw.ai
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
@@ -248,7 +196,6 @@ Docs: https://docs.openclaw.ai
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
@@ -275,7 +222,6 @@ Docs: https://docs.openclaw.ai
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.

View File

@@ -149,8 +149,6 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
## Gateway and Node trust concept

View File

@@ -33,7 +33,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
@@ -98,7 +101,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseJsonParamsObject(paramsJson)
val params = parseParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(params) ?: 1600
@@ -164,7 +167,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseJsonParamsObject(paramsJson)
val params = parseParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
@@ -290,8 +293,20 @@ class CameraCaptureManager(private val context: Context) {
return rotated
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
params?.get(key) as? JsonPrimitive
private fun parseFacing(params: JsonObject?): String? {
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"front", "back" -> value
else -> null
@@ -299,21 +314,31 @@ class CameraCaptureManager(private val context: Context) {
}
private fun parseQuality(params: JsonObject?): Double? =
parseJsonDouble(params, "quality")
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
private fun parseMaxWidth(params: JsonObject?): Int? =
parseJsonInt(params, "maxWidth")
readPrimitive(params, "maxWidth")
?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
private fun parseDeviceId(params: JsonObject?): String? =
parseJsonString(params, "deviceId")
readPrimitive(params, "deviceId")
?.contentOrNull
?.trim()
?.takeIf { it.isNotEmpty() }
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)

View File

@@ -1,12 +1,10 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
@@ -23,35 +21,6 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun parseJsonInt(params: JsonObject?, key: String): Int? =
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonString(params: JsonObject?, key: String): String? =
readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null

View File

@@ -10,7 +10,10 @@ import ai.openclaw.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.io.File
import kotlin.math.roundToInt
@@ -36,7 +39,7 @@ class ScreenRecordManager(private val context: Context) {
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val params = parseJsonParamsObject(paramsJson)
val params = parseParamsObject(paramsJson)
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
val fpsInt = fps.roundToInt().coerceIn(1, 60)
@@ -143,19 +146,38 @@ class ScreenRecordManager(private val context: Context) {
}
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
params?.get(key) as? JsonPrimitive
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
private fun parseFps(params: JsonObject?): Double? =
parseJsonDouble(params, "fps")
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
private fun parseScreenIndex(params: JsonObject?): Int? =
parseJsonInt(params, "screenIndex")
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
private fun parseString(params: JsonObject?, key: String): String? =
parseJsonString(params, key)
readPrimitive(params, key)?.contentOrNull
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()

View File

@@ -3,14 +3,12 @@ package ai.openclaw.android.gateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
@@ -29,10 +27,6 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
private const val CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private val tokens = mutableMapOf<String, String>()
@@ -43,150 +37,69 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
}
}
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: Job,
)
private data class InvokeScenarioResult(
val request: GatewaySession.InvokeRequest,
val resultParams: JsonObject,
)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference<String?>(null)
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
assertEquals("invoke-1", result.request.id)
assertEquals("node-1", result.request.nodeId)
assertEquals("debug.ping", result.request.command)
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
true,
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
)
}
@Test
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
assertEquals("invoke-2", result.request.id)
assertEquals("node-2", result.request.nodeId)
assertEquals("debug.raw", result.request.command)
assertEquals("""{"raw":true}""", result.request.paramsJson)
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
}
@Test
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
"CAMERA_PERMISSION_REQUIRED",
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
)
assertEquals(
"grant Camera permission",
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
)
}
@Test
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
val json = testJson()
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val refreshRequestParams = CompletableDeferred<String?>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val handshakeOrigin = AtomicReference<String?>(null)
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
}
"node.canvas.capability.refresh" -> {
if (!refreshRequestParams.isCompleted) {
refreshRequestParams.complete(frame["params"]?.toString())
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
)
webSocket.close(1000, "done")
}
}
start()
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
assertEquals(true, refreshed)
assertEquals("{}", refreshParamsJson)
assertEquals(
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
harness.session.currentCanvasHostUrl(),
)
} finally {
shutdownHarness(harness, server)
}
}
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
private fun createNodeHarness(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
@@ -194,150 +107,460 @@ class GatewaySessionInvokeTest {
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = onInvoke,
onInvoke = { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
GatewaySession.InvokeResult.ok("""{"handled":true}""")
},
)
return NodeHarness(session = session, sessionJob = sessionJob)
}
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|$port",
name = "test",
host = "127.0.0.1",
port = port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
}
private suspend fun awaitConnectedOrThrow(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
server: MockWebServer,
) {
val connectedWithinTimeout =
withTimeoutOrNull(TEST_TIMEOUT_MS) {
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val req = withTimeout(8_000) { invokeRequest.await() }
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
assertEquals("invoke-1", req.id)
assertEquals("node-1", req.nodeId)
assertEquals("debug.ping", req.command)
assertEquals("""{"ping":"pong"}""", req.paramsJson)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
true,
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
}
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
server.shutdown()
}
private suspend fun runInvokeScenario(
invokeEventFrame: String,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): InvokeScenarioResult {
val json = testJson()
@Test
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(
json = json,
onHandshake = onHandshake,
) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send(invokeEventFrame)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
onInvoke(req)
start()
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
GatewaySession.InvokeResult.ok("""{"handled":true}""")
},
)
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val req = withTimeout(8_000) { invokeRequest.await() }
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
return InvokeScenarioResult(request = request, resultParams = resultParams)
assertEquals("invoke-2", req.id)
assertEquals("node-2", req.nodeId)
assertEquals("debug.raw", req.command)
assertEquals("""{"raw":true}""", req.paramsJson)
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
} finally {
shutdownHarness(harness, server)
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
}
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}
@Test
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
private fun startGatewayServer(
json: Json,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
): MockWebServer =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
onHandshake?.invoke(request)
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(CONNECT_CHALLENGE_FRAME)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
onRequestFrame(webSocket, id, method, frame)
}
},
)
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
}
}
start()
start()
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
},
)
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
"CAMERA_PERMISSION_REQUIRED",
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
)
assertEquals(
"grant Camera permission",
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
}
@Test
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val refreshRequestParams = CompletableDeferred<String?>()
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
}
"node.canvas.capability.refresh" -> {
if (!refreshRequestParams.isCompleted) {
refreshRequestParams.complete(frame["params"]?.toString())
}
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
)
webSocket.close(1000, "done")
}
}
}
},
)
}
}
start()
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
)
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
assertEquals(true, refreshed)
assertEquals("{}", refreshParamsJson)
assertEquals(
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
session.currentCanvasHostUrl(),
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
}
}

View File

@@ -9,8 +9,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
@RunWith(RobolectricTestRunner::class)
class CalendarHandlerTest {
@Test
fun handleCalendarEvents_requiresPermission() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
@@ -79,6 +83,8 @@ class CalendarHandlerTest : NodeHandlerRobolectricTest() {
assertFalse(result.ok)
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeCalendarDataSource(

View File

@@ -9,8 +9,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
@RunWith(RobolectricTestRunner::class)
class ContactsHandlerTest {
@Test
fun handleContactsSearch_requiresReadPermission() {
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
@@ -88,6 +92,8 @@ class ContactsHandlerTest : NodeHandlerRobolectricTest() {
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
assertEquals(1, source.addCalls)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeContactsDataSource(

View File

@@ -16,106 +16,144 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class InvokeCommandRegistryTest {
private val coreCapabilities =
setOf(
OpenClawCapability.Canvas.rawValue,
OpenClawCapability.Screen.rawValue,
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
OpenClawCapability.AppUpdate.rawValue,
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
)
private val optionalCapabilities =
setOf(
OpenClawCapability.Camera.rawValue,
OpenClawCapability.Location.rawValue,
OpenClawCapability.Sms.rawValue,
OpenClawCapability.VoiceWake.rawValue,
OpenClawCapability.Motion.rawValue,
)
private val coreCommands =
setOf(
OpenClawDeviceCommand.Status.rawValue,
OpenClawDeviceCommand.Info.rawValue,
OpenClawDeviceCommand.Permissions.rawValue,
OpenClawDeviceCommand.Health.rawValue,
OpenClawNotificationsCommand.List.rawValue,
OpenClawNotificationsCommand.Actions.rawValue,
OpenClawSystemCommand.Notify.rawValue,
OpenClawPhotosCommand.Latest.rawValue,
OpenClawContactsCommand.Search.rawValue,
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
"app.update",
)
private val optionalCommands =
setOf(
OpenClawCameraCommand.Snap.rawValue,
OpenClawCameraCommand.Clip.rawValue,
OpenClawCameraCommand.List.rawValue,
OpenClawLocationCommand.Get.rawValue,
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@Test
fun advertisedCapabilities_respectsFeatureAvailability() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false,
),
)
assertContainsAll(capabilities, coreCapabilities)
assertMissingAll(capabilities, optionalCapabilities)
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
}
@Test
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(
NodeRuntimeFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = false,
),
)
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
}
@Test
fun advertisedCommands_respectsFeatureAvailability() {
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
val commands =
InvokeCommandRegistry.advertisedCommands(
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false,
),
)
assertContainsAll(commands, coreCommands)
assertMissingAll(commands, optionalCommands + debugCommands)
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(commands.contains("debug.logs"))
assertFalse(commands.contains("debug.ed25519"))
assertTrue(commands.contains("app.update"))
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(
NodeRuntimeFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
),
)
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(commands.contains("debug.logs"))
assertTrue(commands.contains("debug.ed25519"))
assertTrue(commands.contains("app.update"))
}
@Test
@@ -136,31 +174,4 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
debugBuild: Boolean = false,
): NodeRuntimeFlags =
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,
debugBuild = debugBuild,
)
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
expected.forEach { value -> assertTrue(actual.contains(value)) }
}
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
}
}

View File

@@ -10,8 +10,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
class MotionHandlerTest : NodeHandlerRobolectricTest() {
@RunWith(RobolectricTestRunner::class)
class MotionHandlerTest {
@Test
fun handleMotionActivity_requiresPermission() =
runTest {
@@ -82,6 +86,8 @@ class MotionHandlerTest : NodeHandlerRobolectricTest() {
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeMotionDataSource(

View File

@@ -1,11 +0,0 @@
package ai.openclaw.android.node
import android.content.Context
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
abstract class NodeHandlerRobolectricTest {
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
}

View File

@@ -10,8 +10,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
@RunWith(RobolectricTestRunner::class)
class PhotosHandlerTest {
@Test
fun handlePhotosLatest_requiresPermission() {
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
@@ -59,6 +63,8 @@ class PhotosHandlerTest : NodeHandlerRobolectricTest() {
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
assertEquals(640, first.getValue("width").jsonPrimitive.int)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakePhotosDataSource(

View File

@@ -13,29 +13,14 @@ final class PeekabooBridgeHostCoordinator {
private var host: PeekabooBridgeHost?
private var services: OpenClawPeekabooBridgeServices?
private static let legacySocketDirectoryNames = ["clawdbot", "clawdis", "moltbot"]
private static var openclawSocketPath: String {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return Self.makeSocketPath(for: "OpenClaw", in: base)
let directory = base.appendingPathComponent("OpenClaw", isDirectory: true)
return directory.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false).path
}
private static func makeSocketPath(for directoryName: String, in baseDirectory: URL) -> String {
baseDirectory
.appendingPathComponent(directoryName, isDirectory: true)
.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false)
.path
}
private static var legacySocketPaths: [String] {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return Self.legacySocketDirectoryNames.map { Self.makeSocketPath(for: $0, in: base) }
}
func setEnabled(_ enabled: Bool) async {
if enabled {
await self.startIfNeeded()
@@ -61,8 +46,6 @@ final class PeekabooBridgeHostCoordinator {
}
let allowlistedBundles: Set<String> = []
self.ensureLegacySocketSymlinks()
let services = OpenClawPeekabooBridgeServices()
let server = PeekabooBridgeServer(
services: services,
@@ -84,42 +67,6 @@ final class PeekabooBridgeHostCoordinator {
.info("PeekabooBridge host started at \(Self.openclawSocketPath, privacy: .public)")
}
private func ensureLegacySocketSymlinks() {
Self.legacySocketPaths.forEach { legacyPath in
self.ensureLegacySocketSymlink(at: legacyPath)
}
}
private func ensureLegacySocketSymlink(at legacyPath: String) {
let fileManager = FileManager.default
let legacyDirectory = (legacyPath as NSString).deletingLastPathComponent
do {
let directoryAttributes: [FileAttributeKey: Any] = [
.posixPermissions: 0o700,
]
try fileManager.createDirectory(
atPath: legacyDirectory,
withIntermediateDirectories: true,
attributes: directoryAttributes)
let linkURL = URL(fileURLWithPath: legacyPath)
let linkValues = try? linkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
if linkValues?.isSymbolicLink == true {
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: legacyPath)
let destinationURL = URL(fileURLWithPath: destination, relativeTo: linkURL.deletingLastPathComponent())
.standardizedFileURL
if destinationURL.path == URL(fileURLWithPath: Self.openclawSocketPath).standardizedFileURL.path {
return
}
try fileManager.removeItem(atPath: legacyPath)
} else if fileManager.fileExists(atPath: legacyPath) {
try fileManager.removeItem(atPath: legacyPath)
}
try fileManager.createSymbolicLink(atPath: legacyPath, withDestinationPath: Self.openclawSocketPath)
} catch {
self.logger.debug("Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription, privacy: .public)")
}
}
private static func currentTeamID() -> String? {
var code: SecCode?
guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess,

View File

@@ -48,7 +48,6 @@ Security note:
- Always set a webhook password.
- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=<password>` or `x-password`), regardless of loopback/proxy topology.
- Password authentication is checked before reading/parsing full webhook bodies.
## Keeping Messages.app alive (VM / headless setups)

View File

@@ -139,8 +139,6 @@ Configure your tunnel's ingress rules to only route the webhook path:
## How it works
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
- OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present.
- Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget.
2. OpenClaw verifies the token against the configured `audienceType` + `audience`:
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
- `audienceType: "project-number"` → audience is the Cloud project number.

View File

@@ -48,10 +48,6 @@ The gateway responds to LINEs webhook verification (GET) and inbound events (
If you need a custom path, set `channels.line.webhookPath` or
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
Security note:
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
## Configure
Minimal config:

View File

@@ -1,5 +1,5 @@
---
summary: "Zalo personal account support via native zca-js (QR login), capabilities, and configuration"
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
read_when:
- Setting up Zalo Personal for OpenClaw
- Debugging Zalo Personal login or message flow
@@ -8,7 +8,7 @@ title: "Zalo Personal"
# Zalo Personal (unofficial)
Status: experimental. This integration automates a **personal Zalo account** via native `zca-js` inside OpenClaw.
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
@@ -20,14 +20,19 @@ Zalo Personal ships as a plugin and is not bundled with the core install.
- Or from a source checkout: `openclaw plugins install ./extensions/zalouser`
- Details: [Plugins](/tools/plugin)
No external `zca`/`openzca` CLI binary is required.
## Prerequisite: zca-cli
The Gateway machine must have the `zca` binary available in `PATH`.
- Verify: `zca --version`
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
## Quick setup (beginner)
1. Install the plugin (see above).
2. Login (QR, on the Gateway machine):
- `openclaw channels login --channel zalouser`
- Scan the QR code with the Zalo mobile app.
- Scan the QR code in the terminal with the Zalo mobile app.
3. Enable the channel:
```json5
@@ -46,9 +51,8 @@ No external `zca`/`openzca` CLI binary is required.
## What it is
- Runs entirely in-process via `zca-js`.
- Uses native event listeners to receive inbound messages.
- Sends replies directly through the JS API (text/media/link).
- Uses `zca listen` to receive inbound messages.
- Uses `zca msg ...` to send replies (text/media/link).
- Designed for “personal account” use cases where Zalo Bot API is not available.
## Naming
@@ -73,8 +77,7 @@ openclaw directory groups list --channel zalouser --query "work"
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup.
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
Approve via:
@@ -109,7 +112,7 @@ Example:
## Multi-account
Accounts map to `zalouser` profiles in OpenClaw state. Example:
Accounts map to zca profiles. Example:
```json5
{
@@ -127,16 +130,11 @@ Accounts map to `zalouser` profiles in OpenClaw state. Example:
## Troubleshooting
**Login doesn't stick:**
**`zca` not found:**
- Install zca-cli and ensure its on `PATH` for the Gateway process.
**Login doesnt stick:**
- `openclaw channels status --probe`
- Re-login: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
**Allowlist/group name didn't resolve:**
- Use numeric IDs in `allowFrom`/`groups`, or exact friend/group names.
**Upgraded from old CLI-based setup:**
- Remove any old external `zca` process assumptions.
- The channel now runs fully in OpenClaw without external CLI binaries.

View File

@@ -38,8 +38,6 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
workspace and seed the bootstrap files if they are missing.
Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
aliases that resolve outside the source workspace are ignored.
If you already manage the workspace files yourself, you can disable bootstrap
file creation:

View File

@@ -1587,8 +1587,6 @@ Defaults for Talk mode (macOS/iOS/Android).
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved).
| Profile | Includes |
| ----------- | ----------------------------------------------------------------------------------------- |
| `minimal` | `session_status` only |

View File

@@ -291,11 +291,6 @@ When validation fails:
}
```
Security note:
- Treat all hook/webhook payload content as untrusted input.
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
- For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
</Accordion>

View File

@@ -538,11 +538,6 @@ Guidance:
- Only enable temporarily for tightly scoped debugging.
- If enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
Hooks risk note:
- Hook payloads are untrusted content, even when delivery comes from systems you control (mail/docs/web content can carry prompt injection).
- Weak model tiers increase this risk. For hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via

View File

@@ -30,7 +30,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take)
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
- [Windows install says git not found or openclaw not recognized](#windows-install-says-git-not-found-or-openclaw-not-recognized)
- [Windows exec output shows garbled Chinese text what should I do](#windows-exec-output-shows-garbled-chinese-text-what-should-i-do)
- [The docs didn't answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer)
- [How do I install OpenClaw on Linux?](#how-do-i-install-openclaw-on-linux)
- [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps)
@@ -579,40 +578,12 @@ Two common Windows issues:
npm config get prefix
```
- Add that directory to your user PATH (no `\bin` suffix needed on Windows; on most systems it is `%AppData%\npm`).
- Ensure `<prefix>\\bin` is on PATH (on most systems it is `%AppData%\\npm`).
- Close and reopen PowerShell after updating PATH.
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
Docs: [Windows](/platforms/windows).
### Windows exec output shows garbled Chinese text what should I do
This is usually a console code page mismatch on native Windows shells.
Symptoms:
- `system.run`/`exec` output renders Chinese as mojibake
- The same command looks fine in another terminal profile
Quick workaround in PowerShell:
```powershell
chcp 65001
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
```
Then restart the Gateway and retry your command:
```powershell
openclaw gateway restart
```
If you still reproduce this on latest OpenClaw, track/report it in:
- [Issue #30640](https://github.com/openclaw/openclaw/issues/30640)
### The docs didn't answer my question how do I get a better answer
Use the **hackable (git) install** so you have the full source and docs locally, then ask

View File

@@ -384,7 +384,7 @@ Use non-interactive flags/env vars for predictable runs.
</Accordion>
<Accordion title='Windows: "openclaw is not recognized"'>
Run `npm config get prefix` and add that directory to your user PATH (no `\bin` suffix needed on Windows), then reopen PowerShell.
Run `npm config get prefix`, append `\bin`, add that directory to user PATH, then reopen PowerShell.
</Accordion>
<Accordion title="Windows: how to get verbose installer output">

View File

@@ -55,50 +55,6 @@ Repair/migrate:
openclaw doctor
```
## Gateway auto-start before Windows login
For headless setups, ensure the full boot chain runs even when no one logs into
Windows.
### 1) Keep user services running without login
Inside WSL:
```bash
sudo loginctl enable-linger "$(whoami)"
```
### 2) Install the OpenClaw gateway user service
Inside WSL:
```bash
openclaw gateway install
```
### 3) Start WSL automatically at Windows boot
In PowerShell as Administrator:
```powershell
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec /bin/true" /sc onstart /ru SYSTEM
```
Replace `Ubuntu` with your distro name from:
```powershell
wsl --list --verbose
```
### Verify startup chain
After a reboot (before Windows sign-in), check from WSL:
```bash
systemctl --user is-enabled openclaw-gateway
systemctl --user status openclaw-gateway --no-pager
```
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service

View File

@@ -1,5 +1,5 @@
---
summary: "Zalo Personal plugin: QR login + messaging via native zca-js (plugin install + channel config + tool)"
summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
read_when:
- You want Zalo Personal (unofficial) support in OpenClaw
- You are configuring or developing the zalouser plugin
@@ -8,7 +8,7 @@ title: "Zalo Personal Plugin"
# Zalo Personal (plugin)
Zalo Personal support for OpenClaw via a plugin, using native `zca-js` to automate a normal Zalo user account.
Zalo Personal support for OpenClaw via a plugin, using `zca-cli` to automate a normal Zalo user account.
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
@@ -22,8 +22,6 @@ This plugin runs **inside the Gateway process**.
If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway.
No external `zca`/`openzca` CLI binary is required.
## Install
### Option A: install from npm
@@ -43,6 +41,14 @@ cd ./extensions/zalouser && pnpm install
Restart the Gateway afterwards.
## Prerequisite: zca-cli
The Gateway machine must have `zca` on `PATH`:
```bash
zca --version
```
## Config
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):

View File

@@ -13,6 +13,15 @@ default model as `provider/model`.
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
## Highlight: Venice (Venice AI)
Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start
1. Authenticate with the provider (usually via `openclaw onboard`).

View File

@@ -11,6 +11,15 @@ title: "Model Provider Quickstart"
OpenClaw can use many LLM providers. Pick one, authenticate, then set the default
model as `provider/model`.
## Highlight: Venice (Venice AI)
Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start (two steps)
1. Authenticate with the provider (usually via `openclaw onboard`).

View File

@@ -86,8 +86,8 @@ openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?"
After setup, OpenClaw shows all available Venice models. Pick based on your needs:
- **Default model**: `venice/llama-3.3-70b` for private, balanced performance.
- **High-capability option**: `venice/claude-opus-45` for hard jobs.
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
- **Privacy**: Choose "private" models for fully private inference.
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
@@ -112,16 +112,16 @@ openclaw models list | grep venice
## Which Model Should I Use?
| Use Case | Recommended Model | Why |
| ---------------------------- | -------------------------------- | ----------------------------------- |
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
| **High-capability option** | `claude-opus-45` | Higher quality for hard tasks |
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
| **Uncensored** | `venice-uncensored` | No content restrictions |
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
| Use Case | Recommended Model | Why |
| ---------------------------- | -------------------------------- | ----------------------------------------- |
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
| **Uncensored** | `venice-uncensored` | No content restrictions |
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
## Available Models (25 Total)

View File

@@ -245,7 +245,6 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -34,8 +34,6 @@ Security trust model:
- By default, OpenClaw is a personal agent: one trusted operator boundary.
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in.
- If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing.
</Step>
<Step title="Local vs Remote">

View File

@@ -236,7 +236,6 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -50,7 +50,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Workspace default (or existing workspace)
- Gateway port **18789**
- Gateway auth **Token** (autogenerated, even on loopback)
- Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
@@ -66,7 +65,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
Security note: if this agent will run tools or process webhook/hooks content, prefer a strong modern model tier and keep tool policy strict. Weaker model tiers are easier to prompt-inject.
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.

View File

@@ -97,7 +97,7 @@ Notes:
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay.
- Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.

View File

@@ -105,11 +105,6 @@ Validation and limits:
- `title` max 1024 bytes.
- Patch complexity cap: max 128 files and 120000 total lines.
- `patch` and `before` or `after` together are rejected.
- Rendered file safety limits (apply to PNG and PDF):
- `fileQuality: "standard"`: max 8 MP (8,000,000 rendered pixels).
- `fileQuality: "hq"`: max 14 MP (14,000,000 rendered pixels).
- `fileQuality: "print"`: max 24 MP (24,000,000 rendered pixels).
- PDF also has a max of 50 pages.
## Output details contract

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import {
cleanupMockRuntimeFixtures,
@@ -10,7 +10,7 @@ import {
} from "./runtime-internals/test-fixtures.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
afterAll(async () => {
afterEach(async () => {
await cleanupMockRuntimeFixtures();
});
@@ -336,6 +336,12 @@ describe("AcpxRuntime", () => {
expect(runtime.isHealthy()).toBe(false);
});
it("marks runtime healthy when command is available", async () => {
const { runtime } = await createMockRuntimeFixture();
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
});
it("logs ACPX spawn resolution once per command policy", async () => {
const { config } = await createMockRuntimeFixture();
const debugLogs: string[] = [];

View File

@@ -10,7 +10,7 @@ If youre looking for **how to use BlueBubbles as an agent/tool user**, see:
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
- 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`.

View File

@@ -1,6 +1,7 @@
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 = {
@@ -11,6 +12,7 @@ const plugin = {
register(api: OpenClawPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};

View File

@@ -5,7 +5,6 @@ import {
extractToolSend,
jsonResult,
readNumberParam,
readBooleanParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
@@ -53,6 +52,23 @@ 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>([

View File

@@ -1,205 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
export type BlueBubblesDebouncer = {
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
};
export type BlueBubblesDebounceRegistry = {
getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
removeDebouncer: (target: WebhookTarget) => void;
};
/**
* 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,
};
}
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" });
}
export function createBlueBubblesDebounceRegistry(params: {
processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
}): BlueBubblesDebounceRegistry {
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
return {
getOrCreateDebouncer: (target) => {
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 params.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 params.processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(
`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
},
removeDebouncer: (target) => {
targetDebouncers.delete(target);
},
};
}

View File

@@ -535,7 +535,7 @@ describe("BlueBubbles webhook monitor", () => {
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.url = "/bluebubbles-webhook";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
@@ -558,37 +558,6 @@ describe("BlueBubbles webhook monitor", () => {
}
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};

View File

@@ -1,15 +1,20 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
isRequestBodyLimitError,
readRequestBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
requestBodyErrorToText,
resolveSingleWebhookTarget,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
type NormalizedWebhookMessage,
} from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
@@ -19,44 +24,229 @@ 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";
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const registered = registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "bluebubbles",
source: "bluebubbles-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleBlueBubblesWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
/**
* 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
debounceRegistry.removeDebouncer(registered.target);
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 } {
@@ -80,6 +270,36 @@ function parseBlueBubblesWebhookPayload(
}
}
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>)
@@ -128,150 +348,137 @@ export async function handleBlueBubblesWebhookRequest(
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
const requestLifecycle = beginWebhookRequestPipelineOrReject({
req,
res,
allowMethods: ["POST"],
inFlightLimiter: webhookInFlightLimiter,
inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
});
if (!requestLifecycle.ok) {
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
try {
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 target = resolveWebhookTargetWithAuthOrRejectSync({
targets,
res,
isMatch: (target) => {
const token = target.account.config.password?.trim() ?? "";
return safeEqualSecret(guid, token);
},
});
if (!target) {
console.warn(
`[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
);
return true;
}
const body = await readWebhookBodyOrReject({
req,
res,
profile: "post-auth",
invalidBodyMessage: "invalid payload",
});
if (!body.ok) {
console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
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 parsed = parseBlueBubblesWebhookPayload(body.value);
if (!parsed.ok) {
res.statusCode = 400;
res.end(parsed.error);
console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
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}`);
}
const payload = asRecord(parsed.value) ?? {};
const firstTarget = targets[0];
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 received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
`webhook ignored ${eventType || "event"} without reaction`,
);
}
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;
}
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 = debounceRegistry.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;
} finally {
requestLifecycle.release();
}
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(

View File

@@ -1,44 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import type { WebhookTarget } from "./monitor-shared.js";
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
function createTarget(): WebhookTarget {
return {
account: { accountId: "default" } as WebhookTarget["account"],
config: {} as OpenClawConfig,
runtime: {},
core: {} as WebhookTarget["core"],
path: "/bluebubbles-webhook",
};
}
describe("registerBlueBubblesWebhookTarget", () => {
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("registers and unregisters plugin HTTP route at path boundaries", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const unregisterA = registerBlueBubblesWebhookTarget(createTarget());
const unregisterB = registerBlueBubblesWebhookTarget(createTarget());
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]).toEqual(
expect.objectContaining({
pluginId: "bluebubbles",
path: "/bluebubbles-webhook",
source: "bluebubbles-webhook",
}),
);
unregisterA();
expect(registry.httpRoutes).toHaveLength(1);
unregisterB();
expect(registry.httpRoutes).toHaveLength(0);
});
});

View File

@@ -4,9 +4,9 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http route, and prompt guidance hook", () => {
it("registers the tool, http handler, and prompt guidance hook", () => {
const registerTool = vi.fn();
const registerHttpRoute = vi.fn();
const registerHttpHandler = vi.fn();
const on = vi.fn();
plugin.register?.({
@@ -23,7 +23,8 @@ describe("diffs plugin registration", () => {
},
registerTool,
registerHook() {},
registerHttpRoute,
registerHttpHandler,
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
@@ -37,12 +38,7 @@ describe("diffs plugin registration", () => {
});
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
});
@@ -51,7 +47,7 @@ describe("diffs plugin registration", () => {
let registeredTool:
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
| undefined;
let registeredHttpRouteHandler:
let registeredHttpHandler:
| ((
req: IncomingMessage,
res: ReturnType<typeof createMockServerResponse>,
@@ -71,7 +67,6 @@ describe("diffs plugin registration", () => {
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
@@ -90,9 +85,10 @@ describe("diffs plugin registration", () => {
registeredTool = typeof tool === "function" ? undefined : tool;
},
registerHook() {},
registerHttpRoute(params) {
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler;
registerHttpHandler(handler) {
registeredHttpHandler = handler as typeof registeredHttpHandler;
},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
@@ -113,7 +109,7 @@ describe("diffs plugin registration", () => {
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
const handled = await registeredHttpHandler?.(
localReq({
method: "GET",
url: viewerPath,

View File

@@ -25,16 +25,13 @@ const plugin = {
});
api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
handler: createDiffsHttpHandler({
api.registerHttpHandler(
createDiffsHttpHandler({
store,
logger: api.logger,
allowRemoteViewer: security.allowRemoteViewer,
}),
});
);
api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE,
}));

View File

@@ -150,16 +150,6 @@ function buildImageRenderOptions(options: DiffRenderOptions): DiffRenderOptions
};
}
function buildRenderVariants(options: DiffRenderOptions): {
viewerOptions: DiffViewerOptions;
imageOptions: DiffViewerOptions;
} {
return {
viewerOptions: buildDiffOptions(options),
imageOptions: buildDiffOptions(buildImageRenderOptions(options)),
};
}
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
const normalized = value?.trim();
return normalized ? (normalized as SupportedLanguages) : undefined;
@@ -308,35 +298,6 @@ function buildHtmlDocument(params: {
</html>`;
}
type RenderedSection = {
viewer: string;
image: string;
};
function buildRenderedSection(params: {
viewerPrerenderedHtml: string;
imagePrerenderedHtml: string;
payload: Omit<DiffViewerPayload, "prerenderedHTML">;
}): RenderedSection {
return {
viewer: renderDiffCard({
prerenderedHTML: params.viewerPrerenderedHtml,
...params.payload,
}),
image: renderStaticDiffCard(params.imagePrerenderedHtml),
};
}
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
viewerBodyHtml: string;
imageBodyHtml: string;
} {
return {
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
imageBodyHtml: sections.map((section) => section.image).join("\n"),
};
}
async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
@@ -353,35 +314,33 @@ async function renderBeforeAfterDiff(
contents: input.after,
...(lang ? { lang } : {}),
};
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const viewerPayloadOptions = buildDiffOptions(options);
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
const [viewerResult, imageResult] = await Promise.all([
preloadMultiFileDiff({
oldFile,
newFile,
options: viewerOptions,
options: viewerPayloadOptions,
}),
preloadMultiFileDiff({
oldFile,
newFile,
options: imageOptions,
options: imagePayloadOptions,
}),
]);
const section = buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
return {
viewerBodyHtml: renderDiffCard({
prerenderedHTML: viewerResult.prerenderedHTML,
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
options: viewerPayloadOptions,
langs: buildPayloadLanguages({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
},
});
return {
...buildRenderedBodies([section]),
}),
imageBodyHtml: renderStaticDiffCard(imageResult.prerenderedHTML),
fileCount: 1,
};
}
@@ -406,34 +365,36 @@ async function renderPatchDiff(
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
}
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const viewerPayloadOptions = buildDiffOptions(options);
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
const sections = await Promise.all(
files.map(async (fileDiff) => {
const [viewerResult, imageResult] = await Promise.all([
preloadFileDiff({
fileDiff,
options: viewerOptions,
options: viewerPayloadOptions,
}),
preloadFileDiff({
fileDiff,
options: imageOptions,
options: imagePayloadOptions,
}),
]);
return buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
return {
viewer: renderDiffCard({
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
options: viewerPayloadOptions,
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
},
});
}),
image: renderStaticDiffCard(imageResult.prerenderedHTML),
};
}),
);
return {
...buildRenderedBodies(sections),
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
imageBodyHtml: sections.map((section) => section.image).join("\n"),
fileCount: files.length,
};
}

View File

@@ -434,6 +434,7 @@ function createApi(): OpenClawPluginApi {
},
registerTool() {},
registerHook() {},
registerHttpHandler() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},

View File

@@ -187,10 +187,9 @@ export function createDiffsTool(params: {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
}),
text:
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: buildArtifactDetails({
@@ -258,11 +257,10 @@ export function createDiffsTool(params: {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
viewerUrl,
}),
text:
`Diff viewer: ${viewerUrl}\n` +
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: buildArtifactDetails({
@@ -332,17 +330,6 @@ function buildArtifactDetails(params: {
};
}
function buildFileArtifactMessage(params: {
format: DiffOutputFormat;
filePath: string;
viewerUrl?: string;
}): string {
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
lines.push("Use the `message` tool with `path` or `filePath` to send this file.");
return lines.join("\n");
}
async function renderDiffArtifactFile(params: {
screenshotter: DiffScreenshotter;
store: DiffArtifactStore;

View File

@@ -106,9 +106,39 @@ function createToolbarButton(params: {
}
function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): void {
button.style.display = "inline-flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
button.style.width = "24px";
button.style.height = "24px";
button.style.padding = "0";
button.style.margin = "0";
button.style.border = "0";
button.style.borderRadius = "0";
button.style.background = "transparent";
button.style.boxShadow = "none";
button.style.lineHeight = "0";
button.style.cursor = "pointer";
button.style.overflow = "visible";
button.style.flex = "0 0 auto";
button.style.opacity = active ? "0.92" : "0.6";
button.style.color =
viewerState.theme === "dark" ? "rgba(226, 232, 240, 0.74)" : "rgba(15, 23, 42, 0.52)";
button.dataset.active = String(active);
const svg = button.querySelector<SVGElement>("svg");
if (!svg) {
return;
}
svg.style.display = "block";
svg.style.width = "16px";
svg.style.height = "16px";
svg.style.minWidth = "16px";
svg.style.minHeight = "16px";
svg.style.overflow = "visible";
svg.style.flex = "0 0 auto";
svg.style.color = "inherit";
svg.style.fill = "currentColor";
svg.style.pointerEvents = "none";
}
function splitIcon(): string {
@@ -163,6 +193,11 @@ function themeIcon(theme: DiffTheme): string {
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
toolbar.style.display = "inline-flex";
toolbar.style.alignItems = "center";
toolbar.style.gap = "6px";
toolbar.style.marginInlineStart = "6px";
toolbar.style.flex = "0 0 auto";
toolbar.append(
createToolbarButton({

View File

@@ -1,74 +0,0 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveFeishuSendTarget } from "./send-target.js";
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
describe("resolveFeishuSendTarget", () => {
const cfg = {} as ClawdbotConfig;
const client = { id: "client" };
beforeEach(() => {
resolveFeishuAccountMock.mockReset().mockReturnValue({
accountId: "default",
enabled: true,
configured: true,
});
createFeishuClientMock.mockReset().mockReturnValue(client);
});
it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => {
const result = resolveFeishuSendTarget({
cfg,
to: "feishu:group:group_room_alpha",
});
expect(result.receiveId).toBe("group_room_alpha");
expect(result.receiveIdType).toBe("chat_id");
expect(result.client).toBe(client);
});
it("maps dm-prefixed open IDs to open_id", () => {
const result = resolveFeishuSendTarget({
cfg,
to: "lark:dm:ou_123",
});
expect(result.receiveId).toBe("ou_123");
expect(result.receiveIdType).toBe("open_id");
});
it("maps dm-prefixed non-open IDs to user_id", () => {
const result = resolveFeishuSendTarget({
cfg,
to: " feishu:dm:user_123 ",
});
expect(result.receiveId).toBe("user_123");
expect(result.receiveIdType).toBe("user_id");
});
it("throws when target account is not configured", () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "default",
enabled: true,
configured: false,
});
expect(() =>
resolveFeishuSendTarget({
cfg,
to: "feishu:group:oc_123",
}),
).toThrow('Feishu account "default" not configured');
});
});

View File

@@ -8,22 +8,18 @@ export function resolveFeishuSendTarget(params: {
to: string;
accountId?: string;
}) {
const target = params.to.trim();
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(target);
const receiveId = normalizeFeishuTarget(params.to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${params.to}`);
}
// Preserve explicit routing prefixes (chat/group/user/dm/open_id) when present.
// normalizeFeishuTarget strips these prefixes, so infer type from the raw target first.
const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
return {
client,
receiveId,
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
receiveIdType: resolveReceiveIdType(receiveId),
};
}

View File

@@ -13,14 +13,6 @@ describe("resolveReceiveIdType", () => {
it("defaults unprefixed IDs to user_id", () => {
expect(resolveReceiveIdType("u_123")).toBe("user_id");
});
it("treats explicit group targets as chat_id", () => {
expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
});
it("treats dm-prefixed open IDs as open_id", () => {
expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
});
});
describe("normalizeFeishuTarget", () => {
@@ -33,17 +25,9 @@ describe("normalizeFeishuTarget", () => {
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
});
it("strips provider and group prefixes", () => {
expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
});
it("accepts provider-prefixed raw ids", () => {
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
});
it("strips provider and dm prefixes", () => {
expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
});
});
describe("looksLikeFeishuId", () => {
@@ -54,8 +38,4 @@ describe("looksLikeFeishuId", () => {
it("accepts provider-prefixed chat targets", () => {
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
});
it("accepts provider-prefixed group targets", () => {
expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
});
});

View File

@@ -33,15 +33,9 @@ export function normalizeFeishuTarget(raw: string): string | null {
if (lowered.startsWith("chat:")) {
return withoutProvider.slice("chat:".length).trim() || null;
}
if (lowered.startsWith("group:")) {
return withoutProvider.slice("group:".length).trim() || null;
}
if (lowered.startsWith("user:")) {
return withoutProvider.slice("user:".length).trim() || null;
}
if (lowered.startsWith("dm:")) {
return withoutProvider.slice("dm:".length).trim() || null;
}
if (lowered.startsWith("open_id:")) {
return withoutProvider.slice("open_id:".length).trim() || null;
}
@@ -62,17 +56,6 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
const trimmed = id.trim();
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
return "chat_id";
}
if (lowered.startsWith("open_id:")) {
return "open_id";
}
if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
const normalized = trimmed.replace(/^(user|dm):/i, "").trim();
return normalized.startsWith(OPEN_ID_PREFIX) ? "open_id" : "user_id";
}
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
return "chat_id";
}
@@ -87,7 +70,7 @@ export function looksLikeFeishuId(raw: string): boolean {
if (!trimmed) {
return false;
}
if (/^(chat|group|user|dm|open_id):/i.test(trimmed)) {
if (/^(chat|user|open_id):/i.test(trimmed)) {
return true;
}
if (trimmed.startsWith(CHAT_ID_PREFIX)) {

View File

@@ -1,6 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
import { setGoogleChatRuntime } from "./src/runtime.js";
const plugin = {
@@ -11,6 +12,7 @@ const plugin = {
register(api: OpenClawPluginApi) {
setGoogleChatRuntime(api.runtime);
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
api.registerHttpHandler(handleGoogleChatWebhookRequest);
},
};

View File

@@ -48,14 +48,18 @@ describe("googlechatPlugin gateway.startAccount", () => {
statusPatchSink: (next) => patches.push({ ...next }),
}),
);
await new Promise((resolve) => setTimeout(resolve, 20));
let settled = false;
void task.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
expect(unregister).not.toHaveBeenCalled();
abort.abort();

View File

@@ -1,357 +0,0 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveDmGroupAccessWithLists,
resolveMentionGatingWithBypass,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { sendGoogleChatMessage } from "./api.js";
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
function normalizeUserId(raw?: string | null): string {
const trimmed = raw?.trim() ?? "";
if (!trimmed) {
return "";
}
return trimmed.replace(/^users\//i, "").toLowerCase();
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
return allowFrom.some((entry) => {
const normalized = String(entry).trim().toLowerCase();
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}
type GoogleChatGroupEntry = {
requireMention?: boolean;
allow?: boolean;
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
};
function resolveGroupConfig(params: {
groupId: string;
groupName?: string | null;
groups?: Record<string, GoogleChatGroupEntry>;
}) {
const { groupId, groupName, groups } = params;
const entries = groups ?? {};
const keys = Object.keys(entries);
if (keys.length === 0) {
return { entry: undefined, allowlistConfigured: false };
}
const normalizedName = groupName?.trim().toLowerCase();
const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean);
let entry = candidates.map((candidate) => entries[candidate]).find(Boolean);
if (!entry && normalizedName) {
entry = entries[normalizedName];
}
const fallback = entries["*"];
return { entry: entry ?? fallback, allowlistConfigured: true, fallback };
}
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
const hasAnyMention = mentionAnnotations.length > 0;
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
const wasMentioned = mentionAnnotations.some((entry) => {
const userName = entry.userMention?.user?.name;
if (!userName) {
return false;
}
if (botTargets.has(userName)) {
return true;
}
return normalizeUserId(userName) === "app";
});
return { hasAnyMention, wasMentioned };
}
const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void, entries: string[]) {
const deprecated = entries.map((v) => String(v).trim()).filter((v) => /^users\/.+@.+/i.test(v));
if (deprecated.length === 0) {
return;
}
const key = deprecated
.map((v) => v.toLowerCase())
.sort()
.join(",");
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
return;
}
warnedDeprecatedUsersEmailAllowFrom.add(key);
logVerbose(
`Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
);
}
export async function applyGoogleChatInboundAccessPolicy(params: {
account: ResolvedGoogleChatAccount;
config: OpenClawConfig;
core: GoogleChatCoreRuntime;
space: GoogleChatSpace;
message: GoogleChatMessage;
isGroup: boolean;
senderId: string;
senderName: string;
senderEmail?: string;
rawBody: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
logVerbose: (message: string) => void;
}): Promise<
| {
ok: true;
commandAuthorized: boolean | undefined;
effectiveWasMentioned: boolean | undefined;
groupSystemPrompt: string | undefined;
}
| { ok: false }
> {
const {
account,
config,
core,
space,
message,
isGroup,
senderId,
senderName,
senderEmail,
rawBody,
statusSink,
logVerbose,
} = params;
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const spaceId = space.name ?? "";
const pairing = createScopedPairingAccess({
core,
channel: "googlechat",
accountId: account.accountId,
});
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: config.channels?.googlechat !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "googlechat",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space,
log: logVerbose,
});
const groupConfigResolved = resolveGroupConfig({
groupId: spaceId,
groupName: space.displayName ?? null,
groups: account.config.groups ?? undefined,
});
const groupEntry = groupConfigResolved.entry;
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
let effectiveWasMentioned: boolean | undefined;
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`);
return { ok: false };
}
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
if (groupPolicy === "allowlist") {
if (!groupAllowlistConfigured) {
logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`);
return { ok: false };
}
if (!groupAllowed) {
logVerbose(`drop group message (not allowlisted, space=${spaceId})`);
return { ok: false };
}
}
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
logVerbose(`drop group message (space disabled, space=${spaceId})`);
return { ok: false };
}
if (groupUsers.length > 0) {
const normalizedGroupUsers = groupUsers.map((v) => String(v));
warnDeprecatedUsersEmailEntries(logVerbose, normalizedGroupUsers);
const ok = isSenderAllowed(senderId, senderEmail, normalizedGroupUsers, allowNameMatching);
if (!ok) {
logVerbose(`drop group message (sender not allowed, ${senderId})`);
return { ok: false };
}
}
}
const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
const normalizedGroupUsers = groupUsers.map((v) => String(v));
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupUsers.length > 0
? "allowlist"
: "open";
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
? await pairing.readAllowFromStore().catch(() => [])
: [];
const access = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: normalizedGroupUsers,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
});
const effectiveAllowFrom = access.effectiveAllowFrom;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom);
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(
senderId,
senderEmail,
commandAllowFrom,
allowNameMatching,
);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
if (isGroup) {
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
const annotations = message.annotations ?? [];
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "googlechat",
});
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned: mentionInfo.wasMentioned,
implicitMention: false,
hasAnyMention: mentionInfo.hasAnyMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
});
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (mentionGate.shouldSkip) {
logVerbose(`drop group message (mention required, space=${spaceId})`);
return { ok: false };
}
}
if (isGroup && access.decision !== "allow") {
logVerbose(
`drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`,
);
return { ok: false };
}
if (!isGroup) {
if (account.config.dm?.enabled === false) {
logVerbose(`Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
return { ok: false };
}
if (access.decision !== "allow") {
if (access.decision === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName || undefined, email: senderEmail },
});
if (created) {
logVerbose(`googlechat pairing request sender=${senderId}`);
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: core.channel.pairing.buildPairingReply({
channel: "googlechat",
idLine: `Your Google Chat user id: ${senderId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`);
}
return { ok: false };
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(`googlechat: drop control command from ${senderId}`);
return { ok: false };
}
return {
ok: true,
commandAuthorized,
effectiveWasMentioned,
groupSystemPrompt: groupEntry?.systemPrompt?.trim() || undefined,
};
}

View File

@@ -1,33 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import type { GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
export type GoogleChatRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type GoogleChatMonitorOptions = {
account: ResolvedGoogleChatAccount;
config: OpenClawConfig;
runtime: GoogleChatRuntimeEnv;
abortSignal: AbortSignal;
webhookPath?: string;
webhookUrl?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
export type WebhookTarget = {
account: ResolvedGoogleChatAccount;
config: OpenClawConfig;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
path: string;
audienceType?: GoogleChatAudienceType;
audience?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
mediaMaxMb: number;
};

View File

@@ -1,216 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
beginWebhookRequestPipelineOrReject,
readJsonWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrReject,
resolveWebhookTargets,
type WebhookInFlightLimiter,
} from "openclaw/plugin-sdk";
import { verifyGoogleChatRequest } from "./auth.js";
import type { WebhookTarget } from "./monitor-types.js";
import type {
GoogleChatEvent,
GoogleChatMessage,
GoogleChatSpace,
GoogleChatUser,
} from "./types.js";
function extractBearerToken(header: unknown): string {
const authHeader = Array.isArray(header) ? String(header[0] ?? "") : String(header ?? "");
return authHeader.toLowerCase().startsWith("bearer ")
? authHeader.slice("bearer ".length).trim()
: "";
}
type ParsedGoogleChatInboundPayload =
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
| { ok: false };
function parseGoogleChatInboundPayload(
raw: unknown,
res: ServerResponse,
): ParsedGoogleChatInboundPayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
res.statusCode = 400;
res.end("invalid payload");
return { ok: false };
}
let eventPayload = raw;
let addOnBearerToken = "";
// Transform Google Workspace Add-on format to standard Chat API format.
const rawObj = raw as {
commonEventObject?: { hostApp?: string };
chat?: {
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
user?: GoogleChatUser;
eventTime?: string;
};
authorizationEventObject?: { systemIdToken?: string };
};
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
const chat = rawObj.chat;
const messagePayload = chat.messagePayload;
eventPayload = {
type: "MESSAGE",
space: messagePayload?.space,
message: messagePayload?.message,
user: chat.user,
eventTime: chat.eventTime,
};
addOnBearerToken = String(rawObj.authorizationEventObject?.systemIdToken ?? "").trim();
}
const event = eventPayload as GoogleChatEvent;
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
if (typeof eventType !== "string") {
res.statusCode = 400;
res.end("invalid payload");
return { ok: false };
}
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
res.statusCode = 400;
res.end("invalid payload");
return { ok: false };
}
if (eventType === "MESSAGE") {
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
res.statusCode = 400;
res.end("invalid payload");
return { ok: false };
}
}
return { ok: true, event, addOnBearerToken };
}
export function createGoogleChatWebhookRequestHandler(params: {
webhookTargets: Map<string, WebhookTarget[]>;
webhookInFlightLimiter: WebhookInFlightLimiter;
processEvent: (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const resolved = resolveWebhookTargets(req, params.webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
const requestLifecycle = beginWebhookRequestPipelineOrReject({
req,
res,
allowMethods: ["POST"],
requireJsonContentType: true,
inFlightLimiter: params.webhookInFlightLimiter,
inFlightKey: `${path}:${req.socket?.remoteAddress ?? "unknown"}`,
});
if (!requestLifecycle.ok) {
return true;
}
try {
const headerBearer = extractBearerToken(req.headers.authorization);
let selectedTarget: WebhookTarget | null = null;
let parsedEvent: GoogleChatEvent | null = null;
if (headerBearer) {
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
targets,
res,
isMatch: async (target) => {
const verification = await verifyGoogleChatRequest({
bearer: headerBearer,
audienceType: target.audienceType,
audience: target.audience,
});
return verification.ok;
},
});
if (!selectedTarget) {
return true;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "post-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
return true;
}
parsedEvent = parsed.event;
} else {
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "pre-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
return true;
}
parsedEvent = parsed.event;
if (!parsed.addOnBearerToken) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
targets,
res,
isMatch: async (target) => {
const verification = await verifyGoogleChatRequest({
bearer: parsed.addOnBearerToken,
audienceType: target.audienceType,
audience: target.audience,
});
return verification.ok;
},
});
if (!selectedTarget) {
return true;
}
}
if (!selectedTarget || !parsedEvent) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
const dispatchTarget = selectedTarget;
dispatchTarget.statusSink?.({ lastInboundAt: Date.now() });
params.processEvent(parsedEvent, dispatchTarget).catch((err) => {
dispatchTarget.runtime.error?.(
`[${dispatchTarget.account.accountId}] Google Chat webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
} finally {
requestLifecycle.release();
}
};
}

View File

@@ -1,11 +1,23 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createWebhookInFlightLimiter,
GROUP_POLICY_BLOCKED_LABEL,
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
registerWebhookTargetWithPluginRoute,
resolveInboundRouteEnvelopeBuilderWithRuntime,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSingleWebhookTargetAsync,
resolveWebhookPath,
resolveWebhookTargets,
warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk";
import { type ResolvedGoogleChatAccount } from "./accounts.js";
import {
@@ -14,29 +26,47 @@ import {
sendGoogleChatMessage,
updateGoogleChatMessage,
} from "./api.js";
import { type GoogleChatAudienceType } from "./auth.js";
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
import type {
GoogleChatCoreRuntime,
GoogleChatMonitorOptions,
GoogleChatRuntimeEnv,
WebhookTarget,
} from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
export { isSenderAllowed };
import type {
GoogleChatAnnotation,
GoogleChatAttachment,
GoogleChatEvent,
GoogleChatSpace,
GoogleChatMessage,
GoogleChatUser,
} from "./types.js";
export type GoogleChatRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type GoogleChatMonitorOptions = {
account: ResolvedGoogleChatAccount;
config: OpenClawConfig;
runtime: GoogleChatRuntimeEnv;
abortSignal: AbortSignal;
webhookPath?: string;
webhookUrl?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
type WebhookTarget = {
account: ResolvedGoogleChatAccount;
config: OpenClawConfig;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
path: string;
audienceType?: GoogleChatAudienceType;
audience?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
mediaMaxMb: number;
};
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
webhookTargets,
webhookInFlightLimiter,
processEvent: async (event, target) => {
await processGoogleChatEvent(event, target);
},
});
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
@@ -44,27 +74,33 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
}
}
const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
function warnDeprecatedUsersEmailEntries(
core: GoogleChatCoreRuntime,
runtime: GoogleChatRuntimeEnv,
entries: string[],
) {
const deprecated = entries.map((v) => String(v).trim()).filter((v) => /^users\/.+@.+/i.test(v));
if (deprecated.length === 0) {
return;
}
const key = deprecated
.map((v) => v.toLowerCase())
.sort()
.join(",");
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
return;
}
warnedDeprecatedUsersEmailAllowFrom.add(key);
logVerbose(
core,
runtime,
`Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
);
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "googlechat",
source: "googlechat-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleGoogleChatWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
}).unregister;
return registerWebhookTarget(webhookTargets, target).unregister;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
@@ -86,7 +122,136 @@ export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await googleChatWebhookRequestHandler(req, res);
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const authHeader = String(req.headers.authorization ?? "");
const bearer = authHeader.toLowerCase().startsWith("bearer ")
? authHeader.slice("bearer ".length)
: "";
const body = await readJsonBodyWithLimit(req, {
maxBytes: 1024 * 1024,
timeoutMs: 30_000,
emptyObjectOnEmpty: false,
});
if (!body.ok) {
res.statusCode =
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
res.end(
body.code === "REQUEST_BODY_TIMEOUT"
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
: body.error,
);
return true;
}
let raw = body.value;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
// Transform Google Workspace Add-on format to standard Chat API format
const rawObj = raw as {
commonEventObject?: { hostApp?: string };
chat?: {
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
user?: GoogleChatUser;
eventTime?: string;
};
authorizationEventObject?: { systemIdToken?: string };
};
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
const chat = rawObj.chat;
const messagePayload = chat.messagePayload;
raw = {
type: "MESSAGE",
space: messagePayload?.space,
message: messagePayload?.message,
user: chat.user,
eventTime: chat.eventTime,
};
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
if (!bearer && systemIdToken) {
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
}
}
const event = raw as GoogleChatEvent;
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
if (typeof eventType !== "string") {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
if (eventType === "MESSAGE") {
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
}
// Re-extract bearer in case it was updated from Add-on format
const authHeaderNow = String(req.headers.authorization ?? "");
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
? authHeaderNow.slice("bearer ".length)
: bearer;
const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
const audienceType = target.audienceType;
const audience = target.audience;
const verification = await verifyGoogleChatRequest({
bearer: effectiveBearer,
audienceType,
audience,
});
return verification.ok;
});
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
return true;
}
const selected = matchedTarget.target;
selected.statusSink?.({ lastInboundAt: Date.now() });
processGoogleChatEvent(event, selected).catch((err) => {
selected?.runtime.error?.(
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
@@ -109,6 +274,98 @@ async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTar
});
}
function normalizeUserId(raw?: string | null): string {
const trimmed = raw?.trim() ?? "";
if (!trimmed) {
return "";
}
return trimmed.replace(/^users\//i, "").toLowerCase();
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
return allowFrom.some((entry) => {
const normalized = String(entry).trim().toLowerCase();
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}
function resolveGroupConfig(params: {
groupId: string;
groupName?: string | null;
groups?: Record<
string,
{
requireMention?: boolean;
allow?: boolean;
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
}
>;
}) {
const { groupId, groupName, groups } = params;
const entries = groups ?? {};
const keys = Object.keys(entries);
if (keys.length === 0) {
return { entry: undefined, allowlistConfigured: false };
}
const normalizedName = groupName?.trim().toLowerCase();
const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean);
let entry = candidates.map((candidate) => entries[candidate]).find(Boolean);
if (!entry && normalizedName) {
entry = entries[normalizedName];
}
const fallback = entries["*"];
return { entry: entry ?? fallback, allowlistConfigured: true, fallback };
}
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
const hasAnyMention = mentionAnnotations.length > 0;
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
const wasMentioned = mentionAnnotations.some((entry) => {
const userName = entry.userMention?.user?.name;
if (!userName) {
return false;
}
if (botTargets.has(userName)) {
return true;
}
return normalizeUserId(userName) === "app";
});
return { hasAnyMention, wasMentioned };
}
/**
* Resolve bot display name with fallback chain:
* 1. Account config name
@@ -141,6 +398,11 @@ async function processMessageWithPipeline(params: {
mediaMaxMb: number;
}): Promise<void> {
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
const pairing = createScopedPairingAccess({
core,
channel: "googlechat",
accountId: account.accountId,
});
const space = event.space;
const message = event.message;
if (!space || !message) {
@@ -157,6 +419,7 @@ async function processMessageWithPipeline(params: {
const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? "";
const senderEmail = sender?.email ?? undefined;
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const allowBots = account.config.allowBots === true;
if (!allowBots) {
@@ -178,35 +441,220 @@ async function processMessageWithPipeline(params: {
return;
}
const access = await applyGoogleChatInboundAccessPolicy({
account,
config,
core,
space,
message,
isGroup,
senderId,
senderName,
senderEmail,
rawBody,
statusSink,
logVerbose: (message) => logVerbose(core, runtime, message),
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: config.channels?.googlechat !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "googlechat",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space,
log: (message) => logVerbose(core, runtime, message),
});
if (!access.ok) {
const groupConfigResolved = resolveGroupConfig({
groupId: spaceId,
groupName: space.displayName ?? null,
groups: account.config.groups ?? undefined,
});
const groupEntry = groupConfigResolved.entry;
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
let effectiveWasMentioned: boolean | undefined;
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`);
return;
}
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
if (groupPolicy === "allowlist") {
if (!groupAllowlistConfigured) {
logVerbose(
core,
runtime,
`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`,
);
return;
}
if (!groupAllowed) {
logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`);
return;
}
}
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`);
return;
}
if (groupUsers.length > 0) {
warnDeprecatedUsersEmailEntries(
core,
runtime,
groupUsers.map((v) => String(v)),
);
const ok = isSenderAllowed(
senderId,
senderEmail,
groupUsers.map((v) => String(v)),
allowNameMatching,
);
if (!ok) {
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
return;
}
}
}
const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
const normalizedGroupUsers = groupUsers.map((v) => String(v));
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupUsers.length > 0
? "allowlist"
: "open";
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
? await pairing.readAllowFromStore().catch(() => [])
: [];
const access = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: normalizedGroupUsers,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
});
const effectiveAllowFrom = access.effectiveAllowFrom;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(
senderId,
senderEmail,
commandAllowFrom,
allowNameMatching,
);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
if (isGroup) {
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
const annotations = message.annotations ?? [];
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "googlechat",
});
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned: mentionInfo.wasMentioned,
implicitMention: false,
hasAnyMention: mentionInfo.hasAnyMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
});
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (mentionGate.shouldSkip) {
logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`);
return;
}
}
if (isGroup && access.decision !== "allow") {
logVerbose(
core,
runtime,
`drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`,
);
return;
}
const { commandAuthorized, effectiveWasMentioned, groupSystemPrompt } = access;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
if (!isGroup) {
if (account.config.dm?.enabled === false) {
logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (access.decision !== "allow") {
if (access.decision === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName || undefined, email: senderEmail },
});
if (created) {
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: core.channel.pairing.buildPairingReply({
channel: "googlechat",
idLine: `Your Google Chat user id: ${senderId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`);
return;
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "googlechat",
accountId: account.accountId,
peer: {
kind: isGroup ? ("group" as const) : ("direct" as const),
kind: isGroup ? "group" : "direct",
id: spaceId,
},
runtime: core.channel,
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
let mediaPath: string | undefined;
@@ -230,6 +678,8 @@ async function processMessageWithPipeline(params: {
body: rawBody,
});
const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
@@ -508,7 +958,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
const audience = options.account.config.audience?.trim();
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
const unregisterTarget = registerGoogleChatWebhookTarget({
const unregister = registerGoogleChatWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
@@ -520,9 +970,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
mediaMaxMb,
});
return () => {
unregisterTarget();
};
return unregister;
}
export async function startGoogleChatMonitor(

View File

@@ -1,9 +1,7 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { verifyGoogleChatRequest } from "./auth.js";
@@ -21,7 +19,6 @@ function createWebhookRequest(params: {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
};
req.method = "POST";
req.url = params.path ?? "/googlechat";
@@ -30,50 +27,21 @@ function createWebhookRequest(params: {
"content-type": "application/json",
};
req.destroyed = false;
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = () => {
req.destroyed = true;
return req;
};
const originalOn = req.on.bind(req);
let bodyScheduled = false;
req.on = ((event: string, listener: (...args: unknown[]) => void) => {
const result = originalOn(event, listener);
if (!bodyScheduled && event === "data") {
bodyScheduled = true;
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
}
});
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
}
return result;
}) as IncomingMessage["on"];
});
return req;
}
function createHeaderOnlyWebhookRequest(params: {
authorization?: string;
path?: string;
}): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = params.path ?? "/googlechat";
req.headers = {
authorization: params.authorization ?? "",
"content-type": "application/json",
};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
return req;
}
const baseAccount = (accountId: string) =>
({
accountId,
@@ -118,47 +86,6 @@ function registerTwoTargets() {
}
describe("Google Chat webhook routing", () => {
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("registers and unregisters plugin HTTP route at path boundaries", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const unregisterA = registerGoogleChatWebhookTarget({
account: baseAccount("A"),
config: {} as OpenClawConfig,
runtime: {},
core: {} as PluginRuntime,
path: "/googlechat",
statusSink: vi.fn(),
mediaMaxMb: 5,
});
const unregisterB = registerGoogleChatWebhookTarget({
account: baseAccount("B"),
config: {} as OpenClawConfig,
runtime: {},
core: {} as PluginRuntime,
path: "/googlechat",
statusSink: vi.fn(),
mediaMaxMb: 5,
});
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]).toEqual(
expect.objectContaining({
pluginId: "googlechat",
path: "/googlechat",
source: "googlechat-webhook",
}),
);
unregisterA();
expect(registry.httpRoutes).toHaveLength(1);
unregisterB();
expect(registry.httpRoutes).toHaveLength(0);
});
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
@@ -208,59 +135,4 @@ describe("Google Chat webhook routing", () => {
unregister();
}
});
it("rejects invalid bearer before attempting to read the body", async () => {
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
const { unregister } = registerTwoTargets();
try {
const req = createHeaderOnlyWebhookRequest({
authorization: "Bearer invalid-token",
});
const onSpy = vi.spyOn(req, "on");
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
} finally {
unregister();
}
});
it("supports add-on requests that provide systemIdToken in the body", async () => {
vi.mocked(verifyGoogleChatRequest)
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
.mockResolvedValueOnce({ ok: true });
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
payload: {
commonEventObject: { hostApp: "CHAT" },
authorizationEventObject: { systemIdToken: "addon-token" },
chat: {
eventTime: "2026-03-02T00:00:00.000Z",
user: { name: "users/12345", displayName: "Test User" },
messagePayload: {
space: { name: "spaces/AAA" },
message: { text: "Hello from add-on" },
},
},
},
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregister();
}
});
});

View File

@@ -115,15 +115,16 @@ describe("linePlugin gateway.startAccount", () => {
}),
);
await vi.waitFor(() => {
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
// Allow async internals (probeLineBot await) to flush
await new Promise((r) => setTimeout(r, 20));
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
abort.abort();
await task;

View File

@@ -38,6 +38,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
runtime: { version: "test" } as any,
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerTool() {},
registerHttpHandler() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},

View File

@@ -1,4 +1,4 @@
import { resolveAllowlistMatchByCandidates, type AllowlistMatch } from "openclaw/plugin-sdk";
import type { AllowlistMatch } from "openclaw/plugin-sdk";
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
@@ -65,7 +65,6 @@ export function normalizeMatrixAllowList(list?: Array<string | number>) {
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
>;
type MatrixAllowListSource = Exclude<MatrixAllowListMatch["matchSource"], undefined>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
@@ -79,12 +78,24 @@ export function resolveMatrixAllowListMatch(params: {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const candidates: Array<{ value?: string; source: MatrixAllowListSource }> = [
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
];
return resolveAllowlistMatchByCandidates({ allowList, candidates });
for (const candidate of candidates) {
if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {

View File

@@ -24,10 +24,6 @@ vi.mock("@vector-im/matrix-bot-sdk", () => ({
RustSdkCryptoStorageProvider: vi.fn(),
}));
vi.mock("./send-queue.js", () => ({
enqueueSend: async <T>(_roomId: string, fn: () => Promise<T>) => await fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",

View File

@@ -11,7 +11,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
@@ -135,89 +135,6 @@ describe("memory plugin e2e", () => {
expect(config?.autoRecall).toBe(true);
});
test("passes configured dimensions to OpenAI embeddings API", async () => {
const embeddingsCreate = vi.fn(async () => ({
data: [{ embedding: [0.1, 0.2, 0.3] }],
}));
const toArray = vi.fn(async () => []);
const limit = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("@lancedb/lancedb", () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
vectorSearch,
countRows: vi.fn(async () => 0),
add: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
})),
})),
}));
try {
const { default: memoryPlugin } = await import("./index.js");
// oxlint-disable-next-line typescript/no-explicit-any
const registeredTools: any[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
dimensions: 1024,
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
// oxlint-disable-next-line typescript/no-explicit-any
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
// oxlint-disable-next-line typescript/no-explicit-any
registerCli: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
registerService: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
on: vi.fn(),
resolvePath: (p: string) => p,
};
// oxlint-disable-next-line typescript/no-explicit-any
memoryPlugin.register(mockApi as any);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
expect(recallTool).toBeDefined();
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
expect(embeddingsCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello dimensions",
dimensions: 1024,
});
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.resetModules();
}
});
test("shouldCapture applies real capture rules", async () => {
const { shouldCapture } = await import("./index.js");

View File

@@ -167,20 +167,15 @@ class Embeddings {
apiKey: string,
private model: string,
baseUrl?: string,
private dimensions?: number,
) {
this.client = new OpenAI({ apiKey, baseURL: baseUrl });
}
async embed(text: string): Promise<number[]> {
const params: { model: string; input: string; dimensions?: number } = {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
};
if (this.dimensions) {
params.dimensions = this.dimensions;
}
const response = await this.client.embeddings.create(params);
});
return response.data[0].embedding;
}
}
@@ -303,7 +298,7 @@ const memoryPlugin = {
const vectorDim = dimensions ?? vectorDimsForModel(model);
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(apiKey, model, baseUrl, dimensions);
const embeddings = new Embeddings(apiKey, model, baseUrl);
api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`);

View File

@@ -1,8 +1,4 @@
import { readFileSync } from "node:fs";
import {
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
resolveAccountWithDefaultFallback,
} from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -32,10 +28,18 @@ export type ResolvedNextcloudTalkAccount = {
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
return listConfiguredAccountIdsFromSection({
accounts: cfg.channels?.["nextcloud-talk"]?.accounts as Record<string, unknown> | undefined,
normalizeAccountId,
});
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (!key) {
continue;
}
ids.add(normalizeAccountId(key));
}
return [...ids];
}
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
@@ -130,6 +134,7 @@ export function resolveNextcloudTalkAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedNextcloudTalkAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
const resolve = (accountId: string) => {
@@ -157,13 +162,24 @@ export function resolveNextcloudTalkAccount(params: {
} satisfies ResolvedNextcloudTalkAccount;
};
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: resolve,
hasCredential: (account) => account.secretSource !== "none",
resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg),
});
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) {
return primary;
}
if (primary.secretSource !== "none") {
return primary;
}
const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg);
if (fallbackId === primary.accountId) {
return primary;
}
const fallback = resolve(fallbackId);
if (fallback.secretSource === "none") {
return primary;
}
return fallback;
}
export function listEnabledNextcloudTalkAccounts(cfg: CoreConfig): ResolvedNextcloudTalkAccount[] {

View File

@@ -48,14 +48,17 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => {
abortSignal: abort.signal,
}),
);
await new Promise((resolve) => setTimeout(resolve, 20));
let settled = false;
void task.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
expect(stop).not.toHaveBeenCalled();
abort.abort();

View File

@@ -61,12 +61,7 @@ const plugin = {
log: api.logger,
});
api.registerHttpRoute({
path: "/api/channels/nostr",
auth: "gateway",
match: "prefix",
handler: httpHandler,
});
api.registerHttpHandler(httpHandler);
},
};

View File

@@ -1,109 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "../../src/plugins/types.js";
import registerPhoneControl from "./index.js";
function createApi(params: {
stateDir: string;
getConfig: () => Record<string, unknown>;
writeConfig: (next: Record<string, unknown>) => Promise<void>;
registerCommand: (command: OpenClawPluginCommandDefinition) => void;
}): OpenClawPluginApi {
return {
id: "phone-control",
name: "phone-control",
source: "test",
config: {},
pluginConfig: {},
runtime: {
state: {
resolveStateDir: () => params.stateDir,
},
config: {
loadConfig: () => params.getConfig(),
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
},
} as OpenClawPluginApi["runtime"],
logger: { info() {}, warn() {}, error() {} },
registerTool() {},
registerHook() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand: params.registerCommand,
resolvePath(input: string) {
return input;
},
on() {},
};
}
function createCommandContext(args: string): PluginCommandContext {
return {
channel: "test",
isAuthorizedSender: true,
commandBody: `/phone ${args}`,
args,
config: {},
};
}
describe("phone-control plugin", () => {
it("arms sms.send as part of the writes group", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-"));
try {
let config: Record<string, unknown> = {
gateway: {
nodes: {
allowCommands: [],
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
},
},
};
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
config = next;
});
let command: OpenClawPluginCommandDefinition | undefined;
registerPhoneControl(
createApi({
stateDir,
getConfig: () => config,
writeConfig: writeConfigFile,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
expect(command?.name).toBe("phone");
const res = await command?.handler(createCommandContext("arm writes 30s"));
const text = String(res?.text ?? "");
const nodes = (
config.gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }
).nodes;
expect(writeConfigFile).toHaveBeenCalledTimes(1);
expect(nodes?.allowCommands).toEqual([
"calendar.add",
"contacts.add",
"reminders.add",
"sms.send",
]);
expect(nodes?.denyCommands).toEqual([]);
expect(text).toContain("sms.send");
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});

View File

@@ -29,7 +29,7 @@ const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const;
const GROUP_COMMANDS: Record<Exclude<ArmGroup, "all">, string[]> = {
camera: ["camera.snap", "camera.clip"],
screen: ["screen.record"],
writes: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
writes: ["calendar.add", "contacts.add", "reminders.add"],
};
function uniqSorted(values: string[]): string[] {

View File

@@ -295,8 +295,6 @@ export function createSynologyChatPlugin() {
const unregister = registerPluginHttpRoute({
path: account.webhookPath,
auth: "plugin",
replaceExisting: true,
pluginId: CHANNEL_ID,
accountId: account.accountId,
log: (msg: string) => log?.info?.(msg),

View File

@@ -182,47 +182,4 @@ describe("telegramPlugin duplicate token guard", () => {
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;
const alertsAccount = telegramPlugin.config.resolveAccount(cfg, "alerts");
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
});
it("does not crash startup when a resolved account token is undefined", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: false }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const cfg = createCfg();
const ctx = createStartAccountCtx({
cfg,
accountId: "ops",
runtime: createRuntimeEnv(),
});
ctx.account = {
...ctx.account,
token: undefined as unknown as string,
} as ResolvedTelegramAccount;
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
expect(monitorTelegramProvider).toHaveBeenCalledWith(
expect.objectContaining({
token: "",
}),
);
});
});

View File

@@ -44,7 +44,7 @@ function findTelegramTokenOwnerAccountId(params: {
const tokenOwners = new Map<string, string>();
for (const id of listTelegramAccountIds(params.cfg)) {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id });
const token = (account.token ?? "").trim();
const token = account.token.trim();
if (!token) {
continue;
}
@@ -465,7 +465,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
ctx.log?.error?.(`[${account.accountId}] ${reason}`);
throw new Error(reason);
}
const token = (account.token ?? "").trim();
const token = account.token.trim();
let telegramBotLabel = "";
try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(

View File

@@ -134,45 +134,6 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
});
});
describe("VoiceCallWebhookServer path matching", () => {
it("rejects lookalike webhook paths that only match by prefix", async () => {
const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "verified:req:prefix" }));
const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
const strictProvider: VoiceCallProvider = {
...provider,
verifyWebhook,
parseWebhookEvent,
};
const { manager } = createManager([]);
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
const server = new VoiceCallWebhookServer(config, manager, strictProvider);
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
requestUrl.pathname = "/voice/webhook-evil";
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
expect(response.status).toBe(404);
expect(verifyWebhook).not.toHaveBeenCalled();
expect(parseWebhookEvent).not.toHaveBeenCalled();
} finally {
await server.stop();
}
});
});
describe("VoiceCallWebhookServer replay handling", () => {
it("acknowledges replayed webhook requests and skips event side effects", async () => {
const replayProvider: VoiceCallProvider = {

View File

@@ -255,25 +255,6 @@ export class VoiceCallWebhookServer {
}
}
private normalizeWebhookPathForMatch(pathname: string): string {
const trimmed = pathname.trim();
if (!trimmed) {
return "/";
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (prefixed === "/") {
return prefixed;
}
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
}
private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean {
return (
this.normalizeWebhookPathForMatch(requestPath) ===
this.normalizeWebhookPathForMatch(configuredPath)
);
}
/**
* Handle incoming HTTP request.
*/
@@ -285,7 +266,7 @@ export class VoiceCallWebhookServer {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
// Check path
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
if (!url.pathname.startsWith(webhookPath)) {
res.statusCode = 404;
res.end("Not Found");
return;

View File

@@ -1,6 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js";
import { setZaloRuntime } from "./src/runtime.js";
const plugin = {
@@ -11,6 +12,7 @@ const plugin = {
register(api: OpenClawPluginApi) {
setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
api.registerHttpHandler(handleZaloWebhookRequest);
},
};

View File

@@ -1,102 +0,0 @@
import type { ReplyPayload } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { zaloPlugin } from "./channel.js";
vi.mock("./send.js", () => ({
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
}));
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "123456789",
text: "",
payload,
};
}
describe("zaloPlugin outbound sendPayload", () => {
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
beforeEach(async () => {
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalo);
mockedSend.mockClear();
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
});
it("text-only delegates to sendText", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
});
it("single media delegates to sendMedia", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
const result = await zaloPlugin.outbound!.sendPayload!(
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
);
expect(mockedSend).toHaveBeenCalledWith(
"123456789",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "zalo" });
});
it("multi-media iterates URLs with caption on first", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
.mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
const result = await zaloPlugin.outbound!.sendPayload!(
baseCtx({
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
}),
);
expect(mockedSend).toHaveBeenCalledTimes(2);
expect(mockedSend).toHaveBeenNthCalledWith(
1,
"123456789",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(mockedSend).toHaveBeenNthCalledWith(
2,
"123456789",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
});
it("empty payload returns no-op", async () => {
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
expect(mockedSend).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "zalo", messageId: "" });
});
it("chunking splits long text", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
.mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
const longText = "a".repeat(3000);
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of mockedSend.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
}
expect(result).toMatchObject({ channel: "zalo" });
});
});

View File

@@ -302,40 +302,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalo", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zaloPlugin.outbound!.sendMedia!({
...ctx,
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await zaloPlugin.outbound!.sendMedia!({
...ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
const outbound = zaloPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,

View File

@@ -1,13 +1,12 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
resolveSenderCommandAuthorization,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
sendMediaWithLeadingCaption,
resolveWebhookPath,
warnMissingProviderGroupPolicyFallbackOnce,
@@ -75,24 +74,7 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerZaloWebhookTargetInternal(target, {
route: {
auth: "plugin",
match: "exact",
pluginId: "zalo",
source: "zalo-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
});
return registerZaloWebhookTargetInternal(target);
}
export {
@@ -385,76 +367,91 @@ async function processMessageWithPipeline(params: {
}
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const { senderAllowedForCommands, commandAuthorized } =
await resolveSenderCommandAuthorizationWithRuntime({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: pairing.readAllowFromStore,
runtime: core.channel.commands,
});
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
senderAllowedForCommands,
configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: pairing.readAllowFromStore,
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (directDmOutcome === "disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (directDmOutcome === "unauthorized") {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName ?? undefined },
});
if (created) {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: core.channel.pairing.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
},
fetcher,
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName ?? undefined },
});
if (created) {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: core.channel.pairing.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
},
fetcher,
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
core,
runtime,
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
return;
}
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? ("group" as const) : ("direct" as const),
kind: isGroup ? "group" : "direct",
id: chatId,
},
runtime: core.channel,
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
if (

View File

@@ -2,8 +2,6 @@ import { createServer, type RequestListener } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import {
clearZaloWebhookSecurityStateForTest,
getZaloWebhookRateLimitStateSizeForTest,
@@ -49,16 +47,13 @@ function registerTarget(params: {
path: string;
secret?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
account?: ResolvedZaloAccount;
config?: OpenClawConfig;
core?: PluginRuntime;
}): () => void {
return registerZaloWebhookTarget({
token: "tok",
account: params.account ?? DEFAULT_ACCOUNT,
config: params.config ?? ({} as OpenClawConfig),
account: DEFAULT_ACCOUNT,
config: {} as OpenClawConfig,
runtime: {},
core: params.core ?? ({} as PluginRuntime),
core: {} as PluginRuntime,
secret: params.secret ?? "secret",
path: params.path,
mediaMaxMb: 5,
@@ -66,59 +61,9 @@ function registerTarget(params: {
});
}
function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
core: PluginRuntime;
readAllowFromStore: ReturnType<typeof vi.fn>;
upsertPairingRequest: ReturnType<typeof vi.fn>;
} {
const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
const upsertPairingRequest = vi
.fn()
.mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
const core = {
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
},
} as unknown as PluginRuntime;
return { core, readAllowFromStore, upsertPairingRequest };
}
describe("handleZaloWebhookRequest", () => {
afterEach(() => {
clearZaloWebhookSecurityStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("registers and unregisters plugin HTTP route at path boundaries", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const unregisterA = registerTarget({ path: "/hook" });
const unregisterB = registerTarget({ path: "/hook" });
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]).toEqual(
expect.objectContaining({
pluginId: "zalo",
path: "/hook",
source: "zalo-webhook",
}),
);
unregisterA();
expect(registry.httpRoutes).toHaveLength(1);
unregisterB();
expect(registry.httpRoutes).toHaveLength(0);
});
it("returns 400 for non-object payloads", async () => {
@@ -261,6 +206,7 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
});
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
const unregister = registerTarget({ path: "/hook-query-status" });
@@ -313,65 +259,4 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
});
it("scopes DM pairing store reads and writes to accountId", async () => {
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
pairingCreated: false,
});
const account: ResolvedZaloAccount = {
...DEFAULT_ACCOUNT,
accountId: "work",
config: {
dmPolicy: "pairing",
allowFrom: [],
},
};
const unregister = registerTarget({
path: "/hook-account-scope",
account,
core,
});
const payload = {
event_name: "message.text.received",
message: {
from: { id: "123", name: "Attacker" },
chat: { id: "dm-work", chat_type: "PRIVATE" },
message_id: "msg-work-1",
date: Math.floor(Date.now() / 1000),
text: "hello",
},
};
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-account-scope`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
});
} finally {
unregister();
}
expect(readAllowFromStore).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
accountId: "work",
}),
);
expect(upsertPairingRequest).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
id: "123",
accountId: "work",
}),
);
});
});

View File

@@ -7,9 +7,6 @@ import {
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
applyBasicWebhookRequestGuards,
registerWebhookTargetWithPluginRoute,
type RegisterWebhookTargetOptions,
type RegisterWebhookPluginRouteOptions,
registerWebhookTarget,
resolveSingleWebhookTarget,
resolveWebhookTargets,
@@ -109,24 +106,8 @@ function recordWebhookStatus(
});
}
export function registerZaloWebhookTarget(
target: ZaloWebhookTarget,
opts?: {
route?: RegisterWebhookPluginRouteOptions;
} & Pick<
RegisterWebhookTargetOptions<ZaloWebhookTarget>,
"onFirstPathTarget" | "onLastPathTargetRemoved"
>,
): () => void {
if (opts?.route) {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: opts.route,
onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
}).unregister;
}
return registerWebhookTarget(webhookTargets, target, opts).unregister;
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerWebhookTarget(webhookTargets, target).unregister;
}
export async function handleZaloWebhookRequest(

View File

@@ -4,11 +4,7 @@
### Changes
- Rebuilt the plugin to use native `zca-js` integration inside OpenClaw (no external `zca` CLI runtime dependency).
### Breaking
- **BREAKING:** Removed the old external CLI-based backend (`zca`/`openzca`/`zca-cli`) from runtime flow. Existing setups that depended on external CLI binaries should re-login with `openclaw channels login --channel zalouser` after upgrading.
- Version alignment with core OpenClaw release numbers.
## 2026.3.1

View File

@@ -1,52 +1,103 @@
# @openclaw/zalouser
OpenClaw extension for Zalo Personal Account messaging via native `zca-js` integration.
OpenClaw extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
## Features
- Channel plugin integration with onboarding + QR login
- In-process listener/sender via `zca-js` (no external CLI)
- Multi-account support
- Agent tool integration (`zalouser`)
- DM/group policy support
- **Channel Plugin Integration**: Appears in onboarding wizard with QR login
- **Gateway Integration**: Real-time message listening via the gateway
- **Multi-Account Support**: Manage multiple Zalo personal accounts
- **CLI Commands**: Full command-line interface for messaging
- **Agent Tool**: AI agent integration for automated messaging
## Prerequisites
- OpenClaw Gateway
- Zalo mobile app (for QR login)
Install `zca` CLI and ensure it's in your PATH:
No external `zca`, `openzca`, or `zca-cli` binary is required.
## Install
### Option A: npm
**macOS / Linux:**
```bash
openclaw plugins install @openclaw/zalouser
curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Or with custom install directory
ZCA_INSTALL_DIR=~/.local/bin curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Install specific version
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s v1.0.0
# Uninstall
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s uninstall
```
### Option B: local source checkout
**Windows (PowerShell):**
```powershell
irm https://get.zca-cli.dev/install.ps1 | iex
# Or with custom install directory
$env:ZCA_INSTALL_DIR = "C:\Tools\zca"; irm https://get.zca-cli.dev/install.ps1 | iex
# Install specific version
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Version v1.0.0"
# Uninstall
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Uninstall"
```
### Manual Download
Download binary directly:
**macOS / Linux:**
```bash
openclaw plugins install ./extensions/zalouser
cd ./extensions/zalouser && pnpm install
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
```
Restart the Gateway after install.
**Windows (PowerShell):**
## Quick start
```powershell
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
```
### Login (QR)
Available binaries:
- `zca-darwin-arm64` - macOS Apple Silicon
- `zca-darwin-x64` - macOS Intel
- `zca-linux-arm64` - Linux ARM64
- `zca-linux-x64` - Linux x86_64
- `zca-windows-x64.exe` - Windows
See [zca-cli](https://zca-cli.dev) for manual download (binaries for macOS/Linux/Windows) or building from source.
## Quick Start
### Option 1: Onboarding Wizard (Recommended)
```bash
openclaw onboard
# Select "Zalo Personal" from channel list
# Follow QR code login flow
```
### Option 2: Login (QR, on the Gateway machine)
```bash
openclaw channels login --channel zalouser
# Scan QR code with Zalo app
```
Scan the QR code with the Zalo app on your phone.
### Send a Message
### Enable channel
```bash
openclaw message send --channel zalouser --target <threadId> --message "Hello from OpenClaw!"
```
## Configuration
After onboarding, your config will include:
```yaml
channels:
@@ -55,24 +106,7 @@ channels:
dmPolicy: pairing # pairing | allowlist | open | disabled
```
### Send a message
```bash
openclaw message send --channel zalouser --target <threadId> --message "Hello from OpenClaw"
```
## Configuration
Basic:
```yaml
channels:
zalouser:
enabled: true
dmPolicy: pairing
```
Multi-account:
For multi-account:
```yaml
channels:
@@ -88,32 +122,104 @@ channels:
profile: work
```
## Useful commands
## Commands
### Authentication
```bash
openclaw channels login --channel zalouser
openclaw channels login --channel zalouser # Login via QR
openclaw channels login --channel zalouser --account work
openclaw channels status --probe
openclaw channels logout --channel zalouser
```
### Directory (IDs, contacts, groups)
```bash
openclaw directory self --channel zalouser
openclaw directory peers list --channel zalouser --query "name"
openclaw directory groups list --channel zalouser --query "work"
openclaw directory groups members --channel zalouser --group-id <id>
```
## Agent tool
### Account Management
The extension registers a `zalouser` tool for AI agents.
```bash
zca account list # List all profiles
zca account current # Show active profile
zca account switch <profile>
zca account remove <profile>
zca account label <profile> "Work Account"
```
### Messaging
```bash
# Text
openclaw message send --channel zalouser --target <threadId> --message "message"
# Media (URL)
openclaw message send --channel zalouser --target <threadId> --message "caption" --media-url "https://example.com/img.jpg"
```
### Listener
The listener runs inside the Gateway when the channel is enabled. For debugging,
use `openclaw channels logs --channel zalouser` or run `zca listen` directly.
### Data Access
```bash
# Friends
zca friend list
zca friend list -j # JSON output
zca friend find "name"
zca friend online
# Groups
zca group list
zca group info <groupId>
zca group members <groupId>
# Profile
zca me info
zca me id
```
## Multi-Account Support
Use `--profile` or `-p` to work with multiple accounts:
```bash
openclaw channels login --channel zalouser --account work
openclaw message send --channel zalouser --account work --target <id> --message "Hello"
ZCA_PROFILE=work zca listen
```
Profile resolution order: `--profile` flag > `ZCA_PROFILE` env > default
## Agent Tool
The extension registers a `zalouser` tool for AI agents:
```json
{
"action": "send",
"threadId": "123456",
"message": "Hello from AI!",
"isGroup": false,
"profile": "default"
}
```
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
## Troubleshooting
- Login not persisted: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
- Probe status: `openclaw channels status --probe`
- Name resolution issues (allowlist/groups): use numeric IDs or exact Zalo names
- **Login Issues:** Run `zca auth logout` then `zca auth login`
- **API Errors:** Try `zca auth cache-refresh` or re-login
- **File Uploads:** Check size (max 100MB) and path accessibility
## Credits
Built on [zca-js](https://github.com/RFS-ADRENO/zca-js).
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).

View File

@@ -7,12 +7,14 @@ import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via native zca-js integration",
description: "Zalo personal account messaging via zca-cli",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway)
api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock });
// Register agent tool
api.registerTool({
name: "zalouser",
label: "Zalo Personal",

View File

@@ -1,11 +1,10 @@
{
"name": "@openclaw/zalouser",
"version": "2026.3.2",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.48",
"zca-js": "2.1.1"
"@sinclair/typebox": "0.34.48"
},
"openclaw": {
"extensions": [

View File

@@ -1,214 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getZcaUserInfo,
listEnabledZalouserAccounts,
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccount,
resolveZalouserAccountSync,
} from "./accounts.js";
import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: vi.fn(),
getZaloUserInfo: vi.fn(),
}));
const mockCheckAuthenticated = vi.mocked(checkZaloAuthenticated);
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
describe("zalouser account resolution", () => {
beforeEach(() => {
mockCheckAuthenticated.mockReset();
mockGetUserInfo.mockReset();
delete process.env.ZALOUSER_PROFILE;
delete process.env.ZCA_PROFILE;
});
it("returns default account id when no accounts are configured", () => {
expect(listZalouserAccountIds(asConfig({}))).toEqual([DEFAULT_ACCOUNT_ID]);
});
it("returns sorted configured account ids", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {},
personal: {},
default: {},
},
},
},
});
expect(listZalouserAccountIds(cfg)).toEqual(["default", "personal", "work"]);
});
it("uses configured defaultAccount when present", () => {
const cfg = asConfig({
channels: {
zalouser: {
defaultAccount: "work",
accounts: {
default: {},
work: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("work");
});
it("falls back to default account when configured defaultAccount is missing", () => {
const cfg = asConfig({
channels: {
zalouser: {
defaultAccount: "missing",
accounts: {
default: {},
work: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
});
it("falls back to first sorted configured account when default is absent", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
zzz: {},
aaa: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("aaa");
});
it("resolves sync account by merging base + account config", () => {
const cfg = asConfig({
channels: {
zalouser: {
enabled: true,
dmPolicy: "pairing",
accounts: {
work: {
enabled: false,
name: "Work",
dmPolicy: "allowlist",
allowFrom: ["123"],
},
},
},
},
});
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
expect(resolved.accountId).toBe("work");
expect(resolved.enabled).toBe(false);
expect(resolved.name).toBe("Work");
expect(resolved.config.dmPolicy).toBe("allowlist");
expect(resolved.config.allowFrom).toEqual(["123"]);
});
it("resolves profile precedence correctly", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {},
},
},
},
});
process.env.ZALOUSER_PROFILE = "zalo-env";
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zalo-env");
delete process.env.ZALOUSER_PROFILE;
process.env.ZCA_PROFILE = "zca-env";
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zca-env");
delete process.env.ZCA_PROFILE;
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("work");
});
it("uses explicit profile from config over env fallback", () => {
process.env.ZALOUSER_PROFILE = "env-profile";
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {
profile: "explicit-profile",
},
},
},
},
});
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("explicit-profile");
});
it("checks authentication during async account resolution", async () => {
mockCheckAuthenticated.mockResolvedValueOnce(true);
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
default: {},
},
},
},
});
const resolved = await resolveZalouserAccount({ cfg, accountId: "default" });
expect(mockCheckAuthenticated).toHaveBeenCalledWith("default");
expect(resolved.authenticated).toBe(true);
});
it("filters disabled accounts when listing enabled accounts", async () => {
mockCheckAuthenticated.mockResolvedValue(true);
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
default: { enabled: true },
work: { enabled: false },
},
},
},
});
const accounts = await listEnabledZalouserAccounts(cfg);
expect(accounts.map((account) => account.accountId)).toEqual(["default"]);
});
it("maps account info helper from zalo-js", async () => {
mockGetUserInfo.mockResolvedValueOnce({
userId: "123",
displayName: "Alice",
avatar: "https://example.com/avatar.png",
});
expect(await getZcaUserInfo("default")).toEqual({
userId: "123",
displayName: "Alice",
});
mockGetUserInfo.mockResolvedValueOnce(null);
expect(await getZcaUserInfo("default")).toBeNull();
});
});

View File

@@ -5,7 +5,7 @@ import {
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
import { runZca, parseJsonOutput } from "./zca.js";
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
@@ -57,13 +57,10 @@ function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): Zal
return { ...base, ...account };
}
function resolveProfile(config: ZalouserAccountConfig, accountId: string): string {
function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string {
if (config.profile?.trim()) {
return config.profile.trim();
}
if (process.env.ZALOUSER_PROFILE?.trim()) {
return process.env.ZALOUSER_PROFILE.trim();
}
if (process.env.ZCA_PROFILE?.trim()) {
return process.env.ZCA_PROFILE.trim();
}
@@ -73,6 +70,11 @@ function resolveProfile(config: ZalouserAccountConfig, accountId: string): strin
return "default";
}
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
const result = await runZca(["auth", "status"], { profile, timeout: 5000 });
return result.ok;
}
export async function resolveZalouserAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -83,8 +85,8 @@ export async function resolveZalouserAccount(params: {
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const profile = resolveProfile(merged, accountId);
const authenticated = await checkZaloAuthenticated(profile);
const profile = resolveZcaProfile(merged, accountId);
const authenticated = await checkZcaAuthenticated(profile);
return {
accountId,
@@ -106,14 +108,14 @@ export function resolveZalouserAccountSync(params: {
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const profile = resolveProfile(merged, accountId);
const profile = resolveZcaProfile(merged, accountId);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
profile,
authenticated: false,
authenticated: false, // unknown without async check
config: merged,
};
}
@@ -131,16 +133,11 @@ export async function listEnabledZalouserAccounts(
export async function getZcaUserInfo(
profile: string,
): Promise<{ userId?: string; displayName?: string } | null> {
const info = await getZaloUserInfo(profile);
if (!info) {
const result = await runZca(["me", "info", "-j"], { profile, timeout: 10000 });
if (!result.ok) {
return null;
}
return {
userId: info.userId,
displayName: info.displayName,
};
return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout);
}
export { checkZaloAuthenticated as checkZcaAuthenticated };
export type { ResolvedZalouserAccount } from "./types.js";

View File

@@ -1,116 +0,0 @@
import type { ReplyPayload } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { zalouserPlugin } from "./channel.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
}));
vi.mock("./accounts.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
resolveZalouserAccountSync: () => ({
accountId: "default",
profile: "default",
name: "test",
enabled: true,
config: {},
}),
};
});
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "987654321",
text: "",
payload,
};
}
describe("zalouserPlugin outbound sendPayload", () => {
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
beforeEach(async () => {
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear();
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
});
it("text-only delegates to sendText", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" });
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object));
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" });
});
it("single media delegates to sendMedia", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" });
const result = await zalouserPlugin.outbound!.sendPayload!(
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
);
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: "zalouser" });
});
it("multi-media iterates URLs with caption on first", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zlu-1" })
.mockResolvedValueOnce({ ok: true, messageId: "zlu-2" });
const result = await zalouserPlugin.outbound!.sendPayload!(
baseCtx({
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
}),
);
expect(mockedSend).toHaveBeenCalledTimes(2);
expect(mockedSend).toHaveBeenNthCalledWith(
1,
"987654321",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(mockedSend).toHaveBeenNthCalledWith(
2,
"987654321",
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" });
});
it("empty payload returns no-op", async () => {
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({}));
expect(mockedSend).not.toHaveBeenCalled();
expect(result).toEqual({ channel: "zalouser", messageId: "" });
});
it("chunking splits long text", async () => {
mockedSend
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" })
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" });
const longText = "a".repeat(3000);
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of mockedSend.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
}
expect(result).toMatchObject({ channel: "zalouser" });
});
});

View File

@@ -16,51 +16,3 @@ describe("zalouser outbound chunker", () => {
expect(chunks.every((c) => c.length <= limit)).toBe(true);
});
});
describe("zalouser channel policies", () => {
it("resolves group tool policy by explicit group id", () => {
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
expect(resolveToolPolicy).toBeTypeOf("function");
if (!resolveToolPolicy) {
return;
}
const policy = resolveToolPolicy({
cfg: {
channels: {
zalouser: {
groups: {
"123": { tools: { allow: ["search"] } },
},
},
},
},
accountId: "default",
groupId: "123",
groupChannel: "123",
});
expect(policy).toEqual({ allow: ["search"] });
});
it("falls back to wildcard group policy", () => {
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
expect(resolveToolPolicy).toBeTypeOf("function");
if (!resolveToolPolicy) {
return;
}
const policy = resolveToolPolicy({
cfg: {
channels: {
zalouser: {
groups: {
"*": { tools: { deny: ["system.run"] } },
},
},
},
},
accountId: "default",
groupId: "missing",
groupChannel: "missing",
});
expect(policy).toEqual({ deny: ["system.run"] });
});
});

View File

@@ -1,5 +1,3 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
@@ -19,7 +17,6 @@ import {
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolvePreferredOpenClawTmpDir,
resolveChannelAccountConfigBasePath,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
@@ -36,15 +33,8 @@ import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
import { sendMessageZalouser } from "./send.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import {
listZaloFriendsMatching,
listZaloGroupMembers,
listZaloGroupsMatching,
logoutZaloProfile,
startZaloQrLogin,
waitForZaloQrLogin,
getZaloUserInfo,
} from "./zalo-js.js";
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
const meta = {
id: "zalouser",
@@ -61,30 +51,11 @@ const meta = {
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
return process.env.ZCA_PROFILE?.trim() || "default";
}
return normalized;
}
async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}
function mapUser(params: {
id: string;
name?: string | null;
@@ -202,7 +173,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
"messagePrefix",
],
}),
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
isConfigured: async (account) => {
// Check if zca auth status is OK for this profile
const result = await runZca(["auth", "status"], {
profile: account.profile,
timeout: 5000,
});
return result.ok;
},
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
@@ -316,9 +294,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
},
directory: {
self: async ({ cfg, accountId }) => {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error("Missing dependency: `zca` not found in PATH");
}
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const parsed = await getZaloUserInfo(account.profile);
const result = await runZca(["me", "info", "-j"], {
profile: account.profile,
timeout: 10000,
});
if (!result.ok) {
runtime.error(result.stderr || "Failed to fetch profile");
return null;
}
const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
if (!parsed?.userId) {
return null;
}
@@ -330,42 +320,92 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
});
},
listPeers: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error("Missing dependency: `zca` not found in PATH");
}
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const friends = await listZaloFriendsMatching(account.profile, query);
const rows = friends.map((friend) =>
mapUser({
id: String(friend.userId),
name: friend.displayName ?? null,
avatarUrl: friend.avatar ?? null,
raw: friend,
}),
);
const args = query?.trim() ? ["friend", "find", query.trim()] : ["friend", "list", "-j"];
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to list peers");
}
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
const rows = Array.isArray(parsed)
? parsed.map((f) =>
mapUser({
id: String(f.userId),
name: f.displayName ?? null,
avatarUrl: f.avatar ?? null,
raw: f,
}),
)
: [];
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error("Missing dependency: `zca` not found in PATH");
}
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const groups = await listZaloGroupsMatching(account.profile, query);
const rows = groups.map((group) =>
mapGroup({
id: String(group.groupId),
name: group.name ?? null,
raw: group,
}),
);
const result = await runZca(["group", "list", "-j"], {
profile: account.profile,
timeout: 15000,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to list groups");
}
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
let rows = Array.isArray(parsed)
? parsed.map((g) =>
mapGroup({
id: String(g.groupId),
name: g.name ?? null,
raw: g,
}),
)
: [];
const q = query?.trim().toLowerCase();
if (q) {
rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
}
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error("Missing dependency: `zca` not found in PATH");
}
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const members = await listZaloGroupMembers(account.profile, groupId);
const rows = members.map((member) =>
mapUser({
id: member.userId,
name: member.displayName,
avatarUrl: member.avatar ?? null,
raw: member,
}),
const result = await runZca(["group", "members", groupId, "-j"], {
profile: account.profile,
timeout: 20000,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to list group members");
}
const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(
result.stdout,
);
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
const rows = Array.isArray(parsed)
? parsed
.map((m) => {
const id = m.userId ?? (m as { id?: string | number }).id;
if (!id) {
return null;
}
return mapUser({
id: String(id),
name: (m as { displayName?: string }).displayName ?? null,
avatarUrl: (m as { avatar?: string }).avatar ?? null,
raw: m,
});
})
.filter(Boolean)
: [];
const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
return sliced as ChannelDirectoryEntry[];
},
},
resolver: {
@@ -386,27 +426,48 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
cfg: cfg,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const args =
kind === "user"
? trimmed
? ["friend", "find", trimmed]
: ["friend", "list", "-j"]
: ["group", "list", "-j"];
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "zca lookup failed");
}
if (kind === "user") {
const friends = await listZaloFriendsMatching(account.profile, trimmed);
const best = friends[0];
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
const matches = Array.isArray(parsed)
? parsed.map((f) => ({
id: String(f.userId),
name: f.displayName ?? undefined,
}))
: [];
const best = matches[0];
results.push({
input,
resolved: Boolean(best?.userId),
id: best?.userId,
name: best?.displayName,
note: friends.length > 1 ? "multiple matches; chose first" : undefined,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} else {
const groups = await listZaloGroupsMatching(account.profile, trimmed);
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const matches = Array.isArray(parsed)
? parsed.map((g) => ({
id: String(g.groupId),
name: g.name ?? undefined,
}))
: [];
const best =
groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ??
groups[0];
matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
results.push({
input,
resolved: Boolean(best?.groupId),
id: best?.groupId,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: groups.length > 1 ? "multiple matches; chose first" : undefined,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
}
} catch (err) {
@@ -437,32 +498,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
cfg: cfg,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error(
"Missing dependency: `zca` not found in PATH. See docs.openclaw.ai/channels/zalouser",
);
}
runtime.log(
`Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
);
const started = await startZaloQrLogin({
profile: account.profile,
timeoutMs: 35_000,
});
if (!started.qrDataUrl) {
throw new Error(started.message || "Failed to start QR login");
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
if (!result.ok) {
throw new Error(result.stderr || "Zalouser login failed");
}
const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
if (qrPath) {
runtime.log(`Scan QR image: ${qrPath}`);
} else {
runtime.log("QR generated but could not be written to a temp file.");
}
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
if (!waited.connected) {
throw new Error(waited.message || "Zalouser login failed");
}
runtime.log(waited.message);
},
},
outbound: {
@@ -470,40 +518,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalouser", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zalouserPlugin.outbound!.sendMedia!({
...ctx,
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await zalouserPlugin.outbound!.sendMedia!({
...ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
const outbound = zalouserPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
@@ -514,12 +528,11 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
mediaLocalRoots,
});
return {
channel: "zalouser",
@@ -549,8 +562,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
}),
probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
buildAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
const configError = "not authenticated";
const zcaInstalled = await checkZcaInstalled();
const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
return {
accountId: account.accountId,
name: account.name,
@@ -594,21 +608,44 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
loginWithQrStart: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
return await startZaloQrLogin({
// Start login and get QR code
const result = await runZca(["auth", "login", "--qr-base64"], {
profile,
force: params.force,
timeoutMs: params.timeoutMs,
timeout: params.timeoutMs ?? 30000,
});
if (!result.ok) {
return { message: result.stderr || "Failed to start QR login" };
}
// The stdout should contain the base64 QR data URL
const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
if (qrMatch) {
return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
}
return { message: result.stdout || "QR login started" };
},
loginWithQrWait: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
return await waitForZaloQrLogin({
// Check if already authenticated
const statusResult = await runZca(["auth", "status"], {
profile,
timeoutMs: params.timeoutMs,
timeout: params.timeoutMs ?? 60000,
});
return {
connected: statusResult.ok,
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
};
},
logoutAccount: async (ctx) => {
const result = await runZca(["auth", "logout"], {
profile: ctx.account.profile,
timeout: 10000,
});
return {
cleared: result.ok,
loggedOut: result.ok,
message: result.ok ? "Logged out" : result.stderr,
};
},
logoutAccount: async (ctx) =>
await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
},
};

View File

@@ -1,117 +0,0 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { __testing } from "./monitor.js";
import { setZalouserRuntime } from "./runtime.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
}));
describe("zalouser monitor pairing account scoping", () => {
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
const readAllowFromStore = vi.fn(
async (
channelOrParams:
| string
| {
channel?: string;
accountId?: string;
},
_env?: NodeJS.ProcessEnv,
accountId?: string,
) => {
const scopedAccountId =
typeof channelOrParams === "object" && channelOrParams !== null
? channelOrParams.accountId
: accountId;
return scopedAccountId === "beta" ? [] : ["attacker"];
},
);
const upsertPairingRequest = vi.fn(async () => ({ code: "PAIRME88", created: true }));
setZalouserRuntime({
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: vi.fn(() => "pairing reply"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
isControlCommandMessage: vi.fn(() => false),
},
},
} as unknown as PluginRuntime);
const account: ResolvedZalouserAccount = {
accountId: "beta",
enabled: true,
profile: "beta",
authenticated: true,
config: {
dmPolicy: "pairing",
allowFrom: [],
},
};
const config: OpenClawConfig = {
channels: {
zalouser: {
accounts: {
alpha: { dmPolicy: "pairing", allowFrom: [] },
beta: { dmPolicy: "pairing", allowFrom: [] },
},
},
},
};
const message: ZaloInboundMessage = {
threadId: "chat-1",
isGroup: false,
senderId: "attacker",
senderName: "Attacker",
groupName: undefined,
timestampMs: Date.now(),
msgId: "msg-1",
content: "hello",
raw: { source: "test" },
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as RuntimeEnv["exit"],
};
await __testing.processMessage({
message,
account,
config,
runtime,
});
expect(readAllowFromStore).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalouser",
accountId: "beta",
}),
);
expect(upsertPairingRequest).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalouser",
id: "attacker",
accountId: "beta",
}),
);
expect(sendMessageZalouserMock).toHaveBeenCalled();
});
});

View File

@@ -1,3 +1,4 @@
import type { ChildProcess } from "node:child_process";
import type {
MarkdownTableMode,
OpenClawConfig,
@@ -5,6 +6,7 @@ import type {
RuntimeEnv,
} from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveOutboundMediaUrls,
@@ -18,8 +20,8 @@ import {
} from "openclaw/plugin-sdk";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
@@ -61,14 +63,11 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str
}
}
function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean {
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = senderId?.trim().toLowerCase();
if (!normalizedSenderId) {
return false;
}
const normalizedSenderId = senderId.toLowerCase();
return allowFrom.some((entry) => {
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
return normalized === normalizedSenderId;
@@ -116,34 +115,84 @@ function isGroupAllowed(params: {
return false;
}
function startZcaListener(
runtime: RuntimeEnv,
profile: string,
onMessage: (msg: ZcaMessage) => void,
onError: (err: Error) => void,
abortSignal: AbortSignal,
): ChildProcess {
let buffer = "";
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
profile,
onData: (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as ZcaMessage;
onMessage(parsed);
} catch {
// ignore non-JSON lines
}
}
},
onError,
});
proc.stderr?.on("data", (data: Buffer) => {
const text = data.toString().trim();
if (text) {
runtime.error(`[zalouser] zca stderr: ${text}`);
}
});
void promise.then((result) => {
if (!result.ok && !abortSignal.aborted) {
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
}
});
abortSignal.addEventListener(
"abort",
() => {
proc.kill("SIGTERM");
},
{ once: true },
);
return proc;
}
async function processMessage(
message: ZaloInboundMessage,
message: ZcaMessage,
account: ResolvedZalouserAccount,
config: OpenClawConfig,
core: ZalouserCoreRuntime,
runtime: RuntimeEnv,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
): Promise<void> {
const { threadId, content, timestamp, metadata } = message;
const pairing = createScopedPairingAccess({
core,
channel: "zalouser",
accountId: account.accountId,
});
const rawBody = message.content?.trim();
if (!rawBody) {
if (!content?.trim()) {
return;
}
const isGroup = message.isGroup;
const chatId = message.threadId;
const senderId = message.senderId?.trim();
if (!senderId) {
logVerbose(core, runtime, `zalouser: drop message ${chatId} (missing senderId)`);
return;
}
const senderName = message.senderName ?? "";
const groupName = message.groupName ?? "";
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const groupName = metadata?.threadName ?? "";
const chatId = threadId;
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
@@ -155,9 +204,8 @@ async function processMessage(
providerMissingFallbackApplied,
providerKey: "zalouser",
accountId: account.accountId,
log: (entry) => logVerbose(core, runtime, entry),
log: (message) => logVerbose(core, runtime, message),
});
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
@@ -175,6 +223,7 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
@@ -198,6 +247,7 @@ async function processMessage(
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
@@ -265,22 +315,21 @@ async function processMessage(
id: peer.id,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: message.timestampMs,
previousTimestamp,
envelope: envelopeOptions,
timestamp: timestamp ? timestamp * 1000 : undefined,
body: rawBody,
});
@@ -300,7 +349,7 @@ async function processMessage(
CommandAuthorized: commandAuthorized,
Provider: "zalouser",
Surface: "zalouser",
MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`,
MessageSid: message.msgId ?? `${timestamp}`,
OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`,
});
@@ -417,6 +466,10 @@ export async function monitorZalouserProvider(
const { abortSignal, statusSink, runtime } = options;
const core = getZalouserRuntime();
let stopped = false;
let proc: ChildProcess | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let resolveRunning: (() => void) | null = null;
try {
const profile = account.profile;
@@ -425,144 +478,147 @@ export async function monitorZalouserProvider(
.filter((entry) => entry && entry !== "*");
if (allowFromEntries.length > 0) {
const friends = await listZaloFriends(profile);
const byName = buildNameIndex(friends, (friend) => friend.displayName);
const additions: string[] = [];
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of allowFromEntries) {
if (/^\d+$/.test(entry)) {
additions.push(entry);
continue;
}
const matches = byName.get(entry.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.userId ? String(match.userId) : undefined;
if (id) {
additions.push(id);
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
const byName = buildNameIndex(friends, (friend) => friend.displayName);
const additions: string[] = [];
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of allowFromEntries) {
if (/^\d+$/.test(entry)) {
additions.push(entry);
continue;
}
const matches = byName.get(entry.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.userId ? String(match.userId) : undefined;
if (id) {
additions.push(id);
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
}
}
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
account = {
...account,
config: {
...account.config,
allowFrom,
},
};
summarizeMapping("zalouser users", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
}
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
account = {
...account,
config: {
...account.config,
allowFrom,
},
};
summarizeMapping("zalouser users", mapping, unresolved, runtime);
}
const groupsConfig = account.config.groups ?? {};
const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
if (groupKeys.length > 0) {
const groups = await listZaloGroups(profile);
const byName = buildNameIndex(groups, (group) => group.name);
const mapping: string[] = [];
const unresolved: string[] = [];
const nextGroups = { ...groupsConfig };
for (const entry of groupKeys) {
const cleaned = normalizeZalouserEntry(entry);
if (/^\d+$/.test(cleaned)) {
if (!nextGroups[cleaned]) {
nextGroups[cleaned] = groupsConfig[entry];
const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const byName = buildNameIndex(groups, (group) => group.name);
const mapping: string[] = [];
const unresolved: string[] = [];
const nextGroups = { ...groupsConfig };
for (const entry of groupKeys) {
const cleaned = normalizeZalouserEntry(entry);
if (/^\d+$/.test(cleaned)) {
if (!nextGroups[cleaned]) {
nextGroups[cleaned] = groupsConfig[entry];
}
mapping.push(`${entry}${cleaned}`);
continue;
}
mapping.push(`${entry}${cleaned}`);
continue;
}
const matches = byName.get(cleaned.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.groupId ? String(match.groupId) : undefined;
if (id) {
if (!nextGroups[id]) {
nextGroups[id] = groupsConfig[entry];
const matches = byName.get(cleaned.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.groupId ? String(match.groupId) : undefined;
if (id) {
if (!nextGroups[id]) {
nextGroups[id] = groupsConfig[entry];
}
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
}
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
}
account = {
...account,
config: {
...account.config,
groups: nextGroups,
},
};
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
}
account = {
...account,
config: {
...account.config,
groups: nextGroups,
},
};
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
}
} catch (err) {
runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
}
let listenerStop: (() => void) | null = null;
let stopped = false;
const stop = () => {
if (stopped) {
return;
}
stopped = true;
listenerStop?.();
listenerStop = null;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
resolveRunning?.();
};
const listener = await startZaloListener({
accountId: account.accountId,
profile: account.profile,
abortSignal,
onMessage: (msg) => {
if (stopped) {
return;
}
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
});
},
onError: (err) => {
if (stopped || abortSignal.aborted) {
return;
}
runtime.error(`[${account.accountId}] Zalo listener error: ${String(err)}`);
},
});
const startListener = () => {
if (stopped || abortSignal.aborted) {
resolveRunning?.();
return;
}
listenerStop = listener.stop;
await new Promise<void>((resolve) => {
abortSignal.addEventListener(
"abort",
() => {
stop();
resolve();
},
{ once: true },
logVerbose(
core,
runtime,
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
);
proc = startZcaListener(
runtime,
account.profile,
(msg) => {
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
});
},
(err) => {
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
if (!stopped && !abortSignal.aborted) {
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
restartTimer = setTimeout(startListener, 5000);
} else {
resolveRunning?.();
}
},
abortSignal,
);
};
// Create a promise that stays pending until abort or stop
const runningPromise = new Promise<void>((resolve) => {
resolveRunning = resolve;
abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
startListener();
// Wait for the running promise to resolve (on abort/stop)
await runningPromise;
return { stop };
}
export const __testing = {
processMessage: async (params: {
message: ZaloInboundMessage;
account: ResolvedZalouserAccount;
config: OpenClawConfig;
runtime: RuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}) => {
await processMessage(
params.message,
params.account,
params.config,
getZalouserRuntime(),
params.runtime,
params.statusSink,
);
},
};

View File

@@ -1,5 +1,3 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
@@ -14,7 +12,6 @@ import {
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
resolvePreferredOpenClawTmpDir,
} from "openclaw/plugin-sdk";
import {
listZalouserAccountIds,
@@ -22,13 +19,8 @@ import {
resolveZalouserAccountSync,
checkZcaAuthenticated,
} from "./accounts.js";
import {
logoutZaloProfile,
resolveZaloAllowFromEntries,
resolveZaloGroupsByEntries,
startZaloQrLogin,
waitForZaloQrLogin,
} from "./zalo-js.js";
import type { ZcaFriend, ZcaGroup } from "./types.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
const channel = "zalouser" as const;
@@ -95,7 +87,9 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
[
"Zalo Personal Account login via QR code.",
"",
"This plugin uses zca-js directly (no external CLI dependency).",
"Prerequisites:",
"1) Install zca-cli",
"2) You'll scan a QR code with your Zalo app",
"",
"Docs: https://docs.openclaw.ai/channels/zalouser",
].join("\n"),
@@ -103,25 +97,6 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
);
}
async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}
async function promptZalouserAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
@@ -136,40 +111,58 @@ async function promptZalouserAllowFrom(params: {
.map((entry) => entry.trim())
.filter(Boolean);
const resolveUserId = async (input: string): Promise<string | null> => {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return trimmed;
}
const ok = await checkZcaInstalled();
if (!ok) {
return null;
}
const result = await runZca(["friend", "find", trimmed], {
profile: resolved.profile,
timeout: 15000,
});
if (!result.ok) {
return null;
}
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
const rows = Array.isArray(parsed) ? parsed : [];
const match = rows[0];
if (!match?.userId) {
return null;
}
if (rows.length > 1) {
await prompter.note(
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
"Zalo Personal allowlist",
);
}
return String(match.userId);
};
while (true) {
const entry = await prompter.text({
message: "Zalouser allowFrom (name or user id)",
message: "Zalouser allowFrom (username or user id)",
placeholder: "Alice, 123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedEntries = await resolveZaloAllowFromEntries({
profile: resolved.profile,
entries: parts,
});
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
const results = await Promise.all(parts.map((part) => resolveUserId(part)));
const unresolved = parts.filter((_, idx) => !results[idx]);
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
"Zalo Personal allowlist",
);
continue;
}
const resolvedIds = resolvedEntries
.filter((item) => item.resolved && item.id)
.map((item) => item.id as string);
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
const notes = resolvedEntries
.filter((item) => item.note)
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
if (notes.length > 0) {
await prompter.note(notes.join("\n"), "Zalo Personal allowlist");
}
const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]);
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: unique,
@@ -198,6 +191,49 @@ function setZalouserGroupAllowlist(
});
}
async function resolveZalouserGroups(params: {
cfg: OpenClawConfig;
accountId: string;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
const result = await runZca(["group", "list", "-j"], {
profile: account.profile,
timeout: 15000,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to list groups");
}
const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter((group) =>
Boolean(group.groupId),
);
const byName = new Map<string, ZcaGroup[]>();
for (const group of groups) {
const name = group.name?.trim().toLowerCase();
if (!name) {
continue;
}
const list = byName.get(name) ?? [];
list.push(group);
byName.set(name, list);
}
return params.entries.map((input) => {
const trimmed = input.trim();
if (!trimmed) {
return { input, resolved: false };
}
if (/^\d+$/.test(trimmed)) {
return { input, resolved: true, id: trimmed };
}
const matches = byName.get(trimmed.toLowerCase()) ?? [];
const match = matches[0];
return match?.groupId
? { input, resolved: true, id: String(match.groupId) }
: { input, resolved: false };
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo Personal",
channel,
@@ -211,7 +247,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultZalouserAccountId(cfg);
return promptZalouserAllowFrom({
cfg,
cfg: cfg,
prompter,
accountId: id,
});
@@ -225,7 +261,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
const ids = listZalouserAccountIds(cfg);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
@@ -247,13 +283,28 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
shouldPromptAccountIds,
forceAllowFrom,
}) => {
// Check zca is installed
const zcaInstalled = await checkZcaInstalled();
if (!zcaInstalled) {
await prompter.note(
[
"The `zca` binary was not found in PATH.",
"",
"Install zca-cli, then re-run onboarding:",
"Docs: https://docs.openclaw.ai/channels/zalouser",
].join("\n"),
"Missing Dependency",
);
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
}
const zalouserOverride = accountOverrides.zalouser?.trim();
const defaultAccountId = resolveDefaultZalouserAccountId(cfg);
let accountId = zalouserOverride ? normalizeAccountId(zalouserOverride) : defaultAccountId;
if (shouldPromptAccountIds && !zalouserOverride) {
accountId = await promptAccountId({
cfg,
cfg: cfg,
prompter,
label: "Zalo Personal",
currentId: accountId,
@@ -275,32 +326,23 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
if (wantsLogin) {
const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
if (start.qrDataUrl) {
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
await prompter.note(
[
start.message,
qrPath
? `QR image saved to: ${qrPath}`
: "Could not write QR image file; use gateway web login UI instead.",
"Scan + approve on phone, then continue.",
].join("\n"),
"QR Login",
);
const scanned = await prompter.confirm({
message: "Did you scan and approve the QR on your phone?",
initialValue: true,
});
if (scanned) {
const waited = await waitForZaloQrLogin({
profile: account.profile,
timeoutMs: 120_000,
});
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
}
await prompter.note(
"A QR code will appear in your terminal.\nScan it with your Zalo app to login.",
"QR Login",
);
// Run interactive login
const result = await runZcaInteractive(["auth", "login"], {
profile: account.profile,
});
if (!result.ok) {
await prompter.note(`Login failed: ${result.stderr || "Unknown error"}`, "Error");
} else {
await prompter.note(start.message, "Login pending");
const isNowAuth = await checkZcaAuthenticated(account.profile);
if (isNowAuth) {
await prompter.note("Login successful!", "Success");
}
}
}
} else {
@@ -309,26 +351,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (!keepSession) {
await logoutZaloProfile(account.profile);
const start = await startZaloQrLogin({
profile: account.profile,
force: true,
timeoutMs: 35_000,
});
if (start.qrDataUrl) {
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
await prompter.note(
[start.message, qrPath ? `QR image saved to: ${qrPath}` : undefined]
.filter(Boolean)
.join("\n"),
"QR Login",
);
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
}
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
await runZcaInteractive(["auth", "login"], { profile: account.profile });
}
}
// Enable the channel
next = setZalouserAccountScopedConfig(
next,
accountId,
@@ -344,16 +372,14 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId });
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Zalo groups",
currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(updatedAccount.config.groups ?? {}),
currentPolicy: account.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(account.config.groups ?? {}),
placeholder: "Family, Work, 123456789",
updatePrompt: Boolean(updatedAccount.config.groups),
updatePrompt: Boolean(account.config.groups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
@@ -361,8 +387,9 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
let keys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolved = await resolveZaloGroupsByEntries({
profile: updatedAccount.profile,
const resolved = await resolveZalouserGroups({
cfg: next,
accountId,
entries: accessConfig.entries,
});
const resolvedIds = resolved

View File

@@ -1,60 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeZalouser } from "./probe.js";
import { getZaloUserInfo } from "./zalo-js.js";
vi.mock("./zalo-js.js", () => ({
getZaloUserInfo: vi.fn(),
}));
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
describe("probeZalouser", () => {
beforeEach(() => {
mockGetUserInfo.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns ok=true with user when authenticated", async () => {
mockGetUserInfo.mockResolvedValueOnce({
userId: "123",
displayName: "Alice",
});
await expect(probeZalouser("default")).resolves.toEqual({
ok: true,
user: { userId: "123", displayName: "Alice" },
});
});
it("returns not authenticated when no user info is returned", async () => {
mockGetUserInfo.mockResolvedValueOnce(null);
await expect(probeZalouser("default")).resolves.toEqual({
ok: false,
error: "Not authenticated",
});
});
it("returns error when user lookup throws", async () => {
mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
await expect(probeZalouser("default")).resolves.toEqual({
ok: false,
error: "network down",
});
});
it("times out when lookup takes too long", async () => {
vi.useFakeTimers();
mockGetUserInfo.mockReturnValueOnce(new Promise(() => undefined));
const pending = probeZalouser("default", 10);
await vi.advanceTimersByTimeAsync(1000);
await expect(pending).resolves.toEqual({
ok: false,
error: "Not authenticated",
});
});
});

View File

@@ -1,6 +1,6 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import type { ZcaUserInfo } from "./types.js";
import { getZaloUserInfo } from "./zalo-js.js";
import { runZca, parseJsonOutput } from "./zca.js";
export type ZalouserProbeResult = BaseProbeResult<string> & {
user?: ZcaUserInfo;
@@ -10,25 +10,18 @@ export async function probeZalouser(
profile: string,
timeoutMs?: number,
): Promise<ZalouserProbeResult> {
try {
const user = timeoutMs
? await Promise.race([
getZaloUserInfo(profile),
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
),
])
: await getZaloUserInfo(profile);
const result = await runZca(["me", "info", "-j"], {
profile,
timeout: timeoutMs,
});
if (!user) {
return { ok: false, error: "Not authenticated" };
}
return { ok: true, user };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
if (!result.ok) {
return { ok: false, error: result.stderr || "Failed to probe" };
}
const user = parseJsonOutput<ZcaUserInfo>(result.stdout);
if (!user) {
return { ok: false, error: "Failed to parse user info" };
}
return { ok: true, user };
}

View File

@@ -1,65 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
type ZalouserSendResult,
} from "./send.js";
import { runZca } from "./zca.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
sendZaloLink: vi.fn(),
vi.mock("./zca.js", () => ({
runZca: vi.fn(),
}));
const mockSendText = vi.mocked(sendZaloTextMessage);
const mockSendLink = vi.mocked(sendZaloLink);
const mockRunZca = vi.mocked(runZca);
const originalZcaProfile = process.env.ZCA_PROFILE;
function okResult(stdout = "message_id: msg-1") {
return {
ok: true,
stdout,
stderr: "",
exitCode: 0,
};
}
function failResult(stderr = "") {
return {
ok: false,
stdout: "",
stderr,
exitCode: 1,
};
}
describe("zalouser send helpers", () => {
beforeEach(() => {
mockSendText.mockReset();
mockSendLink.mockReset();
mockRunZca.mockReset();
delete process.env.ZCA_PROFILE;
});
it("delegates text send to JS transport", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
const result = await sendMessageZalouser("thread-1", "hello", {
profile: "default",
isGroup: true,
});
expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
profile: "default",
isGroup: true,
});
expect(result).toEqual({ ok: true, messageId: "mid-1" });
afterEach(() => {
if (originalZcaProfile) {
process.env.ZCA_PROFILE = originalZcaProfile;
return;
}
delete process.env.ZCA_PROFILE;
});
it("maps image helper to media send", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
caption: "cap",
isGroup: false,
});
expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
profile: "p2",
caption: "cap",
isGroup: false,
mediaUrl: "https://example.com/a.png",
});
it("returns validation error when thread id is missing", async () => {
const result = await sendMessageZalouser("", "hello");
expect(result).toEqual({
ok: false,
error: "No threadId provided",
} satisfies ZalouserSendResult);
expect(mockRunZca).not.toHaveBeenCalled();
});
it("delegates link helper to JS transport", async () => {
mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
it("builds text send command with truncation and group flag", async () => {
mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123"));
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
profile: "p3",
const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), {
profile: "profile-a",
isGroup: true,
});
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
profile: "p3",
expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], {
profile: "profile-a",
});
expect(result).toEqual({ ok: true, messageId: "mid-123" });
});
it("routes media sends from sendMessage and keeps text as caption", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-2", "media caption", {
profile: "profile-b",
mediaUrl: "https://cdn.example.com/video.mp4",
isGroup: true,
});
expect(result).toEqual({ ok: false, error: "boom" });
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"video",
"thread-2",
"-u",
"https://cdn.example.com/video.mp4",
"-m",
"media caption",
"-g",
],
{ profile: "profile-b" },
);
});
it("maps audio media to voice command", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-3", "", {
profile: "profile-c",
mediaUrl: "https://cdn.example.com/clip.mp3",
});
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"],
{ profile: "profile-c" },
);
});
it("builds image command with caption and returns fallback error", async () => {
mockRunZca.mockResolvedValueOnce(failResult(""));
const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", {
profile: "profile-d",
caption: "caption text",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"image",
"thread-4",
"-u",
"https://cdn.example.com/img.png",
"-m",
"caption text",
"-g",
],
{ profile: "profile-d" },
);
expect(result).toEqual({ ok: false, error: "Failed to send image" });
});
it("uses env profile fallback and builds link command", async () => {
process.env.ZCA_PROFILE = "env-profile";
mockRunZca.mockResolvedValueOnce(okResult("abc123"));
const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true });
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "link", "thread-5", "https://openclaw.ai", "-g"],
{ profile: "env-profile" },
);
expect(result).toEqual({ ok: true, messageId: "abc123" });
});
it("returns caught command errors", async () => {
mockRunZca.mockRejectedValueOnce(new Error("zca unavailable"));
await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({
ok: false,
error: "zca unavailable",
});
});
});

View File

@@ -1,15 +1,104 @@
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
import { runZca } from "./zca.js";
export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
export type ZalouserSendOptions = {
profile?: string;
mediaUrl?: string;
caption?: string;
isGroup?: boolean;
};
export type ZalouserSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
function resolveProfile(options: ZalouserSendOptions): string {
return options.profile || process.env.ZCA_PROFILE || "default";
}
function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void {
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) {
args.push("-g");
}
}
async function runSendCommand(
args: string[],
profile: string,
fallbackError: string,
): Promise<ZalouserSendResult> {
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || fallbackError };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
return await sendZaloTextMessage(threadId, text, options);
const profile = resolveProfile(options);
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
}
// Handle media sending
if (options.mediaUrl) {
return sendMediaZalouser(threadId, options.mediaUrl, {
...options,
caption: text || options.caption,
});
}
// Send text message
const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)];
if (options.isGroup) {
args.push("-g");
}
return runSendCommand(args, profile, "Failed to send message");
}
async function sendMediaZalouser(
threadId: string,
mediaUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = resolveProfile(options);
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
}
if (!mediaUrl?.trim()) {
return { ok: false, error: "No media URL provided" };
}
// Determine media type from URL
const lowerUrl = mediaUrl.toLowerCase();
let command: string;
if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) {
command = "video";
} else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) {
command = "voice";
} else {
command = "image";
}
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
appendCaptionAndGroupFlags(args, options);
return runSendCommand(args, profile, `Failed to send ${command}`);
}
export async function sendImageZalouser(
@@ -17,10 +106,10 @@ export async function sendImageZalouser(
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
return await sendZaloTextMessage(threadId, options.caption ?? "", {
...options,
mediaUrl: imageUrl,
});
const profile = resolveProfile(options);
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
appendCaptionAndGroupFlags(args, options);
return runSendCommand(args, profile, "Failed to send image");
}
export async function sendLinkZalouser(
@@ -28,5 +117,25 @@ export async function sendLinkZalouser(
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
return await sendZaloLink(threadId, url, options);
const profile = resolveProfile(options);
const args = ["msg", "link", threadId.trim(), url.trim()];
if (options.isGroup) {
args.push("-g");
}
return runSendCommand(args, profile, "Failed to send link");
}
function extractMessageId(stdout: string): string | undefined {
// Try to extract message ID from output
const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i);
if (match) {
return match[1];
}
// Return first word if it looks like an ID
const firstWord = stdout.trim().split(/\s+/)[0];
if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) {
return firstWord;
}
return undefined;
}

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