chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

3
.gitignore vendored
View File

@@ -17,6 +17,8 @@ __pycache__/
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
packages/dashboard-next/.next/
packages/dashboard-next/out/
# Mise configuration files
mise.toml
@@ -101,3 +103,4 @@ package-lock.json
apps/ios/LocalSigning.xcconfig
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json
.ant-colony/

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
@@ -15,9 +16,16 @@ Docs: https://docs.openclaw.ai
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
### Fixes
- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows.
- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows.
- Auth/Profiles: keep active `cooldownUntil`/`disabledUntil` windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual `usageStats` cleanup. (#23516, #23536) Thanks @arosstale.
- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.<provider>` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3.
- Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to `wss://`, rejecting insecure non-loopback `ws://` targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3.
- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck.
- Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber.
- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
@@ -27,9 +35,11 @@ Docs: https://docs.openclaw.ai
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`.
- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns.
- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine.
@@ -48,6 +58,7 @@ Docs: https://docs.openclaw.ai
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic.
- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue.
@@ -58,10 +69,13 @@ Docs: https://docs.openclaw.ai
- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS.
- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg.
- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic.
- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31.
- Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure.
- Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable.
@@ -92,6 +106,7 @@ Docs: https://docs.openclaw.ai
- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
- Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre.
- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic.
- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
- Gateway/Daemon: verify gateway health after daemon restart.
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
@@ -126,6 +141,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Bootstrap: skip malformed bootstrap files with missing/invalid paths instead of crashing agent sessions; hooks using `filePath` (or non-string `path`) are skipped with a warning. (#22693, #22698) Thanks @arosstale.
- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops.
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.

View File

@@ -28,7 +28,8 @@ enum ExecApprovalEvaluator {
let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let env = HostEnvSanitizer.sanitize(overrides: envOverrides)
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,

View File

@@ -15,6 +15,8 @@ enum HostEnvSanitizer {
"BASH_ENV",
"ENV",
"SHELL",
"SHELLOPTS",
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
@@ -29,13 +31,36 @@ enum HostEnvSanitizer {
"HOME",
"ZDOTDIR",
]
private static let shellWrapperAllowedOverrideKeys: Set<String> = [
"TERM",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"COLORTERM",
"NO_COLOR",
"FORCE_COLOR",
]
private static func isBlocked(_ upperKey: String) -> Bool {
if self.blockedKeys.contains(upperKey) { return true }
return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) })
}
static func sanitize(overrides: [String: String]?) -> [String: String] {
private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var filtered: [String: String] = [:]
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
if self.shellWrapperAllowedOverrideKeys.contains(key.uppercased()) {
filtered[key] = value
}
}
return filtered.isEmpty ? nil : filtered
}
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
var merged: [String: String] = [:]
for (rawKey, value) in ProcessInfo.processInfo.environment {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -45,8 +70,12 @@ enum HostEnvSanitizer {
merged[key] = value
}
guard let overrides else { return merged }
for (rawKey, value) in overrides {
let effectiveOverrides = shellWrapper
? self.filterOverridesForShellWrapper(overrides)
: overrides
guard let effectiveOverrides else { return merged }
for (rawKey, value) in effectiveOverrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()

View File

@@ -15,7 +15,7 @@ struct ConnectOptions {
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var scopes: [String] = defaultOperatorConnectScopes
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {

View File

@@ -0,0 +1,7 @@
let defaultOperatorConnectScopes: [String] = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
]

View File

@@ -251,7 +251,7 @@ actor GatewayWizardClient {
let clientMode = "ui"
let role = "operator"
// Explicit scopes; gateway no longer defaults empty scopes to admin.
let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
let scopes = defaultOperatorConnectScopes
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"),

View File

@@ -0,0 +1,36 @@
import Testing
@testable import OpenClaw
struct HostEnvSanitizerTests {
@Test func sanitizeBlocksShellTraceVariables() {
let env = HostEnvSanitizer.sanitize(overrides: [
"SHELLOPTS": "xtrace",
"PS4": "$(touch /tmp/pwned)",
"OPENCLAW_TEST": "1",
])
#expect(env["SHELLOPTS"] == nil)
#expect(env["PS4"] == nil)
#expect(env["OPENCLAW_TEST"] == "1")
}
@Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() {
let env = HostEnvSanitizer.sanitize(
overrides: [
"LANG": "C",
"LC_ALL": "C",
"OPENCLAW_TOKEN": "secret",
"PS4": "$(touch /tmp/pwned)",
],
shellWrapper: true)
#expect(env["LANG"] == "C")
#expect(env["LC_ALL"] == "C")
#expect(env["OPENCLAW_TOKEN"] == nil)
#expect(env["PS4"] == nil)
}
@Test func sanitizeNonShellWrapperKeepsRegularOverrides() {
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
#expect(env["OPENCLAW_TOKEN"] == "secret")
}
}

View File

@@ -127,6 +127,14 @@ private enum ConnectChallengeError: Error {
case timeout
}
private let defaultOperatorConnectScopes: [String] = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
]
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
@@ -318,7 +326,7 @@ public actor GatewayChannelActor {
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let options = self.connectOptions ?? GatewayConnectOptions(
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
scopes: defaultOperatorConnectScopes,
caps: [],
commands: [],
permissions: [:],

View File

@@ -425,7 +425,7 @@ Example:
}
```
If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs).
If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`.
</Tab>

View File

@@ -190,6 +190,7 @@ Notes:
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
- Runtime safety: when a provider block is completely missing (`channels.<provider>` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`.
Quick mental model (evaluation order for group messages):

View File

@@ -158,6 +158,7 @@ imsg send <handle> "test"
Group sender allowlist: `channels.imessage.groupAllowFrom`.
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
Mention gating for groups:

View File

@@ -118,6 +118,7 @@ Allowlists and policies:
- `channels.line.groupPolicy`: `allowlist | open | disabled`
- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups
- Per-group overrides: `channels.line.groups.<groupId>.allowFrom`
- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
LINE IDs are case-sensitive. Valid IDs look like:

View File

@@ -195,6 +195,7 @@ Notes:
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set).
- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match):
```json5

View File

@@ -103,6 +103,7 @@ Notes:
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
## Targets for outbound delivery

View File

@@ -195,6 +195,7 @@ Groups:
- `channels.signal.groupPolicy = open | allowlist | disabled`.
- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
## How it works (behavior)

View File

@@ -165,7 +165,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
Channel allowlist lives under `channels.slack.channels`.
Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning.
Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
Name/ID resolution:

View File

@@ -148,6 +148,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
`groupAllowFrom` entries must be numeric Telegram user IDs.
Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set).
Example: allow any member in one specific group:
@@ -670,6 +671,25 @@ openclaw message send --channel telegram --target @name --message "hi"
- Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch.
- Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures.
- If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors.
- On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`:
```yaml
channels:
telegram:
proxy: socks5://user:pass@proxy-host:1080
```
- If DNS/IPv6 selection is unstable, force Node family selection behavior explicitly:
```yaml
channels:
telegram:
network:
autoSelectFamily: false
```
- Environment override (temporary): set `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`.
- Validate DNS answers:
```bash

View File

@@ -171,7 +171,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
- if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available
- sender allowlists are evaluated before mention/reply activation
Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`.
Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set.
</Tab>

View File

@@ -60,6 +60,7 @@ Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- Local onboarding DM scope behavior: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals).
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
including hosted providers not listed. Use Unknown to auto-detect.

View File

@@ -49,6 +49,7 @@ Use `session.dmScope` to control how **direct messages** are grouped:
Notes:
- Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups.
- Local CLI onboarding writes `session.dmScope: "per-channel-peer"` by default when unset (existing explicit values are preserved).
- For multi-account inboxes on the same channel, prefer `per-account-channel-peer`.
- If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity.
- You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)).

View File

@@ -35,7 +35,7 @@ All channels support DM policies and group policies:
<Note>
`channels.defaults.groupPolicy` sets the default when a provider's `groupPolicy` is unset.
Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3 per channel**.
Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning).
If a provider block is missing entirely (`channels.<provider>` absent), runtime group policy falls back to `allowlist` (fail-closed) with a startup warning.
</Note>
### Channel model overrides

View File

@@ -117,33 +117,34 @@ When the audit prints findings, treat this as a priority order:
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
## Control UI over HTTP
@@ -332,6 +333,7 @@ This is a messaging-context boundary, not a host-admin boundary. If users are mu
Treat the snippet above as **secure DM mode**:
- Default: `session.dmScope: "main"` (all DMs share one session for continuity).
- Local CLI onboarding default: writes `session.dmScope: "per-channel-peer"` when unset (keeps existing explicit values).
- Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context).
If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).

View File

