Compare commits

..

130 Commits

Author SHA1 Message Date
liuxiaopai-ai
32e4558e4f Config: newline-join sandbox setupCommand arrays 2026-03-02 18:10:31 +00:00
bmendonca3
8b27582509 fix(cli): apply --profile before dotenv bootstrap in runCli (#31950)
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
2026-03-02 18:09:45 +00:00
bmendonca3
a6489ab5e9 fix(agents): cap openai-completions tool call ids to provider-safe format (#31947)
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
2026-03-02 18:08:20 +00:00
Peter Steinberger
83c8406f01 refactor(security): split gateway auth suites and share safe write path checks 2026-03-02 18:07:03 +00:00
Peter Steinberger
602f6439bd test(memory): stabilize windows qmd spawn expectation 2026-03-02 18:06:12 +00:00
Peter Steinberger
1c9deeda97 refactor: split webhook ingress and policy guards 2026-03-02 18:02:21 +00:00
Peter Steinberger
fc0d374390 test(perf): drop duplicate gateway config patch validation case 2026-03-02 18:00:11 +00:00
Peter Steinberger
0ebe0480fa test(perf): replace relay fixed sleeps with condition waits 2026-03-02 17:55:47 +00:00
Peter Steinberger
8ae8056622 test(perf): trim telegram webhook artificial delay windows 2026-03-02 17:48:36 +00:00
Peter Steinberger
54382a66b4 test(perf): bypass matrix send queue delay in send tests 2026-03-02 17:46:31 +00:00
Peter Steinberger
d7ae61c412 test(gateway): fix trusted-proxy control-ui auth test origin config 2026-03-02 17:45:45 +00:00
Peter Steinberger
b07589642d test(perf): remove redundant acpx healthy-command case 2026-03-02 17:41:51 +00:00
Peter Steinberger
26b8e6d510 test(perf): avoid cron min-refire delay in auto-run coverage 2026-03-02 17:41:51 +00:00
Peter Steinberger
e339c75d5d style(docs): format faq markdown spacing 2026-03-02 17:38:11 +00:00
Peter Steinberger
7dac9b05dd fix(security): harden zip write race handling 2026-03-02 17:38:11 +00:00
Peter Steinberger
eb35fb745d docs: remove provider recommendation language 2026-03-02 17:33:38 +00:00
Peter Steinberger
b9e820b7ed test(perf): cut exec approval metadata test timeout 2026-03-02 17:33:06 +00:00
Peter Steinberger
aee27d0e38 refactor(security): table-drive wrapper approval pinning tests 2026-03-02 17:30:48 +00:00
Peter Steinberger
34ff873a7e test(perf): trim fixed waits in relay and startup tests 2026-03-02 17:30:33 +00:00
Peter Steinberger
310dd24ce3 test(perf): clean acpx runtime fixtures at suite end 2026-03-02 17:30:33 +00:00
Peter Steinberger
d4bf07d075 refactor(security): unify hardened install and fs write flows 2026-03-02 17:23:29 +00:00
Peter Steinberger
d3e8b17aa6 fix: harden webhook auth-before-body handling 2026-03-02 17:21:09 +00:00
Peter Steinberger
dded569626 fix(security): preserve system.run wrapper approval semantics 2026-03-02 17:20:52 +00:00
Peter Steinberger
104d32bb64 fix(security): unify root-bound write hardening 2026-03-02 17:12:33 +00:00
Peter Steinberger
be3a62c5e0 test(perf): defer delivery queue fixture cleanup to suite end 2026-03-02 17:10:55 +00:00
Hiren Thakore
193ad2f4f0 fix: handle PowerShell execution policy on Windows install (#24794)
* fix: add Arch Linux support to install.sh (GH#8051)

* fix: handle PowerShell execution policy on Windows install (GH#24784)
2026-03-02 11:09:01 -06:00
Dalomeve
a0e11e63fe docs(faq): add Windows exec encoding troubleshooting (#30736)
Co-authored-by: dalomeve <dalomeve@users.noreply.github.com>
2026-03-02 11:08:26 -06:00
Peter Steinberger
07b16d5ad0 fix(security): harden workspace bootstrap boundary reads 2026-03-02 17:07:36 +00:00
Mark L
67b2dde7c5 Docs: add WSL2 boot auto-start guide (#31616) 2026-03-02 11:07:15 -06:00
Glucksberg
7a55a3ca07 fix(install): correct Windows PATH troubleshooting docs (#28102)
* fix(install): correct Windows PATH troubleshooting — no \bin suffix needed (closes #19921)

* fix(docs): apply same PATH fix to FAQ
2026-03-02 11:07:07 -06:00
Peter Steinberger
11562c452a test(perf): avoid unused heartbeat fixture file writes 2026-03-02 17:01:40 +00:00
Val Alexander
eb2e20c994 fix(ui): preserve margin-top: 0 for onboarding mode
- Change margin from -12px -16px -32px to 0 -16px -32px
- Preserves zero top offset required for onboarding mode
- Prevents clipping of top edge/actions area when padding-top: 0
2026-03-02 11:01:27 -06:00
Val Alexander
24a13c05b3 fix(ui): add mobile responsive margins and overflow fallback
- Add margin: 0 for mobile viewports (<=600px, <=400px) to prevent clipping
- Add overflow: hidden fallback for older browsers (Safari <16, Firefox <81)
- Fixes mobile regression where negative margins over-cancel padding

Addresses issue where save button was clipped on mobile due to
hard-coded desktop negative margins not accounting for mobile's
smaller content padding (4px 4px 16px).
2026-03-02 11:01:27 -06:00
SidQin-cyber
20c36f7e84 fix(ui): prevent config page save button from being clipped by overflow
The config-layout used a uniform margin: -16px that did not match the
parent .content padding (12px 16px 32px), causing the right edge of the
actions bar—including the Save button—to extend into the overflow-hidden
region on systems with non-overlay scrollbars (e.g. Ubuntu/GTK).

Changes:
- Match negative margin to actual .content padding (-12px -16px -32px).
- Use overflow: clip instead of overflow: hidden on .config-main so it
  does not create a scroll container that shifts the stacking context.
- Add flex-shrink: 0 and position: relative on .config-actions to
  guarantee the actions bar is never collapsed or layered behind the
  scrollable content area.

Closes #31658
2026-03-02 11:01:27 -06:00
Peter Steinberger
db7a8a6982 test(perf): reuse delivery queue suite temp root 2026-03-02 16:55:18 +00:00
Peter Steinberger
4a80311628 refactor(security): split sandbox media staging and stream safe copies 2026-03-02 16:53:14 +00:00
Peter Steinberger
7a7eee920a refactor(gateway): harden plugin http route contracts 2026-03-02 16:48:00 +00:00
Peter Steinberger
33e76db12a refactor(gateway): scope ws origin fallback metrics to runtime 2026-03-02 16:47:00 +00:00
Peter Steinberger
9a68590385 refactor(logging): extract bounded regex redaction util 2026-03-02 16:47:00 +00:00
Peter Steinberger
031bf0c6c0 refactor(security): split safe-regex parse and bounded matching 2026-03-02 16:47:00 +00:00
Peter Steinberger
8611fd67b5 test(perf): remove duplicate bundled memory slot loader case 2026-03-02 16:46:17 +00:00
Peter Steinberger
14c93d2646 docs(changelog): add skills archive extraction hardening note 2026-03-02 16:45:47 +00:00
Artale
1b462ed174 fix(test): use NTFS junctions and platform guards for symlink tests on Windows (openclaw#28747) thanks @arosstale
Verified:
- pnpm install --frozen-lockfile
- pnpm test src/agents/apply-patch.test.ts src/agents/sandbox/fs-bridge.test.ts src/agents/sandbox/validate-sandbox-security.test.ts src/infra/archive.test.ts

Co-authored-by: arosstale <117890364+arosstale@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:45:19 -06:00
Peter Steinberger
18f8393b6c fix: harden sandbox writes and centralize atomic file writes 2026-03-02 16:45:12 +00:00
Peter Steinberger
14e4575af5 docs(changelog): note gateway and regex hardening 2026-03-02 16:38:03 +00:00
Peter Steinberger
b1592457fa perf(security): bound regex input in filters and redaction 2026-03-02 16:37:45 +00:00
Peter Steinberger
31c7637e0f fix(security): block quantified ambiguous alternation regex 2026-03-02 16:37:45 +00:00
Peter Steinberger
d5ae4b8337 fix(gateway): require local client for loopback origin fallback 2026-03-02 16:37:45 +00:00
Peter Steinberger
0dbb92dd2b fix(security): harden tar archive extraction parity 2026-03-02 16:36:56 +00:00
Peter Steinberger
17ede52a4b fix(security): harden sandbox media staging destination writes 2026-03-02 16:35:08 +00:00
Gustavo Madeira Santana
be65dc8acc docs(diffs): clarify file size limitations 2026-03-02 11:34:12 -05:00
zwffff
8828418111 test(subagent-announce): fix flaky Windows-only test failure (#31298) (openclaw#31370) thanks @zwffff
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check (fails on main baseline issues in extensions/googlechat and extensions/phone-control)
- pnpm test:e2e src/agents/subagent-announce.format.e2e.test.ts

Co-authored-by: zwffff <5809959+zwffff@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:33:07 -06:00
Peter Steinberger
4dd6c7a509 test(perf): avoid redundant root mkdir in hooks install tests 2026-03-02 16:33:00 +00:00
bboyyan
d94de5c4a1 fix(cron): normalize topic-qualified target.to in messaging tool suppress check (#29480)
* fix(cron): pass job.delivery.accountId through to delivery target resolution

* fix(cron): normalize topic-qualified target.to in messaging tool suppress check

When a cron job targets a Telegram forum topic (e.g. delivery.to =
"-1003597428309:topic:462"), delivery.to is stripped to the chatId
only by resolveOutboundTarget. However, the agent's message tool may
pass the full topic-qualified address as its target, causing
matchesMessagingToolDeliveryTarget to fail the equality check and not
suppress the tool send.

Strip the :topic:NNN suffix from target.to before comparing so the
suppress check works correctly for topic-bound cron deliveries.
Without this, the agent's message tool fires separately using the
announce session's accountId (often "default"), hitting 403 when
default bot is not in the multi-account target group.

* fix(cron): remove duplicate accountId keys after rebase

---------

Co-authored-by: jaxpkm <jaxpkm@jaxpkmdeMac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:32:06 -06:00
Glucksberg
09f49cd921 fix(cron): accept delivery mode "none" for sessionTarget="main" (#27431) (#28871) 2026-03-02 10:32:00 -06:00
Ayaan Zaidi
87d05592ea docs(changelog): add telegram dm streaming note (#31824) 2026-03-02 21:59:19 +05:30
Peter Steinberger
d74bc257d8 fix(line): mark webhook route as plugin-authenticated 2026-03-02 16:27:47 +00:00
Ayaan Zaidi
6edb512efa feat(telegram): use sendMessageDraft for private chat streaming (#31824)
* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
2026-03-02 21:56:59 +05:30
Peter Steinberger
c973b053a5 refactor(net): unify proxy env checks and guarded fetch modes 2026-03-02 16:24:26 +00:00
Peter Steinberger
a229ae6c3e chore(lint): add registerHttpHandler usage guard script 2026-03-02 16:24:06 +00:00
Peter Steinberger
2fd8264ab0 refactor(gateway): hard-break plugin wildcard http handlers 2026-03-02 16:24:06 +00:00
Peter Steinberger
b13d48987c refactor(gateway): unify control-ui and plugin webhook routing 2026-03-02 16:18:12 +00:00
Tak Hoffman
21708f58ce fix(exec): resolve PATH key case-insensitively for Windows pathPrepend (#25399) (#31879)
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-03-02 10:14:38 -06:00
Tak Hoffman
1ea42ebe98 fix(tsgo): unblock baseline type errors (#31873) 2026-03-02 10:09:49 -06:00
Peter Steinberger
3e5762c288 fix(security): harden sms.send dangerous-node defaults 2026-03-02 16:06:52 +00:00
SidQin-cyber
c4711a9b69 fix(gateway): let POST requests pass through root-mounted Control UI to plugin handlers
The Control UI handler checked HTTP method before path routing, causing
all POST requests (including plugin webhook endpoints like /bluebubbles-webhook)
to receive 405 Method Not Allowed.  Move the method check after path-based
exclusions so non-GET/HEAD requests reach plugin HTTP handlers.

Closes #31344

Made-with: Cursor
2026-03-02 16:06:48 +00:00
Peter Steinberger
ea204e65a0 fix(browser): fail closed navigation guard with env proxy 2026-03-02 16:06:31 +00:00
Peter Steinberger
14fbd0e6b6 test(perf): reduce timer teardown overhead in cron issue regressions 2026-03-02 16:06:04 +00:00
Peter Steinberger
17c434f2f3 refactor: split browser context/actions and unify CDP timeout policy 2026-03-02 16:02:39 +00:00
Peter Steinberger
19f5d1345c test(perf): cache redact hints and tune guardrail scan concurrency 2026-03-02 16:01:41 +00:00
Peter Steinberger
64c443ac65 docs(changelog): credit sessions_spawn agentId validation fix (#31381) 2026-03-02 15:59:45 +00:00
Peter Steinberger
b28e472fa5 fix(agents): validate sessions_spawn agentId format (#31381) 2026-03-02 15:59:45 +00:00
root
0c6db05cc0 fix(agents): add strict format validation to sessions_spawn for agentId
Implements a strict format validation for the agentId parameter in
sessions_spawn to fully resolve the ghost workspace creation bug reported
in #31311.

This fix introduces a regex format gate at the entry point to
immediately reject malformed agentId strings. This prevents error
messages (e.g., 'Agent not found: xyz') or path traversals from being
mangled by normalizeAgentId into seemingly valid IDs (e.g.,
'agent-not-found--xyz'), which was the root cause of the bug.

The validation is placed before normalization and does not interfere
with existing workflows, including delegating to agents that are
allowlisted but not globally configured.

New, non-redundant tests are added to
sessions-spawn.allowlist.test.ts to cover format validation and
ensure no regressions in allowlist behavior.

Fixes #31311
2026-03-02 15:59:45 +00:00
Liu Yuan
ade46d8ab7 fix(logging): log timestamps use local time instead of UTC (#28434)
* fix(logging): log timestamps use local time instead of UTC

Problem: Log timestamps used UTC, but docs say they should use host local timezone

* test(logging): add test for logger timestamp format

Verify logger uses local time (not UTC) in file logs

* changelog: note logger timestamp local-time fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-02 07:57:03 -08:00
Peter Steinberger
82247f09a7 test(perf): remove redundant module reset in system presence version tests 2026-03-02 15:56:30 +00:00
Peter Steinberger
d01e82d54a test(perf): avoid module reload churn in config guard tests 2026-03-02 15:56:30 +00:00
Peter Steinberger
93b0724025 fix(gateway): fail closed plugin auth path canonicalization 2026-03-02 15:55:32 +00:00
Peter Steinberger
44270c533b docs(changelog): credit sandbox mkdirp boundary fix (#31547) 2026-03-02 15:55:00 +00:00
Peter Steinberger
dec2c9e74d fix(sandbox): allow mkdirp boundary checks on existing directories (#31547) 2026-03-02 15:55:00 +00:00
User
6135eb3353 fix(sandbox): allow mkdirp boundary check on existing directories 2026-03-02 15:55:00 +00:00
Peter Steinberger
345abf0b20 fix: preserve dns pinning for strict web SSRF fetches 2026-03-02 15:54:46 +00:00
Peter Steinberger
a3d2021eea test(cron): stabilize model precedence mocks in bun runs (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
e08ba063d8 test(android): fix GatewaySessionInvokeTest coroutine job typing (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
998d477f5e test: stabilize cross-platform regression suites (#31594) 2026-03-02 15:47:21 +00:00
Peter Steinberger
a49afd25ea fix(secrets): ignore stdin EPIPE from fast-exit exec resolvers 2026-03-02 15:47:21 +00:00
Peter Steinberger
d86c1a67e0 fix(slack): correct typed message event overloads (#31701) 2026-03-02 15:47:21 +00:00
Peter Steinberger
05b84e718b fix(feishu): preserve explicit target routing hints (#31594) (thanks @liuxiaopai-ai) 2026-03-02 15:47:21 +00:00
liuxiaopai-ai
07b419a0e7 Feishu: honor group/dm prefixes in target parsing 2026-03-02 15:47:21 +00:00
Gustavo Madeira Santana
12be9a08fe refactor(diffs): dedupe functions 2026-03-02 10:46:45 -05:00
Peter Steinberger
ee1b147631 fix(zalouser): harden inbound sender id handling 2026-03-02 15:44:07 +00:00
Peter Steinberger
208a9b1ad1 docs(zalouser): document js-native migration and breaking change 2026-03-02 15:44:07 +00:00
Peter Steinberger
0f00110f5d test(zalouser): expand native runtime regression coverage 2026-03-02 15:44:07 +00:00
Peter Steinberger
174f2de447 feat(zalouser): migrate runtime to native zca-js 2026-03-02 15:44:07 +00:00
Peter Steinberger
db3d8d82c1 test(perf): avoid module reset churn in daemon lifecycle tests 2026-03-02 15:43:20 +00:00
Peter Steinberger
3f2848433a test(perf): reuse suite temp-home fixture in config io write tests 2026-03-02 15:43:20 +00:00
Peter Steinberger
663c1858b8 refactor(browser): split server context and unify CDP transport 2026-03-02 15:43:05 +00:00
Peter Steinberger
729ddfd7c8 fix: add zalo webhook account-scope regression assertions (#26121) (thanks @bmendonca3) 2026-03-02 15:38:36 +00:00
bmendonca3
f39882d57e zalo: update pairing-store read assertion to scoped params object 2026-03-02 15:38:36 +00:00
bmendonca3
6b7d3fb011 security(zalo): scope pairing store by account 2026-03-02 15:38:36 +00:00
Peter Steinberger
c63c179278 chore: add changelog note for adapter sendPayload rollout (#30144) (thanks @nohat) 2026-03-02 15:35:47 +00:00
David Friedland
dd3f7d57ee sendPayload: add chunking, empty-payload guard, and tests 2026-03-02 15:35:47 +00:00
David Friedland
47ef180fb7 sendPayload: explicit text fallback in text-only path 2026-03-02 15:35:47 +00:00
David Friedland
ebe54e6903 fix(adapters): restructure sendPayload media loop to avoid uninitialized lastResult 2026-03-02 15:35:47 +00:00
David Friedland
d06ee86292 feat(adapters): add sendPayload to batch-d adapters 2026-03-02 15:35:47 +00:00
Peter Steinberger
f1cab9c5e5 fix: stabilize zalouser account-scope regression hook (#26672) (thanks @bmendonca3) 2026-03-02 15:34:17 +00:00
bmendonca3
f4c3e483fe zalouser: update account-scope test for scoped store API 2026-03-02 15:34:17 +00:00
bmendonca3
6aa20e91d9 security(zalouser): scope pairing-store auth to accountId 2026-03-02 15:34:17 +00:00
Evgeny Zislis
4b4ea5df8b feat(cron): add failure destination support to failed cron jobs (#31059)
* feat(cron): add failure destination support with webhook mode and bestEffort handling

Extends PR #24789 failure alerts with features from PR #29145:
- Add webhook delivery mode for failure alerts (mode: 'webhook')
- Add accountId support for multi-account channel configurations
- Add bestEffort handling to skip alerts when job has bestEffort=true
- Add separate failureDestination config (global + per-job in delivery)
- Add duplicate prevention (prevents sending to same as primary delivery)
- Add CLI flags: --failure-alert-mode, --failure-alert-account-id
- Add UI fields for new options in web cron editor

* fix(cron): merge failureAlert mode/accountId and preserve failureDestination on updates

- Fix mergeCronFailureAlert to merge mode and accountId fields
- Fix mergeCronDelivery to preserve failureDestination on updates
- Fix isSameDeliveryTarget to use 'announce' as default instead of 'none'
  to properly detect duplicates when delivery.mode is undefined

* fix(cron): validate webhook mode requires URL in resolveFailureDestination

When mode is 'webhook' but no 'to' URL is provided, return null
instead of creating an invalid plan that silently fails later.

* fix(cron): fail closed on webhook mode without URL and make failureDestination fields clearable

- sendCronFailureAlert: fail closed when mode is webhook but URL is missing
- mergeCronDelivery: use per-key presence checks so callers can clear
  nested failureDestination fields via cron.update

Note: protocol:check shows missing internalEvents in Swift models - this is
a pre-existing issue unrelated to these changes (upstream sync needed).

* fix(cron): use separate schema for failureDestination and fix type cast

- Create CronFailureDestinationSchema excluding after/cooldownMs fields
- Fix type cast in sendFailureNotificationAnnounce to use CronMessageChannel

* fix(cron): merge global failureDestination with partial job overrides

When job has partial failureDestination config, fall back to global
config for unset fields instead of treating it as a full override.

* fix(cron): avoid forcing announce mode and clear inherited to on mode change

- UI: only include mode in patch if explicitly set to non-default
- delivery.ts: clear inherited 'to' when job overrides mode, since URL
  semantics differ between announce and webhook modes

* fix(cron): preserve explicit to on mode override and always include mode in UI patches

- delivery.ts: preserve job-level explicit 'to' when overriding mode
- UI: always include mode in failureAlert patch so users can switch between announce/webhook

* fix(cron): allow clearing accountId and treat undefined global mode as announce

- UI: always include accountId in patch so users can clear it
- delivery.ts: treat undefined global mode as announce when comparing for clearing inherited 'to'

* Cron: harden failure destination routing and add regression coverage

* Cron: resolve failure destination review feedback

* Cron: drop unrelated timeout assertions from conflict resolution

* Cron: format cron CLI regression test

* Cron: align gateway cron test mock types

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 09:27:41 -06:00
Peter Steinberger
a905b6dabc test(perf): merge duplicate one-shot retry regression paths 2026-03-02 15:23:58 +00:00
Peter Steinberger
44c50d9a73 fix(types): tighten shared helper typing contracts 2026-03-02 15:21:19 +00:00
Peter Steinberger
ed21b63bb8 refactor(plugin-sdk): share auth, routing, and stream/account helpers 2026-03-02 15:21:19 +00:00
Peter Steinberger
e9dd6121f2 refactor(core): dedupe embedding imports and env parsing 2026-03-02 15:21:19 +00:00
Peter Steinberger
dcf8308c8f refactor(ui): share channel config extras and hint types 2026-03-02 15:21:19 +00:00
Peter Steinberger
d212721df1 test(perf): merge forum-topic direct-delivery scenarios 2026-03-02 15:17:28 +00:00
Peter Steinberger
a469d00345 test(perf): reuse cron heartbeat delivery temp homes per suite 2026-03-02 15:14:17 +00:00
Peter Steinberger
3fb0ab7435 test(perf): tighten cron issue-regression timeout windows 2026-03-02 15:11:14 +00:00
Peter Steinberger
64ac790aa8 test(perf): reuse temp-home root in cron announce delivery suite 2026-03-02 15:08:35 +00:00
Felix Lu
f1cd3ea531 fix(app:macos): 【 OpenClaw ⇄ clawdbot 】- Peekaboo Bridge discovery after the OpenClaw rename (#6033)
* fix(mac): keep OpenClaw bridge socket and harden legacy symlink

* fix(mac): add clawdis legacy Peekaboo bridge symlink

* macos: include moltbot in PeekabooBridge legacy socket paths

* changelog: note peekaboo legacy socket compatibility paths

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-02 07:00:30 -08:00
Peter Steinberger
c5f1cf3c3b test(perf): reuse isolated-agent temp home root per suite 2026-03-02 15:00:08 +00:00
Peter Steinberger
87bd6226bd test(perf): merge overlapping preaction hook scenarios 2026-03-02 14:52:38 +00:00
Robin Waslander
9f98d2766a fix(logs): respect TZ env var for timestamp display, fix Windows timezone (#21859) 2026-03-02 08:44:37 -06:00
StingNing
944abe0a6c fix(security): recognize localized Windows SYSTEM account in ACL audit (#29698)
* fix(security): recognize localized Windows SYSTEM account in ACL audit

On non-English Windows (e.g. French "AUTORITE NT\Système"), the security
audit falsely reports fs.config.perms_writable because the localized
SYSTEM account name is not recognized as trusted.

Changes:
- Add common localized SYSTEM principal names (French, German, Spanish,
  Portuguese) to TRUSTED_BASE
- Add diacritics-stripping fallback in classifyPrincipal for unhandled
  locales
- Use well-known SID *S-1-5-18 in icacls reset commands instead of
  hardcoded "SYSTEM" string for locale independence

Fixes #29681

* style: format windows acl files

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:38:56 -06:00
Peter Steinberger
dbc78243f4 refactor(scripts): share guard runners and paged select UI 2026-03-02 14:36:41 +00:00
Peter Steinberger
e41f9998f7 refactor(test): extract shared gateway hook and vitest scoped config helpers 2026-03-02 14:36:41 +00:00
Peter Steinberger
741e74972b refactor(plugin-sdk): share boolean action param parsing 2026-03-02 14:36:41 +00:00
Peter Steinberger
693f61404d refactor(shared): centralize assistant identity and usage timeseries types 2026-03-02 14:36:41 +00:00
Peter Steinberger
3efd224ec6 refactor(commands): dedupe session target resolution and fs tool test setup 2026-03-02 14:36:41 +00:00
Peter Steinberger
b85facfb5d refactor(android): share node JSON param parsing helpers 2026-03-02 14:36:41 +00:00
Ajay Elika
e23b6fb2ba fix(gateway): add Windows-compatible port detection using netstat fallback (openclaw#29239) thanks @ajay99511
Verified:
- pnpm vitest src/cli/program.force.test.ts
- pnpm check
- pnpm build

Co-authored-by: ajay99511 <73169130+ajay99511@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:33:59 -06:00
tda
d145518f94 fix(cli): wait for process exit before restarting gateway on Windows (openclaw#27913) thanks @tda1017
Verified:
- pnpm vitest src/cli/update-cli/restart-helper.test.ts
- pnpm check
- pnpm build

Co-authored-by: tda1017 <95275462+tda1017@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:31:03 -06:00
371 changed files with 20255 additions and 8122 deletions

View File

@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
import { showPagedSelectList } from "./ui/paged-select";
interface FileInfo {
status: string;
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
}
};
// 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) => {
const items = files.map((file) => ({
value: file,
label: `${file.status} ${file.file}`,
}));
await showPagedSelectList({
ctx,
title: " Select file to diff",
items,
onSelect: (item) => {
void openSelected(item.value as FileInfo);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
},
});
},
});

View File

@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
import { showPagedSelectList } from "./ui/paged-select";
interface FileEntry {
path: string;
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
}
};
// 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)));
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 {
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();
},
value: file,
label: `${ops.join("")} ${file.path}`,
};
});
await showPagedSelectList({
ctx,
title: " Select file to open",
items,
onSelect: (item) => {
void openSelected(item.value as FileEntry);
},
});
},
});
}

View File

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

View File

@@ -6,6 +6,8 @@ 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.
@@ -15,6 +17,7 @@ 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.
@@ -36,11 +39,32 @@ 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:** 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
- 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.
- 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)
@@ -48,6 +72,7 @@ Docs: https://docs.openclaw.ai
- 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.
@@ -70,6 +95,7 @@ Docs: https://docs.openclaw.ai
- 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.
@@ -78,6 +104,7 @@ 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.
@@ -200,6 +227,7 @@ 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.
@@ -226,6 +254,7 @@ Docs: https://docs.openclaw.ai
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.

View File

@@ -33,10 +33,7 @@ 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
@@ -101,7 +98,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseParamsObject(paramsJson)
val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(params) ?: 1600
@@ -167,7 +164,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseParamsObject(paramsJson)
val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
@@ -293,20 +290,8 @@ 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 = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
return when (value) {
"front", "back" -> value
else -> null
@@ -314,31 +299,21 @@ class CameraCaptureManager(private val context: Context) {
}
private fun parseQuality(params: JsonObject?): Double? =
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
readPrimitive(params, "maxWidth")
?.contentOrNull
?.toIntOrNull()
parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? =
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
readPrimitive(params, "deviceId")
?.contentOrNull
parseJsonString(params, "deviceId")
?.trim()
?.takeIf { it.isNotEmpty() }
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 parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)

View File

@@ -1,10 +1,12 @@
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
@@ -21,6 +23,35 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
}
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun parseJsonInt(params: JsonObject?, key: String): Int? =
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonString(params: JsonObject?, key: String): String? =
readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"true" -> true
"false" -> false
else -> null
}
}
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null

View File

@@ -10,10 +10,7 @@ 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
@@ -39,7 +36,7 @@ class ScreenRecordManager(private val context: Context) {
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val params = parseParamsObject(paramsJson)
val params = parseJsonParamsObject(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)
@@ -146,38 +143,19 @@ 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? =
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
parseJsonInt(params, "durationMs")
private fun parseFps(params: JsonObject?): Double? =
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
parseJsonDouble(params, "fps")
private fun parseScreenIndex(params: JsonObject?): Int? =
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
parseJsonInt(params, "screenIndex")
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 parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseString(params: JsonObject?, key: String): String? =
readPrimitive(params, key)?.contentOrNull
parseJsonString(params, key)
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()

View File

@@ -3,6 +3,7 @@ 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
@@ -44,7 +45,7 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: SupervisorJob,
val sessionJob: Job,
)
private data class InvokeScenarioResult(
@@ -86,7 +87,7 @@ class GatewaySessionInvokeTest {
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}}""",
"""{"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}""")
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
---
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
summary: "Zalo personal account support via native zca-js (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 `zca-cli`.
Status: experimental. This integration automates a **personal Zalo account** via native `zca-js` inside OpenClaw.
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
@@ -20,19 +20,14 @@ 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)
## 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).
No external `zca`/`openzca` CLI binary is required.
## 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 in the terminal with the Zalo mobile app.
- Scan the QR code with the Zalo mobile app.
3. Enable the channel:
```json5
@@ -51,8 +46,9 @@ The Gateway machine must have the `zca` binary available in `PATH`.
## What it is
- Uses `zca listen` to receive inbound messages.
- Uses `zca msg ...` to send replies (text/media/link).
- 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).
- Designed for “personal account” use cases where Zalo Bot API is not available.
## Naming
@@ -77,7 +73,8 @@ 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. The wizard resolves names to IDs via `zca friend find` when available.
`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup.
Approve via:
@@ -112,7 +109,7 @@ Example:
## Multi-account
Accounts map to zca profiles. Example:
Accounts map to `zalouser` profiles in OpenClaw state. Example:
```json5
{
@@ -130,11 +127,16 @@ Accounts map to zca profiles. Example:
## Troubleshooting
**`zca` not found:**
- Install zca-cli and ensure its on `PATH` for the Gateway process.
**Login doesnt stick:**
**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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
---
summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
summary: "Zalo Personal plugin: QR login + messaging via native zca-js (plugin install + channel config + 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 `zca-cli` to automate a normal Zalo user account.
Zalo Personal support for OpenClaw via a plugin, using native `zca-js` to automate a normal Zalo user account.
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
@@ -22,6 +22,8 @@ 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
@@ -41,14 +43,6 @@ cd ./extensions/zalouser && pnpm install
Restart the Gateway afterwards.
## Prerequisite: zca-cli
The Gateway machine must have `zca` on `PATH`:
```bash
zca --version
```
## Config
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):

View File

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

View File

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

View File

@@ -86,8 +86,8 @@ openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?"
After setup, OpenClaw shows all available Venice models. Pick based on your needs:
- **Default (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).
- **Default model**: `venice/llama-3.3-70b` for private, balanced performance.
- **High-capability option**: `venice/claude-opus-45` for hard jobs.
- **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 |
| **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 |
| 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 |
## Available Models (25 Total)

View File

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

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, 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";
afterEach(async () => {
afterAll(async () => {
await cleanupMockRuntimeFixtures();
});
@@ -336,12 +336,6 @@ describe("AcpxRuntime", () => {
expect(runtime.isHealthy()).toBe(false);
});
it("marks runtime healthy when command is available", async () => {
const { runtime } = await createMockRuntimeFixture();
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
});
it("logs ACPX spawn resolution once per command policy", async () => {
const { config } = await createMockRuntimeFixture();
const debugLogs: string[] = [];

View File

@@ -10,7 +10,7 @@ If youre looking for **how to use BlueBubbles as an agent/tool user**, see:
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.

View File

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

View File

@@ -5,6 +5,7 @@ import {
extractToolSend,
jsonResult,
readNumberParam,
readBooleanParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
@@ -52,23 +53,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
const raw = params[key];
if (typeof raw === "boolean") {
return raw;
}
if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") {
return true;
}
if (trimmed === "false") {
return false;
}
}
return undefined;
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([

View File

@@ -0,0 +1,205 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
export type BlueBubblesDebouncer = {
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
};
export type BlueBubblesDebounceRegistry = {
getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
removeDebouncer: (target: WebhookTarget) => void;
};
/**
* Default debounce window for inbound message coalescing (ms).
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
/**
* Combines multiple debounced messages into a single message for processing.
* Used when multiple webhook events arrive within the debounce window.
*/
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
if (entries.length === 0) {
throw new Error("Cannot combine empty entries");
}
if (entries.length === 1) {
return entries[0].message;
}
// Use the first message as the base (typically the text message)
const first = entries[0].message;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
const text = entry.message.text.trim();
if (!text) {
continue;
}
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) {
continue;
}
seenTexts.add(normalizedText);
textParts.push(text);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
// Use the latest timestamp
const timestamps = entries
.map((e) => e.message.timestamp)
.filter((t): t is number => typeof t === "number");
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
// Collect all message IDs for reference
const messageIds = entries
.map((e) => e.message.messageId)
.filter((id): id is string => Boolean(id));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
timestamp: latestTimestamp,
// Use first message's ID as primary (for reply reference), but we've coalesced others
messageId: messageIds[0] ?? first.messageId,
// Preserve reply context if present
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
balloonBundleId: undefined,
};
}
function resolveBlueBubblesDebounceMs(
config: OpenClawConfig,
core: BlueBubblesCoreRuntime,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) {
return DEFAULT_INBOUND_DEBOUNCE_MS;
}
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
export function createBlueBubblesDebounceRegistry(params: {
processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
}): BlueBubblesDebounceRegistry {
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
return {
getOrCreateDebouncer: (target) => {
const existing = targetDebouncers.get(target);
if (existing) {
return existing;
}
const { account, config, runtime, core } = target;
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
// same message (e.g., text-only then text+attachment).
//
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
// messageId than the originating text. When present, key by associatedMessageGuid
// to keep text + balloon coalescing working.
const balloonBundleId = msg.balloonBundleId?.trim();
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
if (balloonBundleId && associatedMessageGuid) {
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
}
const messageId = msg.messageId?.trim();
if (messageId) {
return `bluebubbles:${account.accountId}:msg:${messageId}`;
}
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) {
return false;
}
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) {
return false;
}
// Debounce all other messages to coalesce rapid-fire webhook events
// (e.g., text+image arriving as separate webhooks for the same messageId)
return true;
},
onFlush: async (entries) => {
if (entries.length === 0) {
return;
}
// Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target;
if (entries.length === 1) {
// Single message - process normally
await params.processMessage(entries[0].message, flushTarget);
return;
}
// Multiple messages - combine and process
const combined = combineDebounceEntries(entries);
if (core.logging.shouldLogVerbose()) {
const count = entries.length;
const preview = combined.text.slice(0, 50);
runtime.log?.(
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
);
}
await params.processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(
`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
},
removeDebouncer: (target) => {
targetDebouncers.delete(target);
},
};
}

View File

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

View File

@@ -1,20 +1,15 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
requestBodyErrorToText,
resolveSingleWebhookTarget,
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
type NormalizedWebhookMessage,
} from "./monitor-normalize.js";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
@@ -24,229 +19,44 @@ import {
DEFAULT_WEBHOOK_PATH,
normalizeWebhookPath,
resolveWebhookPathFromConfig,
type BlueBubblesCoreRuntime,
type BlueBubblesMonitorOptions,
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import { getBlueBubblesRuntime } from "./runtime.js";
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
/**
* Default debounce window for inbound message coalescing (ms).
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
/**
* Combines multiple debounced messages into a single message for processing.
* Used when multiple webhook events arrive within the debounce window.
*/
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
if (entries.length === 0) {
throw new Error("Cannot combine empty entries");
}
if (entries.length === 1) {
return entries[0].message;
}
// Use the first message as the base (typically the text message)
const first = entries[0].message;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
const text = entry.message.text.trim();
if (!text) {
continue;
}
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) {
continue;
}
seenTexts.add(normalizedText);
textParts.push(text);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
// Use the latest timestamp
const timestamps = entries
.map((e) => e.message.timestamp)
.filter((t): t is number => typeof t === "number");
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
// Collect all message IDs for reference
const messageIds = entries
.map((e) => e.message.messageId)
.filter((id): id is string => Boolean(id));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
timestamp: latestTimestamp,
// Use first message's ID as primary (for reply reference), but we've coalesced others
messageId: messageIds[0] ?? first.messageId,
// Preserve reply context if present
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
balloonBundleId: undefined,
};
}
const webhookTargets = new Map<string, WebhookTarget[]>();
type BlueBubblesDebouncer = {
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
};
/**
* Maps webhook targets to their inbound debouncers.
* Each target gets its own debouncer keyed by a unique identifier.
*/
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
function resolveBlueBubblesDebounceMs(
config: OpenClawConfig,
core: BlueBubblesCoreRuntime,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) {
return DEFAULT_INBOUND_DEBOUNCE_MS;
}
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
/**
* Creates or retrieves a debouncer for a webhook target.
*/
function getOrCreateDebouncer(target: WebhookTarget) {
const existing = targetDebouncers.get(target);
if (existing) {
return existing;
}
const { account, config, runtime, core } = target;
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
// same message (e.g., text-only then text+attachment).
//
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
// messageId than the originating text. When present, key by associatedMessageGuid
// to keep text + balloon coalescing working.
const balloonBundleId = msg.balloonBundleId?.trim();
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
if (balloonBundleId && associatedMessageGuid) {
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
}
const messageId = msg.messageId?.trim();
if (messageId) {
return `bluebubbles:${account.accountId}:msg:${messageId}`;
}
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) {
return false;
}
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) {
return false;
}
// Debounce all other messages to coalesce rapid-fire webhook events
// (e.g., text+image arriving as separate webhooks for the same messageId)
return true;
},
onFlush: async (entries) => {
if (entries.length === 0) {
return;
}
// Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target;
if (entries.length === 1) {
// Single message - process normally
await processMessage(entries[0].message, flushTarget);
return;
}
// Multiple messages - combine and process
const combined = combineDebounceEntries(entries);
if (core.logging.shouldLogVerbose()) {
const count = entries.length;
const preview = combined.text.slice(0, 50);
runtime.log?.(
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
);
}
await processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
}
/**
* Removes a debouncer for a target (called during unregistration).
*/
function removeDebouncer(target: WebhookTarget): void {
targetDebouncers.delete(target);
}
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const registered = registerWebhookTarget(webhookTargets, target);
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");
}
},
},
});
return () => {
registered.unregister();
// Clean up debouncer when target is unregistered
removeDebouncer(registered.target);
debounceRegistry.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 } {
@@ -270,36 +80,6 @@ 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>)
@@ -348,137 +128,150 @@ export async function handleBlueBubblesWebhookRequest(
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.statusCode;
res.end(body.error ?? "invalid payload");
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
return true;
}
const payload = asRecord(body.value) ?? {};
const firstTarget = targets[0];
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
);
}
const eventTypeRaw = payload.type;
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
const allowedEventTypes = new Set([
"new-message",
"updated-message",
"message-reaction",
"reaction",
]);
if (eventType && !allowedEventTypes.has(eventType)) {
res.statusCode = 200;
res.end("ok");
if (firstTarget) {
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
}
return true;
}
const reaction = normalizeWebhookReaction(payload);
if (
(eventType === "updated-message" ||
eventType === "message-reaction" ||
eventType === "reaction") &&
!reaction
) {
res.statusCode = 200;
res.end("ok");
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook ignored ${eventType || "event"} without reaction`,
);
}
return true;
}
const message = reaction ? null : normalizeWebhookMessage(payload);
if (!message && !reaction) {
res.statusCode = 400;
res.end("invalid payload");
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
return true;
}
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
const headerToken =
req.headers["x-guid"] ??
req.headers["x-password"] ??
req.headers["x-bluebubbles-guid"] ??
req.headers["authorization"];
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
const token = target.account.config.password?.trim() ?? "";
return safeEqualSecret(guid, token);
const requestLifecycle = beginWebhookRequestPipelineOrReject({
req,
res,
allowMethods: ["POST"],
inFlightLimiter: webhookInFlightLimiter,
inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
});
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") ?? "")}`,
);
if (!requestLifecycle.ok) {
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)}`,
);
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);
},
});
} 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)}`,
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;
}
res.statusCode = 200;
res.end("ok");
if (reaction) {
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(parsed.value) ?? {};
const firstTarget = targets[0];
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
);
}
} else if (message) {
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
);
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();
}
return true;
}
export async function monitorBlueBubblesProvider(

View File

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

View File

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

View File

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

View File

@@ -150,6 +150,16 @@ 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;
@@ -298,6 +308,35 @@ 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,
@@ -314,33 +353,35 @@ async function renderBeforeAfterDiff(
contents: input.after,
...(lang ? { lang } : {}),
};
const viewerPayloadOptions = buildDiffOptions(options);
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const [viewerResult, imageResult] = await Promise.all([
preloadMultiFileDiff({
oldFile,
newFile,
options: viewerPayloadOptions,
options: viewerOptions,
}),
preloadMultiFileDiff({
oldFile,
newFile,
options: imagePayloadOptions,
options: imageOptions,
}),
]);
return {
viewerBodyHtml: renderDiffCard({
prerenderedHTML: viewerResult.prerenderedHTML,
const section = buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerPayloadOptions,
options: viewerOptions,
langs: buildPayloadLanguages({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
}),
imageBodyHtml: renderStaticDiffCard(imageResult.prerenderedHTML),
},
});
return {
...buildRenderedBodies([section]),
fileCount: 1,
};
}
@@ -365,36 +406,34 @@ async function renderPatchDiff(
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
}
const viewerPayloadOptions = buildDiffOptions(options);
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const sections = await Promise.all(
files.map(async (fileDiff) => {
const [viewerResult, imageResult] = await Promise.all([
preloadFileDiff({
fileDiff,
options: viewerPayloadOptions,
options: viewerOptions,
}),
preloadFileDiff({
fileDiff,
options: imagePayloadOptions,
options: imageOptions,
}),
]);
return {
viewer: renderDiffCard({
prerenderedHTML: viewerResult.prerenderedHTML,
return buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
fileDiff: viewerResult.fileDiff,
options: viewerPayloadOptions,
options: viewerOptions,
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
}),
image: renderStaticDiffCard(imageResult.prerenderedHTML),
};
},
});
}),
);
return {
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
imageBodyHtml: sections.map((section) => section.image).join("\n"),
...buildRenderedBodies(sections),
fileCount: files.length,
};
}

