mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
chore: merge origin/main into main
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift
Normal file
7
apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
let defaultOperatorConnectScopes: [String] = [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: [:],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 app’s environment.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app’s 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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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** (auto‑generated, 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>
|
||||
|
||||
@@ -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, per‑agent
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
97
extensions/mattermost/src/mattermost/probe.test.ts
Normal file
97
extensions/mattermost/src/mattermost/probe.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
66
extensions/msteams/src/graph-users.test.ts
Normal file
66
extensions/msteams/src/graph-users.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
29
extensions/msteams/src/graph-users.ts
Normal file
29
extensions/msteams/src/graph-users.ts
Normal 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 ?? [];
|
||||
}
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
23
extensions/msteams/src/token-response.test.ts
Normal file
23
extensions/msteams/src/token-response.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
extensions/msteams/src/token-response.ts
Normal file
11
extensions/msteams/src/token-response.ts
Normal 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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
156
extensions/zalouser/src/send.test.ts
Normal file
156
extensions/zalouser/src/send.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user