@@ -278,8 +278,9 @@ Notes:
- `system.run` returns stdout/stderr/exit code in the payload.
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Node hosts ignore `PATH` overrides. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`).

View File

@@ -105,7 +105,8 @@ Notes:
- `allowlist` entries are glob patterns for resolved binary paths.
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the apps environment.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the apps environment.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
## Deep links

View File

@@ -243,6 +243,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
- `skills.install.nodeManager`

View File

@@ -215,6 +215,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible)
- `skills.install.nodeManager`

View File

@@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Workspace default (or existing workspace)
- Gateway port **18789**
- Gateway auth **Token** (autogenerated, even on loopback)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
</Tab>

View File

@@ -124,6 +124,10 @@ are treated as allowlisted on nodes (macOS node or headless node host). This use
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
positional file args and path-like tokens, so they can only operate on the incoming stream.
Treat this as a narrow fast-path for stream filters, not a general trust list.
Do **not** add interpreter or runtime binaries (for example `python3`, `node`, `ruby`, `bash`, `sh`, `zsh`) to `safeBins`.
If a command can evaluate code, execute subcommands, or read files by design, prefer explicit allowlist entries and keep approval prompts enabled.
Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles.<bin>`.
Validation is deterministic from argv shape only (no host filesystem existence checks), which
prevents file-existence oracle behavior from allow/deny differences.
File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`,
@@ -155,6 +159,8 @@ double quotes; use single quotes if you need literal `$()` text.
On macOS companion-app approvals, raw shell text containing shell control or expansion syntax
(`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss unless
the shell binary itself is allowlisted.
For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are reduced to a
small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.
@@ -163,6 +169,44 @@ their non-stdin workflows.
For `grep` in safe-bin mode, provide the pattern with `-e`/`--regexp`; positional pattern form is
rejected so file operands cannot be smuggled as ambiguous positionals.
### Safe bins versus allowlist
| Topic | `tools.exec.safeBins` | Allowlist (`exec-approvals.json`) |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables |
| Match type | Executable name + safe-bin argv policy | Resolved executable path glob pattern |
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match only; arguments are otherwise your responsibility |
| Typical examples | `jq`, `head`, `tail`, `wc` | `python3`, `node`, `ffmpeg`, custom CLIs |
| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects |
Configuration location:
- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`).
- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys.
- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles.
- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.<bin>` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded.
Custom profile example:
```json5
{
tools: {
exec: {
safeBins: ["jq", "myfilter"],
safeBinProfiles: {
myfilter: {
minPositional: 0,
maxPositional: 0,
allowedValueFlags: ["-n", "--limit"],
deniedFlags: ["-f", "--file", "-c", "--command"],
},
},
},
},
}
```
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, peragent

View File

@@ -55,6 +55,7 @@ Notes:
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only).
- `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`).
Example:
@@ -126,6 +127,17 @@ allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejec
allowlist mode unless every top-level segment satisfies the allowlist (including safe bins).
Redirections remain unsupported.
Use the two controls for different jobs:
- `tools.exec.safeBins`: small, stdin-only stream filters.
- `tools.exec.safeBinProfiles`: explicit argv policy for custom safe bins.
- allowlist: explicit trust for executable paths.
Do not treat `safeBins` as a generic allowlist, and do not add interpreter/runtime binaries (for example `python3`, `node`, `ruby`, `bash`). If you need those, use explicit allowlist entries and keep approval prompts enabled.
`openclaw security audit` warns when interpreter/runtime `safeBins` entries are missing explicit profiles, and `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles` entries.
For full policy details and examples, see [Exec approvals](/tools/exec-approvals#safe-bins-stdin-only) and [Safe bins versus allowlist](/tools/exec-approvals#safe-bins-versus-allowlist).
## Examples
Foreground:

View File

@@ -23,7 +23,9 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session*
- `/subagents steer <id|#> <message>`
- `/subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]`
Discord thread binding controls:
Thread binding controls:
These commands work on channels that support persistent thread bindings. See **Thread supporting channels** below.
- `/focus <subagent-label|session-key|session-id|session-label>`
- `/unfocus`
@@ -85,14 +87,18 @@ Tool params:
- `mode: "session"` requires `thread: true`
- `cleanup?` (`delete|keep`, default `keep`)
## Discord thread-bound sessions
## Thread-bound sessions
When thread bindings are enabled, a sub-agent can stay bound to a Discord thread so follow-up user messages in that thread keep routing to the same sub-agent session.
When thread bindings are enabled for a channel, a sub-agent can stay bound to a thread so follow-up user messages in that thread keep routing to the same sub-agent session.
### Thread supporting channels
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
Quick flow:
1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`).
2. OpenClaw creates or binds a Discord thread to that session target.
2. OpenClaw creates or binds a thread to that session target in the active channel.
3. Replies and follow-up messages in that thread route to the bound session.
4. Use `/session ttl` to inspect/update auto-unfocus TTL.
5. Use `/unfocus` to detach manually.
@@ -100,17 +106,16 @@ Quick flow:
Manual controls:
- `/focus <target>` binds the current thread (or creates one) to a sub-agent/session target.
- `/unfocus` removes the binding for the current Discord thread.
- `/unfocus` removes the binding for the current bound thread.
- `/agents` lists active runs and binding state (`thread:<id>` or `unbound`).
- `/session ttl` only works for focused Discord threads.
- `/session ttl` only works for focused bound threads.
Config switches:
- Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`
- Discord override: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`
- Spawn auto-bind opt-in: `channels.discord.threadBindings.spawnSubagentSessions`
- Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above.
See [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), and [Slash commands](/tools/slash-commands).
See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details.
Allowlist:
@@ -202,7 +207,7 @@ Sub-agents report back via an announce step:
- The announce step runs inside the sub-agent session (not the requester session).
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`).
- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads).
- Announce replies preserve thread/topic routing when available on channel adapters.
- Announce messages are normalized to a stable template:
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
- `Result:` the summary content from the announce step (or `(not available)` if missing).

View File

@@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./reactions.js", () => ({
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),

View File