View File

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

View File

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

View File

@@ -106,39 +106,9 @@ 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)";
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";
button.dataset.active = String(active);
}
function splitIcon(): string {
@@ -193,11 +163,6 @@ function themeIcon(theme: DiffTheme): string {
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
toolbar.style.display = "inline-flex";
toolbar.style.alignItems = "center";
toolbar.style.gap = "6px";
toolbar.style.marginInlineStart = "6px";
toolbar.style.flex = "0 0 auto";
toolbar.append(
createToolbarButton({

View File

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

View File

@@ -8,18 +8,22 @@ 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(params.to);
const receiveId = normalizeFeishuTarget(target);
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(receiveId),
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
};
}

View File

@@ -13,6 +13,14 @@ 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", () => {
@@ -25,9 +33,17 @@ 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", () => {
@@ -38,4 +54,8 @@ describe("looksLikeFeishuId", () => {
it("accepts provider-prefixed chat targets", () => {
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
});
it("accepts provider-prefixed group targets", () => {
expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
});
});

View File

@@ -33,9 +33,15 @@ 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;
}
@@ -56,6 +62,17 @@ 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";
}
@@ -70,7 +87,7 @@ export function looksLikeFeishuId(raw: string): boolean {
if (!trimmed) {
return false;
}
if (/^(chat|user|open_id):/i.test(trimmed)) {
if (/^(chat|group|user|dm|open_id):/i.test(trimmed)) {
return true;
}
if (trimmed.startsWith(CHAT_ID_PREFIX)) {

View File

@@ -1,7 +1,6 @@
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 = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setGoogleChatRuntime(api.runtime);
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
api.registerHttpHandler(handleGoogleChatWebhookRequest);
},
};

View File

@@ -48,18 +48,14 @@ 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 new Promise((resolve) => setTimeout(resolve, 20));
await vi.waitFor(() => {
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
});
expect(settled).toBe(false);
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
expect(unregister).not.toHaveBeenCalled();
abort.abort();

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
GROUP_POLICY_BLOCKED_LABEL,
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createWebhookInFlightLimiter,
createReplyPrefixOptions,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSingleWebhookTargetAsync,
registerWebhookTargetWithPluginRoute,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
resolveWebhookTargets,
warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk";
import { type ResolvedGoogleChatAccount } from "./accounts.js";
import {
@@ -26,47 +14,29 @@ import {
sendGoogleChatMessage,
updateGoogleChatMessage,
} from "./api.js";
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { type GoogleChatAudienceType } from "./auth.js";
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
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;
};
GoogleChatCoreRuntime,
GoogleChatMonitorOptions,
GoogleChatRuntimeEnv,
WebhookTarget,
} from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
export { isSenderAllowed };
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()) {
@@ -74,33 +44,27 @@ 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 registerWebhookTarget(webhookTargets, target).unregister;
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;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
@@ -122,136 +86,7 @@ export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
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;
return await googleChatWebhookRequestHandler(req, res);
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
@@ -274,98 +109,6 @@ 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
@@ -398,11 +141,6 @@ 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) {
@@ -419,7 +157,6 @@ 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) {
@@ -441,220 +178,35 @@ async function processMessageWithPipeline(params: {
return;
}
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),
});
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({
const access = await applyGoogleChatInboundAccessPolicy({
account,
config,
core,
space,
message,
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,
senderName,
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})`,
);
rawBody,
statusSink,
logVerbose: (message) => logVerbose(core, runtime, message),
});
if (!access.ok) {
return;
}
const { commandAuthorized, effectiveWasMentioned, groupSystemPrompt } = access;
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({
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: config,
channel: "googlechat",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
kind: isGroup ? ("group" as const) : ("direct" as const),
id: spaceId,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
runtime: core.channel,
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;
@@ -678,8 +230,6 @@ async function processMessageWithPipeline(params: {
body: rawBody,
});
const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
@@ -958,7 +508,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
const audience = options.account.config.audience?.trim();
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
const unregister = registerGoogleChatWebhookTarget({
const unregisterTarget = registerGoogleChatWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
@@ -970,7 +520,9 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
mediaMaxMb,
});
return unregister;
return () => {
unregisterTarget();
};
}
export async function startGoogleChatMonitor(

View File

@@ -1,7 +1,9 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { verifyGoogleChatRequest } from "./auth.js";
@@ -19,6 +21,7 @@ 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";
@@ -27,21 +30,50 @@ 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;
};
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
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");
}
});
}
});
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,
@@ -86,6 +118,47 @@ 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 });
@@ -135,4 +208,59 @@ describe("Google Chat webhook routing", () => {
unregister();
}
});
it("rejects invalid bearer before attempting to read the body", async () => {
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
const { unregister } = registerTwoTargets();
try {
const req = createHeaderOnlyWebhookRequest({
authorization: "Bearer invalid-token",
});
const onSpy = vi.spyOn(req, "on");
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
} finally {
unregister();
}
});
it("supports add-on requests that provide systemIdToken in the body", async () => {
vi.mocked(verifyGoogleChatRequest)
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
.mockResolvedValueOnce({ ok: true });
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
payload: {
commonEventObject: { hostApp: "CHAT" },
authorizationEventObject: { systemIdToken: "addon-token" },
chat: {
eventTime: "2026-03-02T00:00:00.000Z",
user: { name: "users/12345", displayName: "Test User" },
messagePayload: {
space: { name: "spaces/AAA" },
message: { text: "Hello from add-on" },
},
},
},
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregister();
}
});
});

View File

@@ -115,16 +115,15 @@ describe("linePlugin gateway.startAccount", () => {
}),
);
// 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",
}),
);
await vi.waitFor(() => {
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
abort.abort();
await task;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import { readFileSync } from "node:fs";
import {
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
resolveAccountWithDefaultFallback,
} from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -28,18 +32,10 @@ export type ResolvedNextcloudTalkAccount = {
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
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];
return listConfiguredAccountIdsFromSection({
accounts: cfg.channels?.["nextcloud-talk"]?.accounts as Record<string, unknown> | undefined,
normalizeAccountId,
});
}
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
@@ -134,7 +130,6 @@ 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) => {
@@ -162,24 +157,13 @@ export function resolveNextcloudTalkAccount(params: {
} satisfies ResolvedNextcloudTalkAccount;
};
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;
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: resolve,
hasCredential: (account) => account.secretSource !== "none",
resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg),
});
}
export function listEnabledNextcloudTalkAccounts(cfg: CoreConfig): ResolvedNextcloudTalkAccount[] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
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 = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
api.registerHttpHandler(handleZaloWebhookRequest);
},
};

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveSenderCommandAuthorization,
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
sendMediaWithLeadingCaption,
resolveWebhookPath,
warnMissingProviderGroupPolicyFallbackOnce,
@@ -74,7 +75,24 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerZaloWebhookTargetInternal(target);
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");
}
},
},
});
}
export {
@@ -367,91 +385,76 @@ async function processMessageWithPipeline(params: {
}
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
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({
isGroup,
dmPolicy,
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),
senderAllowedForCommands,
});
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 (!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})`,
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)}`);
}
return;
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
const route = core.channel.routing.resolveAgentRoute({
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
kind: isGroup ? ("group" as const) : ("direct" as const),
id: chatId,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
runtime: core.channel,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
if (

View File

@@ -2,6 +2,8 @@ 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,
@@ -47,13 +49,16 @@ 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: DEFAULT_ACCOUNT,
config: {} as OpenClawConfig,
account: params.account ?? DEFAULT_ACCOUNT,
config: params.config ?? ({} as OpenClawConfig),
runtime: {},
core: {} as PluginRuntime,
core: params.core ?? ({} as PluginRuntime),
secret: params.secret ?? "secret",
path: params.path,
mediaMaxMb: 5,
@@ -61,9 +66,59 @@ 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 () => {
@@ -206,7 +261,6 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
});
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
const unregister = registerTarget({ path: "/hook-query-status" });
@@ -259,4 +313,65 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
});
it("scopes DM pairing store reads and writes to accountId", async () => {
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
pairingCreated: false,
});
const account: ResolvedZaloAccount = {
...DEFAULT_ACCOUNT,
accountId: "work",
config: {
dmPolicy: "pairing",
allowFrom: [],
},
};
const unregister = registerTarget({
path: "/hook-account-scope",
account,
core,
});
const payload = {
event_name: "message.text.received",
message: {
from: { id: "123", name: "Attacker" },
chat: { id: "dm-work", chat_type: "PRIVATE" },
message_id: "msg-work-1",
date: Math.floor(Date.now() / 1000),
text: "hello",
},
};
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-account-scope`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
});
} finally {
unregister();
}
expect(readAllowFromStore).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
accountId: "work",
}),
);
expect(upsertPairingRequest).toHaveBeenCalledWith(
expect.objectContaining({
channel: "zalo",
id: "123",
accountId: "work",
}),
);
});
});

