mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 22:43:15 +08:00
Compare commits
3 Commits
fix/telegr
...
fix/browse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
babb41ccc4 | ||
|
|
41756f94bf | ||
|
|
a33ded8841 |
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,10 +48,6 @@ The gateway responds to LINE’s 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:
|
||||
|
||||
@@ -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 it’s on `PATH` for the Gateway process.
|
||||
|
||||
**Login doesn’t 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*`):
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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** (auto‑generated, 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -10,7 +10,7 @@ If you’re 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`.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,6 +434,7 @@ function createApi(): OpenClawPluginApi {
|
||||
},
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpHandler() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -61,12 +61,7 @@ const plugin = {
|
||||
log: api.logger,
|
||||
});
|
||||
|
||||
api.registerHttpRoute({
|
||||
path: "/api/channels/nostr",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: httpHandler,
|
||||
});
|
||||
api.registerHttpHandler(httpHandler);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user