@@ -4,7 +4,12 @@ import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatus,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
@@ -278,7 +283,10 @@ describe("sendBlueBubblesAttachment", () => {
fetchRemoteMediaMock.mockClear();
setBlueBubblesRuntime(runtimeStub);
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
mockBlueBubblesPrivateApiStatus(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
);
});
afterEach(() => {
@@ -381,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => {
});
it("downgrades attachment reply threading when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockBlueBubblesPrivateApiStatusOnce(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
@@ -402,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => {
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({
...runtimeStub,
log: runtimeLog,
} as unknown as PluginRuntime);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-unknown",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
});

View File

@@ -3,9 +3,12 @@ import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
import {
@@ -139,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: {
contentType = contentType?.trim() || undefined;
const { baseUrl, password, accountId } = resolveAccount(opts);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
const isAudioMessage = wantsVoice;
@@ -207,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: {
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
if (privateApiStatus !== false) {
if (privateApiEnabled) {
addField("method", "private-api");
}
@@ -217,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: {
}
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo && privateApiStatus !== false) {
if (trimmedReplyTo && privateApiEnabled) {
addField("selectedMessageGuid", trimmedReplyTo);
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
} else if (trimmedReplyTo && privateApiStatus === null) {
warnBlueBubbles(
"Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
);
}
// Add optional caption

View File

@@ -1,6 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
import {
addBlueBubblesParticipant,
editBlueBubblesMessage,
leaveBlueBubblesChat,
markBlueBubblesChatRead,
removeBlueBubblesParticipant,
renameBlueBubblesChat,
sendBlueBubblesTyping,
setGroupIconBlueBubbles,
unsendBlueBubblesMessage,
} from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
@@ -278,6 +288,188 @@ describe("chat", () => {
});
});
describe("editBlueBubblesMessage", () => {
it("throws when required args are missing", async () => {
await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText");
});
it("sends edit request with default payload values", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await editBlueBubblesMessage(" message-guid ", " updated text ", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/message-guid/edit"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).toEqual({
editedMessage: "updated text",
backwardsCompatibilityMessage: "Edited to: updated text",
partIndex: 0,
});
});
it("supports custom part index and backwards compatibility message", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await editBlueBubblesMessage("message-guid", "new text", {
serverUrl: "http://localhost:1234",
password: "test-password",
partIndex: 3,
backwardsCompatMessage: "custom-backwards-message",
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 422,
text: () => Promise.resolve("Unprocessable"),
});
await expect(
editBlueBubblesMessage("message-guid", "new text", {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("edit failed (422): Unprocessable");
});
});
describe("unsendBlueBubblesMessage", () => {
it("throws when messageGuid is missing", async () => {
await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
});
it("sends unsend request with default part index", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await unsendBlueBubblesMessage(" msg-123 ", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/msg-123/unsend"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(0);
});
it("uses custom part index", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await unsendBlueBubblesMessage("msg-123", {
serverUrl: "http://localhost:1234",
password: "test-password",
partIndex: 2,
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(2);
});
});
describe("group chat mutation actions", () => {
it("renames chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/chat-guid"),
expect.objectContaining({ method: "PUT" }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.displayName).toBe("New Group Name");
});
it("adds and removes participant using matching endpoint", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await addBlueBubblesParticipant("chat-guid", "+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(addBody.address).toBe("+15551234567");
expect(removeBody.address).toBe("+15551234567");
});
it("leaves chat without JSON body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await leaveBlueBubblesChat("chat-guid", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/chat-guid/leave"),
expect.objectContaining({ method: "POST" }),
);
expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
});
});
describe("setGroupIconBlueBubbles", () => {
it("throws when chatGuid is empty", async () => {
await expect(

View File

@@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
}
}
function resolvePartIndex(partIndex: number | undefined): number {
return typeof partIndex === "number" ? partIndex : 0;
}
async function sendPrivateApiJsonRequest(params: {
opts: BlueBubblesChatOpts;
feature: string;
action: string;
path: string;
method: "POST" | "PUT" | "DELETE";
payload?: unknown;
}): Promise<void> {
const { baseUrl, password, accountId } = resolveAccount(params.opts);
assertPrivateApiEnabled(accountId, params.feature);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: params.path,
password,
});
const request: RequestInit = { method: params.method };
if (params.payload !== undefined) {
request.headers = { "Content-Type": "application/json" };
request.body = JSON.stringify(params.payload);
}
const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
@@ -97,34 +132,18 @@ export async function editBlueBubblesMessage(
throw new Error("BlueBubbles edit requires newText");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "edit");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "edit",
action: "edit",
method: "POST",
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
password,
});
const payload = {
editedMessage: trimmedText,
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
payload: {
editedMessage: trimmedText,
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
partIndex: resolvePartIndex(opts.partIndex),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
}
});
}
/**
@@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage(
throw new Error("BlueBubbles unsend requires messageGuid");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "unsend");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "unsend",
action: "unsend",
method: "POST",
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
password,
payload: { partIndex: resolvePartIndex(opts.partIndex) },
});
const payload = {
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
@@ -181,28 +182,14 @@ export async function renameBlueBubblesChat(
throw new Error("BlueBubbles rename requires chatGuid");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "renameGroup");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "renameGroup",
action: "rename",
method: "PUT",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
password,
payload: { displayName },
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
@@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant(
throw new Error("BlueBubbles addParticipant requires address");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "addParticipant");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "addParticipant",
action: "addParticipant",
method: "POST",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
payload: { address: trimmedAddress },
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmedAddress }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
@@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant(
throw new Error("BlueBubbles removeParticipant requires address");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "removeParticipant");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "removeParticipant",
action: "removeParticipant",
method: "DELETE",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
payload: { address: trimmedAddress },
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmedAddress }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
/**
@@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat(
throw new Error("BlueBubbles leaveChat requires chatGuid");
}
const { baseUrl, password, accountId } = resolveAccount(opts);
assertPrivateApiEnabled(accountId, "leaveGroup");
const url = buildBlueBubblesApiUrl({
baseUrl,
await sendPrivateApiJsonRequest({
opts,
feature: "leaveGroup",
action: "leaveChat",
method: "POST",
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**

View File

@@ -39,7 +39,7 @@ import type {
BlueBubblesRuntimeEnv,
WebhookTarget,
} from "./monitor-shared.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
@@ -420,7 +420,7 @@ export async function processMessage(
target: WebhookTarget,
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false;
const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId);
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;

View File

@@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea
return info.private_api;
}
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
return status === true;
}
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/

View File

@@ -1,17 +1,10 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendBlueBubblesReaction } from "./reactions.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
const mockFetch = vi.fn();

View File

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

View File

@@ -1,15 +1,22 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesSendTarget } from "./types.js";
const mockFetch = vi.fn();
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
privateApiStatusMock,
});
function mockResolvedHandleTarget(
@@ -527,6 +534,10 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
@@ -548,7 +559,10 @@ describe("send", () => {
});
it("downgrades threaded reply to plain send when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
@@ -568,6 +582,10 @@ describe("send", () => {
});
it("normalizes effect names and uses private-api for effects", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
@@ -586,6 +604,38 @@ describe("send", () => {
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
});
it("warns and downgrades private-api features when status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-unknown" } });
try {
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
effectId: "invisible ink",
});
expect(result.messageId).toBe("msg-uuid-unknown");
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
expect(warnSpy).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
expect(body.selectedMessageGuid).toBeUndefined();
expect(body.partIndex).toBeUndefined();
expect(body.effectId).toBeUndefined();
} finally {
clearBlueBubblesRuntime();
warnSpy.mockRestore();
}
});
it("sends message with chat_guid target directly", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,

View File

@@ -2,7 +2,11 @@ import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
@@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined {
return raw;
}
type PrivateApiDecision = {
canUsePrivateApi: boolean;
throwEffectDisabledError: boolean;
warningMessage?: string;
};
function resolvePrivateApiDecision(params: {
privateApiStatus: boolean | null;
wantsReplyThread: boolean;
wantsEffect: boolean;
}): PrivateApiDecision {
const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi =
needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
if (!needsPrivateApi || privateApiStatus !== null) {
return { canUsePrivateApi, throwEffectDisabledError };
}
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
return {
canUsePrivateApi,
throwEffectDisabledError,
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
};
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -372,30 +408,36 @@ export async function sendMessageBlueBubbles(
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
if (wantsEffect && privateApiStatus === false) {
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
});
if (privateApiDecision.throwEffectDisabledError) {
throw new Error(
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
}
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (canUsePrivateApi) {
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (wantsReplyThread && canUsePrivateApi) {
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId) {
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}

View File

@@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean {
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
}
function parseGroupTarget(params: {
trimmed: string;
lower: string;
requireValue: boolean;
}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null {
if (!params.lower.startsWith("group:")) {
return null;
}
const value = stripPrefix(params.trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
if (params.requireValue) {
throw new Error("group target is required");
}
return null;
}
function parseRawChatIdentifierTarget(
trimmed: string,
): { kind: "chat_identifier"; chatIdentifier: string } | null {
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
return null;
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
@@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
return chatTarget;
}
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (!value) {
throw new Error("group target is required");
}
return { kind: "chat_guid", chatGuid: value };
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true });
if (groupTarget) {
return groupTarget;
}
const rawChatGuid = parseRawChatGuid(trimmed);
@@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
return { kind: "chat_guid", chatGuid: rawChatGuid };
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", to: trimmed, service: "auto" };
@@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
return chatTarget;
}
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false });
if (groupTarget) {
return groupTarget;
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
if (rawChatIdentifierTarget) {
return rawChatIdentifierTarget;
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };

View File

@@ -1,6 +1,31 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true,
disabled: false,
unknown: null,
} as const;
type BlueBubblesPrivateApiStatusMock = {
mockReturnValue: (value: boolean | null) => unknown;
mockReturnValueOnce: (value: boolean | null) => unknown;
};
export function mockBlueBubblesPrivateApiStatus(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
value: boolean | null,
) {
mock.mockReturnValue(value);
}
export function mockBlueBubblesPrivateApiStatusOnce(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
value: boolean | null,
) {
mock.mockReturnValueOnce(value);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
@@ -22,11 +47,15 @@ export function createBlueBubblesAccountsMockModule() {
type BlueBubblesProbeMockModule = {
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
};
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
getCachedBlueBubblesPrivateApiStatus: vi
.fn()
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true),
};
}
@@ -41,7 +70,7 @@ export function installBlueBubblesFetchTestHooks(params: {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(null);
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {

View File

@@ -22,6 +22,8 @@ import {
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -130,8 +132,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.discord !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const channelAllowlistConfigured = guildsConfigured;

View File

@@ -64,6 +64,95 @@ function registerHandlersForTest(
return handlers;
}
function getRequiredHandler(
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
hookName: string,
): (event: unknown, ctx: unknown) => unknown {
const handler = handlers.get(hookName);
if (!handler) {
throw new Error(`expected ${hookName} hook handler`);
}
return handler;
}
function createSpawnEvent(overrides?: {
childSessionKey?: string;
agentId?: string;
label?: string;
mode?: string;
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string;
};
threadRequested?: boolean;
}): {
childSessionKey: string;
agentId: string;
label: string;
mode: string;
requester: {
channel: string;
accountId: string;
to: string;
threadId?: string;
};
threadRequested: boolean;
} {
const base = {
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
};
return {
...base,
...overrides,
requester: {
...base.requester,
...(overrides?.requester ?? {}),
},
};
}
function createSpawnEventWithoutThread() {
return createSpawnEvent({
label: "",
requester: { threadId: undefined },
});
}
async function runSubagentSpawning(
config?: Record<string, unknown>,
event = createSpawnEventWithoutThread(),
) {
const handlers = registerHandlersForTest(config);
const handler = getRequiredHandler(handlers, "subagent_spawning");
return await handler(event, {});
}
async function expectSubagentSpawningError(params?: {
config?: Record<string, unknown>;
errorContains?: string;
event?: ReturnType<typeof createSpawnEvent>;
}) {
const result = await runSubagentSpawning(params?.config, params?.event);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
if (params?.errorContains) {
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain(params.errorContains);
}
}
describe("discord subagent hook handlers", () => {
beforeEach(() => {
hookMocks.resolveDiscordAccount.mockClear();
@@ -90,27 +179,9 @@ describe("discord subagent hook handlers", () => {
it("binds thread routing on subagent_spawning", async () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const handler = getRequiredHandler(handlers, "subagent_spawning");
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{},
);
const result = await handler(createSpawnEvent(), {});
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
@@ -127,82 +198,42 @@ describe("discord subagent hook handlers", () => {
});
it("returns error when thread-bound subagent spawn is disabled", async () => {
const handlers = registerHandlersForTest({
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: false,
await expectSubagentSpawningError({
config: {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: false,
},
},
},
},
errorContains: "spawnSubagentSessions=true",
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain("spawnSubagentSessions=true");
});
it("returns error when global thread bindings are disabled", async () => {
const handlers = registerHandlersForTest({
session: {
threadBindings: {
enabled: false,
},
},
channels: {
discord: {
await expectSubagentSpawningError({
config: {
session: {
threadBindings: {
spawnSubagentSessions: true,
enabled: false,
},
},
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
},
},
},
},
errorContains: "threadBindings.enabled=true",
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain("threadBindings.enabled=true");
});
it("allows account-level threadBindings.enabled to override global disable", async () => {
const handlers = registerHandlersForTest({
const result = await runSubagentSpawning({
session: {
threadBindings: {
enabled: false,
@@ -221,79 +252,34 @@ describe("discord subagent hook handlers", () => {
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
const handlers = registerHandlersForTest({
channels: {
discord: {
threadBindings: {},
await expectSubagentSpawningError({
config: {
channels: {
discord: {
threadBindings: {},
},
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
});
it("no-ops when thread binding is requested on non-discord channel", async () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
mode: "session",
const result = await runSubagentSpawning(
undefined,
createSpawnEvent({
requester: {
channel: "signal",
accountId: "",
to: "+123",
threadId: undefined,
},
threadRequested: true,
},
{},
}),
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
@@ -302,26 +288,7 @@ describe("discord subagent hook handlers", () => {
it("returns error when thread bind fails", async () => {
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
const result = await runSubagentSpawning();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
@@ -330,10 +297,7 @@ describe("discord subagent hook handlers", () => {
it("unbinds thread routing on subagent_ended", () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_ended");
if (!handler) {
throw new Error("expected subagent_ended hook handler");
}
const handler = getRequiredHandler(handlers, "subagent_ended");
handler(
{
@@ -361,10 +325,7 @@ describe("discord subagent hook handlers", () => {
{ accountId: "work", threadId: "777" },
]);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_delivery_target");
if (!handler) {
throw new Error("expected subagent_delivery_target hook handler");
}
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
const result = handler(
{
@@ -404,10 +365,7 @@ describe("discord subagent hook handlers", () => {
{ accountId: "work", threadId: "888" },
]);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_delivery_target");
if (!handler) {
throw new Error("expected subagent_delivery_target hook handler");
}
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
const result = handler(
{

View File

@@ -22,6 +22,20 @@ function makeEvent(
};
}
function makePostEvent(content: unknown) {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: JSON.stringify(content),
mentions: [],
},
};
}
describe("parseFeishuMessageEvent mentionedBot", () => {
const BOT_OPEN_ID = "ou_bot_123";
@@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent mentionedBot", () => {
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
const BOT_OPEN_ID = "ou_bot_123";
const postContent = JSON.stringify({
const event = makePostEvent({
content: [
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
[{ tag: "text", text: "What does this document say" }],
],
});
const event = {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: postContent,
mentions: [],
},
};
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false for post message with no at", () => {
const postContent = JSON.stringify({
const event = makePostEvent({
content: [[{ tag: "text", text: "hello" }]],
});
const event = {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: postContent,
mentions: [],
},
};
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false for post message with at for another user", () => {
const postContent = JSON.stringify({
const event = makePostEvent({
content: [
[{ tag: "at", user_id: "ou_other", user_name: "other" }],
[{ tag: "text", text: "hello" }],
],
});
const event = {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: postContent,
mentions: [],
},
};
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});

View File

@@ -25,6 +25,24 @@ vi.mock("./send.js", () => ({
getMessageFeishu: mockGetMessageFeishu,
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv;
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
await handleFeishuMessage({
cfg: params.cfg,
event: params.event,
runtime: createRuntimeEnv(),
});
}
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
@@ -96,17 +114,7 @@ describe("handleFeishuMessage command authorization", () => {
},
};
await handleFeishuMessage({
cfg,
event,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv,
});
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
@@ -151,17 +159,7 @@ describe("handleFeishuMessage command authorization", () => {
},
};
await handleFeishuMessage({
cfg,
event,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv,
});
await dispatchMessage({ cfg, event });
expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
@@ -198,17 +196,7 @@ describe("handleFeishuMessage command authorization", () => {
},
};
await handleFeishuMessage({
cfg,
event,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv,
});
await dispatchMessage({ cfg, event });
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
channel: "feishu",
@@ -262,17 +250,7 @@ describe("handleFeishuMessage command authorization", () => {
},
};
await handleFeishuMessage({
cfg,
event,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv,
});
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,

View File

@@ -2,10 +2,13 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import {
buildAgentMediaPayload,
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
recordPendingHistoryEntryIfEnabled,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
@@ -563,7 +566,18 @@ export async function handleFeishuMessage(params: {
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
if (isGroup) {
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "feishu",
accountId: account.accountId,
log,
});
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);

View File

@@ -4,6 +4,8 @@ import {
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk";
import {
resolveFeishuAccount,
@@ -224,10 +226,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const defaultGroupPolicy = (
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
)?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") return [];
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,

View File

@@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
describe("FeishuConfigSchema webhook validation", () => {
it("applies top-level defaults", () => {
const result = FeishuConfigSchema.parse({});
expect(result.domain).toBe("feishu");
expect(result.connectionMode).toBe("websocket");
expect(result.webhookPath).toBe("/feishu/events");
expect(result.dmPolicy).toBe("pairing");
expect(result.groupPolicy).toBe("allowlist");
expect(result.requireMention).toBe(true);
});
it("does not force top-level policy defaults into account config", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {},
},
});
expect(result.accounts?.main?.dmPolicy).toBeUndefined();
expect(result.accounts?.main?.groupPolicy).toBeUndefined();
expect(result.accounts?.main?.requireMention).toBeUndefined();
});
it("rejects top-level webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",

View File

@@ -112,6 +112,31 @@ export const FeishuGroupSchema = z
})
.strict();
const FeishuSharedConfigShape = {
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
};
/**
* Per-account configuration.
* All fields are optional - missing fields inherit from top-level config.
@@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
webhookPath: z.string().optional(),
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
tools: FeishuToolsConfigSchema,
...FeishuSharedConfigShape,
})
.strict();
@@ -163,29 +167,11 @@ export const FeishuConfigSchema = z
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
...FeishuSharedConfigShape,
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional().default(true),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
topicSessionMode: TopicSessionModeSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
tools: FeishuToolsConfigSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Multi-account configuration

View File

@@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({
import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = path.resolve(os.tmpdir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
}
describe("sendMediaFeishu msg_type routing", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(result.buffer).toEqual(Buffer.from("image-data"));
expect(capturedPath).toBeDefined();
expect(capturedPath).not.toContain(imageKey);
expect(capturedPath).not.toContain("..");
const tmpRoot = path.resolve(os.tmpdir());
const resolved = path.resolve(capturedPath as string);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
});
it("uses isolated temp paths for message resource downloads", async () => {
@@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(result.buffer).toEqual(Buffer.from("resource-data"));
expect(capturedPath).toBeDefined();
expect(capturedPath).not.toContain(fileKey);
expect(capturedPath).not.toContain("..");
const tmpRoot = path.resolve(os.tmpdir());
const resolved = path.resolve(capturedPath as string);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
});
it("rejects invalid image keys before calling feishu api", async () => {

View File

@@ -78,6 +78,41 @@ function buildConfig(params: {
} as ClawdbotConfig;
}
async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
},
run: (url: string) => Promise<void>,
) {
const port = await getFreePort();
const cfg = buildConfig({
accountId: params.accountId,
path: params.path,
port,
verificationToken: params.verificationToken,
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
const url = `http://127.0.0.1:${port}${params.path}`;
await waitUntilServerReady(url);
try {
await run(url);
} finally {
abortController.abort();
await monitorPromise;
}
}
afterEach(() => {
stopFeishuMonitor();
});
@@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => {
it("returns 415 for POST requests without json content type", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const port = await getFreePort();
const path = "/hook-content-type";
const cfg = buildConfig({
accountId: "content-type",
path,
port,
verificationToken: "verify_token",
});
await withRunningWebhookMonitor(
{
accountId: "content-type",
path: "/hook-content-type",
verificationToken: "verify_token",
},
async (url) => {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
expect(response.status).toBe(415);
expect(await response.text()).toBe("Unsupported Media Type");
abortController.abort();
await monitorPromise;
expect(response.status).toBe(415);
expect(await response.text()).toBe("Unsupported Media Type");
},
);
});
it("rate limits webhook burst traffic with 429", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const port = await getFreePort();
const path = "/hook-rate-limit";
const cfg = buildConfig({
accountId: "rate-limit",
path,
port,
verificationToken: "verify_token",
});
await withRunningWebhookMonitor(
{
accountId: "rate-limit",
path: "/hook-rate-limit",
verificationToken: "verify_token",
},
async (url) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
if (response.status === 429) {
saw429 = true;
expect(await response.text()).toBe("Too Many Requests");
break;
}
}
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
if (response.status === 429) {
saw429 = true;
expect(await response.text()).toBe("Too Many Requests");
break;
}
}
expect(saw429).toBe(true);
abortController.abort();
await monitorPromise;
expect(saw429).toBe(true);
},
);
});
});

View File

@@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
);
}
async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
appId: string;
appSecret: string;
}> {
const appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { appId, appSecret };
}
function setFeishuGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
@@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
} else {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
@@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (!keep) {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
if (appId && appSecret) {

View File

@@ -11,6 +11,8 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelDock,
type ChannelMessageActionAdapter,
@@ -198,8 +200,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.googlechat !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy === "open") {
warnings.push(
`- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`,

View File

@@ -1,13 +1,17 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
GROUP_POLICY_BLOCKED_LABEL,
createReplyPrefixOptions,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSingleWebhookTargetAsync,
resolveWebhookPath,
resolveWebhookTargets,
warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
} from "openclaw/plugin-sdk";
@@ -426,8 +430,20 @@ async function processMessageWithPipeline(params: {
return;
}
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
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,

View File

@@ -18,6 +18,8 @@ import {
resolveIMessageAccount,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ResolvedIMessageAccount,
@@ -97,8 +99,12 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.imessage !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -4,6 +4,8 @@ import {
formatPairingApproveHint,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
deleteAccountFromConfigSection,
type ChannelPlugin,
@@ -134,8 +136,12 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.irc !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy === "open") {
warnings.push(
'- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.',

View File

@@ -1,7 +1,11 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createReplyPrefixOptions,
logInboundDrop,
resolveControlCommandGate,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
type OpenClawConfig,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
@@ -84,8 +88,20 @@ export async function handleIrcInbound(params: {
: message.senderNick;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: config.channels?.irc !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "irc",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
log: (message) => runtime.log?.(message),
});
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);

View File

@@ -47,15 +47,50 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function resolveAccount(
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
cfg: OpenClawConfig,
accountId: string,
): ResolvedLineAccount {
const resolver = resolveLineAccount as unknown as (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => ResolvedLineAccount;
return resolver({ cfg, accountId });
}
async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{
result: Awaited<ReturnType<NonNullable<NonNullable<typeof linePlugin.gateway>["logoutAccount"]>>>;
mocks: LineRuntimeMocks;
}> {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId);
const result = await linePlugin.gateway!.logoutAccount!({
accountId: params.accountId,
cfg: params.cfg,
account,
runtime: createRuntimeEnv(),
});
return { result, mocks };
}
describe("linePlugin gateway.logoutAccount", () => {
beforeEach(() => {
setLineRuntime(createRuntime().runtime);
});
it("clears tokenFile/secretFile on default account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: OpenClawConfig = {
channels: {
line: {
@@ -64,38 +99,17 @@ describe("linePlugin gateway.logoutAccount", () => {
},
},
};
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
const resolveAccount = mocks.resolveLineAccount as unknown as (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => ResolvedLineAccount;
const account = resolveAccount({
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: DEFAULT_ACCOUNT_ID,
});
const result = await linePlugin.gateway!.logoutAccount!({
accountId: DEFAULT_ACCOUNT_ID,
cfg,
account,
runtime: runtimeEnv,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("clears tokenFile/secretFile on account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: OpenClawConfig = {
channels: {
line: {
@@ -108,31 +122,35 @@ describe("linePlugin gateway.logoutAccount", () => {
},
},
};
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
const resolveAccount = mocks.resolveLineAccount as unknown as (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => ResolvedLineAccount;
const account = resolveAccount({
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: "primary",
});
const result = await linePlugin.gateway!.logoutAccount!({
accountId: "primary",
cfg,
account,
runtime: runtimeEnv,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("does not write config when account has no token/secret fields", async () => {
const cfg: OpenClawConfig = {
channels: {
line: {
accounts: {
primary: {
name: "Primary",
},
},
},
},
};
const { result, mocks } = await runLogoutScenario({
cfg,
accountId: "primary",
});
expect(result.cleared).toBe(false);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,8 @@ import {
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
type ChannelPlugin,
type ChannelStatusIssue,
type OpenClawConfig,
@@ -161,9 +163,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined)
?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.line !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -6,6 +6,8 @@ import {
formatPairingApproveHint,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
@@ -169,8 +171,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -1,5 +1,13 @@
import { format } from "node:util";
import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk";
import {
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -242,8 +250,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.matrix !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => logVerboseMessage(message),
});
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = accountConfig.threadReplies ?? "inbound";

View File

@@ -54,6 +54,25 @@ describe("mattermostPlugin", () => {
resetMattermostReactionBotUserCacheForTests();
});
const runReactAction = async (params: Record<string, unknown>, fetchMode: "add" | "remove") => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: fetchMode,
postId: "POST1",
emojiName: "thumbsup",
});
return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
return await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params,
cfg,
accountId: "default",
} as any);
});
};
it("exposes react when mattermost is configured", () => {
const cfg: OpenClawConfig = {
channels: {
@@ -152,51 +171,32 @@ describe("mattermostPlugin", () => {
});
it("handles react by calling Mattermost reactions API", async () => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params: { messageId: "POST1", emoji: "thumbsup" },
cfg,
accountId: "default",
} as any);
return result;
});
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
expect(result?.details).toEqual({});
});
it("only treats boolean remove flag as removal", async () => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params: { messageId: "POST1", emoji: "thumbsup", remove: "true" },
cfg,
accountId: "default",
} as any);
return result;
});
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: "true" },
"add",
);
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
});
it("removes reaction when remove flag is boolean true", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: true },
"remove",
);
expect(result?.content).toEqual([
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
]);
expect(result?.details).toEqual({});
});
});
describe("config", () => {

View File

@@ -6,6 +6,8 @@ import {
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
@@ -228,8 +230,12 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.mattermost !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -58,7 +58,7 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string {
return `${normalized}/api/v4${suffix}`;
}
async function readMattermostError(res: Response): Promise<string> {
export async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;

View File

@@ -16,7 +16,10 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveChannelMediaMaxBytes,
warnMissingProviderGroupPolicyFallbackOnce,
type HistoryEntry,
} from "openclaw/plugin-sdk";
import { getMattermostRuntime } from "../runtime.js";
@@ -242,6 +245,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
const channelHistories = new Map<string, HistoryEntry[]>();
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.mattermost !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "mattermost",
accountId: account.accountId,
log: (message) => logVerboseMessage(message),
});
const fetchWithAuth: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
@@ -375,8 +391,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderId;
const rawText = post.message?.trim() || "";
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
const storeAllowFrom = normalizeAllowList(
@@ -887,8 +901,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
} else if (kind) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "disabled") {
logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`);
return;

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const mockFetch = vi.fn<typeof fetch>();
describe("probeMattermost", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns baseUrl missing for empty base URL", async () => {
await expect(probeMattermost(" ", "token")).resolves.toEqual({
ok: false,
error: "baseUrl missing",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("normalizes base URL and returns bot info", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
expect(mockFetch).toHaveBeenCalledWith(
"https://mm.example.com/api/v4/users/me",
expect.objectContaining({
headers: { Authorization: "Bearer bot-token" },
}),
);
expect(result).toEqual(
expect.objectContaining({
ok: true,
status: 200,
bot: { id: "bot-1", username: "clawbot" },
}),
);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
});
it("returns API error details from JSON response", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "invalid auth token" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
}),
);
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 401,
error: "invalid auth token",
}),
);
});
it("falls back to statusText when error body is empty", async () => {
mockFetch.mockResolvedValueOnce(
new Response("", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
}),
);
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 403,
error: "Forbidden",
}),
);
});
it("returns fetch error when request throws", async () => {
mockFetch.mockRejectedValueOnce(new Error("network down"));
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: null,
error: "network down",
}),
);
});
});

View File

@@ -1,5 +1,5 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
export type MattermostProbe = BaseProbeResult & {
status?: number | null;
@@ -7,18 +7,6 @@ export type MattermostProbe = BaseProbeResult & {
bot?: MattermostUser;
};
async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) {
return data.message;
}
return JSON.stringify(data);
}
return await res.text();
}
export async function probeMattermost(
baseUrl: string,
botToken: string,

View File

@@ -22,6 +22,25 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
);
}
async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{
botToken: string;
baseUrl: string;
}> {
const botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { botToken, baseUrl };
}
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
@@ -90,18 +109,9 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else if (accountConfigured) {
const keep = await prompter.confirm({
@@ -109,32 +119,14 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (!keep) {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
if (botToken || baseUrl) {

View File

@@ -6,6 +6,8 @@ import {
DEFAULT_ACCOUNT_ID,
MSTeamsConfigSchema,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk";
import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
import { msteamsOnboardingAdapter } from "./onboarding.js";
@@ -127,8 +129,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
security: {
collectWarnings: ({ cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.msteams !== undefined,
groupPolicy: cfg.channels?.msteams?.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -1,11 +1,8 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
import { searchGraphUsers } from "./graph-users.js";
import {
escapeOData,
fetchGraphJson,
type GraphChannel,
type GraphGroup,
type GraphResponse,
type GraphUser,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
@@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: {
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
const users = await searchGraphUsers({ token, query, top: limit });
return users
.map((user) => {

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { searchGraphUsers } from "./graph-users.js";
import { fetchGraphJson } from "./graph.js";
vi.mock("./graph.js", () => ({
escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")),
fetchGraphJson: vi.fn(),
}));
describe("searchGraphUsers", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("returns empty array for blank queries", async () => {
await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]);
expect(fetchGraphJson).not.toHaveBeenCalled();
});
it("uses exact mail/upn filter lookup for email-like queries", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "user-1", displayName: "User One" }],
} as never);
const result = await searchGraphUsers({
token: "token-2",
query: "alice.o'hara@example.com",
});
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-2",
path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
});
expect(result).toEqual([{ id: "user-1", displayName: "User One" }]);
});
it("uses displayName search with eventual consistency and custom top", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "user-2", displayName: "Bob" }],
} as never);
const result = await searchGraphUsers({
token: "token-3",
query: "bob",
top: 25,
});
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-3",
path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
headers: { ConsistencyLevel: "eventual" },
});
expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]);
});
it("falls back to default top and empty value handling", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-4",
path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
headers: { ConsistencyLevel: "eventual" },
});
});
});

View File

@@ -0,0 +1,29 @@
import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
export async function searchGraphUsers(params: {
token: string;
query: string;
top?: number;
}): Promise<GraphUser[]> {
const query = params.query.trim();
if (!query) {
return [];
}
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token: params.token, path });
return res.value ?? [];
}
const top = typeof params.top === "number" && params.top > 0 ? params.top : 10;
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token: params.token,
path,
headers: { ConsistencyLevel: "eventual" },
});
return res.value ?? [];
}

View File

@@ -1,6 +1,7 @@
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type GraphUser = {
@@ -22,18 +23,6 @@ export type GraphChannel = {
export type GraphResponse<T> = { value?: T[] };
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}

View File

@@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: {
}
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
@@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: {
messageIds.push(extractMessageId(response) ?? "unknown");
}
return messageIds;
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
return await sendMessagesInContext(ctx);
}
const baseRef = buildConversationReference(params.conversationRef);
@@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: {
const messageIds: string[] = [];
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(
message,
params.conversationRef,
params.tokenProvider,
params.sharePointSiteId,
params.mediaMaxBytes,
),
),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
}
messageIds.push(...(await sendMessagesInContext(ctx)));
});
return messageIds;
}

View File

@@ -5,6 +5,7 @@ import {
logInboundDrop,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveDefaultGroupPolicy,
resolveMentionGating,
formatAllowlistMatchMeta,
type HistoryEntry,
@@ -174,7 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
}
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const groupPolicy =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")

View File

@@ -1,6 +1,7 @@
import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = BaseProbeResult<string> & {
@@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult<string> & {
};
};
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) {

View File

@@ -1,8 +1,5 @@
import { searchGraphUsers } from "./graph-users.js";
import {
escapeOData,
fetchGraphJson,
type GraphResponse,
type GraphUser,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
@@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: {
results.push({ input, resolved: true, id: query });
continue;
}
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
const users = await searchGraphUsers({ token, query, top: 10 });
const match = users[0];
if (!match?.id) {
results.push({ input, resolved: false });

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { readAccessToken } from "./token-response.js";
describe("readAccessToken", () => {
it("returns raw string token values", () => {
expect(readAccessToken("abc")).toBe("abc");
});
it("returns accessToken from object value", () => {
expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
});
it("returns token fallback from object value", () => {
expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
});
it("returns null for unsupported values", () => {
expect(readAccessToken({ accessToken: 123 })).toBeNull();
expect(readAccessToken({ token: false })).toBeNull();
expect(readAccessToken(null)).toBeNull();
expect(readAccessToken(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,11 @@
export function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}

View File

@@ -5,6 +5,8 @@ import {
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type OpenClawConfig,
@@ -128,8 +130,13 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent:
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -1,7 +1,11 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createReplyPrefixOptions,
logInboundDrop,
resolveControlCommandGate,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
type OpenClawConfig,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
@@ -84,12 +88,22 @@ export async function handleNextcloudTalkInbound(params: {
statusSink?.({ lastInboundAt: message.timestamp });
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = (config.channels as Record<string, unknown> | undefined)?.defaults as
| { groupPolicy?: string }
| undefined;
const groupPolicy = (account.config.groupPolicy ??
defaultGroupPolicy?.groupPolicy ??
"allowlist") as GroupPolicy;
const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent:
((config.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] ??
undefined) !== undefined,
groupPolicy: account.config.groupPolicy as GroupPolicy | undefined,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "nextcloud-talk",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => runtime.log?.(message),
});
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);

View File

@@ -17,6 +17,8 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveDefaultSignalAccountId,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSignalAccount,
setAccountEnabledInConfigSection,
signalOnboardingAdapter,
@@ -123,8 +125,12 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.signal !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -19,6 +19,8 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
@@ -150,8 +152,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.slack !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
const channelAllowlistConfigured =
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0;

View File

@@ -17,6 +17,8 @@ import {
parseTelegramReplyToMessageId,
parseTelegramThreadId,
resolveDefaultTelegramAccountId,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
@@ -195,8 +197,12 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.telegram !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider {
}
}
let storeSeq = 0;
function createTestStorePath(): string {
storeSeq += 1;
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
}
function createManagerHarness(
configOverrides: Record<string, unknown> = {},
provider = new FakeProvider(),
): {
manager: CallManager;
provider: FakeProvider;
} {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
...configOverrides,
});
const manager = new CallManager(config, createTestStorePath());
manager.initialize(provider, "https://example.com/voice/webhook");
return { manager, provider };
}
function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
manager.processEvent({
id: eventId,
type: "call.answered",
callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
}
describe("CallManager", () => {
it("upgrades providerCallId mapping when provider ID changes", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const manager = new CallManager(config, storePath);
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
const { manager } = createManagerHarness();
const { callId, success, error } = await manager.initiateCall("+15550000001");
expect(success).toBe(true);
@@ -81,16 +108,7 @@ describe("CallManager", () => {
});
it("speaks initial message on answered for notify mode (non-Twilio)", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const { manager, provider } = createManagerHarness();
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
message: "Hello there",
@@ -113,19 +131,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-missing",
type: "call.initiated",
@@ -142,19 +152,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-anon",
type: "call.initiated",
@@ -172,19 +174,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls that only match allowlist suffixes", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-suffix",
type: "call.initiated",
@@ -202,18 +196,10 @@ describe("CallManager", () => {
});
it("rejects duplicate inbound events with a single hangup call", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "disabled",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-reject-init",
type: "call.initiated",
@@ -242,18 +228,11 @@ describe("CallManager", () => {
});
it("accepts inbound calls that exactly match the allowlist", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const manager = new CallManager(config, storePath);
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-exact",
type: "call.initiated",
@@ -269,28 +248,14 @@ describe("CallManager", () => {
});
it("completes a closed-loop turn without live audio", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000003");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-closed-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
const turnPromise = manager.continueCall(started.callId, "How can I help?");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -323,28 +288,14 @@ describe("CallManager", () => {
});
it("rejects overlapping continueCall requests for the same call", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000004");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-overlap-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-overlap-answered");
const first = manager.continueCall(started.callId, "First prompt");
const second = await manager.continueCall(started.callId, "Second prompt");
@@ -369,28 +320,14 @@ describe("CallManager", () => {
});
it("tracks latency metadata across multiple closed-loop turns", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000005");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-multi-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-multi-answered");
const firstTurn = manager.continueCall(started.callId, "First question");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -436,28 +373,14 @@ describe("CallManager", () => {
});
it("handles repeated closed-loop turns without waiter churn", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000006");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-loop-answered");
for (let i = 1; i <= 5; i++) {
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);

View File

@@ -45,6 +45,32 @@ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallPr
};
}
function createInboundDisabledConfig() {
return VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
});
}
function createInboundInitiatedEvent(params: {
id: string;
providerCallId: string;
from: string;
}): NormalizedEvent {
return {
id: params.id,
type: "call.initiated",
callId: params.providerCallId,
providerCallId: params.providerCallId,
timestamp: Date.now(),
direction: "inbound",
from: params.from,
to: "+15550000000",
};
}
describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => {
const hangupCalls: HangupCallInput[] = [];
@@ -55,24 +81,14 @@ describe("processEvent (functional)", () => {
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-1",
type: "call.initiated",
callId: "prov-1",
providerCallId: "prov-1",
timestamp: Date.now(),
direction: "inbound",
from: "+15559999999",
to: "+15550000000",
};
});
processEvent(ctx, event);
@@ -87,24 +103,14 @@ describe("processEvent (functional)", () => {
it("does not call hangup when provider is null", () => {
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider: null,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-2",
type: "call.initiated",
callId: "prov-2",
providerCallId: "prov-2",
timestamp: Date.now(),
direction: "inbound",
from: "+15551111111",
to: "+15550000000",
};
});
processEvent(ctx, event);
@@ -119,24 +125,14 @@ describe("processEvent (functional)", () => {
},
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event1: NormalizedEvent = {
const event1 = createInboundInitiatedEvent({
id: "evt-init",
type: "call.initiated",
callId: "prov-dup",
providerCallId: "prov-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222",
to: "+15550000000",
};
});
const event2: NormalizedEvent = {
id: "evt-ring",
type: "call.ringing",
@@ -228,24 +224,14 @@ describe("processEvent (functional)", () => {
},
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-fail",
type: "call.initiated",
callId: "prov-fail",
providerCallId: "prov-fail",
timestamp: Date.now(),
direction: "inbound",
from: "+15553333333",
to: "+15550000000",
};
});
expect(() => processEvent(ctx, event)).not.toThrow();
expect(ctx.activeCalls.size).toBe(0);

View File

@@ -51,6 +51,32 @@ type EndCallContext = Pick<
| "maxDurationTimers"
>;
type ConnectedCallContext = Pick<CallManagerContext, "activeCalls" | "provider">;
type ConnectedCallLookup =
| { kind: "error"; error: string }
| { kind: "ended"; call: CallRecord }
| {
kind: "ok";
call: CallRecord;
providerCallId: string;
provider: NonNullable<ConnectedCallContext["provider"]>;
};
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { kind: "error", error: "Call not found" };
}
if (!ctx.provider || !call.providerCallId) {
return { kind: "error", error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { kind: "ended", call };
}
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
}
export async function initiateCall(
ctx: InitiateContext,
to: string,
@@ -149,26 +175,25 @@ export async function speak(
callId: CallId,
text: string,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
try {
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
addTranscriptEntry(call, "bot", text);
const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({
const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await provider.playTts({
callId,
providerCallId: call.providerCallId,
providerCallId,
text,
voice,
});
@@ -232,16 +257,15 @@ export async function continueCall(
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
return { success: false, error: "Already waiting for transcript" };
}
@@ -256,13 +280,13 @@ export async function continueCall(
persistCallRecord(ctx.storePath, call);
const listenStartedAt = Date.now();
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
await provider.startListening({ callId, providerCallId });
const transcript = await waitForFinalTranscript(ctx, callId);
const transcriptReceivedAt = Date.now();
// Best-effort: stop listening after final transcript.
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
await provider.stopListening({ callId, providerCallId });
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
@@ -302,21 +326,19 @@ export async function endCall(
ctx: EndCallContext,
callId: CallId,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: true };
}
const { call, providerCallId, provider } = lookup;
try {
await ctx.provider.hangupCall({
await provider.hangupCall({
callId,
providerCallId: call.providerCallId,
providerCallId,
reason: "hangup-bot",
});
@@ -329,9 +351,7 @@ export async function endCall(
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
ctx.activeCalls.delete(callId);
if (call.providerCallId) {
ctx.providerCallIdMap.delete(call.providerCallId);
}
ctx.providerCallIdMap.delete(providerCallId);
return { success: true };
} catch (err) {

View File

@@ -19,6 +19,8 @@ import {
readStringParam,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppOutboundTarget,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveWhatsAppAccount,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
@@ -142,8 +144,12 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
groupPolicy: account.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}

View File

@@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro
}
}
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const webhookRequestHandler: RequestListener = async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
};
function registerTarget(params: {
path: string;
secret?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): () => void {
return registerZaloWebhookTarget({
token: "tok",
account: DEFAULT_ACCOUNT,
config: {} as OpenClawConfig,
runtime: {},
core: {} as PluginRuntime,
secret: params.secret ?? "secret",
path: params.path,
mediaMaxMb: 5,
statusSink: params.statusSink,
});
}
describe("handleZaloWebhookRequest", () => {
it("returns 400 for non-object payloads", async () => {
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,
});
const unregister = registerTarget({ path: "/hook" });
try {
await withServer(
async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "null",
});
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "null",
});
expect(response.status).toBe(400);
expect(await response.text()).toBe("Bad Request");
},
);
expect(response.status).toBe(400);
expect(await response.text()).toBe("Bad Request");
});
} finally {
unregister();
}
});
it("rejects ambiguous routing when multiple targets match the same secret", async () => {
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const sinkA = vi.fn();
const sinkB = vi.fn();
const unregisterA = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,
statusSink: sinkA,
});
const unregisterB = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,
statusSink: sinkB,
});
const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
try {
await withServer(
async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
expect(response.status).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
},
);
expect(response.status).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
});
} finally {
unregisterA();
unregisterB();
@@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => {
});
it("returns 415 for non-json content-type", async () => {
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook-content-type",
mediaMaxMb: 5,
});
const unregister = registerTarget({ path: "/hook-content-type" });
try {
await withServer(
async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-content-type`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "text/plain",
},
body: "{}",
});
await withServer(webhookRequestHandler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook-content-type`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "text/plain",
},
body: "{}",
});
expect(response.status).toBe(415);
},
);
expect(response.status).toBe(415);
});
} finally {
unregister();
}
});
it("deduplicates webhook replay by event_name + message_id", async () => {
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const sink = vi.fn();
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook-replay",
mediaMaxMb: 5,
statusSink: sink,
});
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
const payload = {
event_name: "message.text.received",
@@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => {
};
try {
await withServer(
async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const first = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const second = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
await withServer(webhookRequestHandler, async (baseUrl) => {
const first = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const second = await fetch(`${baseUrl}/hook-replay`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(sink).toHaveBeenCalledTimes(1);
},
);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(sink).toHaveBeenCalledTimes(1);
});
} finally {
unregister();
}
});
it("returns 429 when per-path request rate exceeds threshold", async () => {
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as OpenClawConfig,
runtime: {},
core,
secret: "secret",
path: "/hook-rate",
mediaMaxMb: 5,
});
const unregister = registerTarget({ path: "/hook-rate" });
try {
await withServer(
async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-rate`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-rate`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
}
expect(saw429).toBe(true);
},
);
expect(saw429).toBe(true);
});
} finally {
unregister();
}

View File

@@ -3,8 +3,11 @@ import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plu
import {
createReplyPrefixOptions,
mergeAllowlist,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
@@ -177,8 +180,18 @@ async function processMessage(
const groupName = metadata?.threadName ?? "";
const chatId = threadId;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: config.channels?.zalouser !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "zalouser",
accountId: account.accountId,
log: (message) => logVerbose(core, runtime, message),
});
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {

View File

@@ -23,6 +23,45 @@ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from ".
const channel = "zalouser" as const;
function setZalouserAccountScopedConfig(
cfg: OpenClawConfig,
accountId: string,
defaultPatch: Record<string, unknown>,
accountPatch: Record<string, unknown> = defaultPatch,
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
...defaultPatch,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
...accountPatch,
},
},
},
},
} as OpenClawConfig;
}
function setZalouserDmPolicy(
cfg: OpenClawConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
@@ -123,40 +162,10 @@ async function promptZalouserAllowFrom(params: {
continue;
}
const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]);
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as OpenClawConfig;
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: unique,
});
}
}
@@ -165,37 +174,9 @@ function setZalouserGroupPolicy(
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groupPolicy,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groupPolicy,
},
},
},
},
} as OpenClawConfig;
return setZalouserAccountScopedConfig(cfg, accountId, {
groupPolicy,
});
}
function setZalouserGroupAllowlist(
@@ -204,37 +185,9 @@ function setZalouserGroupAllowlist(
groupKeys: string[],
): OpenClawConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groups,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groups,
},
},
},
},
} as OpenClawConfig;
return setZalouserAccountScopedConfig(cfg, accountId, {
groups,
});
}
async function resolveZalouserGroups(params: {
@@ -403,38 +356,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
}
// Enable the channel
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as OpenClawConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
accounts: {
...next.channels?.zalouser?.accounts,
[accountId]: {
...next.channels?.zalouser?.accounts?.[accountId],
enabled: true,
profile: account.profile,
},
},
},
},
} as OpenClawConfig;
}
next = setZalouserAccountScopedConfig(
next,
accountId,
{ profile: account.profile !== "default" ? account.profile : undefined },
{ profile: account.profile, enabled: true },
);
if (forceAllowFrom) {
next = await promptZalouserAllowFrom({
@@ -447,7 +374,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Zalo groups",
currentPolicy: account.config.groupPolicy ?? "open",
currentPolicy: account.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(account.config.groups ?? {}),
placeholder: "Family, Work, 123456789",
updatePrompt: Boolean(account.config.groups),

View File

@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
type ZalouserSendResult,
} from "./send.js";
import { runZca } from "./zca.js";
vi.mock("./zca.js", () => ({
runZca: 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,
};
}
describe("zalouser send helpers", () => {
beforeEach(() => {
mockRunZca.mockReset();
delete process.env.ZCA_PROFILE;
});
afterEach(() => {
if (originalZcaProfile) {
process.env.ZCA_PROFILE = originalZcaProfile;
return;
}
delete process.env.ZCA_PROFILE;
});
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",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], {
profile: "profile-a",
});
expect(result).toEqual({ ok: true, messageId: "mid-123" });
});
it("routes media sends from sendMessage and keeps text as caption", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-2", "media caption", {
profile: "profile-b",
mediaUrl: "https://cdn.example.com/video.mp4",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"video",
"thread-2",
"-u",
"https://cdn.example.com/video.mp4",
"-m",
"media caption",
"-g",
],
{ profile: "profile-b" },
);
});
it("maps audio media to voice command", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-3", "", {
profile: "profile-c",
mediaUrl: "https://cdn.example.com/clip.mp3",
});
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"],
{ profile: "profile-c" },
);
});
it("builds image command with caption and returns fallback error", async () => {
mockRunZca.mockResolvedValueOnce(failResult(""));
const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", {
profile: "profile-d",
caption: "caption text",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"image",
"thread-4",
"-u",
"https://cdn.example.com/img.png",
"-m",
"caption text",
"-g",
],
{ profile: "profile-d" },
);
expect(result).toEqual({ ok: false, error: "Failed to send image" });
});
it("uses env profile fallback and builds link command", async () => {
process.env.ZCA_PROFILE = "env-profile";
mockRunZca.mockResolvedValueOnce(okResult("abc123"));
const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true });
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "link", "thread-5", "https://openclaw.ai", "-g"],
{ profile: "env-profile" },
);
expect(result).toEqual({ ok: true, messageId: "abc123" });
});
it("returns caught command errors", async () => {
mockRunZca.mockRejectedValueOnce(new Error("zca unavailable"));
await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({
ok: false,
error: "zca unavailable",
});
});
});

View File

@@ -13,12 +13,41 @@ export type ZalouserSendResult = {
error?: string;
};
function resolveProfile(options: ZalouserSendOptions): string {
return options.profile || process.env.ZCA_PROFILE || "default";
}
function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void {
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) {
args.push("-g");
}
}
async function runSendCommand(
args: string[],
profile: string,
fallbackError: string,
): Promise<ZalouserSendResult> {
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || fallbackError };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const profile = resolveProfile(options);
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
@@ -38,17 +67,7 @@ export async function sendMessageZalouser(
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
return runSendCommand(args, profile, "Failed to send message");
}
async function sendMediaZalouser(
@@ -56,7 +75,7 @@ async function sendMediaZalouser(
mediaUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const profile = resolveProfile(options);
if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" };
@@ -78,24 +97,8 @@ async function sendMediaZalouser(
}
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) {
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || `Failed to send ${command}` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
appendCaptionAndGroupFlags(args, options);
return runSendCommand(args, profile, `Failed to send ${command}`);
}
export async function sendImageZalouser(
@@ -103,24 +106,10 @@ export async function sendImageZalouser(
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const profile = resolveProfile(options);
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) {
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send image" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
appendCaptionAndGroupFlags(args, options);
return runSendCommand(args, profile, "Failed to send image");
}
export async function sendLinkZalouser(
@@ -128,21 +117,13 @@ export async function sendLinkZalouser(
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const profile = resolveProfile(options);
const args = ["msg", "link", threadId.trim(), url.trim()];
if (options.isGroup) {
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send link" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
return runSendCommand(args, profile, "Failed to send link");
}
function extractMessageId(stdout: string): string | undefined {

View File

@@ -68,35 +68,30 @@ export type ListenOptions = CommonOptions & {
prefix?: string;
};
export type ZalouserAccountConfig = {
type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
type ZalouserGroupConfig = {
allow?: boolean;
enabled?: boolean;
tools?: ZalouserToolConfig;
};
type ZalouserSharedConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<
string,
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
groups?: Record<string, ZalouserGroupConfig>;
messagePrefix?: string;
responsePrefix?: string;
};
export type ZalouserConfig = {
enabled?: boolean;
name?: string;
profile?: string;
export type ZalouserAccountConfig = ZalouserSharedConfig;
export type ZalouserConfig = ZalouserSharedConfig & {
defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<
string,
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
messagePrefix?: string;
responsePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};

View File

@@ -83,6 +83,32 @@ describe("markAuthProfileFailure", () => {
expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000);
});
});
it("keeps persisted cooldownUntil unchanged across mid-window retries", async () => {
await withAuthProfileStore(async ({ agentDir, store }) => {
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "rate_limit",
agentDir,
});
const firstCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil;
expect(typeof firstCooldownUntil).toBe("number");
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "rate_limit",
agentDir,
});
const secondCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil;
expect(secondCooldownUntil).toBe(firstCooldownUntil);
const reloaded = ensureAuthProfileStore(agentDir);
expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil);
});
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {

View File

@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, ProfileUsageStats } from "./types.js";
import {
clearAuthProfileCooldown,
clearExpiredCooldowns,
isProfileInCooldown,
markAuthProfileFailure,
resolveProfileUnusableUntil,
} from "./usage.js";
@@ -347,3 +348,116 @@ describe("clearAuthProfileCooldown", () => {
expect(store.usageStats).toBeUndefined();
});
});
describe("markAuthProfileFailure — active windows do not extend on retry", () => {
// Regression for https://github.com/openclaw/openclaw/issues/23516
// When all providers are at saturation backoff (60 min) and retries fire every 30 min,
// each retry was resetting cooldownUntil to now+60m, preventing recovery.
type WindowStats = ProfileUsageStats;
async function markFailureAt(params: {
store: ReturnType<typeof makeStore>;
now: number;
reason: "rate_limit" | "billing";
}): Promise<void> {
vi.useFakeTimers();
vi.setSystemTime(params.now);
try {
await markAuthProfileFailure({
store: params.store,
profileId: "anthropic:default",
reason: params.reason,
});
} finally {
vi.useRealTimers();
}
}
const activeWindowCases = [
{
label: "cooldownUntil",
reason: "rate_limit" as const,
buildUsageStats: (now: number): WindowStats => ({
cooldownUntil: now + 50 * 60 * 1000,
errorCount: 3,
lastFailureAt: now - 10 * 60 * 1000,
}),
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
},
{
label: "disabledUntil",
reason: "billing" as const,
buildUsageStats: (now: number): WindowStats => ({
disabledUntil: now + 20 * 60 * 60 * 1000,
disabledReason: "billing",
errorCount: 5,
failureCounts: { billing: 5 },
lastFailureAt: now - 60_000,
}),
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
];
for (const testCase of activeWindowCases) {
it(`keeps active ${testCase.label} unchanged on retry`, async () => {
const now = 1_000_000;
const existingStats = testCase.buildUsageStats(now);
const existingUntil = testCase.readUntil(existingStats);
const store = makeStore({ "anthropic:default": existingStats });
await markFailureAt({
store,
now,
reason: testCase.reason,
});
const stats = store.usageStats?.["anthropic:default"];
expect(testCase.readUntil(stats)).toBe(existingUntil);
});
}
const expiredWindowCases = [
{
label: "cooldownUntil",
reason: "rate_limit" as const,
buildUsageStats: (now: number): WindowStats => ({
cooldownUntil: now - 60_000,
errorCount: 3,
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
},
{
label: "disabledUntil",
reason: "billing" as const,
buildUsageStats: (now: number): WindowStats => ({
disabledUntil: now - 60_000,
disabledReason: "billing",
errorCount: 5,
failureCounts: { billing: 2 },
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
];
for (const testCase of expiredWindowCases) {
it(`recomputes ${testCase.label} after the previous window expires`, async () => {
const now = 1_000_000;
const store = makeStore({
"anthropic:default": testCase.buildUsageStats(now),
});
await markFailureAt({
store,
now,
reason: testCase.reason,
});
const stats = store.usageStats?.["anthropic:default"];
expect(testCase.readUntil(stats)).toBe(testCase.expectedUntil(now));
});
}
});

View File

@@ -256,6 +256,17 @@ export function resolveProfileUnusableUntilForDisplay(
return resolveProfileUnusableUntil(stats);
}
function keepActiveWindowOrRecompute(params: {
existingUntil: number | undefined;
now: number;
recomputedUntil: number;
}): number {
const { existingUntil, now, recomputedUntil } = params;
const hasActiveWindow =
typeof existingUntil === "number" && Number.isFinite(existingUntil) && existingUntil > now;
return hasActiveWindow ? existingUntil : recomputedUntil;
}
function computeNextProfileUsageStats(params: {
existing: ProfileUsageStats;
now: number;
@@ -287,11 +298,23 @@ function computeNextProfileUsageStats(params: {
baseMs: params.cfgResolved.billingBackoffMs,
maxMs: params.cfgResolved.billingMaxMs,
});
updatedStats.disabledUntil = params.now + backoffMs;
// Keep active disable windows immutable so retries within the window cannot
// extend recovery time indefinitely.
updatedStats.disabledUntil = keepActiveWindowOrRecompute({
existingUntil: params.existing.disabledUntil,
now: params.now,
recomputedUntil: params.now + backoffMs,
});
updatedStats.disabledReason = "billing";
} else {
const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount);
updatedStats.cooldownUntil = params.now + backoffMs;
// Keep active cooldown windows immutable so retries within the window
// cannot push recovery further out.
updatedStats.cooldownUntil = keepActiveWindowOrRecompute({
existingUntil: params.existing.cooldownUntil,
now: params.now,
recomputedUntil: params.now + backoffMs,
});
}
return updatedStats;

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