View File

@@ -7,6 +7,9 @@ import {
createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject,
applyBasicWebhookRequestGuards,
registerWebhookTargetWithPluginRoute,
type RegisterWebhookTargetOptions,
type RegisterWebhookPluginRouteOptions,
registerWebhookTarget,
resolveSingleWebhookTarget,
resolveWebhookTargets,
@@ -106,8 +109,24 @@ function recordWebhookStatus(
});
}
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerWebhookTarget(webhookTargets, target).unregister;
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 async function handleZaloWebhookRequest(

View File

@@ -4,7 +4,11 @@
### Changes
- Version alignment with core OpenClaw release numbers.
- 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.
## 2026.3.1

View File

@@ -1,103 +1,52 @@
# @openclaw/zalouser
OpenClaw extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
OpenClaw extension for Zalo Personal Account messaging via native `zca-js` integration.
> **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**: 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
- 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
## Prerequisites
Install `zca` CLI and ensure it's in your PATH:
- OpenClaw Gateway
- Zalo mobile app (for QR login)
**macOS / Linux:**
No external `zca`, `openzca`, or `zca-cli` binary is required.
## Install
### Option A: npm
```bash
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
openclaw plugins install @openclaw/zalouser
```
**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:**
### Option B: local source checkout
```bash
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
openclaw plugins install ./extensions/zalouser
cd ./extensions/zalouser && pnpm install
```
**Windows (PowerShell):**
Restart the Gateway after install.
```powershell
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
```
## Quick start
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)
### Login (QR)
```bash
openclaw channels login --channel zalouser
# Scan QR code with Zalo app
```
### Send a Message
Scan the QR code with the Zalo app on your phone.
```bash
openclaw message send --channel zalouser --target <threadId> --message "Hello from OpenClaw!"
```
## Configuration
After onboarding, your config will include:
### Enable channel
```yaml
channels:
@@ -106,7 +55,24 @@ channels:
dmPolicy: pairing # pairing | allowlist | open | disabled
```
For multi-account:
### 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:
```yaml
channels:
@@ -122,104 +88,32 @@ channels:
profile: work
```
## Commands
### Authentication
## Useful commands
```bash
openclaw channels login --channel zalouser # Login via QR
openclaw channels login --channel zalouser
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>
```
### Account Management
## Agent tool
```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"
}
```
The extension registers a `zalouser` tool for AI agents.
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
## Troubleshooting
- **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
- 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
## Credits
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).
Built on [zca-js](https://github.com/RFS-ADRENO/zca-js).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
@@ -17,6 +19,7 @@ import {
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolvePreferredOpenClawTmpDir,
resolveChannelAccountConfigBasePath,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
@@ -33,8 +36,15 @@ import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
import { sendMessageZalouser } from "./send.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import {
listZaloFriendsMatching,
listZaloGroupMembers,
listZaloGroupsMatching,
logoutZaloProfile,
startZaloQrLogin,
waitForZaloQrLogin,
getZaloUserInfo,
} from "./zalo-js.js";
const meta = {
id: "zalouser",
@@ -51,11 +61,30 @@ const meta = {
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZCA_PROFILE?.trim() || "default";
return process.env.ZALOUSER_PROFILE?.trim() || 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;
@@ -173,14 +202,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
"messagePrefix",
],
}),
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;
},
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
@@ -294,21 +316,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
},
directory: {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error("Missing dependency: `zca` not found in PATH");
}
self: async ({ cfg, accountId }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
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);
const parsed = await getZaloUserInfo(account.profile);
if (!parsed?.userId) {
return null;
}
@@ -320,92 +330,42 @@ 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 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,
}),
)
: [];
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,
}),
);
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 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));
}
const groups = await listZaloGroupsMatching(account.profile, query);
const rows = groups.map((group) =>
mapGroup({
id: String(group.groupId),
name: group.name ?? null,
raw: group,
}),
);
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 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,
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 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[];
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
},
resolver: {
@@ -426,48 +386,27 @@ 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 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];
const friends = await listZaloFriendsMatching(account.profile, trimmed);
const best = friends[0];
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
resolved: Boolean(best?.userId),
id: best?.userId,
name: best?.displayName,
note: friends.length > 1 ? "multiple matches; chose first" : undefined,
});
} else {
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const matches = Array.isArray(parsed)
? parsed.map((g) => ({
id: String(g.groupId),
name: g.name ?? undefined,
}))
: [];
const groups = await listZaloGroupsMatching(account.profile, trimmed);
const best =
matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ??
groups[0];
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
resolved: Boolean(best?.groupId),
id: best?.groupId,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
note: groups.length > 1 ? "multiple matches; chose first" : undefined,
});
}
} catch (err) {
@@ -498,19 +437,32 @@ 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(
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
`Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
);
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
if (!result.ok) {
throw new Error(result.stderr || "Zalouser login failed");
const started = await startZaloQrLogin({
profile: account.profile,
timeoutMs: 35_000,
});
if (!started.qrDataUrl) {
throw new Error(started.message || "Failed to start QR login");
}
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: {
@@ -518,6 +470,40 @@ 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 });
@@ -528,11 +514,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
mediaLocalRoots,
});
return {
channel: "zalouser",
@@ -562,9 +549,8 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
}),
probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
buildAccountSnapshot: async ({ account, runtime }) => {
const zcaInstalled = await checkZcaInstalled();
const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
const configured = await checkZcaAuthenticated(account.profile);
const configError = "not authenticated";
return {
accountId: account.accountId,
name: account.name,
@@ -608,44 +594,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
loginWithQrStart: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
// Start login and get QR code
const result = await runZca(["auth", "login", "--qr-base64"], {
return await startZaloQrLogin({
profile,
timeout: params.timeoutMs ?? 30000,
force: params.force,
timeoutMs: params.timeoutMs,
});
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);
// Check if already authenticated
const statusResult = await runZca(["auth", "status"], {
return await waitForZaloQrLogin({
profile,
timeout: params.timeoutMs ?? 60000,
timeoutMs: params.timeoutMs,
});
return {
connected: statusResult.ok,
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
};
},
logoutAccount: async (ctx) => {
const result = await runZca(["auth", "logout"], {
profile: ctx.account.profile,
timeout: 10000,
});
return {
cleared: result.ok,
loggedOut: result.ok,
message: result.ok ? "Logged out" : result.stderr,
};
},
logoutAccount: async (ctx) =>
await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
},
};

View File

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

View File

@@ -1,4 +1,3 @@
import type { ChildProcess } from "node:child_process";
import type {
MarkdownTableMode,
OpenClawConfig,
@@ -6,7 +5,6 @@ import type {
RuntimeEnv,
} from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveOutboundMediaUrls,
@@ -20,8 +18,8 @@ import {
} from "openclaw/plugin-sdk";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
@@ -63,11 +61,14 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str
}
}
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = senderId.toLowerCase();
const normalizedSenderId = senderId?.trim().toLowerCase();
if (!normalizedSenderId) {
return false;
}
return allowFrom.some((entry) => {
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
return normalized === normalizedSenderId;
@@ -115,84 +116,34 @@ 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: ZcaMessage,
message: ZaloInboundMessage,
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,
});
if (!content?.trim()) {
const rawBody = message.content?.trim();
if (!rawBody) {
return;
}
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const groupName = metadata?.threadName ?? "";
const chatId = threadId;
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 defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
@@ -204,8 +155,9 @@ async function processMessage(
providerMissingFallbackApplied,
providerKey: "zalouser",
accountId: account.accountId,
log: (message) => logVerbose(core, runtime, message),
log: (entry) => logVerbose(core, runtime, entry),
});
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
@@ -223,7 +175,6 @@ 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,
@@ -247,7 +198,6 @@ async function processMessage(
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
@@ -315,21 +265,22 @@ 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 ? `group:${chatId}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({
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({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
timestamp: message.timestampMs,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
@@ -349,7 +300,7 @@ async function processMessage(
CommandAuthorized: commandAuthorized,
Provider: "zalouser",
Surface: "zalouser",
MessageSid: message.msgId ?? `${timestamp}`,
MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`,
OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`,
});
@@ -466,10 +417,6 @@ 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;
@@ -478,147 +425,144 @@ export async function monitorZalouserProvider(
.filter((entry) => entry && entry !== "*");
if (allowFromEntries.length > 0) {
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 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 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 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;
}
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);
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];
}
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];
}
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)}`);
}
const stop = () => {
stopped = true;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
resolveRunning?.();
};
let listenerStop: (() => void) | null = null;
let stopped = false;
const startListener = () => {
if (stopped || abortSignal.aborted) {
resolveRunning?.();
const stop = () => {
if (stopped) {
return;
}
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,
);
stopped = true;
listenerStop?.();
listenerStop = null;
};
// Create a promise that stays pending until abort or stop
const runningPromise = new Promise<void>((resolve) => {
resolveRunning = resolve;
abortSignal.addEventListener("abort", () => resolve(), { once: true });
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)}`);
},
});
startListener();
listenerStop = listener.stop;
// Wait for the running promise to resolve (on abort/stop)
await runningPromise;
await new Promise<void>((resolve) => {
abortSignal.addEventListener(
"abort",
() => {
stop();
resolve();
},
{ once: true },
);
});
return { stop };
}
export const __testing = {
processMessage: async (params: {
message: ZaloInboundMessage;
account: ResolvedZalouserAccount;
config: OpenClawConfig;
runtime: RuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}) => {
await processMessage(
params.message,
params.account,
params.config,
getZalouserRuntime(),
params.runtime,
params.statusSink,
);
},
};

