mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
120 Commits
fix/cron-f
...
fix/sandbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e4558e4f | ||
|
|
8b27582509 | ||
|
|
a6489ab5e9 | ||
|
|
83c8406f01 | ||
|
|
602f6439bd | ||
|
|
1c9deeda97 | ||
|
|
fc0d374390 | ||
|
|
0ebe0480fa | ||
|
|
8ae8056622 | ||
|
|
54382a66b4 | ||
|
|
d7ae61c412 | ||
|
|
b07589642d | ||
|
|
26b8e6d510 | ||
|
|
e339c75d5d | ||
|
|
7dac9b05dd | ||
|
|
eb35fb745d | ||
|
|
b9e820b7ed | ||
|
|
aee27d0e38 | ||
|
|
34ff873a7e | ||
|
|
310dd24ce3 | ||
|
|
d4bf07d075 | ||
|
|
d3e8b17aa6 | ||
|
|
dded569626 | ||
|
|
104d32bb64 | ||
|
|
be3a62c5e0 | ||
|
|
193ad2f4f0 | ||
|
|
a0e11e63fe | ||
|
|
07b16d5ad0 | ||
|
|
67b2dde7c5 | ||
|
|
7a55a3ca07 | ||
|
|
11562c452a | ||
|
|
eb2e20c994 | ||
|
|
24a13c05b3 | ||
|
|
20c36f7e84 | ||
|
|
db7a8a6982 | ||
|
|
4a80311628 | ||
|
|
7a7eee920a | ||
|
|
33e76db12a | ||
|
|
9a68590385 | ||
|
|
031bf0c6c0 | ||
|
|
8611fd67b5 | ||
|
|
14c93d2646 | ||
|
|
1b462ed174 | ||
|
|
18f8393b6c | ||
|
|
14e4575af5 | ||
|
|
b1592457fa | ||
|
|
31c7637e0f | ||
|
|
d5ae4b8337 | ||
|
|
0dbb92dd2b | ||
|
|
17ede52a4b | ||
|
|
be65dc8acc | ||
|
|
8828418111 | ||
|
|
4dd6c7a509 | ||
|
|
d94de5c4a1 | ||
|
|
09f49cd921 | ||
|
|
87d05592ea | ||
|
|
d74bc257d8 | ||
|
|
6edb512efa | ||
|
|
c973b053a5 | ||
|
|
a229ae6c3e | ||
|
|
2fd8264ab0 | ||
|
|
b13d48987c | ||
|
|
21708f58ce | ||
|
|
1ea42ebe98 | ||
|
|
3e5762c288 | ||
|
|
c4711a9b69 | ||
|
|
ea204e65a0 | ||
|
|
14fbd0e6b6 | ||
|
|
17c434f2f3 | ||
|
|
19f5d1345c | ||
|
|
64c443ac65 | ||
|
|
b28e472fa5 | ||
|
|
0c6db05cc0 | ||
|
|
ade46d8ab7 | ||
|
|
82247f09a7 | ||
|
|
d01e82d54a | ||
|
|
93b0724025 | ||
|
|
44270c533b | ||
|
|
dec2c9e74d | ||
|
|
6135eb3353 | ||
|
|
345abf0b20 | ||
|
|
a3d2021eea | ||
|
|
e08ba063d8 | ||
|
|
998d477f5e | ||
|
|
a49afd25ea | ||
|
|
d86c1a67e0 | ||
|
|
05b84e718b | ||
|
|
07b419a0e7 | ||
|
|
12be9a08fe | ||
|
|
ee1b147631 | ||
|
|
208a9b1ad1 | ||
|
|
0f00110f5d | ||
|
|
174f2de447 | ||
|
|
db3d8d82c1 | ||
|
|
3f2848433a | ||
|
|
663c1858b8 | ||
|
|
729ddfd7c8 | ||
|
|
f39882d57e | ||
|
|
6b7d3fb011 | ||
|
|
c63c179278 | ||
|
|
dd3f7d57ee | ||
|
|
47ef180fb7 | ||
|
|
ebe54e6903 | ||
|
|
d06ee86292 | ||
|
|
f1cab9c5e5 | ||
|
|
f4c3e483fe | ||
|
|
6aa20e91d9 | ||
|
|
4b4ea5df8b | ||
|
|
a905b6dabc | ||
|
|
44c50d9a73 | ||
|
|
ed21b63bb8 | ||
|
|
e9dd6121f2 | ||
|
|
dcf8308c8f | ||
|
|
d212721df1 | ||
|
|
a469d00345 | ||
|
|
3fb0ab7435 | ||
|
|
64ac790aa8 | ||
|
|
f1cd3ea531 | ||
|
|
c5f1cf3c3b | ||
|
|
87bd6226bd |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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.
|
||||
@@ -227,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.
|
||||
|
||||
@@ -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}""")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events (
|
||||
If you need a custom path, set `channels.line.webhookPath` or
|
||||
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
|
||||
|
||||
Security note:
|
||||
|
||||
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
|
||||
|
||||
## Configure
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Zalo personal account support via 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 it’s on `PATH` for the Gateway process.
|
||||
|
||||
**Login doesn’t 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*`):
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
|
||||
|
||||
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register 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`.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
205
extensions/bluebubbles/src/monitor-debounce.ts
Normal file
205
extensions/bluebubbles/src/monitor-debounce.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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(
|
||||
|
||||
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal file
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi {
|
||||
},
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpHandler() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
74
extensions/feishu/src/send-target.test.ts
Normal file
74
extensions/feishu/src/send-target.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
357
extensions/googlechat/src/monitor-access.ts
Normal file
357
extensions/googlechat/src/monitor-access.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
extensions/googlechat/src/monitor-types.ts
Normal file
33
extensions/googlechat/src/monitor-types.ts
Normal 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;
|
||||
};
|
||||
216
extensions/googlechat/src/monitor-webhook.ts
Normal file
216
extensions/googlechat/src/monitor-webhook.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -61,7 +61,12 @@ const plugin = {
|
||||
log: api.logger,
|
||||
});
|
||||
|
||||
api.registerHttpHandler(httpHandler);
|
||||
api.registerHttpRoute({
|
||||
path: "/api/channels/nostr",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: httpHandler,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
109
extensions/phone-control/index.test.ts
Normal file
109
extensions/phone-control/index.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
102
extensions/zalo/src/channel.sendpayload.test.ts
Normal file
102
extensions/zalo/src/channel.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
214
extensions/zalouser/src/accounts.test.ts
Normal file
214
extensions/zalouser/src/accounts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
116
extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
116
extensions/zalouser/src/channel.sendpayload.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
117
extensions/zalouser/src/monitor.account-scope.test.ts
Normal file
117
extensions/zalouser/src/monitor.account-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
60
extensions/zalouser/src/probe.test.ts
Normal file
60
extensions/zalouser/src/probe.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
148
extensions/zalouser/src/tool.test.ts
Normal file
148
extensions/zalouser/src/tool.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
1113
extensions/zalouser/src/zalo-js.ts
Normal file
1113
extensions/zalouser/src/zalo-js.ts
Normal file
File diff suppressed because it is too large
Load Diff
167
extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal file
167
extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
36
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
38
scripts/check-no-register-http-handler.mjs
Normal file
38
scripts/check-no-register-http-handler.mjs
Normal 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);
|
||||
55
scripts/check-webhook-auth-body-order.mjs
Normal file
55
scripts/check-webhook-auth-body-order.mjs
Normal 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
329
scripts/install.ps1
Normal 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
2385
scripts/install.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,10 @@ describe("applyPatch", () => {
|
||||
});
|
||||
|
||||
it("rejects symlink escape attempts by default", async () => {
|
||||
// File symlinks require SeCreateSymbolicLinkPrivilege on Windows.
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await withTempDir(async (dir) => {
|
||||
const outside = path.join(path.dirname(dir), "outside-target.txt");
|
||||
const linkPath = path.join(dir, "link.txt");
|
||||
@@ -232,6 +236,10 @@ describe("applyPatch", () => {
|
||||
});
|
||||
|
||||
it("allows symlinks that resolve within cwd by default", async () => {
|
||||
// File symlinks require SeCreateSymbolicLinkPrivilege on Windows.
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await withTempDir(async (dir) => {
|
||||
const target = path.join(dir, "target.txt");
|
||||
const linkPath = path.join(dir, "link.txt");
|
||||
@@ -259,7 +267,9 @@ describe("applyPatch", () => {
|
||||
await fs.writeFile(outsideFile, "victim\n", "utf8");
|
||||
|
||||
const linkDir = path.join(dir, "linkdir");
|
||||
await fs.symlink(outsideDir, linkDir);
|
||||
// Use 'junction' on Windows — junctions target directories without
|
||||
// requiring SeCreateSymbolicLinkPrivilege.
|
||||
await fs.symlink(outsideDir, linkDir, process.platform === "win32" ? "junction" : undefined);
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Delete File: linkdir/victim.txt
|
||||
@@ -310,7 +320,13 @@ describe("applyPatch", () => {
|
||||
await fs.writeFile(outsideTarget, "keep\n", "utf8");
|
||||
|
||||
const linkDir = path.join(dir, "link");
|
||||
await fs.symlink(outsideDir, linkDir);
|
||||
// Use 'junction' on Windows — junctions target directories without
|
||||
// requiring SeCreateSymbolicLinkPrivilege.
|
||||
await fs.symlink(
|
||||
outsideDir,
|
||||
linkDir,
|
||||
process.platform === "win32" ? "junction" : undefined,
|
||||
);
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Delete File: link
|
||||
|
||||
@@ -128,6 +128,16 @@ export async function waitForExecApprovalDecision(id: string): Promise<string |
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRegisteredExecApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
}): Promise<string | null> {
|
||||
if (params.preResolvedDecision !== undefined) {
|
||||
return params.preResolvedDecision ?? null;
|
||||
}
|
||||
return await waitForExecApprovalDecision(params.approvalId);
|
||||
}
|
||||
|
||||
export async function requestExecApprovalDecision(
|
||||
params: RequestExecApprovalDecisionParams,
|
||||
): Promise<string | null> {
|
||||
|
||||
@@ -19,9 +19,9 @@ import { logInfo } from "../logger.js";
|
||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
waitForExecApprovalDecision,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
@@ -172,13 +172,12 @@ export async function processGatewayAllowlist(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
let decision: string | null = preResolvedDecision ?? null;
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
// Some gateways may return a final decision inline during registration.
|
||||
// Only call waitDecision when registration did not already carry one.
|
||||
if (preResolvedDecision === undefined) {
|
||||
decision = await waitForExecApprovalDecision(approvalId);
|
||||
}
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
|
||||
@@ -17,9 +17,9 @@ import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-cont
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
waitForExecApprovalDecision,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
@@ -243,13 +243,12 @@ export async function executeNodeHostCommand(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
let decision: string | null = preResolvedDecision ?? null;
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
// Some gateways may return a final decision inline during registration.
|
||||
// Only call waitDecision when registration did not already carry one.
|
||||
if (preResolvedDecision === undefined) {
|
||||
decision = await waitForExecApprovalDecision(approvalId);
|
||||
}
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
import { mergePathPrepend } from "../infra/path-prepend.js";
|
||||
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js";
|
||||
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import type { ManagedRun } from "../process/supervisor/index.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
@@ -210,9 +210,10 @@ export function applyShellPath(env: Record<string, string>, shellPath?: string |
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const merged = mergePathPrepend(env.PATH, entries);
|
||||
const pathKey = findPathKey(env);
|
||||
const merged = mergePathPrepend(env[pathKey], entries);
|
||||
if (merged) {
|
||||
env.PATH = merged;
|
||||
env[pathKey] = merged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
@@ -547,3 +548,57 @@ describe("exec PATH handling", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPathKey", () => {
|
||||
it("returns PATH when key is uppercase", () => {
|
||||
expect(findPathKey({ PATH: "/usr/bin" })).toBe("PATH");
|
||||
});
|
||||
|
||||
it("returns Path when key is mixed-case (Windows style)", () => {
|
||||
expect(findPathKey({ Path: "C:\\Windows\\System32" })).toBe("Path");
|
||||
});
|
||||
|
||||
it("returns PATH as default when no PATH-like key exists", () => {
|
||||
expect(findPathKey({ HOME: "/home/user" })).toBe("PATH");
|
||||
});
|
||||
|
||||
it("prefers uppercase PATH when both PATH and Path exist", () => {
|
||||
expect(findPathKey({ PATH: "/usr/bin", Path: "C:\\Windows" })).toBe("PATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPathPrepend with case-insensitive PATH key", () => {
|
||||
it("prepends to Path key on Windows-style env (no uppercase PATH)", () => {
|
||||
const env: Record<string, string> = { Path: "C:\\Windows\\System32" };
|
||||
applyPathPrepend(env, ["C:\\custom\\bin"]);
|
||||
// Should write back to the same `Path` key, not create a new `PATH`
|
||||
expect(env.Path).toContain("C:\\custom\\bin");
|
||||
expect(env.Path).toContain("C:\\Windows\\System32");
|
||||
expect("PATH" in env).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves all existing entries when prepending via Path key", () => {
|
||||
// Use platform-appropriate paths and delimiters
|
||||
const delim = path.delimiter;
|
||||
const existing = isWin
|
||||
? ["C:\\Windows\\System32", "C:\\Windows", "C:\\Program Files\\nodejs"]
|
||||
: ["/usr/bin", "/usr/local/bin", "/opt/node/bin"];
|
||||
const prepend = isWin ? ["C:\\custom\\bin"] : ["/custom/bin"];
|
||||
const existingPath = existing.join(delim);
|
||||
const env: Record<string, string> = { Path: existingPath };
|
||||
applyPathPrepend(env, prepend);
|
||||
const parts = env.Path.split(delim);
|
||||
expect(parts[0]).toBe(prepend[0]);
|
||||
for (const entry of existing) {
|
||||
expect(parts).toContain(entry);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects requireExisting option with Path key", () => {
|
||||
const env: Record<string, string> = { HOME: "/home/user" };
|
||||
applyPathPrepend(env, ["C:\\custom\\bin"], { requireExisting: true });
|
||||
// No Path/PATH key exists, so nothing should be written
|
||||
expect("PATH" in env).toBe(false);
|
||||
expect("Path" in env).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,14 @@ import type {
|
||||
TextContent,
|
||||
ToolCall,
|
||||
Tool,
|
||||
Usage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildStreamErrorAssistantMessage } from "./stream-message-shared.js";
|
||||
import {
|
||||
buildAssistantMessage as buildStreamAssistantMessage,
|
||||
buildStreamErrorAssistantMessage,
|
||||
buildUsageWithNoCost,
|
||||
} from "./stream-message-shared.js";
|
||||
|
||||
const log = createSubsystemLogger("ollama-stream");
|
||||
|
||||
@@ -343,25 +346,15 @@ export function buildAssistantMessage(
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0;
|
||||
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
|
||||
|
||||
const usage: Usage = {
|
||||
input: response.prompt_eval_count ?? 0,
|
||||
output: response.eval_count ?? 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: (response.prompt_eval_count ?? 0) + (response.eval_count ?? 0),
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
return buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content,
|
||||
stopReason,
|
||||
api: modelInfo.api,
|
||||
provider: modelInfo.provider,
|
||||
model: modelInfo.id,
|
||||
usage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
usage: buildUsageWithNoCost({
|
||||
input: response.prompt_eval_count ?? 0,
|
||||
output: response.eval_count ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── NDJSON streaming parser ─────────────────────────────────────────────────
|
||||
|
||||
@@ -30,7 +30,6 @@ import type {
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
Usage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { createAssistantMessageEventStream, streamSimple } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
@@ -43,7 +42,9 @@ import {
|
||||
} from "./openai-ws-connection.js";
|
||||
import { log } from "./pi-embedded-runner/logger.js";
|
||||
import {
|
||||
buildAssistantMessage,
|
||||
buildAssistantMessageWithZeroUsage,
|
||||
buildUsageWithNoCost,
|
||||
buildStreamErrorAssistantMessage,
|
||||
} from "./stream-message-shared.js";
|
||||
|
||||
@@ -298,25 +299,16 @@ export function buildAssistantMessageFromResponse(
|
||||
const hasToolCalls = content.some((c) => c.type === "toolCall");
|
||||
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
|
||||
|
||||
const usage: Usage = {
|
||||
input: response.usage?.input_tokens ?? 0,
|
||||
output: response.usage?.output_tokens ?? 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: response.usage?.total_tokens ?? 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
return buildAssistantMessage({
|
||||
model: modelInfo,
|
||||
content,
|
||||
stopReason,
|
||||
api: modelInfo.api,
|
||||
provider: modelInfo.provider,
|
||||
model: modelInfo.id,
|
||||
usage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
usage: buildUsageWithNoCost({
|
||||
input: response.usage?.input_tokens ?? 0,
|
||||
output: response.usage?.output_tokens ?? 0,
|
||||
totalTokens: response.usage?.total_tokens ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -223,4 +223,123 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
expect(details.error).toContain('sandbox="require"');
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// agentId format validation (#31311)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("rejects error-message-like strings as agentId (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "research" }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-err-msg", {
|
||||
task: "do thing",
|
||||
agentId: "Agent not found: xyz",
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(details.error).toContain("agents_list");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agentId containing path separators (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-path", {
|
||||
task: "do thing",
|
||||
agentId: "../../../etc/passwd",
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agentId exceeding 64 characters (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-long", {
|
||||
task: "do thing",
|
||||
agentId: "a".repeat(65),
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts well-formed agentId with hyphens and underscores (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "my-research_agent01" }],
|
||||
},
|
||||
});
|
||||
callGatewayMock.mockImplementation(async () => ({
|
||||
runId: "run-1",
|
||||
status: "accepted",
|
||||
acceptedAt: 1000,
|
||||
}));
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-valid", {
|
||||
task: "do thing",
|
||||
agentId: "my-research_agent01",
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
expect(details.status).toBe("accepted");
|
||||
});
|
||||
|
||||
it("allows allowlisted-but-unconfigured agentId (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", subagents: { allowAgents: ["research"] } },
|
||||
// "research" is NOT in agents.list — only in allowAgents
|
||||
],
|
||||
},
|
||||
});
|
||||
callGatewayMock.mockImplementation(async () => ({
|
||||
runId: "run-1",
|
||||
status: "accepted",
|
||||
acceptedAt: 1000,
|
||||
}));
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-unconfigured", {
|
||||
task: "do thing",
|
||||
agentId: "research",
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
// Must pass: "research" is in allowAgents even though not in agents.list
|
||||
expect(details.status).toBe("accepted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
expect(toolResult.toolUseId).toBe("callabcitem123");
|
||||
});
|
||||
|
||||
it("does not sanitize tool IDs in images-only mode", async () => {
|
||||
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -169,10 +169,10 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
|
||||
const assistant = out[0] as unknown as { content?: Array<{ type?: string; id?: string }> };
|
||||
const toolCall = assistant.content?.find((b) => b.type === "toolCall");
|
||||
expect(toolCall?.id).toBe("call_123|fc_456");
|
||||
expect(toolCall?.id).toBe("call123fc456");
|
||||
|
||||
const toolResult = out[1] as unknown as { toolCallId?: string };
|
||||
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
||||
expect(toolResult.toolCallId).toBe("call123fc456");
|
||||
});
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
|
||||
@@ -54,12 +54,12 @@ export async function sanitizeSessionMessagesImages(
|
||||
maxDimensionPx: options?.maxDimensionPx,
|
||||
maxBytes: options?.maxBytes,
|
||||
};
|
||||
const shouldSanitizeToolCallIds = options?.sanitizeToolCallIds === true;
|
||||
// We sanitize historical session messages because Anthropic can reject a request
|
||||
// if the transcript contains oversized base64 images (default max side 1200px).
|
||||
const sanitizedIds =
|
||||
allowNonImageSanitization && options?.sanitizeToolCallIds
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||
: messages;
|
||||
const sanitizedIds = shouldSanitizeToolCallIds
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||
: messages;
|
||||
const out: AgentMessage[] = [];
|
||||
for (const msg of sanitizedIds) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
|
||||
@@ -191,6 +191,29 @@ describe("sanitizeSessionHistory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for openai-completions", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "openai-completions",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||
mockMessages,
|
||||
"session:history",
|
||||
expect.objectContaining({
|
||||
sanitizeMode: "images-only",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("annotates inter-session user messages before context sanitization", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
@@ -13,6 +16,7 @@ const {
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
readWorkspaceContextForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
@@ -484,3 +488,41 @@ describe("compaction-safeguard double-compaction guard", () => {
|
||||
expect(getApiKeyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWorkspaceContextForSummary", () => {
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"returns empty when AGENTS.md is a symlink escape",
|
||||
async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
const outside = path.join(root, "outside-secret.txt");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
|
||||
process.chdir(root);
|
||||
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"returns empty when AGENTS.md is a hardlink alias",
|
||||
async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
const outside = path.join(root, "outside-secret.txt");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
fs.linkSync(outside, path.join(root, "AGENTS.md"));
|
||||
process.chdir(root);
|
||||
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
|
||||
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
|
||||
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import {
|
||||
BASE_CHUNK_RATIO,
|
||||
@@ -169,11 +170,22 @@ async function readWorkspaceContextForSummary(): Promise<string> {
|
||||
const agentsPath = path.join(workspaceDir, "AGENTS.md");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(agentsPath)) {
|
||||
const opened = await openBoundaryFile({
|
||||
absolutePath: agentsPath,
|
||||
rootPath: workspaceDir,
|
||||
boundaryLabel: "workspace root",
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const content = await fs.promises.readFile(agentsPath, "utf-8");
|
||||
const content = (() => {
|
||||
try {
|
||||
return fs.readFileSync(opened.fd, "utf-8");
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
})();
|
||||
const sections = extractSections(content, ["Session Startup", "Red Lines"]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -392,6 +404,7 @@ export const __testing = {
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
readWorkspaceContextForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user