View File

@@ -1,3 +1,5 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
@@ -12,6 +14,7 @@ import {
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
resolvePreferredOpenClawTmpDir,
} from "openclaw/plugin-sdk";
import {
listZalouserAccountIds,
@@ -19,8 +22,13 @@ import {
resolveZalouserAccountSync,
checkZcaAuthenticated,
} from "./accounts.js";
import type { ZcaFriend, ZcaGroup } from "./types.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
import {
logoutZaloProfile,
resolveZaloAllowFromEntries,
resolveZaloGroupsByEntries,
startZaloQrLogin,
waitForZaloQrLogin,
} from "./zalo-js.js";
const channel = "zalouser" as const;
@@ -87,9 +95,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
[
"Zalo Personal Account login via QR code.",
"",
"Prerequisites:",
"1) Install zca-cli",
"2) You'll scan a QR code with your Zalo app",
"This plugin uses zca-js directly (no external CLI dependency).",
"",
"Docs: https://docs.openclaw.ai/channels/zalouser",
].join("\n"),
@@ -97,6 +103,25 @@ 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;
@@ -111,58 +136,40 @@ 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 (username or user id)",
message: "Zalouser allowFrom (name 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 results = await Promise.all(parts.map((part) => resolveUserId(part)));
const unresolved = parts.filter((_, idx) => !results[idx]);
const resolvedEntries = await resolveZaloAllowFromEntries({
profile: resolved.profile,
entries: parts,
});
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
"Zalo Personal allowlist",
);
continue;
}
const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]);
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");
}
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: unique,
@@ -191,49 +198,6 @@ 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,
@@ -247,7 +211,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultZalouserAccountId(cfg);
return promptZalouserAllowFrom({
cfg: cfg,
cfg,
prompter,
accountId: id,
});
@@ -261,7 +225,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
const ids = listZalouserAccountIds(cfg);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const account = resolveZalouserAccountSync({ cfg, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
@@ -283,28 +247,13 @@ 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,
@@ -326,23 +275,32 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
if (wantsLogin) {
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 {
const isNowAuth = await checkZcaAuthenticated(account.profile);
if (isNowAuth) {
await prompter.note("Login successful!", "Success");
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");
}
} else {
await prompter.note(start.message, "Login pending");
}
}
} else {
@@ -351,12 +309,26 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (!keepSession) {
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
await runZcaInteractive(["auth", "login"], { profile: account.profile });
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");
}
}
}
// Enable the channel
next = setZalouserAccountScopedConfig(
next,
accountId,
@@ -372,14 +344,16 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId });
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Zalo groups",
currentPolicy: account.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(account.config.groups ?? {}),
currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(updatedAccount.config.groups ?? {}),
placeholder: "Family, Work, 123456789",
updatePrompt: Boolean(account.config.groups),
updatePrompt: Boolean(updatedAccount.config.groups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
@@ -387,9 +361,8 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
let keys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolved = await resolveZalouserGroups({
cfg: next,
accountId,
const resolved = await resolveZaloGroupsByEntries({
profile: updatedAccount.profile,
entries: accessConfig.entries,
});
const resolvedIds = resolved

View File

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

View File

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

View File

@@ -1,156 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
type ZalouserSendResult,
} from "./send.js";
import { runZca } from "./zca.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
vi.mock("./zca.js", () => ({
runZca: vi.fn(),
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
sendZaloLink: vi.fn(),
}));
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,
};
}
const mockSendText = vi.mocked(sendZaloTextMessage);
const mockSendLink = vi.mocked(sendZaloLink);
describe("zalouser send helpers", () => {
beforeEach(() => {
mockRunZca.mockReset();
delete process.env.ZCA_PROFILE;
mockSendText.mockReset();
mockSendLink.mockReset();
});
afterEach(() => {
if (originalZcaProfile) {
process.env.ZCA_PROFILE = originalZcaProfile;
return;
}
delete process.env.ZCA_PROFILE;
});
it("delegates text send to JS transport", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
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("builds text send command with truncation and group flag", async () => {
mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123"));
const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), {
profile: "profile-a",
const result = await sendMessageZalouser("thread-1", "hello", {
profile: "default",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], {
profile: "profile-a",
expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
profile: "default",
isGroup: true,
});
expect(result).toEqual({ ok: true, messageId: "mid-123" });
expect(result).toEqual({ ok: true, messageId: "mid-1" });
});
it("routes media sends from sendMessage and keeps text as caption", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
it("maps image helper to media send", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
await sendMessageZalouser("thread-2", "media caption", {
profile: "profile-b",
mediaUrl: "https://cdn.example.com/video.mp4",
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("delegates link helper to JS transport", async () => {
mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
profile: "p3",
isGroup: true,
});
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",
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
profile: "p3",
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",
});
expect(result).toEqual({ ok: false, error: "boom" });
});
});

View File

@@ -1,104 +1,15 @@
import { runZca } from "./zca.js";
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
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 type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
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}`);
return await sendZaloTextMessage(threadId, text, options);
}
export async function sendImageZalouser(
@@ -106,10 +17,10 @@ export async function sendImageZalouser(
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = resolveProfile(options);
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
appendCaptionAndGroupFlags(args, options);
return runSendCommand(args, profile, "Failed to send image");
return await sendZaloTextMessage(threadId, options.caption ?? "", {
...options,
mediaUrl: imageUrl,
});
}
export async function sendLinkZalouser(
@@ -117,25 +28,5 @@ export async function sendLinkZalouser(
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
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;
return await sendZaloLink(threadId, url, options);
}

View File

@@ -2,20 +2,6 @@ import { describe, expect, it } from "vitest";
import { collectZalouserStatusIssues } from "./status-issues.js";
describe("collectZalouserStatusIssues", () => {
it("flags missing zca when configured is false", () => {
const issues = collectZalouserStatusIssues([
{
accountId: "default",
enabled: true,
configured: false,
lastError: "zca CLI not found in PATH",
},
]);
expect(issues).toHaveLength(1);
expect(issues[0]?.kind).toBe("runtime");
expect(issues[0]?.message).toMatch(/zca CLI not found/i);
});
it("flags missing auth when configured is false", () => {
const issues = collectZalouserStatusIssues([
{
@@ -49,7 +35,7 @@ describe("collectZalouserStatusIssues", () => {
accountId: "default",
enabled: false,
configured: false,
lastError: "zca CLI not found in PATH",
lastError: "not authenticated",
},
]);
expect(issues).toHaveLength(0);

View File

@@ -27,14 +27,6 @@ function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccou
};
}
function isMissingZca(lastError?: string): boolean {
if (!lastError) {
return false;
}
const lower = lastError.toLowerCase();
return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
}
export function collectZalouserStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
@@ -51,26 +43,15 @@ export function collectZalouserStatusIssues(
}
const configured = account.configured === true;
const lastError = asString(account.lastError)?.trim();
if (!configured) {
if (isMissingZca(lastError)) {
issues.push({
channel: "zalouser",
accountId,
kind: "runtime",
message: "zca CLI not found in PATH.",
fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
});
} else {
issues.push({
channel: "zalouser",
accountId,
kind: "auth",
message: "Not authenticated (no zca session).",
fix: "Run: openclaw channels login --channel zalouser",
});
}
issues.push({
channel: "zalouser",
accountId,
kind: "auth",
message: "Not authenticated (no saved Zalo session).",
fix: "Run: openclaw channels login --channel zalouser",
});
continue;
}

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { executeZalouserTool } from "./tool.js";
import {
checkZaloAuthenticated,
getZaloUserInfo,
listZaloFriendsMatching,
listZaloGroupsMatching,
} from "./zalo-js.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn(),
sendImageZalouser: vi.fn(),
sendLinkZalouser: vi.fn(),
}));
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: vi.fn(),
getZaloUserInfo: vi.fn(),
listZaloFriendsMatching: vi.fn(),
listZaloGroupsMatching: vi.fn(),
}));
const mockSendMessage = vi.mocked(sendMessageZalouser);
const mockSendImage = vi.mocked(sendImageZalouser);
const mockSendLink = vi.mocked(sendLinkZalouser);
const mockCheckAuth = vi.mocked(checkZaloAuthenticated);
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
const mockListFriends = vi.mocked(listZaloFriendsMatching);
const mockListGroups = vi.mocked(listZaloGroupsMatching);
function extractDetails(result: Awaited<ReturnType<typeof executeZalouserTool>>): unknown {
const text = result.content[0]?.text ?? "{}";
return JSON.parse(text) as unknown;
}
describe("executeZalouserTool", () => {
beforeEach(() => {
mockSendMessage.mockReset();
mockSendImage.mockReset();
mockSendLink.mockReset();
mockCheckAuth.mockReset();
mockGetUserInfo.mockReset();
mockListFriends.mockReset();
mockListGroups.mockReset();
});
it("returns error when send action is missing required fields", async () => {
const result = await executeZalouserTool("tool-1", { action: "send" });
expect(extractDetails(result)).toEqual({
error: "threadId and message required for send action",
});
});
it("sends text message for send action", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" });
const result = await executeZalouserTool("tool-1", {
action: "send",
threadId: "t-1",
message: "hello",
profile: "work",
isGroup: true,
});
expect(mockSendMessage).toHaveBeenCalledWith("t-1", "hello", {
profile: "work",
isGroup: true,
});
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-1" });
});
it("returns tool error when send action fails", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" });
const result = await executeZalouserTool("tool-1", {
action: "send",
threadId: "t-1",
message: "hello",
});
expect(extractDetails(result)).toEqual({ error: "blocked" });
});
it("routes image and link actions to correct helpers", async () => {
mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" });
const imageResult = await executeZalouserTool("tool-1", {
action: "image",
threadId: "g-1",
url: "https://example.com/image.jpg",
message: "caption",
isGroup: true,
});
expect(mockSendImage).toHaveBeenCalledWith("g-1", "https://example.com/image.jpg", {
profile: undefined,
caption: "caption",
isGroup: true,
});
expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" });
mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" });
const linkResult = await executeZalouserTool("tool-1", {
action: "link",
threadId: "t-2",
url: "https://openclaw.ai",
message: "read this",
});
expect(mockSendLink).toHaveBeenCalledWith("t-2", "https://openclaw.ai", {
profile: undefined,
caption: "read this",
isGroup: undefined,
});
expect(extractDetails(linkResult)).toEqual({ success: true, messageId: "lnk-1" });
});
it("returns friends/groups lists", async () => {
mockListFriends.mockResolvedValueOnce([{ userId: "1", displayName: "Alice" }]);
mockListGroups.mockResolvedValueOnce([{ groupId: "2", name: "Work" }]);
const friends = await executeZalouserTool("tool-1", {
action: "friends",
profile: "work",
query: "ali",
});
expect(mockListFriends).toHaveBeenCalledWith("work", "ali");
expect(extractDetails(friends)).toEqual([{ userId: "1", displayName: "Alice" }]);
const groups = await executeZalouserTool("tool-1", {
action: "groups",
profile: "work",
query: "wrk",
});
expect(mockListGroups).toHaveBeenCalledWith("work", "wrk");
expect(extractDetails(groups)).toEqual([{ groupId: "2", name: "Work" }]);
});
it("reports me + status actions", async () => {
mockGetUserInfo.mockResolvedValueOnce({ userId: "7", displayName: "Me" });
mockCheckAuth.mockResolvedValueOnce(true);
const me = await executeZalouserTool("tool-1", { action: "me", profile: "work" });
expect(mockGetUserInfo).toHaveBeenCalledWith("work");
expect(extractDetails(me)).toEqual({ userId: "7", displayName: "Me" });
const status = await executeZalouserTool("tool-1", { action: "status", profile: "work" });
expect(mockCheckAuth).toHaveBeenCalledWith("work");
expect(extractDetails(status)).toEqual({
authenticated: true,
output: "authenticated",
});
});
});

View File

@@ -1,5 +1,11 @@
import { Type } from "@sinclair/typebox";
import { runZca, parseJsonOutput } from "./zca.js";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import {
checkZaloAuthenticated,
getZaloUserInfo,
listZaloFriendsMatching,
listZaloGroupsMatching,
} from "./zalo-js.js";
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
@@ -19,7 +25,6 @@ function stringEnum<T extends readonly string[]>(
});
}
// Tool schema - avoiding Type.Union per tool schema guardrails
export const ZalouserToolSchema = Type.Object(
{
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
@@ -62,15 +67,14 @@ export async function executeZalouserTool(
if (!params.threadId || !params.message) {
throw new Error("threadId and message required for send action");
}
const args = ["msg", "send", params.threadId, params.message];
if (params.isGroup) {
args.push("-g");
}
const result = await runZca(args, { profile: params.profile });
const result = await sendMessageZalouser(params.threadId, params.message, {
profile: params.profile,
isGroup: params.isGroup,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to send message");
throw new Error(result.error || "Failed to send message");
}
return json({ success: true, output: result.stdout });
return json({ success: true, messageId: result.messageId });
}
case "image": {
@@ -80,74 +84,52 @@ export async function executeZalouserTool(
if (!params.url) {
throw new Error("url required for image action");
}
const args = ["msg", "image", params.threadId, "-u", params.url];
if (params.message) {
args.push("-m", params.message);
}
if (params.isGroup) {
args.push("-g");
}
const result = await runZca(args, { profile: params.profile });
const result = await sendImageZalouser(params.threadId, params.url, {
profile: params.profile,
caption: params.message,
isGroup: params.isGroup,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to send image");
throw new Error(result.error || "Failed to send image");
}
return json({ success: true, output: result.stdout });
return json({ success: true, messageId: result.messageId });
}
case "link": {
if (!params.threadId || !params.url) {
throw new Error("threadId and url required for link action");
}
const args = ["msg", "link", params.threadId, params.url];
if (params.isGroup) {
args.push("-g");
}
const result = await runZca(args, { profile: params.profile });
const result = await sendLinkZalouser(params.threadId, params.url, {
profile: params.profile,
caption: params.message,
isGroup: params.isGroup,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to send link");
throw new Error(result.error || "Failed to send link");
}
return json({ success: true, output: result.stdout });
return json({ success: true, messageId: result.messageId });
}
case "friends": {
const args = params.query ? ["friend", "find", params.query] : ["friend", "list", "-j"];
const result = await runZca(args, { profile: params.profile });
if (!result.ok) {
throw new Error(result.stderr || "Failed to get friends");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
const rows = await listZaloFriendsMatching(params.profile, params.query);
return json(rows);
}
case "groups": {
const result = await runZca(["group", "list", "-j"], {
profile: params.profile,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to get groups");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
const rows = await listZaloGroupsMatching(params.profile, params.query);
return json(rows);
}
case "me": {
const result = await runZca(["me", "info", "-j"], {
profile: params.profile,
});
if (!result.ok) {
throw new Error(result.stderr || "Failed to get profile");
}
const parsed = parseJsonOutput(result.stdout);
return json(parsed ?? { raw: result.stdout });
const info = await getZaloUserInfo(params.profile);
return json(info ?? { error: "Not authenticated" });
}
case "status": {
const result = await runZca(["auth", "status"], {
profile: params.profile,
});
const authenticated = await checkZaloAuthenticated(params.profile);
return json({
authenticated: result.ok,
output: result.stdout || result.stderr,
authenticated,
output: authenticated ? "authenticated" : "not authenticated",
});
}

View File

@@ -1,48 +1,32 @@
// zca-cli wrapper types
export type ZcaRunOptions = {
profile?: string;
cwd?: string;
timeout?: number;
};
export type ZcaResult = {
ok: boolean;
stdout: string;
stderr: string;
exitCode: number;
};
export type ZcaProfile = {
name: string;
label?: string;
isDefault?: boolean;
};
export type ZcaFriend = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZcaGroup = {
export type ZaloGroup = {
groupId: string;
name: string;
memberCount?: number;
};
export type ZcaMessage = {
export type ZaloGroupMember = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZaloInboundMessage = {
threadId: string;
isGroup: boolean;
senderId: string;
senderName?: string;
groupName?: string;
content: string;
timestampMs: number;
msgId?: string;
cliMsgId?: string;
type: number;
content: string;
timestamp: number;
metadata?: {
isGroup: boolean;
threadName?: string;
senderName?: string;
fromId?: string;
};
raw: unknown;
};
export type ZcaUserInfo = {
@@ -51,21 +35,23 @@ export type ZcaUserInfo = {
avatar?: string;
};
export type CommonOptions = {
export type ZaloSendOptions = {
profile?: string;
json?: boolean;
mediaUrl?: string;
caption?: string;
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
};
export type SendOptions = CommonOptions & {
group?: boolean;
export type ZaloSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
export type ListenOptions = CommonOptions & {
raw?: boolean;
keepAlive?: boolean;
webhook?: string;
echo?: boolean;
prefix?: string;
export type ZaloAuthStatus = {
connected: boolean;
message: string;
};
type ZalouserToolConfig = { allow?: string[]; deny?: string[] };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
declare module "zca-js" {
export enum ThreadType {
User = 0,
Group = 1,
}
export enum LoginQRCallbackEventType {
QRCodeGenerated = 0,
QRCodeExpired = 1,
QRCodeScanned = 2,
QRCodeDeclined = 3,
GotLoginInfo = 4,
}
export type Credentials = {
imei: string;
cookie: unknown;
userAgent: string;
language?: string;
};
export type User = {
userId: string;
username: string;
displayName: string;
zaloName: string;
avatar: string;
};
export type GroupInfo = {
groupId: string;
name: string;
totalMember?: number;
memberIds?: unknown[];
currentMems?: Array<{
id?: unknown;
dName?: string;
zaloName?: string;
avatar?: string;
}>;
};
export type Message = {
type: ThreadType;
threadId: string;
isSelf: boolean;
data: Record<string, unknown>;
};
export type LoginQRCallbackEvent =
| {
type: LoginQRCallbackEventType.QRCodeGenerated;
data: {
code: string;
image: string;
};
actions: {
saveToFile: (qrPath?: string) => Promise<unknown>;
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: LoginQRCallbackEventType.QRCodeExpired;
data: null;
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: LoginQRCallbackEventType.QRCodeScanned;
data: {
avatar: string;
display_name: string;
};
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: LoginQRCallbackEventType.QRCodeDeclined;
data: {
code: string;
};
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: LoginQRCallbackEventType.GotLoginInfo;
data: {
cookie: unknown;
imei: string;
userAgent: string;
};
actions: null;
};
export type Listener = {
on(event: "message", callback: (message: Message) => void): void;
on(event: "error", callback: (error: unknown) => void): void;
on(event: "closed", callback: (code: number, reason: string) => void): void;
off(event: "message", callback: (message: Message) => void): void;
off(event: "error", callback: (error: unknown) => void): void;
off(event: "closed", callback: (code: number, reason: string) => void): void;
start(opts?: { retryOnClose?: boolean }): void;
stop(): void;
};
export class API {
listener: Listener;
getContext(): {
imei: string;
userAgent: string;
language?: string;
};
getCookie(): {
toJSON(): {
cookies: unknown[];
};
};
fetchAccountInfo(): Promise<{ profile: User } | User>;
getAllFriends(): Promise<User[]>;
getAllGroups(): Promise<{
gridVerMap: Record<string, string>;
}>;
getGroupInfo(groupId: string | string[]): Promise<{
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
}>;
getGroupMembersInfo(memberId: string | string[]): Promise<{
profiles: Record<
string,
{
id?: string;
displayName?: string;
zaloName?: string;
avatar?: string;
}
>;
}>;
sendMessage(
message: string | Record<string, unknown>,
threadId: string,
type?: ThreadType,
): Promise<{
message?: { msgId?: string | number } | null;
attachment?: Array<{ msgId?: string | number }>;
}>;
sendLink(
payload: { link: string; msg?: string },
threadId: string,
type?: ThreadType,
): Promise<{ msgId?: string | number }>;
}
export class Zalo {
constructor(options?: { logging?: boolean; selfListen?: boolean });
login(credentials: Credentials): Promise<API>;
loginQR(
options?: { userAgent?: string; language?: string; qrPath?: string },
callback?: (event: LoginQRCallbackEvent) => unknown,
): Promise<API>;
}
}

View File

@@ -1,198 +0,0 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { stripAnsi } from "openclaw/plugin-sdk";
import type { ZcaResult, ZcaRunOptions } from "./types.js";
const ZCA_BINARY = "zca";
const DEFAULT_TIMEOUT = 30000;
function buildArgs(args: string[], options?: ZcaRunOptions): string[] {
const result: string[] = [];
// Profile flag comes first (before subcommand)
const profile = options?.profile || process.env.ZCA_PROFILE;
if (profile) {
result.push("--profile", profile);
}
result.push(...args);
return result;
}
export async function runZca(args: string[], options?: ZcaRunOptions): Promise<ZcaResult> {
const fullArgs = buildArgs(args, options);
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
return new Promise((resolve) => {
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGTERM");
}, timeout);
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
resolve({
ok: false,
stdout,
stderr: stderr || "Command timed out",
exitCode: code ?? 124,
});
return;
}
resolve({
ok: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
}
export function runZcaInteractive(args: string[], options?: ZcaRunOptions): Promise<ZcaResult> {
const fullArgs = buildArgs(args, options);
return new Promise((resolve) => {
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: "inherit",
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
proc.on("close", (code) => {
resolve({
ok: code === 0,
stdout: "",
stderr: "",
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
}
export function parseJsonOutput<T>(stdout: string): T | null {
try {
return JSON.parse(stdout) as T;
} catch {
const cleaned = stripAnsi(stdout);
try {
return JSON.parse(cleaned) as T;
} catch {
// zca may prefix output with INFO/log lines, try to find JSON
const lines = cleaned.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith("{") || line.startsWith("[")) {
// Try parsing from this line to the end
const jsonCandidate = lines.slice(i).join("\n").trim();
try {
return JSON.parse(jsonCandidate) as T;
} catch {
continue;
}
}
}
return null;
}
}
}
export async function checkZcaInstalled(): Promise<boolean> {
const result = await runZca(["--version"], { timeout: 5000 });
return result.ok;
}
export type ZcaStreamingOptions = ZcaRunOptions & {
onData?: (data: string) => void;
onError?: (err: Error) => void;
};
export function runZcaStreaming(
args: string[],
options?: ZcaStreamingOptions,
): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
const fullArgs = buildArgs(args, options);
const spawnOpts: SpawnOptions = {
cwd: options?.cwd,
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
};
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
stdout += text;
options?.onData?.(text);
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
const promise = new Promise<ZcaResult>((resolve) => {
proc.on("close", (code) => {
resolve({
ok: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
proc.on("error", (err) => {
options?.onError?.(err);
resolve({
ok: false,
stdout: "",
stderr: err.message,
exitCode: 1,
});
});
});
return { proc, promise };
}

View File

@@ -59,7 +59,7 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@@ -102,11 +102,13 @@
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs",
"mac:open": "open dist/OpenClaw.app",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:restart": "bash scripts/restart-mac.sh",

36
pnpm-lock.yaml generated
View File

@@ -477,6 +477,9 @@ importers:
'@sinclair/typebox':
specifier: 0.34.48
version: 0.34.48
zca-js:
specifier: 2.1.1
version: 2.1.1
packages/clawdbot:
dependencies:
@@ -3670,6 +3673,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@@ -5008,6 +5014,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parse-ms@3.0.0:
resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==}
engines: {node: '>=12'}
@@ -5522,6 +5531,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -6063,6 +6075,10 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zca-js@2.1.1:
resolution: {integrity: sha512-6zCmaIIWg/1eYlvCvO4rVsFt6SQ8MRodro3dCzMkk+LNgB3MyaEMBywBJfsw44WhODmOh8iMlPv4xDTNTMWDWA==}
engines: {node: '>=18.0.0'}
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
@@ -9958,6 +9974,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -11543,6 +11561,8 @@ snapshots:
pako@1.0.11: {}
pako@2.1.0: {}
parse-ms@3.0.0: {}
parse-ms@4.0.0: {}
@@ -12201,6 +12221,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
split2@4.2.0: {}
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
@@ -12702,6 +12724,20 @@ snapshots:
yoctocolors@2.1.2: {}
zca-js@2.1.1:
dependencies:
crypto-js: 4.2.0
form-data: 2.5.4
json-bigint: 1.0.0
pako: 2.1.0
semver: 7.7.4
spark-md5: 3.0.2
tough-cookie: 4.1.3
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
zod-to-json-schema@3.25.1(zod@3.25.76):
dependencies:
zod: 3.25.76

View File

@@ -1,24 +1,22 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import {
collectFileViolations,
getPropertyNameText,
resolveRepoRoot,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
const { repoRoot, sourceRoots, resolveFromRepo } = createPairingGuardContext(import.meta.url);
const allowedFiles = new Set([
path.join(repoRoot, "src", "security", "dm-policy-shared.ts"),
path.join(repoRoot, "src", "channels", "allow-from.ts"),
resolveFromRepo("src/security/dm-policy-shared.ts"),
resolveFromRepo("src/channels/allow-from.ts"),
// Config migration/audit logic may intentionally reference store + group fields.
path.join(repoRoot, "src", "security", "fix.ts"),
path.join(repoRoot, "src", "security", "audit-channel.ts"),
resolveFromRepo("src/security/fix.ts"),
resolveFromRepo("src/security/audit-channel.ts"),
]);
const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i;

View File

@@ -1,25 +1,17 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFiles,
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [
path.join(repoRoot, "src", "channels"),
path.join(repoRoot, "src", "infra", "outbound"),
path.join(repoRoot, "src", "line"),
path.join(repoRoot, "src", "media-understanding"),
path.join(repoRoot, "extensions"),
"src/channels",
"src/infra/outbound",
"src/line",
"src/media-understanding",
"extensions",
];
const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]);
const allowedRelativePaths = new Set(["extensions/feishu/src/dedup.ts"]);
function collectOsTmpdirImports(sourceFile) {
const osModuleSpecifiers = new Set(["node:os", "os"]);
@@ -82,40 +74,16 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
}
export async function main() {
const files = (
await Promise.all(
sourceRoots.map(
async (dir) =>
await collectTypeScriptFiles(dir, {
ignoreMissing: true,
}),
),
)
).flat();
const violations = [];
for (const filePath of files) {
if (allowedCallsites.has(filePath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of findMessagingTmpdirCallLines(content, filePath)) {
violations.push(`${path.relative(repoRoot, filePath)}:${line}`);
}
}
if (violations.length === 0) {
return;
}
console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
);
process.exit(1);
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findMessagingTmpdirCallLines,
skipRelativePath: (relativePath) => allowedRelativePaths.has(relativePath),
header: "Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:",
footer:
"Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
sortViolations: false,
});
}
runAsScript(import.meta.url, main);

View File

@@ -1,28 +1,20 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFiles,
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [
path.join(repoRoot, "src", "telegram"),
path.join(repoRoot, "src", "discord"),
path.join(repoRoot, "src", "slack"),
path.join(repoRoot, "src", "signal"),
path.join(repoRoot, "src", "imessage"),
path.join(repoRoot, "src", "web"),
path.join(repoRoot, "src", "channels"),
path.join(repoRoot, "src", "routing"),
path.join(repoRoot, "src", "line"),
path.join(repoRoot, "extensions"),
"src/telegram",
"src/discord",
"src/slack",
"src/signal",
"src/imessage",
"src/web",
"src/channels",
"src/routing",
"src/line",
"extensions",
];
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
@@ -100,43 +92,15 @@ export function findRawFetchCallLines(content, fileName = "source.ts") {
}
export async function main() {
const files = (
await Promise.all(
sourceRoots.map(
async (sourceRoot) =>
await collectTypeScriptFiles(sourceRoot, {
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
ignoreMissing: true,
}),
),
)
).flat();
const violations = [];
for (const filePath of files) {
const content = await fs.readFile(filePath, "utf8");
const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
for (const line of findRawFetchCallLines(content, filePath)) {
const callsite = `${relPath}:${line}`;
if (allowedRawFetchCallsites.has(callsite)) {
continue;
}
violations.push(callsite);
}
}
if (violations.length === 0) {
return;
}
console.error("Found raw fetch() usage in channel/plugin runtime sources outside allowlist:");
for (const violation of violations.toSorted()) {
console.error(`- ${violation}`);
}
console.error(
"Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
);
process.exit(1);
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
findCallLines: findRawFetchCallLines,
allowCallsite: (callsite) => allowedRawFetchCallsites.has(callsite),
header: "Found raw fetch() usage in channel/plugin runtime sources outside allowlist:",
footer: "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
});
}
runAsScript(import.meta.url, main);

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
import ts from "typescript";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["src", "extensions"];
function isDeprecatedRegisterHttpHandlerCall(expression) {
const callee = unwrapExpression(expression);
return ts.isPropertyAccessExpression(callee) && callee.name.text === "registerHttpHandler";
}
export function findDeprecatedRegisterHttpHandlerLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const lines = [];
const visit = (node) => {
if (ts.isCallExpression(node) && isDeprecatedRegisterHttpHandlerCall(node.expression)) {
lines.push(toLine(sourceFile, node.expression));
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return lines;
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findDeprecatedRegisterHttpHandlerLines,
header: "Found deprecated plugin API call registerHttpHandler(...):",
footer:
"Use registerHttpRoute({ path, auth, match, handler }) and registerPluginHttpRoute for dynamic webhook paths.",
});
}
runAsScript(import.meta.url, main);

View File

@@ -1,17 +1,15 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import {
collectFileViolations,
getPropertyNameText,
resolveRepoRoot,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
const { repoRoot, sourceRoots } = createPairingGuardContext(import.meta.url);
function isUndefinedLikeExpression(node) {
if (ts.isIdentifier(node) && node.text === "undefined") {

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["extensions"];
const enforcedFiles = new Set([
"extensions/bluebubbles/src/monitor.ts",
"extensions/googlechat/src/monitor.ts",
"extensions/zalo/src/monitor.webhook.ts",
]);
const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]);
function getCalleeName(expression) {
const callee = unwrapExpression(expression);
if (ts.isIdentifier(callee)) {
return callee.text;
}
if (ts.isPropertyAccessExpression(callee)) {
return callee.name.text;
}
return null;
}
export function findBlockedWebhookBodyReadLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const lines = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const calleeName = getCalleeName(node.expression);
if (calleeName && blockedCallees.has(calleeName)) {
lines.push(toLine(sourceFile, node.expression));
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return lines;
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findBlockedWebhookBodyReadLines,
skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
header: "Found forbidden low-level body reads in auth-sensitive webhook handlers:",
footer:
"Use plugin-sdk webhook guards (`readJsonWebhookBodyOrReject` / `readWebhookBodyOrReject`) with explicit pre-auth/post-auth profiles.",
});
}
runAsScript(import.meta.url, main);

329
scripts/install.ps1 Normal file
View File

@@ -0,0 +1,329 @@
# OpenClaw Installer for Windows (PowerShell)
# Usage: iwr -useb https://openclaw.ai/install.ps1 | iex
# Or: & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard
param(
[string]$InstallMethod = "npm",
[string]$Tag = "latest",
[string]$GitDir = "$env:USERPROFILE\openclaw",
[switch]$NoOnboard,
[switch]$NoGitUpdate,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
# Colors
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
$WARN = "`e[38;2;255;176;32m" # amber
$ERROR = "`e[38;2;230;57;70m" # coral-mid
$MUTED = "`e[38;2;90;100;128m" # text-muted
$NC = "`e[0m" # No Color
function Write-Host {
param([string]$Message, [string]$Level = "info")
$msg = switch ($Level) {
"success" { "$SUCCESS$NC $Message" }
"warn" { "$WARN!$NC $Message" }
"error" { "$ERROR$NC $Message" }
default { "$MUTED·$NC $Message" }
}
Microsoft.PowerShell.Host\Write-Host $msg
}
function Write-Banner {
Write-Host ""
Write-Host "${ACCENT} 🦞 OpenClaw Installer$NC" -Level info
Write-Host "${MUTED} All your chats, one OpenClaw.$NC" -Level info
Write-Host ""
}
function Get-ExecutionPolicyStatus {
$policy = Get-ExecutionPolicy
if ($policy -eq "Restricted" -or $policy -eq "AllSigned") {
return @{ Blocked = $true; Policy = $policy }
}
return @{ Blocked = $false; Policy = $policy }
}
function Test-Admin {
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-ExecutionPolicy {
$status = Get-ExecutionPolicyStatus
if ($status.Blocked) {
Write-Host "PowerShell execution policy is set to: $($status.Policy)" -Level warn
Write-Host "This prevents scripts like npm.ps1 from running." -Level warn
Write-Host ""
# Try to set execution policy for current process
try {
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -ErrorAction Stop
Write-Host "Set execution policy to RemoteSigned for current process" -Level success
return $true
} catch {
Write-Host "Could not automatically set execution policy" -Level error
Write-Host ""
Write-Host "To fix this, run:" -Level info
Write-Host " Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process" -Level info
Write-Host ""
Write-Host "Or run PowerShell as Administrator and execute:" -Level info
Write-Host " Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine" -Level info
return $false
}
}
return $true
}
function Get-NodeVersion {
try {
$version = node --version 2>$null
if ($version) {
return $version -replace '^v', ''
}
} catch { }
return $null
}
function Get-NpmVersion {
try {
$version = npm --version 2>$null
if ($version) {
return $version
}
} catch { }
return $null
}
function Install-Node {
Write-Host "Node.js not found" -Level info
Write-Host "Installing Node.js..." -Level info
# Try winget first
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host " Using winget..." -Level info
try {
winget install OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host " Node.js installed via winget" -Level success
return $true
} catch {
Write-Host " Winget install failed: $_" -Level warn
}
}
# Try chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host " Using chocolatey..." -Level info
try {
choco install nodejs-lts -y 2>&1 | Out-Null
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host " Node.js installed via chocolatey" -Level success
return $true
} catch {
Write-Host " Chocolatey install failed: $_" -Level warn
}
}
# Try scoop
if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Host " Using scoop..." -Level info
try {
scoop install nodejs-lts 2>&1 | Out-Null
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host " Node.js installed via scoop" -Level success
return $true
} catch {
Write-Host " Scoop install failed: $_" -Level warn
}
}
Write-Host "Could not install Node.js automatically" -Level error
Write-Host "Please install Node.js 22+ manually from: https://nodejs.org" -Level info
return $false
}
function Ensure-Node {
$nodeVersion = Get-NodeVersion
if ($nodeVersion) {
$major = [int]($nodeVersion -split '\.')[0]
if ($major -ge 22) {
Write-Host "Node.js v$nodeVersion found" -Level success
return $true
}
Write-Host "Node.js v$nodeVersion found, but need v22+" -Level warn
}
return Install-Node
}
function Get-GitVersion {
try {
$version = git --version 2>$null
if ($version) {
return $version
}
} catch { }
return $null
}
function Install-Git {
Write-Host "Git not found" -Level info
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host " Installing Git via winget..." -Level info
try {
winget install Git.Git --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host " Git installed" -Level success
return $true
} catch {
Write-Host " Winget install failed" -Level warn
}
}
Write-Host "Please install Git for Windows from: https://git-scm.com" -Level error
return $false
}
function Ensure-Git {
$gitVersion = Get-GitVersion
if ($gitVersion) {
Write-Host "$gitVersion found" -Level success
return $true
}
return Install-Git
}
function Install-OpenClawNpm {
param([string]$Version = "latest")
Write-Host "Installing OpenClaw (openclaw@$Version)..." -Level info
try {
# Use -ExecutionPolicy Bypass to handle restricted execution policy
npm install -g openclaw@$Version --no-fund --no-audit 2>&1
Write-Host "OpenClaw installed" -Level success
return $true
} catch {
Write-Host "npm install failed: $_" -Level error
return $false
}
}
function Install-OpenClawGit {
param([string]$RepoDir, [switch]$Update)
Write-Host "Installing OpenClaw from git..." -Level info
if (!(Test-Path $RepoDir)) {
Write-Host " Cloning repository..." -Level info
git clone https://github.com/openclaw/openclaw.git $RepoDir 2>&1
} elseif ($Update) {
Write-Host " Updating repository..." -Level info
git -C $RepoDir pull --rebase 2>&1
}
# Install pnpm if not present
if (!(Get-Command pnpm -ErrorAction SilentlyContinue)) {
Write-Host " Installing pnpm..." -Level info
npm install -g pnpm 2>&1
}
# Install dependencies
Write-Host " Installing dependencies..." -Level info
pnpm install --dir $RepoDir 2>&1
# Build
Write-Host " Building..." -Level info
pnpm --dir $RepoDir build 2>&1
# Create wrapper
$wrapperDir = "$env:USERPROFILE\.local\bin"
if (!(Test-Path $wrapperDir)) {
New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null
}
@"
@echo off
node "%~dp0..\openclaw\dist\entry.js" %*
"@ | Out-File -FilePath "$wrapperDir\openclaw.cmd" -Encoding ASCII -Force
Write-Host "OpenClaw installed" -Level success
return $true
}
function Add-ToPath {
param([string]$Path)
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$Path*") {
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$Path", "User")
Write-Host "Added $Path to user PATH" -Level info
}
}
# Main
function Main {
Write-Banner
Write-Host "Windows detected" -Level success
# Check and handle execution policy FIRST, before any npm calls
if (!(Ensure-ExecutionPolicy)) {
Write-Host ""
Write-Host "Installation cannot continue due to execution policy restrictions" -Level error
exit 1
}
if (!(Ensure-Node)) {
exit 1
}
if ($InstallMethod -eq "git") {
if (!(Ensure-Git)) {
exit 1
}
if ($DryRun) {
Write-Host "[DRY RUN] Would install OpenClaw from git to $GitDir" -Level info
} else {
Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate)
}
} else {
# npm method
if (!(Ensure-Git)) {
Write-Host "Git is required for npm installs. Please install Git and try again." -Level warn
}
if ($DryRun) {
Write-Host "[DRY RUN] Would install OpenClaw via npm (tag: $Tag)" -Level info
} else {
if (!(Install-OpenClawNpm -Version $Tag)) {
exit 1
}
}
}
# Try to add npm global bin to PATH
try {
$npmPrefix = npm config get prefix 2>$null
if ($npmPrefix) {
Add-ToPath -Path "$npmPrefix"
}
} catch { }
if (!$NoOnboard -and !$DryRun) {
Write-Host ""
Write-Host "Run 'openclaw onboard' to complete setup" -Level info
}
Write-Host ""
Write-Host "🦞 OpenClaw installed successfully!" -Level success
}
Main

2385
scripts/install.sh Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import {
collectTypeScriptFilesFromRoots,
resolveRepoRoot,
resolveSourceRoots,
} from "./ts-guard-utils.mjs";
export async function runCallsiteGuard(params) {
const repoRoot = resolveRepoRoot(params.importMetaUrl);
const sourceRoots = resolveSourceRoots(repoRoot, params.sourceRoots);
const files = await collectTypeScriptFilesFromRoots(sourceRoots, {
extraTestSuffixes: params.extraTestSuffixes,
});
const violations = [];
for (const filePath of files) {
const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
if (params.skipRelativePath?.(relPath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of params.findCallLines(content, filePath)) {
const callsite = `${relPath}:${line}`;
if (params.allowCallsite?.(callsite)) {
continue;
}
violations.push(callsite);
}
}
if (violations.length === 0) {
return;
}
console.error(params.header);
const output = params.sortViolations === false ? violations : violations.toSorted();
for (const violation of output) {
console.error(`- ${violation}`);
}
if (params.footer) {
console.error(params.footer);
}
process.exit(1);
}

View File

@@ -0,0 +1,13 @@
import path from "node:path";
import { resolveRepoRoot, resolveSourceRoots } from "./ts-guard-utils.mjs";
export function createPairingGuardContext(importMetaUrl) {
const repoRoot = resolveRepoRoot(importMetaUrl);
const sourceRoots = resolveSourceRoots(repoRoot, ["src", "extensions"]);
return {
repoRoot,
sourceRoots,
resolveFromRepo: (relativePath) =>
path.join(repoRoot, ...relativePath.split("/").filter(Boolean)),
};
}

View File

@@ -9,6 +9,10 @@ export function resolveRepoRoot(importMetaUrl) {
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", "..");
}
export function resolveSourceRoots(repoRoot, relativeRoots) {
return relativeRoots.map((root) => path.join(repoRoot, ...root.split("/").filter(Boolean)));
}
export function isTestLikeTypeScriptFile(filePath, options = {}) {
const extraTestSuffixes = options.extraTestSuffixes ?? [];
return [...baseTestSuffixes, ...extraTestSuffixes].some((suffix) => filePath.endsWith(suffix));
@@ -68,18 +72,24 @@ export async function collectTypeScriptFiles(targetPath, options = {}) {
return out;
}
export async function collectFileViolations(params) {
const files = (
export async function collectTypeScriptFilesFromRoots(sourceRoots, options = {}) {
return (
await Promise.all(
params.sourceRoots.map(
sourceRoots.map(
async (root) =>
await collectTypeScriptFiles(root, {
ignoreMissing: true,
extraTestSuffixes: params.extraTestSuffixes,
...options,
}),
),
)
).flat();
}
export async function collectFileViolations(params) {
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
extraTestSuffixes: params.extraTestSuffixes,
});
const violations = [];
for (const filePath of files) {

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