From e259751ec9c9ed10b292ce3e0b987c4b18b19a0c Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Thu, 7 May 2026 19:20:18 -0700 Subject: [PATCH] feat(imessage): private-API support via imsg JSON-RPC [AI-assisted] (#78317) Merged via squash. Prepared head SHA: b7d336b2969b1391075c4a9fb03ca64d08f63050 Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine --- CHANGELOG.md | 10 + docs/.generated/config-baseline.sha256 | 8 +- docs/.i18n/glossary.zh-CN.json | 20 + docs/channels/imessage-from-bluebubbles.md | 229 +++++++ docs/channels/imessage.md | 305 ++++++++- docs/channels/index.md | 2 +- docs/docs.json | 1 + extensions/imessage/package.json | 33 +- extensions/imessage/src/actions-contract.ts | 19 + .../imessage/src/actions.runtime.test.ts | 185 ++++++ extensions/imessage/src/actions.runtime.ts | 502 ++++++++++++++ extensions/imessage/src/actions.test.ts | 535 +++++++++++++++ extensions/imessage/src/actions.ts | 624 ++++++++++++++++++ extensions/imessage/src/channel.ts | 2 + extensions/imessage/src/chat.ts | 183 +++++ extensions/imessage/src/config-schema.test.ts | 21 + .../imessage/src/imessage.test-plugin.ts | 41 +- .../imessage/src/markdown-format.test.ts | 99 +++ extensions/imessage/src/markdown-format.ts | 154 +++++ .../imessage/src/monitor-reply-cache.test.ts | 406 ++++++++++++ .../imessage/src/monitor-reply-cache.ts | 587 ++++++++++++++++ .../imessage/src/monitor.gating.test.ts | 28 +- .../imessage/src/monitor/coalesce.test.ts | 143 ++++ extensions/imessage/src/monitor/coalesce.ts | 123 ++++ extensions/imessage/src/monitor/echo-cache.ts | 5 + .../src/monitor/inbound-processing.test.ts | 190 +++++- .../src/monitor/inbound-processing.ts | 98 ++- .../monitor-provider.echo-cache.test.ts | 79 +++ .../imessage/src/monitor/monitor-provider.ts | 246 +++++-- .../src/monitor/persisted-echo-cache.ts | 236 +++++++ .../src/monitor/reflection-guard.test.ts | 16 + .../imessage/src/monitor/reflection-guard.ts | 4 + extensions/imessage/src/probe.test.ts | 94 +++ extensions/imessage/src/probe.ts | 209 +++++- extensions/imessage/src/send.ts | 61 ++ extensions/imessage/src/setup-core.ts | 1 + extensions/imessage/src/test-plugin.test.ts | 8 + ...ndled-channel-config-metadata.generated.ts | 22 +- src/config/types.imessage.ts | 26 + src/config/zod-schema.providers-core.ts | 21 + 40 files changed, 5467 insertions(+), 109 deletions(-) create mode 100644 docs/channels/imessage-from-bluebubbles.md create mode 100644 extensions/imessage/src/actions-contract.ts create mode 100644 extensions/imessage/src/actions.runtime.test.ts create mode 100644 extensions/imessage/src/actions.runtime.ts create mode 100644 extensions/imessage/src/actions.test.ts create mode 100644 extensions/imessage/src/actions.ts create mode 100644 extensions/imessage/src/chat.ts create mode 100644 extensions/imessage/src/markdown-format.test.ts create mode 100644 extensions/imessage/src/markdown-format.ts create mode 100644 extensions/imessage/src/monitor-reply-cache.test.ts create mode 100644 extensions/imessage/src/monitor-reply-cache.ts create mode 100644 extensions/imessage/src/monitor/coalesce.test.ts create mode 100644 extensions/imessage/src/monitor/coalesce.ts create mode 100644 extensions/imessage/src/monitor/persisted-echo-cache.ts create mode 100644 extensions/imessage/src/probe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54eea8e7fe4..ee1e5b41988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Highlights + +- Channels/iMessage: bundled `imessage` plugin upgraded with full BlueBubbles parity over `imsg` JSON-RPC, offering a complete replacement for BlueBubbles-backed setups. See [docs/channels/imessage-from-bluebubbles.md](docs/channels/imessage-from-bluebubbles.md) for the migration guide. (#78317) Thanks @omarshahine. + ### Changes - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. @@ -155,6 +159,7 @@ Docs: https://docs.openclaw.ai - Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi. - Config/Nix: keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of rewriting `openclaw.json`; in Nix mode, config writers, mutating `openclaw update`, plugin lifecycle mutators, and doctor repair/token-generation now refuse with agent-first nix-openclaw guidance. (#78047) Thanks @joshp123. - Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026. +- Channels/iMessage: drive the bundled `imessage` plugin over `imsg` JSON-RPC so private API actions (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`) are reachable when `imsg launch` is running, capability-gated per-method via `imsg status --json`, and inbound chats are marked read with a typing bubble before dispatch unless `channels.imessage.sendReadReceipts: false` [AI-assisted]. (#78317) Thanks @omarshahine. ### Breaking @@ -258,6 +263,11 @@ Docs: https://docs.openclaw.ai - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. - Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf. +- Channels/iMessage: probe all persistable echo-cache scope shapes (`chat_id:N`, `chat_guid:`, `chat_identifier:`, `imessage:`) on inbound match, so an outbound message addressed by `chat_guid` no longer bypasses the chat_id-only inbound lookup and re-feeds the agent its own reply [AI-assisted]. Thanks @omarshahine. +- Security/iMessage: clamp `reply-cache.jsonl` to `0600` (parent dir `0700`) on every write/append and chmod existing entries from older gateway versions, blocking same-UID enumeration of conversation guids and shortId injection on multi-user hosts [AI-assisted]. Thanks @omarshahine. +- Security/iMessage: apply the same `0600`/`0700` clamp to `sent-echoes.jsonl` so outbound message text and scope keys are not world-readable on multi-user hosts [AI-assisted]. Thanks @omarshahine. +- Config/iMessage: add `probeTimeoutMs` to `IMessageAccountSchemaBase` so the `channels.imessage.probeTimeoutMs` option declared on `IMessageAccountConfig` actually round-trips through validation instead of being silently stripped by zod parse [AI-assisted]. Thanks @omarshahine. +- Security/iMessage: gate `edit` and `unsend` private API actions on `isFromMe`, so an agent in a group chat can only modify messages the gateway itself sent, not messages received from other participants. Records `isFromMe: true` for outbound sends and `false` for inbound, then refuses to resolve message ids that fail the check before dispatch [AI-assisted]. Thanks @omarshahine. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. - Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index ea5278f2931..f661e2131fd 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -7238265b921affbb481198f603293c9b1c988025713c55ee19fdbf132a8339ab config-baseline.json -97579293de31bc607194bce3e22c16d140c08ab9e6f1e38298f3ce47fbc9d68b config-baseline.core.json -463c45a79d02598184caccbc6f316692df962fe6b0e84d1a3e3cc1809f862b15 config-baseline.channel.json -b6d36d17e554a2ec5a1a6c6d32107a9a1113c274a700100962d97b6afbdafb25 config-baseline.plugin.json +0a77e8265b3bf5d75e06c2e5aad7f0b7c60667de2ec57c9676e2b18305b0cc08 config-baseline.json +b2ed92dd6a269d54f263728a2a761d8f6e60f849ec0562dfad17c959bfe90dfa config-baseline.core.json +ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json +dfc16c21bdd6d727c920de871bf7fe86b771c80df86335c6376b436c0c4898ee config-baseline.plugin.json diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 91a1508c089..a30868dc141 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -3,6 +3,26 @@ "source": "OpenClaw", "target": "OpenClaw" }, + { + "source": "iMessage", + "target": "iMessage" + }, + { + "source": "Coming from BlueBubbles", + "target": "Coming from BlueBubbles" + }, + { + "source": "BlueBubbles", + "target": "BlueBubbles" + }, + { + "source": "Pairing", + "target": "配对" + }, + { + "source": "Channel Routing", + "target": "频道路由" + }, { "source": "ClawHub", "target": "ClawHub" diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md new file mode 100644 index 00000000000..84d5d32fd12 --- /dev/null +++ b/docs/channels/imessage-from-bluebubbles.md @@ -0,0 +1,229 @@ +--- +summary: "Switch from the BlueBubbles plugin to the bundled iMessage plugin without losing pairing, allowlists, or group bindings." +read_when: + - Planning a move from BlueBubbles to the bundled iMessage plugin + - Translating BlueBubbles config keys to iMessage equivalents + - Rolling back a partial iMessage cutover +title: "Coming from BlueBubbles" +--- + +The bundled `imessage` plugin now reaches the same private API surface as BlueBubbles (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, group management, attachments) by driving [`steipete/imsg`](https://github.com/steipete/imsg) over JSON-RPC. If you already run a Mac with `imsg` installed, you can drop the BlueBubbles server and let the plugin talk to Messages.app directly. + +This guide is opt-in. BlueBubbles still works and remains the right choice if you cannot run `imsg` on the host where the Mac signs into iMessage (for example, if the Mac is unreachable from the gateway). + +## When this migration makes sense + +- You already run `imsg` on the same Mac (or one reachable over SSH) where Messages.app is signed in. +- You want one fewer moving part — no separate BlueBubbles server, no REST endpoint to authenticate, no webhook plumbing. Single CLI binary instead of a server + client app + helper. +- You are on a [supported macOS / `imsg` build](/channels/imessage#requirements-and-permissions-macos) where the private API probe reports `available: true`. + +## When to stay on BlueBubbles + +- The Mac with Messages.app is on a network the gateway cannot reach via SSH. +- You depend on BlueBubbles features the bundled plugin does not yet cover (rich text formatting attributes beyond bold/italic/underline/strikethrough, BlueBubbles-specific webhook integrations). +- Your current setup hard-codes BlueBubbles webhook URLs into other systems that you cannot rewire. + +## Before you start + +1. Install `imsg` on the Mac that runs Messages.app: + + ```bash + brew install steipete/tap/imsg + imsg launch + imsg rpc --help + ``` + +2. Verify the private API bridge: + + ```bash + openclaw channels status --probe + ``` + + You want `imessage.privateApi.available: true`. If it reports `false`, fix that first — see [Capability detection](/channels/imessage#private-api-actions). + +3. Snapshot your config so you can roll back: + + ```bash + cp ~/.openclaw/openclaw.json5 ~/.openclaw/openclaw.json5.bak + ``` + +## Config translation + +iMessage and BlueBubbles share a lot of channel-level config. The keys that change are mostly transport (REST server vs local CLI). Behavior keys (`dmPolicy`, `groupPolicy`, `allowFrom`, etc.) keep the same meaning. + +| BlueBubbles | bundled iMessage | Notes | +| ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `channels.bluebubbles.enabled` | `channels.imessage.enabled` | Same semantics. | +| `channels.bluebubbles.serverUrl` | _(removed)_ | No REST server — the plugin spawns `imsg rpc` over stdio. | +| `channels.bluebubbles.password` | _(removed)_ | No webhook authentication needed. | +| _(implicit)_ | `channels.imessage.cliPath` | Path to `imsg` (default `imsg`); use a wrapper script for SSH. | +| _(implicit)_ | `channels.imessage.dbPath` | Optional Messages.app `chat.db` override; auto-detected when omitted. | +| _(implicit)_ | `channels.imessage.remoteHost` | `host` or `user@host` — only needed when `cliPath` is an SSH wrapper and you want SCP attachment fetches. | +| `channels.bluebubbles.dmPolicy` | `channels.imessage.dmPolicy` | Same values (`pairing` / `allowlist` / `open` / `disabled`). | +| `channels.bluebubbles.allowFrom` | `channels.imessage.allowFrom` | Pairing approvals carry over by handle, not by token. | +| `channels.bluebubbles.groupPolicy` | `channels.imessage.groupPolicy` | Same values (`allowlist` / `open` / `disabled`). | +| `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. | +| `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. | +| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. | +| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same. | +| `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. | +| _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. | +| `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. | +| `channels.bluebubbles.textChunkLimit` | `channels.imessage.textChunkLimit` | Default 4000 on both. | +| `channels.bluebubbles.coalesceSameSenderDms` | `channels.imessage.coalesceSameSenderDms` | Same opt-in. DM-only — group chats keep instant per-message dispatch on both channels. Widens the default inbound debounce to 2500 ms when enabled without an explicit `messages.inbound.byChannel.imessage`. See [iMessage docs § Coalescing split-send DMs](/channels/imessage#coalescing-split-send-dms-command--url-in-one-composition). | +| `channels.bluebubbles.enrichGroupParticipantsFromContacts` | _(N/A)_ | iMessage already reads sender display names from `chat.db`. | +| `channels.bluebubbles.actions.*` | `channels.imessage.actions.*` | Per-action toggles: `reactions`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`. | + +Multi-account configs (`channels.bluebubbles.accounts.*`) translate one-to-one to `channels.imessage.accounts.*`. + +## Group registry footgun + +The bundled iMessage plugin runs **two** separate group allowlist gates back-to-back. Both must pass for a group message to reach the agent: + +1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — checked by `isAllowedIMessageSender`. Matches inbound messages by sender handle, `chat_guid`, `chat_identifier`, or `chat_id`. Same shape as BlueBubbles. +2. **Group registry** (`channels.imessage.groups`) — checked by `resolveChannelGroupPolicy` from `inbound-processing.ts:199`. With `groupPolicy: "allowlist"`, this gate requires either: + - a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or + - an explicit per-`chat_id` entry under `groups`. + +If gate 1 passes but gate 2 fails, the message is dropped and the rejection logs **only at `verbose`/`debug` level** (`inbound-processing.ts:337`). At the default `info` log level every group message vanishes silently — DMs continue to work because they take a different code path. + +This is the most common BlueBubbles → bundled-iMessage migration failure mode: operators copy `groupAllowFrom` and `groupPolicy` but skip the `groups` block, because BlueBubbles' `groups: { "*": { "requireMention": true } }` looks like an unrelated mention setting. It's actually load-bearing for the registry gate. + +The minimum config to keep group messages flowing after `groupPolicy: "allowlist"`: + +```json5 +{ + channels: { + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15555550123", "chat_guid:any;-;..."], + groups: { + "*": { requireMention: true }, + }, + }, + }, +} +``` + +`requireMention: true` under `*` is harmless when no mention patterns are configured: the runtime sets `canDetectMention = false` and short-circuits the mention drop at `inbound-processing.ts:512`. With mention patterns configured (`agents.list[].groupChat.mentionPatterns`), it works as expected. + +To debug a suspected silent drop, raise log level temporarily: + +```bash +OPENCLAW_LOG_LEVEL=debug openclaw gateway +``` + +Then look for `imessage: skipping group message () not in allowlist`. If you see that line, gate 2 is dropping — add the `groups` block. + +Tracker for raising the drop's log severity so this is no longer silent at `info`: [openclaw#78749](https://github.com/openclaw/openclaw/issues/78749). + +## Step-by-step + +1. Add an iMessage block alongside the existing BlueBubbles block. Do not delete BlueBubbles yet: + + ```json5 + { + channels: { + bluebubbles: { + enabled: true, + // ... existing config ... + }, + imessage: { + enabled: false, // turn on after the dry run below + cliPath: "/opt/homebrew/bin/imsg", + dmPolicy: "pairing", + allowFrom: ["+15555550123"], // copy from bluebubbles.allowFrom + groupPolicy: "allowlist", + groupAllowFrom: [], // copy from bluebubbles.groupAllowFrom + groups: { "*": { requireMention: true } }, // copy from bluebubbles.groups — silently drops groups if missing, see "Group registry footgun" above + actions: { + reactions: true, + edit: true, + unsend: true, + reply: true, + sendWithEffect: true, + sendAttachment: true, + }, + }, + }, + } + ``` + +2. **Dry-run probe** — start the gateway and confirm both channels report healthy: + + ```bash + openclaw gateway + openclaw channels status + openclaw channels status --probe # expect imessage.privateApi.available: true + ``` + + Because `imessage.enabled` is still `false`, no inbound iMessage traffic is routed yet — but `--probe` exercises the bridge so you catch permission/install issues before the cutover. + +3. **Cut over.** Disable BlueBubbles and enable iMessage in one config edit: + + ```json5 + { + channels: { + bluebubbles: { enabled: false }, // keep the rest of the block for rollback + imessage: { enabled: true /* ... */ }, + }, + } + ``` + + Restart the gateway. Inbound iMessage traffic now flows through the bundled plugin. + +4. **Verify DMs.** Send the agent a direct message; confirm the reply lands. + +5. **Verify groups separately.** DMs and groups take different code paths — DM success does not prove groups are routing. Send the agent a message in a paired group chat and confirm the reply lands. If the group goes silent (no agent reply, no error), restart the gateway with `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message (...) not in allowlist`. If that line appears, your `groups` block is missing or empty — see "Group registry footgun" above. + +6. **Verify the action surface** — from a paired DM, ask the agent to react, edit, unsend, reply, send a photo, and (in a group) rename the group / add or remove a participant. Each action should land natively in Messages.app. If any throws "iMessage `` requires the imsg private API bridge", run `imsg launch` again and refresh `channels status --probe`. + +7. **Stop the BlueBubbles server** once you have run on iMessage for at least a few hours of normal traffic. Remove the BlueBubbles block from config and restart the gateway. + +## Action parity at a glance + +| Action | BlueBubbles | bundled iMessage | +| ---------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------ | +| Send text / SMS fallback | ✅ | ✅ | +| Send media (photo, video, file, voice) | ✅ | ✅ | +| Threaded reply (`reply_to_guid`) | ✅ | ✅ (closes [#51892](https://github.com/openclaw/openclaw/issues/51892)) | +| Tapback (`react`) | ✅ | ✅ | +| Edit / unsend (macOS 13+ recipients) | ✅ | ✅ | +| Send with screen effect | ✅ | ✅ (closes part of [#9394](https://github.com/openclaw/openclaw/issues/9394)) | +| Rich text bold / italic / underline / strikethrough | ✅ | ✅ (typed-run formatting via attributedBody) | +| Rename group / set group icon | ✅ | ✅ | +| Add / remove participant, leave group | ✅ | ✅ | +| Read receipts and typing indicator | ✅ | ✅ (gated on private API probe) | +| Same-sender DM coalescing | ✅ | ✅ (DM-only; opt-in via `channels.imessage.coalesceSameSenderDms`) | +| Catchup of inbound messages received while gateway is down | ✅ (webhook replay + history fetch) | _(not yet — tracked at [#78649](https://github.com/openclaw/openclaw/issues/78649))_ | + +The catchup gap is the most operationally significant one for production deployments: planned restarts, mac sleep, or an unexpected gateway crash that takes more than a few seconds will silently drop any inbound iMessage traffic that arrives during the gap when running on bundled iMessage. BlueBubbles' webhook + history-fetch flow recovers those messages on reconnect. If your deployment is sensitive to that, stay on BlueBubbles until [#78649](https://github.com/openclaw/openclaw/issues/78649) lands. + +## Pairing, sessions, and ACP bindings + +- **Pairing approvals** carry over by handle. You do not need to re-approve known senders — `channels.imessage.allowFrom` recognizes the same `+15555550123` / `user@example.com` strings BlueBubbles used. +- **Sessions** stay scoped per agent + chat. DMs collapse into the agent main session under default `session.dmScope=main`; group sessions stay isolated per `chat_id`. The session keys differ (`agent::imessage:group:` vs the BlueBubbles equivalent) — old conversation history under BlueBubbles session keys does not carry into iMessage sessions. +- **ACP bindings** referencing `match.channel: "bluebubbles"` need to be updated to `"imessage"`. The `match.peer.id` shapes (`chat_id:`, `chat_guid:`, `chat_identifier:`, bare handle) are identical. + +## Running both at once + +You can keep both `bluebubbles` and `imessage` enabled during cutover testing. BlueBubbles' manifest still declares `preferOver: ["imessage"]`, so the auto-enable resolver continues to prefer BlueBubbles when both channels are configured — the bundled iMessage plugin will not pick up traffic until BlueBubbles is disabled (`channels.bluebubbles.enabled: false`) or removed from config. + +If you want both channels to run simultaneously instead of in cutover mode, that is not currently supported through plugin auto-enable; use one channel at a time. + +## Rollback + +Because you kept the BlueBubbles config block: + +1. Set `channels.bluebubbles.enabled: true` and `channels.imessage.enabled: false`. +2. Restart the gateway. +3. Inbound traffic returns to BlueBubbles. Reply caches and ACP bindings on the iMessage side stay on disk under `~/.openclaw/state/imessage/` and resume cleanly if you re-enable later. + +The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate. + +## Related + +- [iMessage](/channels/imessage) — full iMessage channel reference, including `imsg launch` setup and capability detection. +- [BlueBubbles](/channels/bluebubbles) — full BlueBubbles channel reference for the legacy path. +- [Pairing](/channels/pairing) — DM authentication and pairing flow. +- [Channel Routing](/channels/channel-routing) — how the gateway picks a channel for outbound replies. diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 186346b4d41..9715e6fafc7 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -1,5 +1,5 @@ --- -summary: "Native iMessage support via imsg (JSON-RPC over stdio). Preferred for new OpenClaw iMessage setups when host requirements fit." +summary: "Native iMessage support via imsg (JSON-RPC over stdio), with private API actions for replies, tapbacks, effects, attachments, and group management. Preferred for new OpenClaw iMessage setups when host requirements fit." read_when: - Setting up iMessage support - Debugging iMessage send/receive @@ -8,15 +8,20 @@ title: "iMessage" For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host. If your Gateway runs on Linux or Windows, point `channels.imessage.cliPath` at an SSH wrapper that runs `imsg` on the Mac. + +**Known gap: no gateway-downtime catchup.** Messages that arrive while the gateway is down (crash, restart, Mac sleep, machine off) are not delivered to the agent once the gateway comes back up — `imsg watch` resumes from the current state and ignores anything that landed in `chat.db` during the gap. Tracked at [openclaw#78649](https://github.com/openclaw/openclaw/issues/78649). BlueBubbles is deprecated and no longer ships as a bundled OpenClaw channel. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw now supports iMessage through `imsg` only. If you still need a BlueBubbles-backed bridge, publish or install it as a third-party plugin outside core. -Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). +Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). Advanced actions require `imsg launch` and a successful private API probe. + + Replies, tapbacks, effects, attachments, and group management. + iMessage DMs default to pairing mode. @@ -38,6 +43,8 @@ Status: native external CLI integration. Gateway spawns `imsg rpc` and communica ```bash brew install steipete/tap/imsg imsg rpc --help +imsg launch +openclaw channels status --probe ``` @@ -119,6 +126,7 @@ exec ssh -T gateway-host imsg "$@" - Messages must be signed in on the Mac running `imsg`. - Full Disk Access is required for the process context running OpenClaw/`imsg` (Messages DB access). - Automation permission is required to send messages through Messages.app. +- For advanced actions (react / edit / unsend / threaded reply / effects / group ops), System Integrity Protection must be disabled — see [Enabling the imsg private API](#enabling-the-imsg-private-api) below. Basic text and media send/receive work without it. Permissions are granted per process context. If gateway runs headless (LaunchAgent/SSH), run a one-time interactive command in that same context to trigger prompts: @@ -131,6 +139,71 @@ imsg send "test" +## Enabling the imsg private API + +`imsg` ships in two operational modes: + +- **Basic mode** (default, no SIP changes needed): outbound text and media via `send`, inbound watch/history, chat list. This is what you get out of the box from a fresh `brew install steipete/tap/imsg` plus the standard macOS permissions above. +- **Private API mode**: `imsg` injects a helper dylib into `Messages.app` to call internal `IMCore` functions. This is what unlocks `react`, `edit`, `unsend`, `reply` (threaded), `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, plus typing indicators and read receipts. + +To reach the advanced action surface that this channel page documents, you need Private API mode. The `imsg` README is explicit about the requirement: + +> Advanced features such as `read`, `typing`, `launch`, bridge-backed rich send, message mutation, and chat management are opt-in. They require SIP to be disabled and a helper dylib to be injected into `Messages.app`. `imsg launch` refuses to inject when SIP is enabled. + +The helper-injection technique is a manual port of the BlueBubbles private-API surface (Apache-2.0 inspired) into `imsg`'s own dylib — no third-party binary, but the same SIP-disabled requirement that BlueBubbles' Private API mode has. There is no SIP-asymmetry between the two channels. + + +**Disabling SIP is a real security tradeoff.** SIP is one of macOS's core protections against running modified system code; turning it off system-wide opens up additional attack surface and side effects. Notably, **disabling SIP on Apple Silicon Macs also disables the ability to install and run iOS apps on your Mac**. + +Treat this as a deliberate operational choice, not a default. If your threat model can't tolerate SIP being off, both bundled iMessage and BlueBubbles will be limited to their basic modes — text and media send/receive only, no reactions / edit / unsend / effects / group ops on either channel. + + +### Setup + +1. **Install (or upgrade) `imsg`** on the Mac that runs Messages.app: + + ```bash + brew install steipete/tap/imsg + imsg --version + imsg status --json + ``` + + The `imsg status --json` output reports `bridge_version`, `rpc_methods`, and per-method `selectors` so you can see what the current build supports before you start. + +2. **Disable System Integrity Protection.** This is macOS-version-specific, identical to the BlueBubbles flow because the underlying Apple requirement is the same: + - **macOS 10.13–10.15 (Sierra–Catalina):** disable Library Validation via Terminal, reboot to Recovery Mode, run `csrutil disable`, restart. + - **macOS 11+ (Big Sur and later), Intel:** Recovery Mode (or Internet Recovery), `csrutil disable`, restart. + - **macOS 11+, Apple Silicon:** power-button startup sequence to enter Recovery; on recent macOS versions hold the **Left Shift** key when you click Continue, then `csrutil disable`. Virtual-machine setups follow a separate flow — take a VM snapshot first. + - **macOS 26 / Tahoe:** library-validation policies and `imagent` private-entitlement checks have tightened further; `imsg` may need an updated build to keep up. If `imsg launch` injection or specific `selectors` start returning false after a macOS major upgrade, check `imsg`'s release notes before assuming the SIP step succeeded. + + The [BlueBubbles Private API installation guide](https://docs.bluebubbles.app/private-api/installation) is the canonical step-by-step for the SIP-disable flow itself; the macOS-side steps are not specific to BB, only the helper that gets injected differs. + +3. **Inject the helper.** With SIP disabled and Messages.app signed in: + + ```bash + imsg launch + ``` + + `imsg launch` refuses to inject when SIP is still enabled, so this also doubles as a confirmation that step 2 took. + +4. **Verify the bridge from OpenClaw:** + + ```bash + openclaw channels status --probe + ``` + + The iMessage entry should report `works`, and `imsg status --json | jq '.selectors'` should show `retractMessagePart: true` plus whichever edit / typing / read selectors your macOS build exposes. The OpenClaw plugin per-method gating in `actions.ts` only advertises actions whose underlying selector is `true`, so the action surface you see in the agent's tool list reflects what the bridge can actually do on this host. + +If `openclaw channels status --probe` reports the channel as `works` but specific actions throw "iMessage `` requires the imsg private API bridge" at dispatch time, run `imsg launch` again — the helper can fall out (Messages.app restart, OS update, etc.) and the cached `available: true` status will keep advertising actions until the next probe refreshes. + +### When you can't disable SIP + +If SIP-disabled isn't acceptable for your threat model: + +- Both `imsg` and BlueBubbles fall back to basic mode — text + media + receive only. +- The OpenClaw plugin still advertises text/media send and inbound monitoring; it just hides `react`, `edit`, `unsend`, `reply`, `sendWithEffect`, and group ops from the action surface (per the per-method capability gate). +- You can run a separate non-Apple-Silicon Mac (or a dedicated bot Mac) with SIP off for the iMessage workload, while keeping SIP enabled on your primary devices. See [Dedicated bot macOS user (separate iMessage identity)](#deployment-patterns) below. + ## Access control and routing @@ -160,6 +233,31 @@ imsg send "test" 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). + + Group routing has **two** allowlist gates running back-to-back, and both must pass: + + 1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — handle, `chat_guid`, `chat_identifier`, or `chat_id`. + 2. **Group registry** (`channels.imessage.groups`) — with `groupPolicy: "allowlist"`, this gate requires either a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or an explicit per-`chat_id` entry under `groups`. + + If gate 2 has nothing in it, every group message is dropped — and the rejection logs only at `verbose`/`debug` level, so the drops are silent at the default `info` log level. DMs continue to work because they take a different code path. + + Minimum config to keep groups flowing under `groupPolicy: "allowlist"`: + + ```json5 + { + channels: { + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15555550123"], + groups: { "*": { "requireMention": true } }, + }, + }, + } + ``` + + To debug a suspected silent drop, run `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message () not in allowlist`. If that line appears, gate 2 is dropping — add the `groups` block. + + Mention gating for groups: - iMessage has no native mention metadata @@ -264,24 +362,24 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior. Example: -```json5 -{ - channels: { - imessage: { - enabled: true, - cliPath: "~/.openclaw/scripts/imsg-ssh", - remoteHost: "bot@mac-mini.tailnet-1234.ts.net", - includeAttachments: true, - dbPath: "/Users/bot/Library/Messages/chat.db", - }, - }, -} -``` + ```json5 + { + channels: { + imessage: { + enabled: true, + cliPath: "~/.openclaw/scripts/imsg-ssh", + remoteHost: "bot@mac-mini.tailnet-1234.ts.net", + includeAttachments: true, + dbPath: "/Users/bot/Library/Messages/chat.db", + }, + }, + } + ``` -```bash -#!/usr/bin/env bash -exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" -``` + ```bash + #!/usr/bin/env bash + exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" + ``` Use SSH keys so both SSH and SCP are non-interactive. Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated. @@ -332,10 +430,76 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" - `sms:+1555...` - `user@example.com` -```bash -imsg chats --limit 20 + ```bash + imsg chats --limit 20 + ``` + + + + +## Private API actions + +When `imsg launch` is running and `openclaw channels status --probe` reports `privateApi.available: true`, the message tool can use iMessage-native actions in addition to normal text sends. + +```json5 +{ + channels: { + imessage: { + actions: { + reactions: true, + edit: true, + unsend: true, + reply: true, + sendWithEffect: true, + sendAttachment: true, + renameGroup: true, + setGroupIcon: true, + addParticipant: true, + removeParticipant: true, + leaveGroup: true, + }, + }, + }, +} ``` + + + - **react**: Add/remove iMessage tapbacks (`messageId`, `emoji`, `remove`). Supported tapbacks map to love, like, dislike, laugh, emphasize, and question. + - **reply**: Send a threaded reply to an existing message (`messageId`, `text` or `message`, plus `chatGuid`, `chatId`, `chatIdentifier`, or `to`). + - **sendWithEffect**: Send text with an iMessage effect (`text` or `message`, `effect` or `effectId`). + - **edit**: Edit a sent message on supported macOS/private API versions (`messageId`, `text` or `newText`). + - **unsend**: Retract a sent message on supported macOS/private API versions (`messageId`). + - **upload-file**: Send media/files (`buffer` as base64 or a hydrated `media`/`path`/`filePath`, `filename`, optional `asVoice`). Legacy alias: `sendAttachment`. + - **renameGroup**, **setGroupIcon**, **addParticipant**, **removeParticipant**, **leaveGroup**: Manage group chats when the current target is a group conversation. + + + + + Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`. + + + + + OpenClaw hides private API actions only when the cached probe status says the bridge is unavailable. If the status is unknown, actions remain visible and dispatch probes lazily so the first action can succeed after `imsg launch` without a separate manual status refresh. + + + + + When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with: + + ```json5 + { + channels: { + imessage: { + sendReadReceipts: false, + }, + }, + } + ``` + + Older `imsg` builds that pre-date the per-method capability list will gate off typing/read silently; OpenClaw logs a one-time warning per restart so the missing receipt is attributable. + @@ -355,18 +519,98 @@ Disable: } ``` + + +## Coalescing split-send DMs (command + URL in one composition) + +When a user types a command and a URL together — e.g. `Dump https://example.com/article` — Apple's Messages app splits the send into **two separate `chat.db` rows**: + +1. A text message (`"Dump"`). +2. A URL-preview balloon (`"https://..."`) with OG-preview images as attachments. + +The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces, so the same fix applies as it does on the BlueBubbles channel. + +`channels.imessage.coalesceSameSenderDms` opts a DM into merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved. + + + + Enable when: + + - You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.). + - Your users paste URLs, images, or long content alongside commands. + - You can accept the added DM turn latency (see below). + + Leave disabled when: + + - You need minimum command latency for single-word DM triggers. + - All your flows are one-shot commands without payload follow-ups. + + + + ```json5 + { + channels: { + imessage: { + coalesceSameSenderDms: true, // opt in (default: false) + }, + }, + } + ``` + + With the flag on and no explicit `messages.inbound.byChannel.imessage`, the debounce window widens to **2500 ms** (the legacy default is 0 ms — no debouncing). The wider window is required because Apple's split-send cadence of 0.8-2.0 s does not fit in a tighter default. + + To tune the window yourself: + + ```json5 + { + messages: { + inbound: { + byChannel: { + // 2500 ms works for most setups; raise to 4000 ms if your Mac is + // slow or under memory pressure (observed gap can stretch past 2 s + // then). + imessage: 2500, + }, + }, + }, + } + ``` + + + + - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a payload row is coming. Group-chat messages keep instant dispatch. + - **Merged output is bounded.** Merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked in `coalescedMessageGuids` for downstream telemetry. + - **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing. + - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. The BlueBubbles channel has the same opt-in under `channels.bluebubbles.coalesceSameSenderDms`. + + + + +### Scenarios and what the agent sees + +| User composes | `chat.db` produces | Flag off (default) | Flag on + 2500 ms window | +| ------------------------------------------------------------------ | --------------------- | --------------------------------------- | ----------------------------------------------------------------------- | +| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | +| `Save this 📎image.jpg caption` (attachment + text) | 2 rows | Two turns (attachment dropped on merge) | One turn: text + image preserved | +| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** | +| URL pasted alone | 1 row | Instant dispatch | Instant dispatch (only one entry in bucket) | +| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) | +| Rapid flood (>10 small DMs inside window) | N rows | N turns | One turn, bounded output (first + latest, text/attachment caps applied) | +| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced | + ## Troubleshooting Validate the binary and RPC support: -```bash -imsg rpc --help -openclaw channels status --probe -``` + ```bash + imsg rpc --help + imsg status --json + openclaw channels status --probe + ``` - If probe reports RPC unsupported, update `imsg`. If the Gateway is not running on macOS, use the Remote Mac over SSH setup above instead of the default local `imsg` path. + If probe reports RPC unsupported, update `imsg`. If private API actions are unavailable, run `imsg launch` in the logged-in macOS user session and probe again. If the Gateway is not running on macOS, use the Remote Mac over SSH setup above instead of the default local `imsg` path. @@ -419,10 +663,10 @@ openclaw channels status --probe --channel imessage Re-run in an interactive GUI terminal in the same user/session context and approve prompts: -```bash -imsg chats --limit 1 -imsg send "test" -``` + ```bash + imsg chats --limit 1 + imsg send "test" + ``` Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`. @@ -438,6 +682,7 @@ imsg send "test" ## Related - [Channels Overview](/channels) — all supported channels +- [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) — config translation table and step-by-step cutover - [Pairing](/channels/pairing) — DM authentication and pairing flow - [Groups](/channels/groups) — group chat behavior and mention gating - [Channel Routing](/channels/channel-routing) — session routing for messages diff --git a/docs/channels/index.md b/docs/channels/index.md index e655ac23e6c..786ffa2454a 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -24,7 +24,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Discord](/channels/discord) - Discord Bot API + Gateway; supports servers, channels, and DMs. - [Feishu](/channels/feishu) - Feishu/Lark bot via WebSocket (bundled plugin). - [Google Chat](/channels/googlechat) - Google Chat API app via HTTP webhook (downloadable plugin). -- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` CLI on a signed-in Mac; use an SSH wrapper when the Gateway runs elsewhere. +- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` bridge on a signed-in Mac (or SSH wrapper when the Gateway runs elsewhere), including private API actions for replies, tapbacks, effects, attachments, and group management. Preferred for new OpenClaw iMessage setups when host permissions and Messages access fit. - [IRC](/channels/irc) - Classic IRC servers; channels + DMs with pairing/allowlist controls. - [LINE](/channels/line) - LINE Messaging API bot (downloadable plugin). - [Matrix](/channels/matrix) - Matrix protocol (downloadable plugin). diff --git a/docs/docs.json b/docs/docs.json index 75e7df30000..46f3b76db01 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1063,6 +1063,7 @@ "channels/msteams", "channels/googlechat", "channels/imessage", + "channels/imessage-from-bluebubbles", "channels/matrix", "channels/matrix-migration", "channels/matrix-push-rules" diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 7b6ca03b4d6..014273b458a 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -19,7 +19,7 @@ "detailLabel": "iMessage", "docsPath": "/channels/imessage", "docsLabel": "imessage", - "blurb": "iMessage via the imsg CLI on a signed-in Mac or SSH wrapper.", + "blurb": "Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.", "aliases": [ "imsg" ], @@ -38,6 +38,37 @@ "description": "iMessage region (for SMS)" } ] + }, + "compat": { + "pluginApi": ">=2026.5.3" + }, + "build": { + "openclawVersion": "2026.5.3" + } + }, + "pluginInspector": { + "version": 1, + "plugin": { + "id": "imessage", + "priority": "high", + "seams": [ + "channel-plugin", + "message-actions", + "conversation-bindings", + "outbound-media" + ], + "sourceRoot": "src", + "expect": { + "registrations": [ + "createChatChannelPlugin" + ], + "manifestContracts": [ + "channels" + ] + } + }, + "capture": { + "mockSdk": true } } } diff --git a/extensions/imessage/src/actions-contract.ts b/extensions/imessage/src/actions-contract.ts new file mode 100644 index 00000000000..39012b4bfc2 --- /dev/null +++ b/extensions/imessage/src/actions-contract.ts @@ -0,0 +1,19 @@ +export const IMESSAGE_ACTIONS = { + react: { gate: "reactions" }, + edit: { gate: "edit" }, + unsend: { gate: "unsend" }, + reply: { gate: "reply" }, + sendWithEffect: { gate: "sendWithEffect" }, + renameGroup: { gate: "renameGroup", groupOnly: true }, + setGroupIcon: { gate: "setGroupIcon", groupOnly: true }, + addParticipant: { gate: "addParticipant", groupOnly: true }, + removeParticipant: { gate: "removeParticipant", groupOnly: true }, + leaveGroup: { gate: "leaveGroup", groupOnly: true }, + sendAttachment: { gate: "sendAttachment" }, +} as const; + +type IMessageActionSpecs = typeof IMESSAGE_ACTIONS; + +export const IMESSAGE_ACTION_NAMES = Object.keys(IMESSAGE_ACTIONS) as Array< + keyof IMessageActionSpecs +>; diff --git a/extensions/imessage/src/actions.runtime.test.ts b/extensions/imessage/src/actions.runtime.test.ts new file mode 100644 index 00000000000..b346dc7b4bb --- /dev/null +++ b/extensions/imessage/src/actions.runtime.test.ts @@ -0,0 +1,185 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + +const { imessageActionsRuntime, _findChatGuidForTest, _normalizeDirectChatIdentifierForTest } = + await import("./actions.runtime.js"); + +function mockSpawnJsonResponse(payload: Record = { success: true }) { + spawnMock.mockImplementationOnce(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: (encoding: string) => void }; + stderr: EventEmitter & { setEncoding: (encoding: string) => void }; + kill: (signal: string) => void; + }; + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); + child.kill = vi.fn(); + queueMicrotask(() => { + child.stdout.emit("data", `${JSON.stringify(payload)}\n`); + child.emit("close", 0); + }); + return child; + }); +} + +describe("imessage actions runtime", () => { + it("passes the configured Messages db path to private API bridge commands", async () => { + mockSpawnJsonResponse(); + + await imessageActionsRuntime.sendReaction({ + chatGuid: "iMessage;+;chat0000", + messageId: "message-guid", + reaction: "like", + options: { + cliPath: "imsg", + dbPath: "/tmp/messages.db", + chatGuid: "iMessage;+;chat0000", + }, + }); + + expect(spawnMock).toHaveBeenCalledWith( + "imsg", + [ + "tapback", + "--chat", + "iMessage;+;chat0000", + "--message", + "message-guid", + "--kind", + "like", + "--part", + "0", + "--db", + "/tmp/messages.db", + "--json", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + }); +}); + +describe("findChatGuid cross-format identifier resolution", () => { + // imsg's chats.list returns DM chats as `identifier: ` and + // `guid: any;-;`. The agent's action surface synthesizes + // `iMessage;-;` from a phone-number target. A naive string-equality + // lookup would miss this match — this is the bug that surfaced in + // production today: agent passes phone target → chat-guid resolver returns + // null → react/edit/unsend throw "no registered chat" even though chats.list + // does have the chat. + const chatsList = [ + { + id: 3, + identifier: "+12069106512", + guid: "any;-;+12069106512", + service: "iMessage", + is_group: false, + }, + { + id: 7, + identifier: "chat0000", + guid: "iMessage;+;chat0000", + service: "iMessage", + is_group: true, + }, + ]; + + it("matches a synthesized iMessage;-; target against the chats.list identifier", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "iMessage;-;+12069106512", + }); + expect(result).toBe("any;-;+12069106512"); + }); + + it("matches a synthesized SMS;-; target the same way", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "SMS;-;+12069106512", + }); + expect(result).toBe("any;-;+12069106512"); + }); + + it("matches a bare identifier exactly", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "+12069106512", + }); + expect(result).toBe("any;-;+12069106512"); + }); + + it("matches an any;-; guid form against the chats.list guid column", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "any;-;+12069106512", + }); + expect(result).toBe("any;-;+12069106512"); + }); + + it("matches a group chat by exact guid", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "iMessage;+;chat0000", + }); + expect(result).toBe("iMessage;+;chat0000"); + }); + + it("matches a group chat by chat_id", () => { + const result = _findChatGuidForTest(chatsList, { kind: "chat_id", chatId: 7 }); + expect(result).toBe("iMessage;+;chat0000"); + }); + + it("returns null for a phone number that does not exist in chats.list", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "iMessage;-;+19999999999", + }); + expect(result).toBeNull(); + }); + + it("does not cross-match different phone numbers via the prefix-stripping path", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "iMessage;-;+18001234567", + }); + expect(result).toBeNull(); + }); + + it("does not match a DM target against a group's chat_identifier", () => { + const result = _findChatGuidForTest(chatsList, { + kind: "chat_identifier", + chatIdentifier: "iMessage;+;chat-not-here", + }); + expect(result).toBeNull(); + }); +}); + +describe("normalizeDirectChatIdentifier", () => { + it("strips the iMessage;-; prefix", () => { + expect(_normalizeDirectChatIdentifierForTest("iMessage;-;+12069106512")).toBe("+12069106512"); + }); + it("strips the SMS;-; prefix", () => { + expect(_normalizeDirectChatIdentifierForTest("SMS;-;+12069106512")).toBe("+12069106512"); + }); + it("strips the any;-; prefix", () => { + expect(_normalizeDirectChatIdentifierForTest("any;-;+12069106512")).toBe("+12069106512"); + }); + it("matches case-insensitively", () => { + expect(_normalizeDirectChatIdentifierForTest("IMESSAGE;-;+12069106512")).toBe("+12069106512"); + }); + it("leaves group identifiers (iMessage;+;chat...) unchanged", () => { + expect(_normalizeDirectChatIdentifierForTest("iMessage;+;chat0000")).toBe( + "iMessage;+;chat0000", + ); + }); + it("leaves bare values unchanged", () => { + expect(_normalizeDirectChatIdentifierForTest("+12069106512")).toBe("+12069106512"); + expect(_normalizeDirectChatIdentifierForTest("foo@bar.com")).toBe("foo@bar.com"); + }); +}); diff --git a/extensions/imessage/src/actions.runtime.ts b/extensions/imessage/src/actions.runtime.ts new file mode 100644 index 00000000000..1b9816c4096 --- /dev/null +++ b/extensions/imessage/src/actions.runtime.ts @@ -0,0 +1,502 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { createIMessageRpcClient } from "./client.js"; +import { extractMarkdownFormatRuns } from "./markdown-format.js"; +import { resolveIMessageMessageId as resolveIMessageMessageIdImpl } from "./monitor-reply-cache.js"; +import type { IMessageTarget } from "./targets.js"; + +type CliRunOptions = { + cliPath: string; + dbPath?: string; + timeoutMs?: number; +}; + +type IMessageBridgeActionOptions = CliRunOptions & { + chatGuid: string; +}; + +type IMessageBridgeSendResult = { + messageId: string; +}; + +type TempFileInput = { + buffer: Uint8Array; + filename: string; +}; + +type IMessageChatListResponse = { + chats?: unknown; +}; + +function asChatList(value: unknown): Array> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + const chats = (value as IMessageChatListResponse).chats; + if (!Array.isArray(chats)) { + return []; + } + return chats.filter( + (chat): chat is Record => + chat != null && typeof chat === "object" && !Array.isArray(chat), + ); +} + +function numberFromUnknown(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value.trim()); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +function stringFromUnknown(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +// 30s TTL on the chats.list cache, keyed by cliPath+dbPath. Long enough to +// absorb a burst of agent actions; short enough that a freshly-created +// chat shows up without restarting the gateway. +const CHAT_LIST_CACHE_TTL_MS = 30 * 1000; +type ChatListCacheEntry = { + list: ReadonlyArray>; + expiresAt: number; +}; +const chatListCache = new Map(); + +function chatListCacheKey(cliPath: string, dbPath?: string): string { + return `${cliPath}\0${dbPath ?? ""}`; +} + +function chatListCacheGet( + cliPath: string, + dbPath?: string, +): ReadonlyArray> | null { + const entry = chatListCache.get(chatListCacheKey(cliPath, dbPath)); + if (!entry) { + return null; + } + if (entry.expiresAt < Date.now()) { + chatListCache.delete(chatListCacheKey(cliPath, dbPath)); + return null; + } + return entry.list; +} + +function chatListCacheSet( + cliPath: string, + dbPath: string | undefined, + list: ReadonlyArray>, +): void { + chatListCache.set(chatListCacheKey(cliPath, dbPath), { + list, + expiresAt: Date.now() + CHAT_LIST_CACHE_TTL_MS, + }); +} + +/** + * Strip the iMessage;-;/SMS;-;/any;-; service prefix that Messages uses + * for direct DM chats. Different layers report direct DMs in different + * forms — the action surface synthesizes `iMessage;-;` from a + * handle target, while imsg's chats.list returns `identifier: ` + * and `guid: any;-;`. Comparing the raw strings would falsely + * miss the match. Mirror of the same helper in monitor-reply-cache.ts. + */ +export function _normalizeDirectChatIdentifierForTest(raw: string): string { + return normalizeDirectChatIdentifier(raw); +} + +export function _findChatGuidForTest( + chats: readonly Record[], + target: Extract, +): string | null { + return findChatGuid(chats, target); +} + +function normalizeDirectChatIdentifier(raw: string): string { + const trimmed = raw.trim(); + const lowered = trimmed.toLowerCase(); + for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) { + if (lowered.startsWith(prefix)) { + return trimmed.slice(prefix.length); + } + } + return trimmed; +} + +function findChatGuid( + chats: readonly Record[], + target: Extract, +): string | null { + if (target.kind === "chat_id") { + for (const chat of chats) { + const id = numberFromUnknown(chat.id); + const guid = stringFromUnknown(chat.guid); + if (id === target.chatId && guid) { + return guid; + } + } + return null; + } + // target.kind === "chat_identifier" + const wanted = normalizeDirectChatIdentifier(target.chatIdentifier); + for (const chat of chats) { + const identifier = stringFromUnknown(chat.identifier); + const guid = stringFromUnknown(chat.guid); + if (!guid) { + continue; + } + if ( + identifier === target.chatIdentifier || + guid === target.chatIdentifier || + (identifier && normalizeDirectChatIdentifier(identifier) === wanted) || + normalizeDirectChatIdentifier(guid) === wanted + ) { + return guid; + } + } + return null; +} + +function buildIMessageCliJsonArgs(args: readonly string[], options: CliRunOptions): string[] { + const dbPath = options.dbPath?.trim(); + return [...args, ...(dbPath ? ["--db", dbPath] : []), "--json"]; +} + +async function runIMessageCliJson( + args: readonly string[], + options: CliRunOptions, +): Promise> { + return await new Promise((resolve, reject) => { + const child = spawn(options.cliPath, buildIMessageCliJsonArgs(args, options), { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let killEscalation: ReturnType | null = null; + const timer = + options.timeoutMs && options.timeoutMs > 0 + ? setTimeout(() => { + child.kill("SIGTERM"); + // If SIGTERM doesn't take within 2s (wedged child, ignored + // signal handler), escalate to SIGKILL so the process doesn't + // linger as a zombie. + killEscalation = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // best-effort + } + }, 2000); + reject(new Error(`iMessage action timed out after ${options.timeoutMs}ms`)); + }, options.timeoutMs) + : null; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + if (timer) { + clearTimeout(timer); + } + if (killEscalation) { + clearTimeout(killEscalation); + } + reject(error); + }); + child.on("close", (code) => { + if (timer) { + clearTimeout(timer); + } + if (killEscalation) { + clearTimeout(killEscalation); + } + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const last = lines.at(-1); + let parsed: Record | null = null; + if (last) { + try { + const value = JSON.parse(last); + if (value && typeof value === "object" && !Array.isArray(value)) { + parsed = value as Record; + } + } catch { + parsed = null; + } + } + if (code !== 0) { + const detail = + (typeof parsed?.error === "string" && parsed.error.trim()) || + stderr.trim() || + stdout.trim() || + `imsg exited with code ${code}`; + reject(new Error(detail)); + return; + } + if (!parsed) { + reject(new Error(`imsg returned non-JSON output: ${stdout.trim() || stderr.trim()}`)); + return; + } + if (parsed.success === false) { + const error = + typeof parsed.error === "string" && parsed.error.trim() + ? parsed.error.trim() + : "iMessage action failed"; + reject(new Error(error)); + return; + } + resolve(parsed); + }); + }); +} + +function resolveMessageId(result: Record): string { + const raw = + (typeof result.messageGuid === "string" && result.messageGuid.trim()) || + (typeof result.messageId === "string" && result.messageId.trim()) || + (typeof result.guid === "string" && result.guid.trim()) || + (typeof result.id === "string" && result.id.trim()); + return raw || "ok"; +} + +async function withTempFile(input: TempFileInput, fn: (path: string) => Promise): Promise { + const dir = await mkdtemp(join(resolvePreferredOpenClawTmpDir(), "openclaw-imessage-")); + const safeExt = extname(input.filename).slice(0, 16) || ".bin"; + const filePath = join(dir, `upload${safeExt}`); + try { + await writeFile(filePath, input.buffer); + return await fn(filePath); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export const imessageActionsRuntime = { + resolveIMessageMessageId: resolveIMessageMessageIdImpl, + + async resolveChatGuidForTarget(params: { + target: Extract; + options: CliRunOptions; + }): Promise { + // Each `chats.list` call spawns a fresh imsg rpc subprocess and pulls + // every chat the account knows about. Bursts of agent actions (react + // then reply, reply then add-participant, etc.) all paid that cost + // until we cached the chats list per cliPath+dbPath for ~30 seconds. + const cached = chatListCacheGet(params.options.cliPath, params.options.dbPath); + if (cached) { + return findChatGuid(cached, params.target); + } + const client = await createIMessageRpcClient({ + cliPath: params.options.cliPath, + dbPath: params.options.dbPath, + }); + try { + const result = await client.request( + "chats.list", + { limit: 1000 }, + { timeoutMs: params.options.timeoutMs }, + ); + const list = asChatList(result); + chatListCacheSet(params.options.cliPath, params.options.dbPath, list); + return findChatGuid(list, params.target); + } finally { + await client.stop(); + } + }, + + async sendReaction(params: { + chatGuid: string; + messageId: string; + reaction: string; + remove?: boolean; + partIndex?: number; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + [ + "tapback", + "--chat", + params.chatGuid, + "--message", + params.messageId, + "--kind", + params.reaction, + "--part", + String(params.partIndex ?? 0), + ...(params.remove ? ["--remove"] : []), + ], + params.options, + ); + }, + + async editMessage(params: { + chatGuid: string; + messageId: string; + text: string; + backwardsCompatMessage?: string; + partIndex?: number; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + [ + "edit", + "--chat", + params.chatGuid, + "--message", + params.messageId, + "--new-text", + params.text, + "--bc-text", + params.backwardsCompatMessage ?? params.text, + "--part", + String(params.partIndex ?? 0), + ], + params.options, + ); + }, + + async unsendMessage(params: { + chatGuid: string; + messageId: string; + partIndex?: number; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + [ + "unsend", + "--chat", + params.chatGuid, + "--message", + params.messageId, + "--part", + String(params.partIndex ?? 0), + ], + params.options, + ); + }, + + async sendRichMessage(params: { + chatGuid: string; + text: string; + effectId?: string; + replyToMessageId?: string; + partIndex?: number; + options: IMessageBridgeActionOptions; + }): Promise { + // Extract markdown bold/italic/underline/strikethrough into typed-run + // ranges so the recipient sees actual styling rather than literal + // asterisks. This mirrors the same extraction the rpc-send path does; + // any caller that hits the bridge via `imsg send-rich` benefits without + // needing to pre-format the text themselves. + const formatted = extractMarkdownFormatRuns(params.text); + const result = await runIMessageCliJson( + [ + "send-rich", + "--chat", + params.chatGuid, + "--text", + formatted.text, + "--part", + String(params.partIndex ?? 0), + ...(params.effectId ? ["--effect", params.effectId] : []), + ...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []), + ...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []), + ], + params.options, + ); + return { messageId: resolveMessageId(result) }; + }, + + async renameGroup(params: { + chatGuid: string; + displayName: string; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + ["chat-name", "--chat", params.chatGuid, "--name", params.displayName], + params.options, + ); + }, + + async setGroupIcon(params: { + chatGuid: string; + buffer: Uint8Array; + filename: string; + options: IMessageBridgeActionOptions; + }) { + await withTempFile({ buffer: params.buffer, filename: params.filename }, async (filePath) => { + await runIMessageCliJson( + ["chat-photo", "--chat", params.chatGuid, "--file", filePath], + params.options, + ); + }); + }, + + async addParticipant(params: { + chatGuid: string; + address: string; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + ["chat-add-member", "--chat", params.chatGuid, "--address", params.address], + params.options, + ); + }, + + async removeParticipant(params: { + chatGuid: string; + address: string; + options: IMessageBridgeActionOptions; + }) { + await runIMessageCliJson( + ["chat-remove-member", "--chat", params.chatGuid, "--address", params.address], + params.options, + ); + }, + + async leaveGroup(params: { chatGuid: string; options: IMessageBridgeActionOptions }) { + await runIMessageCliJson(["chat-leave", "--chat", params.chatGuid], params.options); + }, + + async sendAttachment(params: { + chatGuid: string; + buffer: Uint8Array; + filename: string; + asVoice?: boolean; + options: IMessageBridgeActionOptions; + }): Promise { + return await withTempFile( + { buffer: params.buffer, filename: params.filename }, + async (filePath) => { + const result = await runIMessageCliJson( + [ + "send-attachment", + "--chat", + params.chatGuid, + "--file", + filePath, + ...(params.asVoice ? ["--audio"] : []), + ], + params.options, + ); + return { messageId: resolveMessageId(result) }; + }, + ); + }, +}; + +export type IMessageActionsRuntime = typeof imessageActionsRuntime; diff --git a/extensions/imessage/src/actions.test.ts b/extensions/imessage/src/actions.test.ts new file mode 100644 index 00000000000..08ed7ecdd06 --- /dev/null +++ b/extensions/imessage/src/actions.test.ts @@ -0,0 +1,535 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const probeMock = vi.hoisted(() => ({ + getCachedIMessagePrivateApiStatus: vi.fn(), +})); + +const runtimeMock = vi.hoisted(() => ({ + resolveIMessageMessageId: vi.fn((id: string) => id), + resolveChatGuidForTarget: vi.fn(), + sendReaction: vi.fn(), + sendRichMessage: vi.fn(), + sendAttachment: vi.fn(), +})); + +vi.mock("./probe.js", () => ({ + getCachedIMessagePrivateApiStatus: probeMock.getCachedIMessagePrivateApiStatus, +})); + +vi.mock("./actions.runtime.js", () => ({ + imessageActionsRuntime: runtimeMock, +})); + +const { imessageMessageActions } = await import("./actions.js"); + +function cfg(actions?: Record): OpenClawConfig { + return { + channels: { + imessage: { + cliPath: "imsg", + dbPath: "/tmp/messages.db", + actions, + }, + }, + } as OpenClawConfig; +} + +describe("imessage message actions", () => { + beforeEach(() => { + runtimeMock.resolveIMessageMessageId.mockClear(); + runtimeMock.resolveIMessageMessageId.mockImplementation((id: string) => id); + runtimeMock.resolveChatGuidForTarget.mockReset(); + runtimeMock.sendReaction.mockReset(); + runtimeMock.sendRichMessage.mockReset(); + runtimeMock.sendAttachment.mockReset(); + probeMock.getCachedIMessagePrivateApiStatus.mockReset(); + }); + + it("does not advertise private API actions when the bridge is known unavailable", () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: false, + v2Ready: false, + selectors: {}, + }); + + const described = imessageMessageActions.describeMessageTool({ + cfg: cfg(), + currentChannelId: "chat_guid:iMessage;+;chat0000", + } as never); + + expect(described?.actions).toEqual([]); + }); + + it("advertises private API actions while private API status is unknown", () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue(undefined); + + const described = imessageMessageActions.describeMessageTool({ + cfg: cfg(), + currentChannelId: "chat_guid:iMessage;+;chat0000", + } as never); + + expect(described?.actions).toEqual( + expect.arrayContaining(["react", "reply", "sendWithEffect", "upload-file"]), + ); + }); + + it("advertises BB-parity actions when private API and selectors are available", () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: { + editMessage: true, + retractMessagePart: true, + }, + }); + + const described = imessageMessageActions.describeMessageTool({ + cfg: cfg(), + currentChannelId: "chat_guid:iMessage;+;chat0000", + } as never); + + expect(described?.actions).toEqual( + expect.arrayContaining([ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + "upload-file", + ]), + ); + }); + + it("respects configured action gates", () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: { + editMessage: true, + retractMessagePart: true, + }, + }); + + const described = imessageMessageActions.describeMessageTool({ + cfg: cfg({ reactions: false, reply: false }), + currentChannelId: "chat_guid:iMessage;+;chat0000", + } as never); + + expect(described?.actions).not.toContain("react"); + expect(described?.actions).not.toContain("reply"); + expect(described?.actions).toContain("edit"); + }); + + it("maps message tool reactions to imsg tapback kinds", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + messageId: "message-guid", + emoji: "👍", + }, + } as never); + + expect(runtimeMock.sendReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;+;chat0000", + messageId: "message-guid", + reaction: "like", + options: expect.objectContaining({ + dbPath: "/tmp/messages.db", + }), + }), + ); + }); + + it("resolves chat_id targets before invoking bridge actions", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved"); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + target: "chat_id:42", + messageId: "message-guid", + emoji: "👍", + }, + } as never); + + expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_id", chatId: 42 }, + }), + ); + expect(runtimeMock.sendReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;+;resolved", + }), + ); + }); + + it("resolves short message ids before invoking bridge actions", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("full-guid"); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + messageId: "1", + emoji: "👍", + }, + } as never); + + expect(runtimeMock.resolveIMessageMessageId).toHaveBeenCalledWith("1", { + requireKnownShortId: true, + chatContext: { + chatGuid: "iMessage;+;chat0000", + chatIdentifier: undefined, + chatId: undefined, + }, + }); + expect(runtimeMock.sendReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "full-guid", + }), + ); + }); + + it("resolves chat_identifier targets before invoking bridge actions", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident"); + runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "reply-guid" }); + + await imessageMessageActions.handleAction?.({ + action: "reply", + cfg: cfg(), + params: { + chatIdentifier: "team-thread", + messageId: "message-guid", + text: "reply", + }, + } as never); + + expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_identifier", chatIdentifier: "team-thread" }, + }), + ); + expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;+;resolved-ident", + }), + ); + }); + + describe("phone-number target end-to-end (regressions caught the hard way)", () => { + it("synthesizes iMessage;-; chat_identifier from a handle target and sends through to sendReaction", async () => { + // Scenario from prod: agent calls react with `target:"+12069106512"` and a + // known-cached short messageId. resolveChatGuid synthesizes + // `iMessage;-;+12069106512` and asks the runtime to look it up. The + // runtime returns the real chat guid. sendReaction must receive the + // resolved guid, not the synthesized stand-in. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveChatGuidForTarget.mockResolvedValue("any;-;+12069106512"); + runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("full-guid"); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + target: "+12069106512", + messageId: "5", + emoji: "👍", + }, + } as never); + + // resolveChatGuid synthesizes the chat_identifier; the runtime then + // does the chats.list lookup against it. + expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + kind: "chat_identifier", + chatIdentifier: "iMessage;-;+12069106512", + }, + }), + ); + // The cache lookup uses the synthesized chat_identifier as scope so + // cross-chat checks have something to match against. + expect(runtimeMock.resolveIMessageMessageId).toHaveBeenCalledWith("5", { + requireKnownShortId: true, + chatContext: expect.objectContaining({ + chatIdentifier: "iMessage;-;+12069106512", + }), + }); + // sendReaction lands on the real registered chat guid, not the + // synthesized stand-in. + expect(runtimeMock.sendReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "any;-;+12069106512", + }), + ); + }); + + it("rejects react/edit/unsend when the synthesized chat is not registered", async () => { + // Scenario from prod: agent invokes react against a phone target whose + // chat has never been touched yet. We refuse rather than fabricate the + // identifier and let it fail downstream — there's no message to react + // to in a chat that doesn't exist yet. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveChatGuidForTarget.mockResolvedValue(null); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await expect( + imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + target: "+19999999999", + messageId: "irrelevant", + emoji: "👍", + }, + } as never), + ).rejects.toThrow(/requires a known chat/i); + expect(runtimeMock.sendReaction).not.toHaveBeenCalled(); + }); + + it("falls back to the synthesized identifier for send/reply/sendWithEffect when the chat is not yet registered", async () => { + // Counterpart to the above: send/reply/sendWithEffect targeting a brand- + // new phone-number chat is fine — Messages will register the chat as a + // side effect of the send. Only the mutate-existing-message actions + // need a registered chat. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.resolveChatGuidForTarget.mockResolvedValue(null); + runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" }); + runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("parent-guid"); + + await imessageMessageActions.handleAction?.({ + action: "reply", + cfg: cfg(), + params: { + target: "+18001234567", + messageId: "parent-guid", + text: "first contact", + }, + } as never); + + expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+18001234567", + }), + ); + }); + + it("removes a tapback by fanning out across all known kinds when emoji is empty/unknown and remove:true", async () => { + // Scenario from the audit: agent calls react with `remove: true` but + // forgot which emoji was originally added (or used a non-mapped emoji + // like 🦞). We fan a remove out to every known kind; the bridge no-ops + // kinds that weren't there. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendReaction.mockResolvedValue(undefined); + + await imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + messageId: "message-guid", + emoji: "🦞", + remove: true, + }, + } as never); + + const kinds = runtimeMock.sendReaction.mock.calls.map( + (call: unknown[]) => (call[0] as { reaction: string }).reaction, + ); + expect(kinds.toSorted()).toEqual( + ["dislike", "emphasize", "laugh", "like", "love", "question"].toSorted(), + ); + expect( + runtimeMock.sendReaction.mock.calls.every( + (call: unknown[]) => (call[0] as { remove: boolean }).remove, + ), + ).toBe(true); + }); + + it("rejects an unknown effect with an actionable error message", async () => { + // Scenario from the audit: agent passes a typo like `invisible_ink` + // (note underscore vs `invisibleink` alias). We refuse rather than + // forwarding gibberish to the bridge for an opaque CLI failure. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" }); + + await expect( + imessageMessageActions.handleAction?.({ + action: "sendWithEffect", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + text: "boom", + effect: "invisible_ink", + }, + } as never), + ).rejects.toThrow(/unknown effect|invisible_ink/i); + expect(runtimeMock.sendRichMessage).not.toHaveBeenCalled(); + }); + + it("accepts known effect aliases like 'slam' and 'invisibleink'", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" }); + + await imessageMessageActions.handleAction?.({ + action: "sendWithEffect", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + text: "boom", + effect: "slam", + }, + } as never); + + expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith( + expect.objectContaining({ + effectId: "com.apple.MobileSMS.expressivesend.impact", + }), + ); + }); + + it.each([ + ["echo", "com.apple.messages.effect.CKEchoEffect"], + ["happybirthday", "com.apple.messages.effect.CKHappyBirthdayEffect"], + ["shootingstar", "com.apple.messages.effect.CKShootingStarEffect"], + ["sparkles", "com.apple.messages.effect.CKSparklesEffect"], + ["spotlight", "com.apple.messages.effect.CKSpotlightEffect"], + ])( + "resolves the screen-effect alias %s that the error message advertises", + async (alias, canonical) => { + // Codex review caught these: the error message at effectIdFromParam + // listed echo / happybirthday / shootingstar / sparkles / spotlight + // as valid aliases, but they were missing from the alias map. Agents + // following our own guidance got "unknown effect" thrown back. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" }); + + await imessageMessageActions.handleAction?.({ + action: "sendWithEffect", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + text: "boom", + effect: alias, + }, + } as never); + + expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith( + expect.objectContaining({ effectId: canonical }), + ); + }, + ); + + it("trims whitespace-only currentChannelId so parseIMessageTarget never sees it", async () => { + // Scenario from the audit: a whitespace-only currentChannelId would + // hit parseIMessageTarget which throws on empty input, aborting the + // whole action with a confusing "target is required" message. + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + + await expect( + imessageMessageActions.handleAction?.({ + action: "react", + cfg: cfg(), + params: { messageId: "x", emoji: "👍" }, + toolContext: { currentChannelId: " \t " }, + } as never), + ).rejects.toThrow(/requires chatGuid, chatId, chatIdentifier, or a chat target/); + }); + }); + + it("routes upload-file through the private API attachment bridge", async () => { + probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ + available: true, + v2Ready: true, + selectors: {}, + }); + runtimeMock.sendAttachment.mockResolvedValue({ messageId: "sent-guid" }); + + const result = await imessageMessageActions.handleAction?.({ + action: "upload-file", + cfg: cfg(), + params: { + chatGuid: "iMessage;+;chat0000", + filename: "photo.jpg", + buffer: Buffer.from("image").toString("base64"), + }, + } as never); + + expect(runtimeMock.sendAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;+;chat0000", + filename: "photo.jpg", + }), + ); + expect(result?.details).toEqual({ ok: true, messageId: "sent-guid" }); + }); +}); diff --git a/extensions/imessage/src/actions.ts b/extensions/imessage/src/actions.ts new file mode 100644 index 00000000000..2ce4616a334 --- /dev/null +++ b/extensions/imessage/src/actions.ts @@ -0,0 +1,624 @@ +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "openclaw/plugin-sdk/channel-contract"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; +import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; +import { resolveIMessageAccount } from "./accounts.js"; +import { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./actions-contract.js"; +import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; +import { findLatestIMessageEntryForChat, type IMessageChatContext } from "./monitor-reply-cache.js"; +import { getCachedIMessagePrivateApiStatus } from "./probe.js"; +import { + inferIMessageTargetChatType, + parseIMessageTarget, + type IMessageTarget, +} from "./targets.js"; + +const loadIMessageActionsRuntime = createLazyRuntimeNamedExport( + () => import("./actions.runtime.js"), + "imessageActionsRuntime", +); + +const providerId = "imessage"; + +const SUPPORTED_ACTIONS = new Set([ + ...IMESSAGE_ACTION_NAMES, + "upload-file", +]); +const PRIVATE_API_ACTIONS = new Set([ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + "sendAttachment", +]); + +function readMessageText(params: Record): string | undefined { + return readStringParam(params, "text") ?? readStringParam(params, "message"); +} + +/** + * Read messageId from the action params, falling back to the most recent + * inbound in the same chat when the caller omitted it. The natural intent + * for "react with 👍" or "tapback the last message" is the message that + * just arrived in the current conversation; making the agent re-quote a + * message id every time is friction the cache already has the answer for. + */ +function readMessageIdWithChatFallback( + params: Record, + chatContext: IMessageChatContext & { accountId: string }, +): string { + const explicit = readStringParam(params, "messageId"); + if (explicit) { + return explicit; + } + const latest = findLatestIMessageEntryForChat(chatContext); + if (latest?.messageId) { + return latest.messageId; + } + // Surface the same error the strict readMessageId would have, so the + // agent gets a clear "you must supply messageId" signal when there is + // also no cached message to fall back to. + return readStringParam(params, "messageId", { required: true }); +} + +function isGroupTarget(raw?: string | null): boolean { + // Defer to the canonical target classifier so action gating and the + // routing layer can't drift apart on edge cases (URI-encoded targets, + // service prefixes, etc.). + if (!raw) { + return false; + } + return inferIMessageTargetChatType(raw) === "group"; +} + +type IMessageActionsRuntime = Awaited>; + +async function resolveChatGuid(params: { + action: ChannelMessageActionName; + actionParams: Record; + currentChannelId?: string; + runtime: IMessageActionsRuntime; + options: { + cliPath: string; + dbPath?: string; + timeoutMs?: number; + }; +}): Promise { + const explicitChatGuid = readStringParam(params.actionParams, "chatGuid"); + if (explicitChatGuid) { + return explicitChatGuid; + } + const explicitChatId = readNumberParam(params.actionParams, "chatId", { integer: true }); + if (typeof explicitChatId === "number") { + const resolved = await params.runtime.resolveChatGuidForTarget({ + target: { kind: "chat_id", chatId: explicitChatId }, + options: params.options, + }); + if (resolved) { + return resolved; + } + throw new Error(`iMessage ${params.action} failed: chatGuid not found for chat_id:.`); + } + const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier"); + if (explicitChatIdentifier) { + const resolved = await params.runtime.resolveChatGuidForTarget({ + target: { kind: "chat_identifier", chatIdentifier: explicitChatIdentifier }, + options: params.options, + }); + if (resolved) { + return resolved; + } + throw new Error( + `iMessage ${params.action} failed: chatGuid not found for chat_identifier:.`, + ); + } + const rawTarget = + readStringParam(params.actionParams, "to") ?? + readStringParam(params.actionParams, "target") ?? + (params.currentChannelId?.trim() || undefined); + if (rawTarget) { + const target = parseIMessageTarget(rawTarget); + if (target.kind === "chat_guid") { + return target.chatGuid; + } + if (target.kind === "chat_id" || target.kind === "chat_identifier") { + const resolved = await params.runtime.resolveChatGuidForTarget({ + target, + options: params.options, + }); + if (resolved) { + return resolved; + } + throw new Error( + `iMessage ${params.action} failed: chatGuid not found for ${formatUnresolvedTarget(target)}.`, + ); + } + if (target.kind === "handle") { + // A bare phone/email is a valid chat scope for direct messages — + // Messages addresses DMs as `iMessage;-;` / `SMS;-;`. + // Promote it to chat_identifier so resolveChatGuidForTarget (which + // only accepts chat_id / chat_identifier kinds) can look it up. + const synthesizedIdentifier = `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`; + const resolved = await params.runtime.resolveChatGuidForTarget({ + target: { kind: "chat_identifier", chatIdentifier: synthesizedIdentifier }, + options: params.options, + }); + if (resolved) { + return resolved; + } + // Per-action fallback policy: + // - send / reply / sendWithEffect / sendAttachment: fine to send to + // a synthesized DM identifier; Messages will register the chat. + // - react / edit / unsend: these mutate an existing message that + // must already exist in the chat. If we have no registered chat + // we have no message to act on, and synthesizing the identifier + // just produces a confusing CLI failure. + if (params.action === "react" || params.action === "edit" || params.action === "unsend") { + throw new Error( + `iMessage ${params.action} requires a known chat. ` + + `No registered chat for the supplied target; send a message first or pass an explicit chatGuid.`, + ); + } + return synthesizedIdentifier; + } + } + throw new Error( + `iMessage ${params.action} requires chatGuid, chatId, chatIdentifier, or a chat target.`, + ); +} + +function formatUnresolvedTarget( + target: Extract, +): string { + // Redact the actual identifier — error strings end up in agent tool + // results and log streams, and exposing a chat_id or chat_identifier + // there would leak the conversation handle to anything that observes + // them. + return target.kind === "chat_id" ? "chat_id:" : "chat_identifier:"; +} + +function buildChatContextFromActionParams(params: { + actionParams: Record; + currentChannelId?: string; +}): IMessageChatContext { + const explicitChatGuid = readStringParam(params.actionParams, "chatGuid")?.trim(); + const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier")?.trim(); + const explicitChatId = readNumberParam(params.actionParams, "chatId", { integer: true }); + // Trim before the truthy check so a whitespace-only currentChannelId can't + // reach parseIMessageTarget (which throws on empty/whitespace input and + // would abort the whole action with a confusing "target is required"). + const rawTarget = + readStringParam(params.actionParams, "to") ?? + readStringParam(params.actionParams, "target") ?? + (params.currentChannelId?.trim() || undefined); + const target = rawTarget ? parseIMessageTarget(rawTarget) : null; + // A "handle" target (raw phone or email — what the agent uses most of the + // time) is still a usable chat scope: Messages addresses DMs as + // `iMessage;-;+15551234567` / `SMS;-;+15551234567`. Synthesizing the + // chat-identifier here lets resolveIMessageMessageId succeed without + // forcing every action plumbing site to also surface chatGuid/chatId. + const handleChatIdentifier = + target?.kind === "handle" + ? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}` + : undefined; + return { + chatGuid: explicitChatGuid || (target?.kind === "chat_guid" ? target.chatGuid : undefined), + chatIdentifier: + explicitChatIdentifier || + (target?.kind === "chat_identifier" ? target.chatIdentifier : undefined) || + handleChatIdentifier, + chatId: + typeof explicitChatId === "number" + ? explicitChatId + : target?.kind === "chat_id" + ? target.chatId + : undefined, + }; +} + +function mapTapbackReaction(emoji?: string): string | undefined { + const value = normalizeOptionalLowercaseString(emoji)?.replace(/\ufe0f/g, ""); + if (!value) { + return undefined; + } + if (["love", "heart", "❤", "❤️"].includes(value)) { + return "love"; + } + if (["like", "+1", "thumbsup", "👍"].includes(value)) { + return "like"; + } + if (["dislike", "-1", "thumbsdown", "👎"].includes(value)) { + return "dislike"; + } + if (["laugh", "haha", "😂", "🤣"].includes(value)) { + return "laugh"; + } + if (["emphasize", "!!", "‼", "‼️"].includes(value)) { + return "emphasize"; + } + if (["question", "?", "?", "❓"].includes(value)) { + return "question"; + } + return undefined; +} + +function decodeBase64Buffer(params: Record, action: string): Uint8Array { + const base64Buffer = readStringParam(params, "buffer"); + if (!base64Buffer) { + throw new Error(`iMessage ${action} requires buffer (base64) parameter.`); + } + return Uint8Array.from(Buffer.from(base64Buffer, "base64")); +} + +// Whitelist of expressive-send effect IDs the bridge accepts. Restricting +// to a fixed set lets us return a clear error for typos ("invisible_ink" +// vs "invisibleink") instead of silently forwarding gibberish to the +// bridge and surfacing an opaque CLI failure. +const KNOWN_EFFECT_IDS: ReadonlySet = new Set([ + "com.apple.MobileSMS.expressivesend.impact", + "com.apple.MobileSMS.expressivesend.loud", + "com.apple.MobileSMS.expressivesend.gentle", + "com.apple.MobileSMS.expressivesend.invisibleink", + "com.apple.MobileSMS.expressivesend.confetti", + "com.apple.MobileSMS.expressivesend.lasers", + "com.apple.MobileSMS.expressivesend.fireworks", + "com.apple.MobileSMS.expressivesend.balloon", + "com.apple.MobileSMS.expressivesend.heart", + "com.apple.messages.effect.CKEchoEffect", + "com.apple.messages.effect.CKHappyBirthdayEffect", + "com.apple.messages.effect.CKShootingStarEffect", + "com.apple.messages.effect.CKSparklesEffect", + "com.apple.messages.effect.CKSpotlightEffect", +]); + +function effectIdFromParam(raw?: string): string | undefined { + const value = normalizeOptionalLowercaseString(raw); + if (!value) { + return undefined; + } + const aliases: Record = { + slam: "com.apple.MobileSMS.expressivesend.impact", + impact: "com.apple.MobileSMS.expressivesend.impact", + loud: "com.apple.MobileSMS.expressivesend.loud", + gentle: "com.apple.MobileSMS.expressivesend.gentle", + "invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink", + invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink", + confetti: "com.apple.MobileSMS.expressivesend.confetti", + lasers: "com.apple.MobileSMS.expressivesend.lasers", + fireworks: "com.apple.MobileSMS.expressivesend.fireworks", + balloons: "com.apple.MobileSMS.expressivesend.balloon", + balloon: "com.apple.MobileSMS.expressivesend.balloon", + heart: "com.apple.MobileSMS.expressivesend.heart", + // Background screen effects (com.apple.messages.effect.CK*Effect). + // The error message below advertises these short names, so they must + // map to the canonical CKEffect identifier — without this, agents + // that follow our own guidance get "unknown effect" thrown back. + echo: "com.apple.messages.effect.CKEchoEffect", + happybirthday: "com.apple.messages.effect.CKHappyBirthdayEffect", + "happy-birthday": "com.apple.messages.effect.CKHappyBirthdayEffect", + shootingstar: "com.apple.messages.effect.CKShootingStarEffect", + "shooting-star": "com.apple.messages.effect.CKShootingStarEffect", + sparkles: "com.apple.messages.effect.CKSparklesEffect", + spotlight: "com.apple.messages.effect.CKSpotlightEffect", + }; + const resolved = aliases[value] ?? raw; + if (typeof resolved === "string" && KNOWN_EFFECT_IDS.has(resolved)) { + return resolved; + } + throw new Error( + `iMessage sendWithEffect rejected unknown effect "${raw}". ` + + "Use one of: slam, loud, gentle, invisibleink, confetti, lasers, fireworks, balloon, heart, " + + "echo, happybirthday, shootingstar, sparkles, spotlight (or the canonical com.apple.MobileSMS.expressivesend.* / com.apple.messages.effect.* identifier).", + ); +} + +export const imessageMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: ({ cfg, accountId, currentChannelId }) => { + const account = resolveIMessageAccount({ cfg, accountId }); + if (!account.enabled || !account.configured) { + return null; + } + const privateApiStatus = getCachedIMessagePrivateApiStatus( + account.config.cliPath?.trim() || "imsg", + ); + const gate = createActionGate(account.config.actions); + const actions = new Set(); + for (const action of IMESSAGE_ACTION_NAMES) { + const spec = IMESSAGE_ACTIONS[action]; + if (!spec?.gate || !gate(spec.gate)) { + continue; + } + if (privateApiStatus?.available === false && PRIVATE_API_ACTIONS.has(action)) { + continue; + } + if ( + action === "edit" && + privateApiStatus?.selectors && + !privateApiStatus.selectors.editMessage && + !privateApiStatus.selectors.editMessageItem + ) { + continue; + } + if (action === "unsend" && privateApiStatus?.selectors?.retractMessagePart !== true) { + continue; + } + actions.add(action); + } + if (!isGroupTarget(currentChannelId)) { + for (const action of IMESSAGE_ACTION_NAMES) { + if ("groupOnly" in IMESSAGE_ACTIONS[action] && IMESSAGE_ACTIONS[action].groupOnly) { + actions.delete(action); + } + } + } + if (actions.delete("sendAttachment")) { + actions.add("upload-file"); + } + return { actions: Array.from(actions) }; + }, + supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), + handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + const runtime = await loadIMessageActionsRuntime(); + const account = resolveIMessageAccount({ + cfg, + accountId: accountId ?? undefined, + }); + const cliPathForProbe = account.config.cliPath?.trim() || "imsg"; + let privateApiStatus = getCachedIMessagePrivateApiStatus(cliPathForProbe); + const assertPrivateApiEnabled = async () => { + if (privateApiStatus?.available !== true) { + // Probe lazily: the running gateway only populates the cache via the + // status adapter, which doesn't fire eagerly on first dispatch. Run + // an inline probe so the first react/send-rich attempt after `imsg + // launch` succeeds without requiring a manual `channels status`. + const { probeIMessagePrivateApi } = await import("./probe.js"); + privateApiStatus = await probeIMessagePrivateApi( + cliPathForProbe, + account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS, + ); + } + if (!privateApiStatus?.available) { + throw new Error( + `iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status to refresh capability detection.`, + ); + } + }; + const opts = { + cliPath: account.config.cliPath?.trim() || "imsg", + dbPath: account.config.dbPath?.trim() || undefined, + timeoutMs: account.config.probeTimeoutMs, + chatGuid: "", + }; + const chatGuid = async () => + await resolveChatGuid({ + action, + actionParams: params, + currentChannelId: toolContext?.currentChannelId, + runtime, + options: opts, + }); + const messageId = (resolveOpts?: { requireFromMe?: boolean }) => { + const chatContext = buildChatContextFromActionParams({ + actionParams: params, + currentChannelId: toolContext?.currentChannelId, + }); + const fallbackContext = { ...chatContext, accountId: account.accountId }; + return runtime.resolveIMessageMessageId( + readMessageIdWithChatFallback(params, fallbackContext), + { + requireKnownShortId: true, + chatContext, + ...(resolveOpts?.requireFromMe ? { requireFromMe: true } : {}), + }, + ); + }; + + if (action === "react") { + await assertPrivateApiEnabled(); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove an iMessage reaction.", + }); + const reaction = mapTapbackReaction(emoji); + const TAPBACK_KINDS = ["love", "like", "dislike", "laugh", "emphasize", "question"] as const; + // For add operations we need a recognized tapback kind. For remove + // operations, the agent may not remember which kind it added — when + // the emoji is empty or unrecognized but `remove: true`, fan out a + // remove against every known kind. The bridge no-ops kinds that + // weren't there, so this is safe and matches user intent ("undo my + // reaction, whatever it was"). + if (!remove && (isEmpty || !reaction)) { + throw new Error( + "iMessage react supports love, like, dislike, laugh, emphasize, and question tapbacks.", + ); + } + const resolvedMessageId = messageId(); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const resolvedChatGuid = await chatGuid(); + const reactionsToSend = remove && !reaction ? [...TAPBACK_KINDS] : reaction ? [reaction] : []; + for (const kind of reactionsToSend) { + await runtime.sendReaction({ + chatGuid: resolvedChatGuid, + messageId: resolvedMessageId, + reaction: kind, + remove: remove || undefined, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + } + return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: reaction }) }); + } + + if (action === "edit") { + await assertPrivateApiEnabled(); + const resolvedMessageId = messageId({ requireFromMe: true }); + const text = + readStringParam(params, "text") ?? + readStringParam(params, "newText") ?? + readStringParam(params, "message"); + if (!text) { + throw new Error("iMessage edit requires text, newText, or message."); + } + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); + const resolvedChatGuid = await chatGuid(); + await runtime.editMessage({ + chatGuid: resolvedChatGuid, + messageId: resolvedMessageId, + text, + backwardsCompatMessage: backwardsCompatMessage ?? undefined, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, edited: resolvedMessageId }); + } + + if (action === "unsend") { + await assertPrivateApiEnabled(); + const resolvedMessageId = messageId({ requireFromMe: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const resolvedChatGuid = await chatGuid(); + await runtime.unsendMessage({ + chatGuid: resolvedChatGuid, + messageId: resolvedMessageId, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, unsent: resolvedMessageId }); + } + + if (action === "reply") { + await assertPrivateApiEnabled(); + const resolvedMessageId = messageId(); + const text = readMessageText(params); + if (!text) { + throw new Error("iMessage reply requires text or message."); + } + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const resolvedChatGuid = await chatGuid(); + const result = await runtime.sendRichMessage({ + chatGuid: resolvedChatGuid, + text, + replyToMessageId: resolvedMessageId, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, messageId: result.messageId, repliedTo: resolvedMessageId }); + } + + if (action === "sendWithEffect") { + await assertPrivateApiEnabled(); + const text = readMessageText(params); + const effectId = effectIdFromParam( + readStringParam(params, "effectId") ?? readStringParam(params, "effect"), + ); + if (!text || !effectId) { + throw new Error("iMessage sendWithEffect requires text/message and effect/effectId."); + } + const resolvedChatGuid = await chatGuid(); + const result = await runtime.sendRichMessage({ + chatGuid: resolvedChatGuid, + text, + effectId, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, messageId: result.messageId, effect: effectId }); + } + + if (action === "renameGroup") { + await assertPrivateApiEnabled(); + const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name"); + if (!displayName) { + throw new Error("iMessage renameGroup requires displayName or name."); + } + const resolvedChatGuid = await chatGuid(); + await runtime.renameGroup({ + chatGuid: resolvedChatGuid, + displayName, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); + } + + if (action === "setGroupIcon") { + await assertPrivateApiEnabled(); + const filename = + readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png"; + const resolvedChatGuid = await chatGuid(); + await runtime.setGroupIcon({ + chatGuid: resolvedChatGuid, + buffer: decodeBase64Buffer(params, action), + filename, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true }); + } + + if (action === "addParticipant" || action === "removeParticipant") { + await assertPrivateApiEnabled(); + const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); + if (!address) { + throw new Error(`iMessage ${action} requires address or participant.`); + } + const resolvedChatGuid = await chatGuid(); + if (action === "addParticipant") { + await runtime.addParticipant({ + chatGuid: resolvedChatGuid, + address, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); + } + await runtime.removeParticipant({ + chatGuid: resolvedChatGuid, + address, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); + } + + if (action === "leaveGroup") { + await assertPrivateApiEnabled(); + const resolvedChatGuid = await chatGuid(); + await runtime.leaveGroup({ + chatGuid: resolvedChatGuid, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, left: resolvedChatGuid }); + } + + if (action === "sendAttachment" || action === "upload-file") { + await assertPrivateApiEnabled(); + const filename = readStringParam(params, "filename", { required: true }); + const asVoice = readBooleanParam(params, "asVoice"); + const resolvedChatGuid = await chatGuid(); + const result = await runtime.sendAttachment({ + chatGuid: resolvedChatGuid, + buffer: decodeBase64Buffer(params, action), + filename, + asVoice: asVoice ?? undefined, + options: { ...opts, chatGuid: resolvedChatGuid }, + }); + return jsonResult({ ok: true, messageId: result.messageId }); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + }, +}; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 9275da83a98..8552b3d37d7 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -15,6 +15,7 @@ import { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; +import { imessageMessageActions } from "./actions.js"; import { chunkTextForOutbound, collectStatusIssuesFromLastError, @@ -300,6 +301,7 @@ export const imessagePlugin: ChannelPlugin; + service?: IMessageService; + region?: string; + account: ResolvedIMessageAccount; +} { + const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action"); + const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId }); + const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to); + const params: Record = {}; + if (target.kind === "chat_id") { + params.chat_id = target.chatId; + } else if (target.kind === "chat_guid") { + params.chat_guid = target.chatGuid; + } else if (target.kind === "chat_identifier") { + params.chat_identifier = target.chatIdentifier; + } else { + params.to = target.to; + } + const service = + opts.service ?? + (target.kind === "handle" ? target.service : undefined) ?? + (account.config.service as IMessageService | undefined); + const region = opts.region?.trim() || account.config.region?.trim() || "US"; + return { params, service, region, account }; +} + +async function runChatAction( + method: + | "typing" + | "read" + | "chats.create" + | "chats.delete" + | "chats.markUnread" + | "group.rename" + | "group.setIcon" + | "group.addParticipant" + | "group.removeParticipant" + | "group.leave", + params: Record, + opts: ChatActionOpts, +): Promise { + const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action"); + const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId }); + const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg"; + const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim(); + const client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath })); + const shouldClose = !opts.client; + try { + return await client.request(method, params, { timeoutMs: opts.timeoutMs }); + } finally { + if (shouldClose) { + await client.stop(); + } + } +} + +export async function sendIMessageTyping( + to: string, + isTyping: boolean, + opts: ChatActionOpts, +): Promise { + const { params, service } = buildChatTargetParams(to, opts); + params.typing = isTyping; + if (service) { + params.service = service; + } + await runChatAction<{ ok?: boolean }>("typing", params, opts); +} + +export async function markIMessageChatRead(to: string, opts: ChatActionOpts): Promise { + const { params } = buildChatTargetParams(to, opts); + await runChatAction<{ ok?: boolean }>("read", params, opts); +} + +export async function markIMessageChatUnread(to: string, opts: ChatActionOpts): Promise { + const { params } = buildChatTargetParams(to, opts); + await runChatAction<{ ok?: boolean }>("chats.markUnread", params, opts); +} + +export async function createIMessageChat( + params: { + addresses: string[]; + name?: string; + text?: string; + service?: "iMessage" | "SMS"; + }, + opts: Omit, +): Promise<{ chatGuid?: string }> { + if (!params.addresses.length) { + throw new Error("createIMessageChat requires at least one address"); + } + const rpcParams: Record = { + addresses: params.addresses, + service: params.service ?? "iMessage", + }; + if (params.name) { + rpcParams.name = params.name; + } + if (params.text) { + rpcParams.text = params.text; + } + const result = await runChatAction<{ ok?: boolean; chat_guid?: string }>( + "chats.create", + rpcParams, + opts, + ); + return { chatGuid: result.chat_guid }; +} + +export async function deleteIMessageChat(to: string, opts: ChatActionOpts): Promise { + const { params } = buildChatTargetParams(to, opts); + await runChatAction<{ ok?: boolean }>("chats.delete", params, opts); +} + +export async function renameIMessageGroup( + to: string, + name: string, + opts: ChatActionOpts, +): Promise { + const { params } = buildChatTargetParams(to, opts); + params.name = name; + await runChatAction<{ ok?: boolean }>("group.rename", params, opts); +} + +export async function setIMessageGroupIcon( + to: string, + filePath: string | undefined, + opts: ChatActionOpts, +): Promise { + const { params } = buildChatTargetParams(to, opts); + if (filePath) { + params.file = filePath; + } + await runChatAction<{ ok?: boolean }>("group.setIcon", params, opts); +} + +export async function addIMessageGroupParticipant( + to: string, + address: string, + opts: ChatActionOpts, +): Promise { + const { params } = buildChatTargetParams(to, opts); + params.address = address; + await runChatAction<{ ok?: boolean }>("group.addParticipant", params, opts); +} + +export async function removeIMessageGroupParticipant( + to: string, + address: string, + opts: ChatActionOpts, +): Promise { + const { params } = buildChatTargetParams(to, opts); + params.address = address; + await runChatAction<{ ok?: boolean }>("group.removeParticipant", params, opts); +} + +export async function leaveIMessageGroup(to: string, opts: ChatActionOpts): Promise { + const { params } = buildChatTargetParams(to, opts); + await runChatAction<{ ok?: boolean }>("group.leave", params, opts); +} diff --git a/extensions/imessage/src/config-schema.test.ts b/extensions/imessage/src/config-schema.test.ts index afa864afc28..b9ded5ab58f 100644 --- a/extensions/imessage/src/config-schema.test.ts +++ b/extensions/imessage/src/config-schema.test.ts @@ -71,6 +71,27 @@ describe("imessage config schema", () => { } }); + it("accepts private API action gates", () => { + const res = IMessageConfigSchema.safeParse({ + cliPath: "imsg", + actions: { + reactions: false, + edit: true, + sendAttachment: true, + }, + accounts: { + work: { + actions: { + reply: false, + sendWithEffect: true, + }, + }, + }, + }); + + expect(res.success).toBe(true); + }); + it("accepts safe remoteHost", () => { const res = IMessageConfigSchema.safeParse({ remoteHost: "bot@gateway-host", diff --git a/extensions/imessage/src/imessage.test-plugin.ts b/extensions/imessage/src/imessage.test-plugin.ts index cb3f716c9af..3724b3b3c94 100644 --- a/extensions/imessage/src/imessage.test-plugin.ts +++ b/extensions/imessage/src/imessage.test-plugin.ts @@ -1,4 +1,8 @@ -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelOutboundAdapter, +} from "openclaw/plugin-sdk/channel-contract"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps"; import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers"; @@ -90,8 +94,42 @@ const defaultIMessageOutbound: ChannelOutboundAdapter = { }, }; +const defaultIMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: () => ({ + actions: [ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "upload-file", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + ], + }), + supportsAction: ({ action }) => + new Set([ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "upload-file", + "sendAttachment", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + ]).has(action), +}; + export const createIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter; + actions?: ChannelMessageActionAdapter; }): ChannelPlugin => ({ id: "imessage", meta: { @@ -110,6 +148,7 @@ export const createIMessageTestPlugin = (params?: { status: { collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), }, + actions: params?.actions ?? defaultIMessageActions, outbound: params?.outbound ?? defaultIMessageOutbound, messaging: { targetResolver: { diff --git a/extensions/imessage/src/markdown-format.test.ts b/extensions/imessage/src/markdown-format.test.ts new file mode 100644 index 00000000000..6c138c4b47c --- /dev/null +++ b/extensions/imessage/src/markdown-format.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { extractMarkdownFormatRuns } from "./markdown-format.js"; + +describe("extractMarkdownFormatRuns", () => { + it("returns the text unchanged when there is no markdown", () => { + const { text, ranges } = extractMarkdownFormatRuns("plain text reply"); + expect(text).toBe("plain text reply"); + expect(ranges).toEqual([]); + }); + + it("extracts a bold span", () => { + const { text, ranges } = extractMarkdownFormatRuns("**bold** text"); + expect(text).toBe("bold text"); + expect(ranges).toEqual([{ start: 0, length: 4, styles: ["bold"] }]); + }); + + it("extracts mixed bold and italic", () => { + const { text, ranges } = extractMarkdownFormatRuns("**hi** and *there*"); + expect(text).toBe("hi and there"); + expect(ranges).toEqual([ + { start: 0, length: 2, styles: ["bold"] }, + { start: 7, length: 5, styles: ["italic"] }, + ]); + }); + + it("extracts underline and strikethrough", () => { + const { text, ranges } = extractMarkdownFormatRuns("__under__ and ~~strike~~"); + expect(text).toBe("under and strike"); + expect(ranges).toEqual([ + { start: 0, length: 5, styles: ["underline"] }, + { start: 10, length: 6, styles: ["strikethrough"] }, + ]); + }); + + it("respects word boundaries on single-underscore italics", () => { + const { text, ranges } = extractMarkdownFormatRuns("snake_case_var ok"); + expect(text).toBe("snake_case_var ok"); + expect(ranges).toEqual([]); + }); + + it("treats single-underscore as italic when surrounded by whitespace", () => { + const { text, ranges } = extractMarkdownFormatRuns("a _word_ b"); + expect(text).toBe("a word b"); + expect(ranges).toEqual([{ start: 2, length: 4, styles: ["italic"] }]); + }); + + it("does not treat empty marker pairs as formatting", () => { + const { text, ranges } = extractMarkdownFormatRuns("** ** literal"); + expect(text).toBe("** ** literal"); + expect(ranges).toEqual([]); + }); + + it("leaves a lone asterisk alone", () => { + const { text, ranges } = extractMarkdownFormatRuns("price * quantity"); + expect(text).toBe("price * quantity"); + expect(ranges).toEqual([]); + }); + + it("computes ranges in output coordinates, not input", () => { + const { text, ranges } = extractMarkdownFormatRuns("a **b** c **d** e"); + expect(text).toBe("a b c d e"); + expect(ranges).toEqual([ + { start: 2, length: 1, styles: ["bold"] }, + { start: 6, length: 1, styles: ["bold"] }, + ]); + }); + + it("parses ***triple-marker*** as bold + italic over the same span", () => { + const { text, ranges } = extractMarkdownFormatRuns("***hi***"); + expect(text).toBe("hi"); + // Compound marker emits both styles over the same span. + expect(ranges).toEqual([ + { start: 0, length: 2, styles: ["bold"] }, + { start: 0, length: 2, styles: ["italic"] }, + ]); + }); + + it("parses **bold _and underline_ together** as nested ranges", () => { + const { text, ranges } = extractMarkdownFormatRuns("**bold _and underline_ together**"); + expect(text).toBe("bold and underline together"); + // Inner italic-via-_ at offset 5, length 13; outer bold over the full span. + expect(ranges).toEqual([ + { start: 5, length: 13, styles: ["italic"] }, + { start: 0, length: 27, styles: ["bold"] }, + ]); + }); + + it("respects word boundaries on double-underscore underline", () => { + const { text, ranges } = extractMarkdownFormatRuns("def __init__(self):"); + expect(text).toBe("def __init__(self):"); + expect(ranges).toEqual([]); + }); + + it("does not leak literal asterisks from triple markers when intent is unclear", () => { + // `***bold***` should never produce a bare `*` in the output text. + const { text } = extractMarkdownFormatRuns("hello ***world***"); + expect(text).not.toMatch(/\*/); + }); +}); diff --git a/extensions/imessage/src/markdown-format.ts b/extensions/imessage/src/markdown-format.ts new file mode 100644 index 00000000000..c50622b496f --- /dev/null +++ b/extensions/imessage/src/markdown-format.ts @@ -0,0 +1,154 @@ +/** + * Convert markdown bold/italic/underline/strikethrough markers in agent text + * into typed-run formatting ranges that the imsg bridge's `sendMessage` + * action understands. Returns the marker-stripped text plus an array of + * ranges keyed by their start in the OUTPUT string. + * + * macOS 15+ recipients render typed runs natively; macOS 14 falls back to + * client-side markdown rendering, so passing both raw markdown and ranges + * would double up — callers should send the stripped `text` only. + * + * Supported markers: + * - `**bold**` + * - `*italic*` / `_italic_` (single-underscore enforces word boundaries) + * - `__underline__` (double-underscore also enforces word boundaries so + * Python identifiers like `__init__` are not mangled) + * - `~~strikethrough~~` + * + * Nesting: + * - `***bold-italic***` is parsed as `**` containing `*italic*`, yielding + * two ranges over the same span (one bold, one italic). + * - Other nested combinations (`**bold _underline_**`, etc.) are + * similarly parsed by recursing into the inner text of every marker + * pair we consume. + * + * Out of scope: escaped markers (`\*literal\*`), code spans (` `code` `), + * and combining-character edge cases. The receiver's iMessage style + * vocabulary covers only bold/italic/underline/strikethrough — there is + * nowhere to render anything fancier, and over-eager parsing would mangle + * plain-text emoji/punctuation that happens to look like markdown. + */ + +export type IMessageFormatStyle = "bold" | "italic" | "underline" | "strikethrough"; + +export type IMessageFormatRange = { + start: number; + length: number; + styles: IMessageFormatStyle[]; +}; + +type Marker = { + marker: string; + styles: IMessageFormatStyle[]; + /** + * When true, the marker only counts when both ends sit on a word + * boundary. Single-underscore italics need this so `snake_case_var` is + * left literal, and double-underscore underline needs it so Python + * dunder names like `__init__` are not turned into underline. + */ + requireWordBoundary: boolean; +}; + +// Order matters: longer/compound markers are tried first. +// - `***...***` is bold+italic over the inner span. +// - `___...___` is underline+italic. +// - `~~`, `**`, `__` cover their own styles. +// - `*` / `_` italic match last (with `_` enforcing word boundaries). +const MARKERS: readonly Marker[] = [ + { marker: "***", styles: ["bold", "italic"], requireWordBoundary: false }, + { marker: "___", styles: ["underline", "italic"], requireWordBoundary: true }, + { marker: "~~", styles: ["strikethrough"], requireWordBoundary: false }, + { marker: "**", styles: ["bold"], requireWordBoundary: false }, + { marker: "__", styles: ["underline"], requireWordBoundary: true }, + { marker: "*", styles: ["italic"], requireWordBoundary: false }, + { marker: "_", styles: ["italic"], requireWordBoundary: true }, +]; + +function tryConsumeMarker( + input: string, + i: number, + m: Marker, +): { close: number; inner: string } | null { + if (!input.startsWith(m.marker, i)) { + return null; + } + // For single-char markers, reject when the next char is the same so we + // don't consume the leading half of a longer marker (e.g. `*` matching + // the first asterisk of `**bold**`). + if (m.marker.length === 1 && input[i + 1] === m.marker) { + return null; + } + // For 2-char markers, reject when there's a third repeat — that's the + // longer compound marker (`***`, `___`) which should match first. + if (m.marker.length === 2 && input[i + 2] === m.marker[0]) { + return null; + } + // For underscore markers we use a stricter rule than CommonMark: the + // OUTSIDE of each marker must be whitespace, start-of-string, or + // end-of-string. That keeps `def __init__(self)` literal (`(` after the + // close is neither whitespace nor end-of-string) while `__under__ and` + // still parses cleanly. Asterisk markers don't need this because they + // don't appear inside identifiers. + const isAtBoundary = (ch: string | undefined): boolean => ch === undefined || /\s/.test(ch); + if (m.requireWordBoundary && i > 0 && !isAtBoundary(input[i - 1])) { + return null; + } + const startInner = i + m.marker.length; + const close = input.indexOf(m.marker, startInner); + if (close === -1 || close === startInner) { + return null; + } + if (m.requireWordBoundary && !isAtBoundary(input[close + m.marker.length])) { + return null; + } + const inner = input.slice(startInner, close); + if (!inner.trim()) { + return null; + } + return { close, inner }; +} + +function parseInternal(input: string, baseOffset: number, sink: IMessageFormatRange[]): string { + let out = ""; + let i = 0; + while (i < input.length) { + let consumed = false; + for (const m of MARKERS) { + const hit = tryConsumeMarker(input, i, m); + if (!hit) { + continue; + } + // Recurse on the inner span so nested markers compose. The inner + // ranges are emitted with offsets relative to the new base. + const innerOffset = baseOffset + out.length; + const innerStripped = parseInternal(hit.inner, innerOffset, sink); + // Compound markers (`***`, `___`) emit multiple styles over the same + // span — push them in order so callers see e.g. italic before bold. + for (const style of m.styles) { + sink.push({ + start: innerOffset, + length: innerStripped.length, + styles: [style], + }); + } + out += innerStripped; + i = hit.close + m.marker.length; + consumed = true; + break; + } + if (!consumed) { + out += input[i]; + i += 1; + } + } + return out; +} + +export function extractMarkdownFormatRuns(input: string): { + text: string; + ranges: IMessageFormatRange[]; +} { + const ranges: IMessageFormatRange[] = []; + const text = parseInternal(input, 0, ranges); + return { text, ranges }; +} diff --git a/extensions/imessage/src/monitor-reply-cache.test.ts b/extensions/imessage/src/monitor-reply-cache.test.ts new file mode 100644 index 00000000000..70e31009661 --- /dev/null +++ b/extensions/imessage/src/monitor-reply-cache.test.ts @@ -0,0 +1,406 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + _resetIMessageShortIdState, + findLatestIMessageEntryForChat, + rememberIMessageReplyCache, + resolveIMessageMessageId, +} from "./monitor-reply-cache.js"; + +// Isolate from any live ~/.openclaw/imessage/reply-cache.jsonl that the +// developer might have from a running gateway. Without this, the on-disk +// hydrate path picks up production data and tests get cross-pollinated. +// +// vi.stubEnv defaults to per-test scoping in this codebase, which means a +// beforeAll-only stub gets unstubbed between tests. Mutate process.env +// directly so the override holds across the whole file. +let tempStateDir: string; +let priorStateDir: string | undefined; +beforeAll(() => { + tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-reply-cache-")); + priorStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = tempStateDir; +}); +afterAll(() => { + if (priorStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = priorStateDir; + } + fs.rmSync(tempStateDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + _resetIMessageShortIdState(); + // Belt-and-suspenders: also nuke the persisted file directly. The + // _reset helper does this when OPENCLAW_STATE_DIR is set, but explicitly + // clearing here protects the test from any future refactor of _reset's + // gating logic. + try { + fs.rmSync(path.join(tempStateDir, "imessage", "reply-cache.jsonl"), { force: true }); + } catch { + // best-effort + } +}); + +describe("imessage short message id resolution", () => { + it("resolves a short id to a cached message guid", () => { + const entry = rememberIMessageReplyCache({ + accountId: "default", + messageId: "full-guid", + chatGuid: "iMessage;+;chat0000", + timestamp: Date.now(), + }); + + expect(entry.shortId).toBe("1"); + expect( + resolveIMessageMessageId("1", { + requireKnownShortId: true, + chatContext: { chatGuid: "iMessage;+;chat0000" }, + }), + ).toBe("full-guid"); + }); + + it("resolves a known short id even without caller-supplied chat scope", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "full-guid", + chatGuid: "iMessage;+;chat0000", + timestamp: Date.now(), + }); + + // The cached entry already carries chat info; cross-chat checks only + // matter when the caller separately provides a (potentially conflicting) + // chat scope. A plain known short id from the cache must resolve. + expect(resolveIMessageMessageId("1", { requireKnownShortId: true })).toBe("full-guid"); + }); + + it("requires chat scope when a privileged short id is unknown", () => { + expect(() => resolveIMessageMessageId("9999", { requireKnownShortId: true })).toThrow( + "requires a chat scope", + ); + }); + + it("rejects short ids from another chat", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "full-guid", + chatGuid: "iMessage;+;chat0000", + timestamp: Date.now(), + }); + + expect(() => + resolveIMessageMessageId("1", { + requireKnownShortId: true, + chatContext: { chatGuid: "iMessage;+;other" }, + }), + ).toThrow("belongs to a different chat"); + }); + + it("guards full guid reuse across chats when cached", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "full-guid", + chatId: 42, + timestamp: Date.now(), + }); + + expect(() => resolveIMessageMessageId("full-guid", { chatContext: { chatId: 99 } })).toThrow( + "belongs to a different chat", + ); + }); +}); + +describe("requireFromMe (edit / unsend authorization)", () => { + it("rejects a short id resolution when the cached entry came from inbound", () => { + // The default inbound recorder sets isFromMe:false (or omits it), so + // resolving with requireFromMe must reject — agents cannot edit/unsend + // messages that other participants sent. + const entry = rememberIMessageReplyCache({ + accountId: "default", + messageId: "inbound-guid", + chatGuid: "iMessage;+;chatA", + timestamp: Date.now(), + isFromMe: false, + }); + + expect(() => + resolveIMessageMessageId(entry.shortId, { + requireKnownShortId: true, + chatContext: { chatGuid: "iMessage;+;chatA" }, + requireFromMe: true, + }), + ).toThrow("not one this agent sent"); + }); + + it("allows a short id resolution when the cached entry was sent by the gateway", () => { + const entry = rememberIMessageReplyCache({ + accountId: "default", + messageId: "outbound-guid", + chatGuid: "iMessage;+;chatA", + timestamp: Date.now(), + isFromMe: true, + }); + + expect( + resolveIMessageMessageId(entry.shortId, { + requireKnownShortId: true, + chatContext: { chatGuid: "iMessage;+;chatA" }, + requireFromMe: true, + }), + ).toBe("outbound-guid"); + }); + + it("rejects an uncached full guid under requireFromMe (agent cannot edit/unsend unknown messages)", () => { + expect(() => + resolveIMessageMessageId("never-seen-guid", { + chatContext: { chatGuid: "iMessage;+;chatA" }, + requireFromMe: true, + }), + ).toThrow("not one this agent sent"); + }); + + it("rejects when the cached entry has no isFromMe field (older persisted entry, treated as not-from-me)", () => { + // Persisted entries written before this option existed do not carry + // isFromMe. Treat undefined as the safe default (false) — that pre- + // existing-on-disk caller is the inbound recorder, the only writer that + // existed before. + rememberIMessageReplyCache({ + accountId: "default", + messageId: "legacy-guid", + chatGuid: "iMessage;+;chatA", + timestamp: Date.now(), + // isFromMe deliberately omitted + }); + + expect(() => + resolveIMessageMessageId("legacy-guid", { + chatContext: { chatGuid: "iMessage;+;chatA" }, + requireFromMe: true, + }), + ).toThrow("not one this agent sent"); + }); +}); + +describe("findLatestIMessageEntryForChat", () => { + it("returns the latest entry for the matching chat scope", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "older", + chatGuid: "any;-;+12069106512", + chatIdentifier: "+12069106512", + timestamp: Date.now() - 1000, + }); + rememberIMessageReplyCache({ + accountId: "default", + messageId: "newest", + chatGuid: "any;-;+12069106512", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + const result = findLatestIMessageEntryForChat({ + accountId: "default", + chatIdentifier: "iMessage;-;+12069106512", + }); + expect(result?.messageId).toBe("newest"); + }); + + it("requires a positive identifier match — no overlap means no fallback", () => { + // Cache entry has only chatGuid; caller has only chatId. With the old + // isCrossChatMismatch-as-filter, this entry would have been returned + // (no overlap → no mismatch → pass). The strict positive-match + // semantics require both sides to share at least one identifier kind. + rememberIMessageReplyCache({ + accountId: "default", + messageId: "different-chat", + chatGuid: "iMessage;+;chat0000", + timestamp: Date.now(), + }); + + expect(findLatestIMessageEntryForChat({ accountId: "default", chatId: 99 })).toBeUndefined(); + }); + + it("never crosses account boundaries", () => { + // Diagnostic: verify the temp-dir env stub is actually visible. + expect(process.env.OPENCLAW_STATE_DIR).toBe(tempStateDir); + const cachePath = path.join(tempStateDir, "imessage", "reply-cache.jsonl"); + expect(fs.existsSync(cachePath)).toBe(false); + + rememberIMessageReplyCache({ + accountId: "other-account", + messageId: "foreign-account", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + expect( + findLatestIMessageEntryForChat({ + accountId: "default", + chatIdentifier: "+12069106512", + }), + ).toBeUndefined(); + }); + + it("ignores entries older than the recency window", () => { + const TWELVE_MINUTES_AGO = Date.now() - 12 * 60 * 1000; + rememberIMessageReplyCache({ + accountId: "default", + messageId: "stale", + chatIdentifier: "+12069106512", + timestamp: TWELVE_MINUTES_AGO, + }); + + expect( + findLatestIMessageEntryForChat({ + accountId: "default", + chatIdentifier: "+12069106512", + }), + ).toBeUndefined(); + }); + + it("matches across chat-id-format flavors (iMessage;-;, any;-;, bare phone)", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "phone-msg", + chatGuid: "any;-;+12069106512", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + for (const ctx of [ + { accountId: "default", chatIdentifier: "iMessage;-;+12069106512" }, + { accountId: "default", chatIdentifier: "SMS;-;+12069106512" }, + { accountId: "default", chatGuid: "any;-;+12069106512" }, + { accountId: "default", chatIdentifier: "+12069106512" }, + ]) { + const found = findLatestIMessageEntryForChat(ctx); + expect(found?.messageId).toBe("phone-msg"); + } + }); + + it("requires accountId — refuses to guess across all known chats", () => { + rememberIMessageReplyCache({ + accountId: "default", + messageId: "anywhere", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + // accountId is optional in the signature; calling without it exercises the + // runtime guard that returns undefined rather than a cross-account match. + expect(findLatestIMessageEntryForChat({ chatIdentifier: "+12069106512" })).toBeUndefined(); + }); +}); + +describe("reply cache disk permissions", () => { + it("clamps pre-existing reply-cache.jsonl from older 0644/0755 to 0600/0700", () => { + // Older gateway versions wrote with default modes. Every append must + // clamp existing files back to owner-only — appendFileSync's `mode` + // only applies on creation, so a chmod-on-create-only path would leave + // the upgrade case world-readable forever. + const imsgDir = path.join(tempStateDir, "imessage"); + fs.mkdirSync(imsgDir, { recursive: true, mode: 0o755 }); + const cacheFile = path.join(imsgDir, "reply-cache.jsonl"); + fs.writeFileSync(cacheFile, "", { mode: 0o644 }); + fs.chmodSync(imsgDir, 0o755); + fs.chmodSync(cacheFile, 0o644); + + rememberIMessageReplyCache({ + accountId: "default", + messageId: "clamp-test-guid", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + const fileMode = fs.statSync(cacheFile).mode & 0o777; + const dirMode = fs.statSync(imsgDir).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); + + it("writes the cache file 0600 and parent dir 0700", () => { + // Map gateway-allocated short-ids to message guids; a hostile same-UID + // process reading or writing this file could (a) enumerate active + // conversation guids or (b) inject lines so a future shortId resolves + // to an attacker-chosen guid. Owner-only mode is the mitigation. + rememberIMessageReplyCache({ + accountId: "default", + messageId: "perm-test-guid", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + + const cacheFile = path.join(tempStateDir, "imessage", "reply-cache.jsonl"); + const cacheDir = path.dirname(cacheFile); + expect(fs.existsSync(cacheFile)).toBe(true); + + const fileMode = fs.statSync(cacheFile).mode & 0o777; + const dirMode = fs.statSync(cacheDir).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); +}); + +describe("hydrate-on-resolve (post-restart short-id persistence)", () => { + it("hydrates the on-disk JSONL before resolving a short id whose mapping predates this run", () => { + // Issue-then-restart contract: a shortId we issued before a gateway + // restart must still resolve afterwards. The first resolve call after + // process boot would otherwise miss the persisted mapping because the + // in-memory maps haven't been hydrated yet — that's the bug codex + // review flagged. resolveIMessageMessageId now hydrates on entry. + const issued = rememberIMessageReplyCache({ + accountId: "default", + messageId: "outbound-guid-pre-restart", + chatGuid: "iMessage;+;chatA", + timestamp: Date.now(), + isFromMe: true, + }); + expect(issued.shortId).not.toBe(""); + + // Simulate a restart: clear the in-memory state but leave the JSONL on + // disk. _resetIMessageShortIdState only deletes the persisted file when + // OPENCLAW_STATE_DIR is set, so we have to keep the file ourselves + // since this test runs under the suite's temp state dir. + const cachePath = path.join(tempStateDir, "imessage", "reply-cache.jsonl"); + const persisted = fs.readFileSync(cachePath, "utf8"); + _resetIMessageShortIdState(); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, persisted, "utf8"); + + // Now resolve the short id we issued before the "restart". Without the + // hydrate-on-resolve fix this throws "no longer available" because the + // in-memory maps are empty and rememberIMessageReplyCache hasn't been + // called yet to trigger hydration. + expect( + resolveIMessageMessageId(issued.shortId, { + requireKnownShortId: true, + chatContext: { chatGuid: "iMessage;+;chatA" }, + }), + ).toBe("outbound-guid-pre-restart"); + }); +}); + +describe("hydrate counter advancement (rowid-collision protection)", () => { + it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => { + // Direct hydrate isn't easy to invoke without disk fixtures; instead + // verify the public contract: after rememberIMessageReplyCache fires, + // the next allocation never re-uses an existing live shortId. + const a = rememberIMessageReplyCache({ + accountId: "default", + messageId: "msg-a", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + const b = rememberIMessageReplyCache({ + accountId: "default", + messageId: "msg-b", + chatIdentifier: "+12069106512", + timestamp: Date.now(), + }); + expect(a.shortId).not.toBe(b.shortId); + expect(Number.parseInt(b.shortId, 10)).toBeGreaterThan(Number.parseInt(a.shortId, 10)); + }); +}); diff --git a/extensions/imessage/src/monitor-reply-cache.ts b/extensions/imessage/src/monitor-reply-cache.ts new file mode 100644 index 00000000000..2cc871f5da8 --- /dev/null +++ b/extensions/imessage/src/monitor-reply-cache.ts @@ -0,0 +1,587 @@ +import fs from "node:fs"; +import path from "node:path"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +const REPLY_CACHE_MAX = 2000; +const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +/** Recency window for the "react to the latest message" fallback. */ +const LATEST_FALLBACK_MS = 10 * 60 * 1000; +let persistenceFailureLogged = false; +let parseFailureLogged = false; +function reportPersistenceFailure(scope: string, err: unknown): void { + if (persistenceFailureLogged) { + return; + } + persistenceFailureLogged = true; + logVerbose(`imessage reply-cache: ${scope} disabled after first failure: ${String(err)}`); +} + +export type IMessageChatContext = { + chatGuid?: string; + chatIdentifier?: string; + chatId?: number; +}; + +type IMessageReplyCacheEntry = IMessageChatContext & { + accountId: string; + messageId: string; + shortId: string; + timestamp: number; + /** + * True when the gateway sent this message itself (recorded from the + * outbound path in send.ts after a successful imsg send), false when the + * cache entry came from inbound watch (most common path). + * + * Edit / unsend actions require this to be true: Messages.app only lets + * the original sender edit or retract a message, and even if the bridge + * accepted a non-sender attempt, letting an agent unsend a human user's + * message in a group chat would be a permission boundary violation. + * + * Optional for backwards compatibility with persisted entries from older + * gateway versions that did not record this field; missing values are + * treated as `false` (the safe default — pre-existing entries on disk + * came from the inbound-only writer that existed before this change). + */ + isFromMe?: boolean; +}; + +const imessageReplyCacheByMessageId = new Map(); +const imessageShortIdToUuid = new Map(); +const imessageUuidToShortId = new Map(); +let imessageShortIdCounter = 0; + +// On-disk persistence: short-id ↔ UUID mappings need to survive gateway +// restarts so an agent that received "[message_id:5]" before a restart can +// still react to that message after the restart. The on-disk store is +// best-effort — corruption or write failure falls back to the in-memory +// cache, so the worst case is the same as before persistence existed. + +function resolveReplyCachePath(): string { + return path.join(resolveStateDir(), "imessage", "reply-cache.jsonl"); +} + +function readPersistedEntries(): { + entries: IMessageReplyCacheEntry[]; + maxObservedShortId: number; +} { + let raw: string; + try { + raw = fs.readFileSync(resolveReplyCachePath(), "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + reportPersistenceFailure("read", err); + } + return { entries: [], maxObservedShortId: 0 }; + } + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + const out: IMessageReplyCacheEntry[] = []; + // The counter must advance past every shortId we have ever observed in + // the file — including lines we skip because they are stale or malformed. + // Otherwise a future allocation can collide with a still-live mapping + // that came earlier in the file. + let maxObservedShortId = 0; + for (const line of raw.split(/\n+/)) { + if (!line) { + continue; + } + let parsed: Partial | null = null; + try { + parsed = JSON.parse(line) as Partial; + } catch { + if (!parseFailureLogged) { + parseFailureLogged = true; + logVerbose( + `imessage reply-cache: dropping unparseable line (further parse errors suppressed)`, + ); + } + continue; + } + if (parsed && typeof parsed.shortId === "string") { + const numeric = Number.parseInt(parsed.shortId, 10); + if (Number.isFinite(numeric) && numeric > maxObservedShortId) { + maxObservedShortId = numeric; + } + } + if ( + typeof parsed?.accountId !== "string" || + typeof parsed.messageId !== "string" || + typeof parsed.shortId !== "string" || + typeof parsed.timestamp !== "number" + ) { + continue; + } + if (parsed.timestamp < cutoff) { + continue; + } + out.push({ + accountId: parsed.accountId, + messageId: parsed.messageId, + shortId: parsed.shortId, + timestamp: parsed.timestamp, + chatGuid: typeof parsed.chatGuid === "string" ? parsed.chatGuid : undefined, + chatIdentifier: typeof parsed.chatIdentifier === "string" ? parsed.chatIdentifier : undefined, + chatId: typeof parsed.chatId === "number" ? parsed.chatId : undefined, + isFromMe: typeof parsed.isFromMe === "boolean" ? parsed.isFromMe : undefined, + }); + } + return { entries: out.slice(-REPLY_CACHE_MAX), maxObservedShortId }; +} + +// reply-cache.jsonl maps gateway-allocated short-ids to message guids. A +// hostile same-UID process could otherwise (a) read the file to learn +// active conversation guids, or (b) inject lines so a future shortId +// resolution returns an attacker-chosen guid (allowing the agent to +// react/edit/unsend a message it never saw). Owner-only mode on both the +// directory and file closes that vector — defaults are 0755/0644 which +// are world-readable on a multi-user Mac. +const REPLY_CACHE_DIR_MODE = 0o700; +const REPLY_CACHE_FILE_MODE = 0o600; + +function writePersistedEntries(entries: IMessageReplyCacheEntry[]): void { + const filePath = resolveReplyCachePath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: REPLY_CACHE_DIR_MODE }); + fs.writeFileSync( + filePath, + entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : ""), + { encoding: "utf8", mode: REPLY_CACHE_FILE_MODE }, + ); + // mkdirSync's mode is masked by umask and only applies on creation. If + // the dir already existed from an older gateway version, clamp it now. + try { + fs.chmodSync(path.dirname(filePath), REPLY_CACHE_DIR_MODE); + fs.chmodSync(filePath, REPLY_CACHE_FILE_MODE); + } catch { + // best-effort — fs may not support chmod on every platform + } + } catch (err) { + reportPersistenceFailure("write", err); + } +} + +function appendPersistedEntry(entry: IMessageReplyCacheEntry): void { + const filePath = resolveReplyCachePath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: REPLY_CACHE_DIR_MODE }); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, { + encoding: "utf8", + mode: REPLY_CACHE_FILE_MODE, + }); + // Always clamp — appendFileSync's `mode` only applies on creation, so + // an existing 0644 file from an older gateway version would otherwise + // never get tightened. chmod is microseconds; doing it every append + // keeps the security guarantee monotonic instead of conditional on + // creation order. + try { + fs.chmodSync(path.dirname(filePath), REPLY_CACHE_DIR_MODE); + fs.chmodSync(filePath, REPLY_CACHE_FILE_MODE); + } catch { + // best-effort + } + } catch (err) { + reportPersistenceFailure("append", err); + } +} + +let hydrated = false; +function hydrateFromDiskOnce(): void { + if (hydrated) { + return; + } + hydrated = true; + const { entries, maxObservedShortId } = readPersistedEntries(); + // Bump the counter past every observed shortId, even from dropped lines — + // see comment in readPersistedEntries. + if (maxObservedShortId > imessageShortIdCounter) { + imessageShortIdCounter = maxObservedShortId; + } + if (entries.length === 0) { + return; + } + // Entries are appended chronologically, so iterate forward to keep the + // newest entry as the "live" mapping when the same messageId appears + // multiple times (e.g. after a write-rewrite cycle). + for (const entry of entries) { + imessageReplyCacheByMessageId.set(entry.messageId, entry); + imessageShortIdToUuid.set(entry.shortId, entry.messageId); + imessageUuidToShortId.set(entry.messageId, entry.shortId); + } +} + +function generateShortId(): string { + imessageShortIdCounter += 1; + return String(imessageShortIdCounter); +} + +export function rememberIMessageReplyCache( + entry: Omit, +): IMessageReplyCacheEntry { + hydrateFromDiskOnce(); + const messageId = entry.messageId.trim(); + if (!messageId) { + return { ...entry, shortId: "" }; + } + + let shortId = imessageUuidToShortId.get(messageId); + let allocatedNew = false; + if (!shortId) { + shortId = generateShortId(); + imessageShortIdToUuid.set(shortId, messageId); + imessageUuidToShortId.set(messageId, shortId); + allocatedNew = true; + } + + const fullEntry: IMessageReplyCacheEntry = { ...entry, messageId, shortId }; + imessageReplyCacheByMessageId.delete(messageId); + imessageReplyCacheByMessageId.set(messageId, fullEntry); + + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + let evicted = false; + for (const [key, value] of imessageReplyCacheByMessageId) { + if (value.timestamp >= cutoff) { + break; + } + imessageReplyCacheByMessageId.delete(key); + if (value.shortId) { + imessageShortIdToUuid.delete(value.shortId); + imessageUuidToShortId.delete(key); + } + evicted = true; + } + while (imessageReplyCacheByMessageId.size > REPLY_CACHE_MAX) { + const oldest = imessageReplyCacheByMessageId.keys().next().value; + if (!oldest) { + break; + } + const oldEntry = imessageReplyCacheByMessageId.get(oldest); + imessageReplyCacheByMessageId.delete(oldest); + if (oldEntry?.shortId) { + imessageShortIdToUuid.delete(oldEntry.shortId); + imessageUuidToShortId.delete(oldest); + } + evicted = true; + } + + // Append-only is hot-path cheap; periodic rewrite happens when we evict + // stale entries so the file does not grow unbounded across restarts. + if (allocatedNew) { + appendPersistedEntry(fullEntry); + } + if (evicted) { + writePersistedEntries([...imessageReplyCacheByMessageId.values()]); + } + + return fullEntry; +} + +function hasChatScope(ctx?: IMessageChatContext): boolean { + if (!ctx) { + return false; + } + return Boolean( + normalizeOptionalString(ctx.chatGuid) || + normalizeOptionalString(ctx.chatIdentifier) || + typeof ctx.chatId === "number", + ); +} + +/** + * Strip the `iMessage;-;` / `SMS;-;` / `any;-;` service prefix that Messages + * uses for direct chats. Different layers report direct DMs in different + * forms — imsg's watch emits the bare handle plus an `any;-;…` chat_guid, + * the action surface synthesizes `iMessage;-;…` from a phone-number target — + * so comparing the raw strings would falsely flag the same chat as a + * cross-chat target. Normalize both sides to the bare suffix. + */ +function normalizeDirectChatIdentifier(raw: string): string { + const trimmed = raw.trim(); + const lowered = trimmed.toLowerCase(); + for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) { + if (lowered.startsWith(prefix)) { + return trimmed.slice(prefix.length); + } + } + return trimmed; +} + +function isCrossChatMismatch(cached: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean { + const cachedChatGuid = normalizeOptionalString(cached.chatGuid); + const ctxChatGuid = normalizeOptionalString(ctx.chatGuid); + if (cachedChatGuid && ctxChatGuid) { + if ( + normalizeDirectChatIdentifier(cachedChatGuid) === normalizeDirectChatIdentifier(ctxChatGuid) + ) { + return false; + } + return cachedChatGuid !== ctxChatGuid; + } + const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier); + const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier); + if (cachedChatIdentifier && ctxChatIdentifier) { + if ( + normalizeDirectChatIdentifier(cachedChatIdentifier) === + normalizeDirectChatIdentifier(ctxChatIdentifier) + ) { + return false; + } + return cachedChatIdentifier !== ctxChatIdentifier; + } + const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; + const ctxChatId = typeof ctx.chatId === "number" ? ctx.chatId : undefined; + if (cachedChatId !== undefined && ctxChatId !== undefined) { + return cachedChatId !== ctxChatId; + } + // Cross-format pairing: caller supplied chatIdentifier=iMessage;-; + // and the cache stored chatGuid=any;-; (or vice versa). Compare via + // the direct-DM normalization so we recognize them as the same chat. + const cachedFingerprint = cachedChatGuid + ? normalizeDirectChatIdentifier(cachedChatGuid) + : cachedChatIdentifier + ? normalizeDirectChatIdentifier(cachedChatIdentifier) + : undefined; + const ctxFingerprint = ctxChatGuid + ? normalizeDirectChatIdentifier(ctxChatGuid) + : ctxChatIdentifier + ? normalizeDirectChatIdentifier(ctxChatIdentifier) + : undefined; + if (cachedFingerprint && ctxFingerprint) { + return cachedFingerprint !== ctxFingerprint; + } + return false; +} + +function describeChatForError(values: IMessageChatContext): string { + const parts: string[] = []; + if (normalizeOptionalString(values.chatGuid)) { + parts.push("chatGuid="); + } + if (normalizeOptionalString(values.chatIdentifier)) { + parts.push("chatIdentifier="); + } + if (typeof values.chatId === "number") { + parts.push("chatId="); + } + return parts.length === 0 ? "" : parts.join(", "); +} + +function describeMessageIdForError(inputId: string, inputKind: "short" | "uuid"): string { + if (inputKind === "short") { + return ``; + } + return ``; +} + +function buildCrossChatError( + inputId: string, + inputKind: "short" | "uuid", + cached: IMessageReplyCacheEntry, + ctx: IMessageChatContext, +): Error { + const remediation = + inputKind === "short" + ? "Retry with MessageSidFull to avoid cross-chat reactions/replies landing in the wrong conversation." + : "Retry with the correct chat target."; + return new Error( + `iMessage message id ${describeMessageIdForError(inputId, inputKind)} belongs to a different chat ` + + `(${describeChatForError(cached)}) than the current call target (${describeChatForError(ctx)}). ${remediation}`, + ); +} + +export function resolveIMessageMessageId( + shortOrUuid: string, + opts?: { + requireKnownShortId?: boolean; + chatContext?: IMessageChatContext; + /** + * When true, only resolve message ids that the gateway recorded as sent + * by itself (`isFromMe: true`). Used by `edit` / `unsend` so an agent + * cannot retract or edit messages other participants sent — Messages.app + * enforces this at the OS level too, but failing earlier in the plugin + * gives a clean error and avoids dispatching a guaranteed-to-fail bridge + * call. + * + * Cache entries with no `isFromMe` field (older persisted entries from + * before this option existed, or any uncached UUID the agent passes + * through) are treated as not-from-me and rejected. + */ + requireFromMe?: boolean; + }, +): string { + const trimmed = shortOrUuid.trim(); + if (!trimmed) { + return trimmed; + } + // Hydrate the on-disk JSONL into the in-memory maps before reading them. + // Without this, the first post-restart action that arrives with a short + // MessageSid would miss `imessageShortIdToUuid` and fall through to the + // "no longer available" path, breaking the persistence contract — the + // mapping was on disk, we just hadn't read it yet on this read path. + // `rememberIMessageReplyCache` already hydrates on its own, so this only + // matters for the resolve-first-after-restart sequence. + hydrateFromDiskOnce(); + + if (/^\d+$/.test(trimmed)) { + // Cache hit: the cached entry carries the chat info this short id was + // issued for, so we can resolve the UUID even without a caller-supplied + // chat scope. Cross-chat detection still fires when the caller did + // provide a scope and it disagrees with the cache. + const uuid = imessageShortIdToUuid.get(trimmed); + if (uuid) { + const cached = imessageReplyCacheByMessageId.get(uuid); + if (opts?.chatContext && hasChatScope(opts.chatContext)) { + if (cached && isCrossChatMismatch(cached, opts.chatContext)) { + throw buildCrossChatError(trimmed, "short", cached, opts.chatContext); + } + } + if (opts?.requireFromMe && cached?.isFromMe !== true) { + throw buildFromMeError(trimmed, "short"); + } + return uuid; + } + // Cache miss: now the chat-scope requirement matters — without scope + // we have no way to verify the caller is reacting in the right chat, + // and without a cached UUID the bridge cannot resolve the short id. + if (opts?.requireKnownShortId && !hasChatScope(opts.chatContext)) { + throw new Error( + `iMessage short message id ${describeMessageIdForError(trimmed, "short")} requires a chat scope (chatGuid / chatIdentifier / chatId or a target).`, + ); + } + if (opts?.requireKnownShortId) { + throw new Error( + `iMessage short message id ${describeMessageIdForError(trimmed, "short")} is no longer available. Use MessageSidFull.`, + ); + } + return trimmed; + } + + const cached = imessageReplyCacheByMessageId.get(trimmed); + if (opts?.chatContext) { + if (cached && isCrossChatMismatch(cached, opts.chatContext)) { + throw buildCrossChatError(trimmed, "uuid", cached, opts.chatContext); + } + } + if (opts?.requireFromMe && cached?.isFromMe !== true) { + throw buildFromMeError(trimmed, "uuid"); + } + return trimmed; +} + +function buildFromMeError(inputId: string, inputKind: "short" | "uuid"): Error { + return new Error( + `iMessage message id ${describeMessageIdForError(inputId, inputKind)} is not one this agent sent. ` + + `edit and unsend can only target messages the gateway delivered itself; ` + + `messages received from other participants cannot be modified.`, + ); +} + +/** + * Return the most recent cached entry whose chat scope matches the supplied + * context. Used as a fallback when an agent calls a per-message action (e.g. + * `react`) without specifying a `messageId` — the natural intent is "react + * to the message I just received in this chat." + * + * Strict semantics for safety: + * - Caller must supply a chat scope. We refuse to "guess" the active chat. + * - Cached entry must positively match on at least one identifier kind + * (chatGuid, chatIdentifier, chatId, or normalized direct-DM fingerprint). + * We do NOT fall through on "no overlapping identifier" — that's how a + * cached entry from a foreign chat could be returned when the caller's + * context didn't share any identifier kind with the cache. + * - Caller must supply an accountId; we never cross account boundaries. + * - We only consider entries newer than `LATEST_FALLBACK_MS`. The intent + * of "react to the latest" is "the message I just received," not + * "anything in this chat from any time." + */ +export function findLatestIMessageEntryForChat( + ctx: IMessageChatContext & { accountId?: string }, +): IMessageReplyCacheEntry | undefined { + if (!hasChatScope(ctx)) { + return undefined; + } + if (!ctx.accountId) { + return undefined; + } + const cutoff = Date.now() - LATEST_FALLBACK_MS; + let best: IMessageReplyCacheEntry | undefined; + for (const entry of imessageReplyCacheByMessageId.values()) { + if (entry.accountId !== ctx.accountId) { + continue; + } + if (entry.timestamp < cutoff) { + continue; + } + if (!isPositiveChatMatch(entry, ctx)) { + continue; + } + if (!best || entry.timestamp > best.timestamp) { + best = entry; + } + } + return best; +} + +/** + * Return true when the cached entry positively matches the caller's chat + * context on at least one identifier kind. Unlike `isCrossChatMismatch`, + * which returns false for "no overlap," this requires concrete agreement. + */ +function isPositiveChatMatch(entry: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean { + const cachedChatGuid = normalizeOptionalString(entry.chatGuid); + const ctxChatGuid = normalizeOptionalString(ctx.chatGuid); + if (cachedChatGuid && ctxChatGuid && cachedChatGuid === ctxChatGuid) { + return true; + } + const cachedChatIdentifier = normalizeOptionalString(entry.chatIdentifier); + const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier); + if (cachedChatIdentifier && ctxChatIdentifier && cachedChatIdentifier === ctxChatIdentifier) { + return true; + } + if ( + typeof entry.chatId === "number" && + typeof ctx.chatId === "number" && + entry.chatId === ctx.chatId + ) { + return true; + } + // Cross-format: cached chatGuid vs ctx chatIdentifier, etc. Compare via + // the direct-DM normalization that strips iMessage;-;/SMS;-;/any;-; . + const cachedFingerprint = cachedChatGuid + ? normalizeDirectChatIdentifier(cachedChatGuid) + : cachedChatIdentifier + ? normalizeDirectChatIdentifier(cachedChatIdentifier) + : undefined; + const ctxFingerprint = ctxChatGuid + ? normalizeDirectChatIdentifier(ctxChatGuid) + : ctxChatIdentifier + ? normalizeDirectChatIdentifier(ctxChatIdentifier) + : undefined; + if (cachedFingerprint && ctxFingerprint && cachedFingerprint === ctxFingerprint) { + return true; + } + return false; +} + +export function _resetIMessageShortIdState(): void { + imessageReplyCacheByMessageId.clear(); + imessageShortIdToUuid.clear(); + imessageUuidToShortId.clear(); + imessageShortIdCounter = 0; + hydrated = false; + persistenceFailureLogged = false; + parseFailureLogged = false; + // Only delete the persisted file when the test harness has explicitly + // pointed us at an isolated state directory. Otherwise we would nuke + // whatever live gateway happens to share `~/.openclaw` — and in vitest + // file-level parallelism, two test files calling this at once could + // race a peer's appendFileSync mid-write. + if (!process.env.OPENCLAW_STATE_DIR) { + return; + } + try { + fs.rmSync(resolveReplyCachePath(), { force: true }); + } catch { + // best-effort + } +} diff --git a/extensions/imessage/src/monitor.gating.test.ts b/extensions/imessage/src/monitor.gating.test.ts index 25f7c2c14ac..bc3754d50d4 100644 --- a/extensions/imessage/src/monitor.gating.test.ts +++ b/extensions/imessage/src/monitor.gating.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { _resetIMessageShortIdState } from "./monitor-reply-cache.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, @@ -7,6 +8,10 @@ import { import { parseIMessageNotification } from "./monitor/parse-notification.js"; import type { IMessagePayload } from "./monitor/types.js"; +beforeEach(() => { + _resetIMessageShortIdState(); +}); + function baseCfg(): OpenClawConfig { return { channels: { @@ -159,6 +164,27 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.To).toBe("chat_id:42"); }); + it("uses short message ids in context and keeps the full guid for actions", () => { + const cfg = baseCfg(); + const message: IMessagePayload = { + id: 3, + guid: "full-message-guid", + chat_id: 42, + chat_guid: "iMessage;+;chat0000", + chat_identifier: "thread-42", + sender: "+15550002222", + is_from_me: false, + text: "@openclaw ping", + is_group: true, + chat_name: "Lobster Squad", + participants: ["+1555", "+1556"], + }; + const ctxPayload = buildDispatchContextPayload({ cfg, message }); + + expect(ctxPayload.MessageSid).toBe("1"); + expect(ctxPayload.MessageSidFull).toBe("full-message-guid"); + }); + it("includes reply-to context fields + suffix", () => { const cfg = baseCfg(); const message: IMessagePayload = { diff --git a/extensions/imessage/src/monitor/coalesce.test.ts b/extensions/imessage/src/monitor/coalesce.test.ts new file mode 100644 index 00000000000..5b5d6445719 --- /dev/null +++ b/extensions/imessage/src/monitor/coalesce.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + combineIMessagePayloads, + MAX_COALESCED_ATTACHMENTS, + MAX_COALESCED_ENTRIES, + MAX_COALESCED_TEXT_CHARS, +} from "./coalesce.js"; +import type { IMessagePayload } from "./types.js"; + +const makePayload = (overrides: Partial = {}): IMessagePayload => ({ + guid: `msg-${Math.random().toString(36).slice(2, 10)}`, + chat_id: 1, + sender: "+15555550100", + is_from_me: false, + is_group: false, + text: null, + attachments: null, + created_at: new Date(2025, 0, 1).toISOString(), + ...overrides, +}); + +describe("combineIMessagePayloads", () => { + it("throws on empty input", () => { + expect(() => combineIMessagePayloads([])).toThrowError(); + }); + + it("returns the lone payload unchanged when only one entry", () => { + const payload = makePayload({ text: "alone", guid: "solo" }); + const result = combineIMessagePayloads([payload]); + expect(result).toBe(payload); + expect(result.guid).toBe("solo"); + }); + + it("merges Dump + URL split-send into one payload anchored on the first GUID", () => { + const text = makePayload({ text: "Dump", guid: "row-1", created_at: "2025-01-01T00:00:00Z" }); + const balloon = makePayload({ + text: "https://example.com/article", + guid: "row-2", + created_at: "2025-01-01T00:00:01.500Z", + }); + const merged = combineIMessagePayloads([text, balloon]); + + expect(merged.text).toBe("Dump https://example.com/article"); + expect(merged.guid).toBe("row-1"); + expect(merged.created_at).toBe("2025-01-01T00:00:01.500Z"); + expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]); + }); + + it("preserves attachments instead of dropping them on merge", () => { + const text = makePayload({ text: "Save", guid: "row-1" }); + const image = makePayload({ + text: "caption", + guid: "row-2", + attachments: [{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }], + }); + const merged = combineIMessagePayloads([text, image]); + + expect(merged.attachments).toEqual([{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }]); + }); + + it("dedupes identical text appearing in both rows (URL in text and balloon)", () => { + const a = makePayload({ text: "https://example.com", guid: "row-1" }); + const b = makePayload({ text: "https://example.com", guid: "row-2" }); + const merged = combineIMessagePayloads([a, b]); + + expect(merged.text).toBe("https://example.com"); + expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]); + }); + + it("caps merged text length and appends the truncated marker", () => { + const longA = makePayload({ text: "A".repeat(3000), guid: "row-1" }); + const longB = makePayload({ text: "B".repeat(3000), guid: "row-2" }); + const merged = combineIMessagePayloads([longA, longB]); + + expect(merged.text?.endsWith("…[truncated]")).toBe(true); + expect(merged.text?.length).toBeLessThanOrEqual( + MAX_COALESCED_TEXT_CHARS + "…[truncated]".length, + ); + }); + + it("caps the attachment count", () => { + // 5 attachments per row × 6 rows = 30 attachments offered, capped at 20. + // Stays under the entry cap so the merge isn't pruned for that reason. + const payloads = Array.from({ length: 6 }, (_, i) => + makePayload({ + guid: `row-${i}`, + attachments: Array.from({ length: 5 }, (_, j) => ({ + original_path: `/tmp/${i}-${j}.jpg`, + mime_type: "image/jpeg", + })), + }), + ); + const merged = combineIMessagePayloads(payloads); + + expect(merged.attachments?.length).toBe(MAX_COALESCED_ATTACHMENTS); + }); + + it("keeps first + most recent when entry count exceeds the cap, but tracks every GUID", () => { + const payloads = Array.from({ length: 25 }, (_, i) => + makePayload({ text: `msg ${i}`, guid: `row-${i}` }), + ); + const merged = combineIMessagePayloads(payloads); + + // First payload's GUID anchors the merged shape. + expect(merged.guid).toBe("row-0"); + // Every source GUID is tracked, even those whose text was dropped by the cap. + expect(merged.coalescedMessageGuids?.length).toBe(25); + expect(merged.coalescedMessageGuids?.[0]).toBe("row-0"); + expect(merged.coalescedMessageGuids?.[24]).toBe("row-24"); + // Merged text contains only first MAX_COALESCED_ENTRIES-1 entries plus the latest. + expect(merged.text).toContain("msg 0"); + expect(merged.text).toContain("msg 24"); + expect(merged.text).not.toContain("msg 10"); // dropped by cap + }); + + it("preserves reply context from any entry that carries one", () => { + const noReply = makePayload({ text: "hello", guid: "row-1" }); + const reply = makePayload({ + text: "follow-up", + guid: "row-2", + reply_to_id: "parent-msg", + reply_to_text: "earlier", + reply_to_sender: "+15555550199", + }); + const merged = combineIMessagePayloads([noReply, reply]); + + expect(merged.reply_to_id).toBe("parent-msg"); + expect(merged.reply_to_text).toBe("earlier"); + expect(merged.reply_to_sender).toBe("+15555550199"); + }); + + it("does not set coalescedMessageGuids when no entry carries a GUID", () => { + const a = makePayload({ text: "a", guid: null }); + const b = makePayload({ text: "b", guid: null }); + const merged = combineIMessagePayloads([a, b]); + + expect(merged.coalescedMessageGuids).toBeUndefined(); + }); + + it("respects the documented entry cap value", () => { + expect(MAX_COALESCED_ENTRIES).toBeGreaterThan(1); + }); +}); diff --git a/extensions/imessage/src/monitor/coalesce.ts b/extensions/imessage/src/monitor/coalesce.ts new file mode 100644 index 00000000000..f3207649000 --- /dev/null +++ b/extensions/imessage/src/monitor/coalesce.ts @@ -0,0 +1,123 @@ +import type { IMessagePayload } from "./types.js"; + +// Mirrors BlueBubbles' `combineDebounceEntries` semantics (caps, ID tracking, +// reply-context preference) deliberately, so a future SDK lift into +// `openclaw/plugin-sdk/channel-inbound` is a mechanical extraction instead of +// a behavioral redesign. Both bundled Apple-store readers (BlueBubbles and +// imsg) face the same Apple split-send pipeline. + +/** + * Bounds on the merged output when multiple inbound iMessage payloads are + * folded into one agent turn. Mirrors the BlueBubbles caps so a sender who + * rapid-fires DMs inside the debounce window cannot amplify the downstream + * prompt past a safe ceiling. Every source GUID still surfaces via + * `coalescedMessageGuids` so a future replay path can recognize duplicates. + */ +export const MAX_COALESCED_TEXT_CHARS = 4000; +export const MAX_COALESCED_ATTACHMENTS = 20; +export const MAX_COALESCED_ENTRIES = 10; + +export type CoalescedIMessagePayload = IMessagePayload & { + /** + * Source GUIDs folded into this merged payload, in arrival order. Includes + * GUIDs from entries that were dropped by the entry cap so downstream + * dedupe paths can still recognize them. + */ + coalescedMessageGuids?: string[]; +}; + +/** + * Combine consecutive same-sender iMessage payloads into a single payload for + * downstream dispatch. Used when the debouncer flushes a bucket containing + * more than one event — e.g. Apple's split-send for `Dump https://example.com` + * arriving as two separate `chat.db` rows ~0.8-2.0 s apart. + * + * The first payload anchors the merged shape (preserving its GUID for reply + * threading). Text is concatenated with deduplication, attachments are merged + * (capped), and the latest `created_at` wins so downstream sees the most + * recent activity timestamp. + */ +export function combineIMessagePayloads(payloads: IMessagePayload[]): CoalescedIMessagePayload { + if (payloads.length === 0) { + throw new Error("combineIMessagePayloads: cannot combine empty payloads"); + } + if (payloads.length === 1) { + return payloads[0]; + } + + const first = payloads[0]; + const last = payloads[payloads.length - 1]; + + // Cap entries: keep first (preserves command/context) + most recent + // (preserves latest payload) when a flood exceeds the cap. + const boundedPayloads = + payloads.length > MAX_COALESCED_ENTRIES + ? [...payloads.slice(0, MAX_COALESCED_ENTRIES - 1), last] + : payloads; + + // Combine text across bounded entries. Skip duplicates so a URL appearing + // both as plain text and as a separately-rendered link-preview row does not + // get repeated in the merged prompt. + const seenTexts = new Set(); + const textParts: string[] = []; + for (const payload of boundedPayloads) { + const text = (payload.text ?? "").trim(); + if (!text) { + continue; + } + const normalized = text.toLowerCase(); + if (seenTexts.has(normalized)) { + continue; + } + seenTexts.add(normalized); + textParts.push(text); + } + let combinedText = textParts.join(" "); + if (combinedText.length > MAX_COALESCED_TEXT_CHARS) { + combinedText = `${combinedText.slice(0, MAX_COALESCED_TEXT_CHARS)}…[truncated]`; + } + + // Merge attachments across bounded entries, capped to keep downstream media + // fan-out proportional to a single message. + const allAttachments = boundedPayloads + .flatMap((p) => p.attachments ?? []) + .slice(0, MAX_COALESCED_ATTACHMENTS); + + // Latest `created_at` (lexically max ISO-8601 string) so downstream sees + // the freshest activity timestamp. Falls back to `first.created_at` if no + // entries carry a usable timestamp. + const createdAts = payloads + .map((p) => p.created_at) + .filter((c): c is string => typeof c === "string" && c.length > 0); + const latestCreatedAt = + createdAts.length > 0 ? createdAts.reduce((a, b) => (a > b ? a : b)) : first.created_at; + + // Walk the unbounded `payloads` so even GUIDs whose text/attachments were + // dropped by the cap are still remembered for downstream dedupe. + const seenGuids = new Set(); + const coalescedMessageGuids: string[] = []; + for (const payload of payloads) { + const guid = payload.guid?.trim(); + if (!guid || seenGuids.has(guid)) { + continue; + } + seenGuids.add(guid); + coalescedMessageGuids.push(guid); + } + + // Reply context: prefer any entry that carries one; the last balloon in a + // split-send rarely does, but a manual quote-reply earlier in the bucket + // might. + const entryWithReply = payloads.find((p) => p.reply_to_id != null); + + return { + ...first, + text: combinedText, + attachments: allAttachments.length > 0 ? allAttachments : null, + created_at: latestCreatedAt, + reply_to_id: entryWithReply?.reply_to_id ?? first.reply_to_id ?? null, + reply_to_text: entryWithReply?.reply_to_text ?? first.reply_to_text ?? null, + reply_to_sender: entryWithReply?.reply_to_sender ?? first.reply_to_sender ?? null, + coalescedMessageGuids: coalescedMessageGuids.length > 0 ? coalescedMessageGuids : undefined, + }; +} diff --git a/extensions/imessage/src/monitor/echo-cache.ts b/extensions/imessage/src/monitor/echo-cache.ts index 3ba76dbc8ef..a4a079f2880 100644 --- a/extensions/imessage/src/monitor/echo-cache.ts +++ b/extensions/imessage/src/monitor/echo-cache.ts @@ -1,3 +1,5 @@ +import { hasPersistedIMessageEcho } from "./persisted-echo-cache.js"; + type SentMessageLookup = { text?: string; messageId?: string; @@ -69,6 +71,9 @@ class DefaultSentMessageCache implements SentMessageCache { has(scope: string, lookup: SentMessageLookup, skipIdShortCircuit = false): boolean { this.cleanup(); + if (hasPersistedIMessageEcho({ scope, ...lookup })) { + return true; + } const textKey = normalizeEchoTextKey(lookup.text); const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); if (messageIdKey) { diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index b58b17ff4e6..2775b5fd2f5 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -1,7 +1,12 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { sanitizeTerminalText } from "openclaw/plugin-sdk/test-fixtures"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { _resetIMessageShortIdState } from "../monitor-reply-cache.js"; import { + buildIMessageInboundContext, describeIMessageEchoDropLog, resolveIMessageInboundDecision, } from "./inbound-processing.js"; @@ -248,6 +253,112 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(decision.kind).toBe("dispatch"); }); + it("drops group echoes persisted under chat_guid scope", () => { + // Outbound `send` to a group keyed by chat_guid persists the echo scope + // as `${accountId}:chat_guid:${chatGuid}` (see send.ts:resolveOutboundEchoScope). + // The inbound side has chat_id, chat_guid, and chat_identifier all + // populated by chat.db. Without the multi-scope check, the chat_guid-keyed + // echo would never be matched against the chat_id-only inbound scope and + // the agent would react to its own message. + const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => { + return scope === "default:chat_guid:iMessage;+;chat0000" && lookup.messageId === "9001"; + }); + + const decision = resolveDecision({ + message: { + id: 9001, + chat_id: 42, + chat_guid: "iMessage;+;chat0000", + chat_identifier: "chat0000", + sender: "+15555550123", + text: "echo", + is_group: true, + }, + messageText: "echo", + bodyText: "echo", + echoCache: { has: echoHas }, + }); + + expect(decision).toEqual({ kind: "drop", reason: "echo" }); + // The match should land on the chat_guid scope variant. + const calls = echoHas.mock.calls.map(([scope]) => scope); + expect(calls).toContain("default:chat_guid:iMessage;+;chat0000"); + }); + + it("drops group echoes persisted under chat_identifier scope", () => { + const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => { + return scope === "default:chat_identifier:chat0000" && lookup.messageId === "9001"; + }); + + const decision = resolveDecision({ + message: { + id: 9001, + chat_id: 42, + chat_guid: "iMessage;+;chat0000", + chat_identifier: "chat0000", + sender: "+15555550123", + text: "echo", + is_group: true, + }, + messageText: "echo", + bodyText: "echo", + echoCache: { has: echoHas }, + }); + + expect(decision).toEqual({ kind: "drop", reason: "echo" }); + const calls = echoHas.mock.calls.map(([scope]) => scope); + expect(calls).toContain("default:chat_identifier:chat0000"); + }); + + it("drops group echoes persisted under chat_id scope (baseline)", () => { + const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => { + return scope === "default:chat_id:42" && lookup.messageId === "9001"; + }); + + const decision = resolveDecision({ + message: { + id: 9001, + chat_id: 42, + chat_guid: "iMessage;+;chat0000", + chat_identifier: "chat0000", + sender: "+15555550123", + text: "echo", + is_group: true, + }, + messageText: "echo", + bodyText: "echo", + echoCache: { has: echoHas }, + }); + + expect(decision).toEqual({ kind: "drop", reason: "echo" }); + const calls = echoHas.mock.calls.map(([scope]) => scope); + expect(calls).toContain("default:chat_id:42"); + }); + + it("does not drop a group inbound when echo cache holds an unrelated chat_guid", () => { + const echoHas = vi.fn( + (scope: string, lookup: { text?: string; messageId?: string }) => + scope === "default:chat_guid:iMessage;+;OTHER" && lookup.messageId === "9001", + ); + + const decision = resolveDecision({ + message: { + id: 9001, + chat_id: 42, + chat_guid: "iMessage;+;chat0000", + chat_identifier: "chat0000", + sender: "+15555550123", + text: "fresh inbound", + is_group: true, + }, + messageText: "fresh inbound", + bodyText: "fresh inbound", + echoCache: { has: echoHas }, + }); + + expect(decision.kind).toBe("dispatch"); + }); + it("sanitizes reflected duplicate previews before logging", () => { const selfChatCache = createSelfChatCache(); const logVerbose = vi.fn(); @@ -356,3 +467,80 @@ describe("resolveIMessageInboundDecision command auth", () => { expect(decision.commandAuthorized).toBe(true); }); }); + +describe("buildIMessageInboundContext MessageSid handling (rowid-leak regression)", () => { + let tempStateDir: string; + let priorStateDir: string | undefined; + beforeAll(() => { + tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-inbound-")); + priorStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = tempStateDir; + }); + afterAll(() => { + if (priorStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = priorStateDir; + } + fs.rmSync(tempStateDir, { recursive: true, force: true }); + }); + beforeEach(() => { + _resetIMessageShortIdState(); + try { + fs.rmSync(path.join(tempStateDir, "imessage", "reply-cache.jsonl"), { force: true }); + } catch { + // best-effort + } + }); + + function buildParams(messageOverrides: Partial<{ id: number; guid: string }>) { + const decision = { + kind: "dispatch" as const, + route: { accountId: "default", agentId: "lobster", sessionKey: "k", mainSessionKey: "mk" }, + isGroup: false, + sender: "+15555550123", + senderId: "+15555550123", + senderNormalized: "+15555550123", + historyKey: "h", + chatId: 3, + chatGuid: "any;-;+15555550123", + chatIdentifier: "+15555550123", + replyContext: undefined, + isCommand: false, + commandAuthorized: false, + }; + return { + cfg: {} as OpenClawConfig, + decision: decision as unknown as Parameters< + typeof buildIMessageInboundContext + >[0]["decision"], + message: { sender: "+15555550123", text: "hi", ...messageOverrides }, + historyLimit: 0, + groupHistories: new Map(), + } as unknown as Parameters[0]; + } + + it("uses the gateway-allocated shortId when the inbound has a guid", () => { + const { ctxPayload } = buildIMessageInboundContext( + buildParams({ id: 999, guid: "FAB-INBOUND-1" }), + ); + // First inbound → shortId "1". The chat.db rowid 999 must NOT leak. + expect(ctxPayload.MessageSid).toBe("1"); + }); + + it("does not leak chat.db ROWIDs as MessageSid when the guid is missing", () => { + // Pre-fix bug: when rememberedMessage was nil/empty, MessageSid fell + // back to `String(message.id)` — leaking chat.db ROWID into the agent's + // short-id namespace. Agent then tried to react to a phantom shortId + // that the resolver couldn't find ("13 is no longer available"). + const { ctxPayload } = buildIMessageInboundContext(buildParams({ id: 13, guid: undefined })); + expect(ctxPayload.MessageSid).toBeUndefined(); + // Critically: never the rowid as a string. + expect(ctxPayload.MessageSid).not.toBe("13"); + }); + + it("does not leak chat.db ROWIDs even when the guid is whitespace", () => { + const { ctxPayload } = buildIMessageInboundContext(buildParams({ id: 13, guid: " " })); + expect(ctxPayload.MessageSid).toBeUndefined(); + }); +}); diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 15f9afc3ced..c21824f4a9f 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -31,6 +31,7 @@ import { import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageConversationRoute } from "../conversation-route.js"; +import { rememberIMessageReplyCache } from "../monitor-reply-cache.js"; import { formatIMessageChatTarget, isAllowedIMessageSender, @@ -90,25 +91,44 @@ function hasIMessageEchoMatch(params: { skipIdShortCircuit?: boolean, ) => boolean; }; - scope: string; + scope: string | readonly string[]; text?: string; messageIds: string[]; skipIdShortCircuit?: boolean; }): boolean { - for (const messageId of params.messageIds) { - if (params.echoCache.has(params.scope, { messageId })) { + // Outbound sends persist echo scopes keyed by whichever target shape was + // used (chat_id, chat_guid, chat_identifier, or imessage:). Inbound + // messages from chat.db typically carry chat_id + chat_guid + chat_identifier + // for groups and just sender for DMs, so the same conversation can be + // echo-cached under one shape and re-encountered under another. Probe every + // candidate scope so a chat_guid-keyed send isn't surfaced back to the agent + // as a fresh inbound when chat.db only annotates it with chat_id (or + // vice-versa). + const scopes = typeof params.scope === "string" ? [params.scope] : params.scope; + for (const scope of scopes) { + if (!scope) { + continue; + } + for (const messageId of params.messageIds) { + if (params.echoCache.has(scope, { messageId })) { + return true; + } + } + const fallbackMessageId = params.messageIds[0]; + if (!params.text && !fallbackMessageId) { + continue; + } + if ( + params.echoCache.has( + scope, + { text: params.text, messageId: fallbackMessageId }, + params.skipIdShortCircuit, + ) + ) { return true; } } - const fallbackMessageId = params.messageIds[0]; - if (!params.text && !fallbackMessageId) { - return false; - } - return params.echoCache.has( - params.scope, - { text: params.text, messageId: fallbackMessageId }, - params.skipIdShortCircuit, - ); + return false; } type IMessageInboundDispatchDecision = { @@ -237,6 +257,8 @@ export function resolveIMessageInboundDecision(params: { accountId: params.accountId, isGroup, chatId, + chatGuid, + chatIdentifier, sender, }); if ( @@ -350,6 +372,8 @@ export function resolveIMessageInboundDecision(params: { accountId: params.accountId, isGroup, chatId, + chatGuid, + chatIdentifier, sender, }); if ( @@ -550,6 +574,25 @@ export function buildIMessageInboundContext(params: { const chatId = decision.chatId; const chatTarget = decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined; + const messageGuid = normalizeReplyField(params.message.guid); + const rememberedMessage = messageGuid + ? rememberIMessageReplyCache({ + accountId: decision.route.accountId, + messageId: messageGuid, + chatGuid: decision.chatGuid, + chatIdentifier: decision.chatIdentifier, + chatId: decision.chatId, + timestamp: Date.now(), + isFromMe: false, + }) + : null; + // Only surface the gateway-allocated shortId — never the raw chat.db + // ROWID. Mixing the two namespaces means the agent can call back with a + // numeric id that the gateway will treat as a shortId but never issued + // (e.g. chat.db rowid 13 with shortIds only allocated 1..10), and the + // resolver throws "no longer available". When we have no guid we have + // no stable handle to expose, so drop the field rather than leak rowids. + const messageSid = rememberedMessage?.shortId || undefined; const replySuffix = decision.replyContext ? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${ @@ -629,7 +672,8 @@ export function buildIMessageInboundContext(params: { SenderId: decision.sender, Provider: "imessage", Surface: "imessage", - MessageSid: params.message.id ? String(params.message.id) : undefined, + MessageSid: messageSid, + MessageSidFull: messageGuid, ReplyToId: decision.replyContext?.id, ReplyToBody: decision.replyContext?.body, ReplyToSender: decision.replyContext?.sender, @@ -657,9 +701,33 @@ function buildIMessageEchoScope(params: { accountId: string; isGroup: boolean; chatId?: number; + chatGuid?: string; + chatIdentifier?: string; sender: string; -}): string { - return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`; +}): string[] { + // Mirror every shape resolveOutboundEchoScope can persist (see send.ts). + // Inbound messages carry chat_id, chat_guid, and chat_identifier when + // available, but the outbound side only writes one of them — whichever + // shape the caller used. Returning all candidates lets hasIMessageEchoMatch + // cross-check, so a chat_guid-keyed send is suppressed even when chat.db + // annotates the inbound row with chat_id+chat_identifier (or any other + // permutation). + const scopes: string[] = []; + if (params.isGroup) { + const chatIdScope = formatIMessageChatTarget(params.chatId); + if (chatIdScope) { + scopes.push(`${params.accountId}:${chatIdScope}`); + } + } else { + scopes.push(`${params.accountId}:imessage:${params.sender}`); + } + if (params.chatGuid) { + scopes.push(`${params.accountId}:chat_guid:${params.chatGuid}`); + } + if (params.chatIdentifier) { + scopes.push(`${params.accountId}:chat_identifier:${params.chatIdentifier}`); + } + return scopes; } export function describeIMessageEchoDropLog(params: { diff --git a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts index b8a0798292e..4b82dacd5a8 100644 --- a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts +++ b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts @@ -1,9 +1,19 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createSentMessageCache } from "./echo-cache.js"; +import { rememberPersistedIMessageEcho } from "./persisted-echo-cache.js"; describe("iMessage sent-message echo cache", () => { + const tempDirs: string[] = []; + afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); it("matches recent text within the same scope", () => { @@ -71,4 +81,73 @@ describe("iMessage sent-message echo cache", () => { expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false); expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true); }); + + it("matches persisted echoes written by another process", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-")); + tempDirs.push(stateDir); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const cache = createSentMessageCache(); + + rememberPersistedIMessageEcho({ + scope: "acct:imessage:+1555", + text: "OpenClaw imsg live test", + messageId: "guid-1", + }); + + expect(cache.has("acct:imessage:+1555", { text: "OpenClaw imsg live test" })).toBe(true); + expect(cache.has("acct:imessage:+1666", { text: "OpenClaw imsg live test" })).toBe(false); + expect(cache.has("acct:imessage:+1555", { messageId: "guid-1" })).toBe(true); + }); + + it("writes sent-echoes.jsonl 0600 and parent dir 0700", () => { + // sent-echoes.jsonl carries scope keys + outbound message text + messageIds. + // Same threat model as reply-cache.jsonl: a same-UID hostile process could + // enumerate active conversations or inject lines so a future inbound dedupe + // call wrongly suppresses a legitimate inbound. Owner-only mode is the + // mitigation. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-perm-")); + tempDirs.push(stateDir); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + rememberPersistedIMessageEcho({ + scope: "acct:imessage:+1555", + text: "perm-test", + messageId: "guid-perm", + }); + + const echoFile = path.join(stateDir, "imessage", "sent-echoes.jsonl"); + const echoDir = path.dirname(echoFile); + expect(fs.existsSync(echoFile)).toBe(true); + + const fileMode = fs.statSync(echoFile).mode & 0o777; + const dirMode = fs.statSync(echoDir).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); + + it("clamps pre-existing sent-echoes.jsonl from older 0644/0755 to 0600/0700", () => { + // Older gateway versions wrote with default modes. After upgrade, the next + // remember must clamp the existing file/dir back to owner-only. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-clamp-")); + tempDirs.push(stateDir); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + const imsgDir = path.join(stateDir, "imessage"); + fs.mkdirSync(imsgDir, { recursive: true, mode: 0o755 }); + const echoFile = path.join(imsgDir, "sent-echoes.jsonl"); + fs.writeFileSync(echoFile, "", { mode: 0o644 }); + fs.chmodSync(imsgDir, 0o755); + fs.chmodSync(echoFile, 0o644); + + rememberPersistedIMessageEcho({ + scope: "acct:imessage:+1555", + text: "clamp-test", + messageId: "guid-clamp", + }); + + const fileMode = fs.statSync(echoFile).mode & 0o777; + const dirMode = fs.statSync(imsgDir).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + }); }); diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 81f4f224a51..00f5ce4079a 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, @@ -20,7 +21,7 @@ import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-ru import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -34,16 +35,22 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; import { resolveIMessageAccount } from "../accounts.js"; +import { markIMessageChatRead, sendIMessageTyping } from "../chat.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; import { resolveIMessageAttachmentRoots, resolveIMessageRemoteAttachmentRoots, } from "../media-contract.js"; -import { probeIMessage } from "../probe.js"; +import { + getCachedIMessagePrivateApiStatus, + imessageRpcSupportsMethod, + probeIMessage, +} from "../probe.js"; import { sendMessageIMessage } from "../send.js"; import { normalizeIMessageHandle } from "../targets.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; +import { combineIMessagePayloads } from "./coalesce.js"; import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js"; import { createSentMessageCache } from "./echo-cache.js"; import { @@ -83,11 +90,47 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise { + let fired = false; + return { + fireOnce: ( + rpcMethods: readonly string[], + runtime: { log?: (msg: string) => void; error?: (msg: string) => void }, + ) => { + if (fired) { + return; + } + fired = true; + const detail = + rpcMethods.length === 0 + ? "imsg build pre-dates the rpc_methods capability list" + : `imsg rpc_methods=[${rpcMethods.join(", ")}] does not include typing/read`; + runtime.log?.( + warn( + `imessage: typing indicators / read receipts gated off (${detail}). ` + + `Upgrade imsg (current bridge needs typing+read in rpc_methods).`, + ), + ); + }, + }; +})(); + function isRetriableWatchSubscribeStartupError(error: unknown): boolean { return /imsg rpc timeout \(watch\.subscribe\)|imsg rpc (closed|exited|not running)/i.test( String(error), @@ -187,48 +230,88 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P } } + // When `coalesceSameSenderDms` is enabled and the user has not set an + // explicit inbound debounce for this channel, widen the window to 2500 ms. + // Apple's split-send for ` ` arrives ~0.8-2.0 s apart on most + // setups, so the legacy 0 ms default would flush the command alone before + // the URL row reaches the debouncer. Mirrors the BlueBubbles policy. + const coalesceSameSenderDms = imessageCfg.coalesceSameSenderDms === true; + const inboundCfg = cfg.messages?.inbound; + const hasExplicitInboundDebounce = + typeof inboundCfg?.debounceMs === "number" || + typeof inboundCfg?.byChannel?.imessage === "number"; + const debounceMsOverride = + coalesceSameSenderDms && !hasExplicitInboundDebounce ? 2500 : undefined; + const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{ message: IMessagePayload; }>({ cfg, channel: "imessage", + debounceMsOverride, buildKey: (entry) => { - const sender = entry.message.sender?.trim(); + const msg = entry.message; + const sender = msg.sender?.trim(); if (!sender) { return null; } const conversationId = - entry.message.chat_id != null - ? `chat:${entry.message.chat_id}` - : (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown"); + msg.chat_id != null + ? `chat:${msg.chat_id}` + : (msg.chat_guid ?? msg.chat_identifier ?? "unknown"); + + // With coalesceSameSenderDms enabled, DMs key on chat:sender so two + // distinct user sends — `Dump` followed by a pasted URL that Apple + // delivers as a separate row — fall into the same bucket and merge + // into one agent turn. Group chats fall through to the legacy key so + // shouldDebounce can route them to the instant-dispatch path and + // preserve multi-user turn structure. + if (coalesceSameSenderDms && msg.is_group !== true) { + return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`; + } + return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`; }, shouldDebounce: (entry) => { + const msg = entry.message; + // From-me messages are cached, not processed — never debounce. + if (msg.is_from_me === true) { + return false; + } + + // With coalesceSameSenderDms enabled, debounce DM messages aggressively + // (text, media, control commands) so split-sends — `Dump `, + // `Save 📎image caption`, and rapid floods — merge into one agent + // turn. Group chats keep instant dispatch so the bot stays responsive + // when multiple people are typing. + if (coalesceSameSenderDms) { + return msg.is_group !== true; + } + + // Legacy gate: text-only, no control commands, no media. return shouldDebounceTextInbound({ - text: entry.message.text, + text: msg.text, cfg, - hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0), + hasMedia: Boolean(msg.attachments && msg.attachments.length > 0), }); }, onFlush: async (entries) => { - const last = entries.at(-1); - if (!last) { + if (entries.length === 0) { return; } if (entries.length === 1) { - await handleMessageNow(last.message); + await handleMessageNow(entries[0].message); return; } - const combinedText = entries - .map((entry) => entry.message.text ?? "") - .filter(Boolean) - .join("\n"); - const syntheticMessage: IMessagePayload = { - ...last.message, - text: combinedText, - attachments: null, - }; - await handleMessageNow(syntheticMessage); + + const combined = combineIMessagePayloads(entries.map((e) => e.message)); + if (shouldLogVerbose()) { + const text = combined.text ?? ""; + const preview = text.slice(0, 50); + const ellipsis = text.length > 50 ? "..." : ""; + logVerbose(`[imessage] coalesced ${entries.length} messages: "${preview}${ellipsis}"`); + } + await handleMessageNow(combined); }, onError: (err) => { runtime.error?.(`imessage debounce flush failed: ${String(err)}`); @@ -361,7 +444,9 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); }, onReplyError: (err) => { - logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); + // Pairing relies on the user receiving the challenge — silent + // failure here is the user's only "pairing seems broken" signal. + runtime.error?.(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); }, }); return; @@ -405,14 +490,82 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } + const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath); + const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing"); + const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read"); + if (privateApiStatus?.available === true) { + // Surface a single warning per restart when the bridge is up but we + // had to gate off typing/read because the imsg build pre-dates the + // capability list. Otherwise the user sees no typing bubble / no + // "Read" receipt with no visible reason. + if (!supportsTyping || !supportsRead) { + warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime); + } + } + const sendReadReceipts = imessageCfg.sendReadReceipts !== false; + const typingTarget = ctxPayload.To; + + if (supportsRead && sendReadReceipts && typingTarget) { + try { + await markIMessageChatRead(typingTarget, { + cfg, + accountId: accountInfo.accountId, + client: getActiveClient(), + }); + } catch (err) { + runtime.error?.(`imessage: mark read failed: ${String(err)}`); + } + } + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: decision.route.agentId, channel: "imessage", accountId: decision.route.accountId, + typing: + supportsTyping && typingTarget + ? { + start: async () => { + await sendIMessageTyping(typingTarget, true, { + cfg, + accountId: accountInfo.accountId, + client: getActiveClient(), + }); + }, + stop: async () => { + await sendIMessageTyping(typingTarget, false, { + cfg, + accountId: accountInfo.accountId, + client: getActiveClient(), + }); + }, + onStartError: (err) => { + logTypingFailure({ + log: (msg) => logVerbose(msg), + channel: "imessage", + action: "start", + target: typingTarget, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: (msg) => logVerbose(msg), + channel: "imessage", + action: "stop", + target: typingTarget, + error: err, + }); + }, + } + : undefined, }); - const dispatcher = createReplyDispatcher({ + const { + dispatcher, + replyOptions: typingReplyOptions, + markDispatchIdle, + } = createReplyDispatcherWithTyping({ ...replyPipeline, humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId), deliver: async (payload, info) => { @@ -513,20 +666,30 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P historyMap: groupHistories, limit: historyLimit, }, - onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }), - runDispatch: () => - dispatchInboundMessage({ - ctx: ctxPayload, - cfg, + onPreDispatchFailure: () => + settleReplyDispatcher({ dispatcher, - replyOptions: { - disableBlockStreaming: - typeof accountInfo.config.blockStreaming === "boolean" - ? !accountInfo.config.blockStreaming - : undefined, - onModelSelected, - }, + onSettled: () => markDispatchIdle(), }), + runDispatch: async () => { + try { + return await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...typingReplyOptions, + disableBlockStreaming: + typeof accountInfo.config.blockStreaming === "boolean" + ? !accountInfo.config.blockStreaming + : undefined, + onModelSelected, + }, + }); + } finally { + markDispatchIdle(); + } + }, }), }, }); @@ -535,7 +698,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const handleMessage = async (raw: unknown) => { const message = parseIMessageNotification(raw); if (!message) { - logVerbose("imessage: dropping malformed RPC message payload"); + // A malformed RPC notification means imsg shipped a payload shape + // we do not understand — almost always a real bridge bug. Surface + // the keys so an operator can correlate without leaking content. + const shape = + raw && typeof raw === "object" && !Array.isArray(raw) + ? Object.keys(raw as Record) + .toSorted() + .join(",") + : typeof raw; + runtime.error?.(`imessage: dropping malformed RPC message payload (keys=${shape})`); return; } await inboundDebouncer.enqueue({ message }); diff --git a/extensions/imessage/src/monitor/persisted-echo-cache.ts b/extensions/imessage/src/monitor/persisted-echo-cache.ts new file mode 100644 index 00000000000..3980fd0ebd7 --- /dev/null +++ b/extensions/imessage/src/monitor/persisted-echo-cache.ts @@ -0,0 +1,236 @@ +import fs from "node:fs"; +import path from "node:path"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; + +type PersistedEchoEntry = { + scope: string; + text?: string; + messageId?: string; + timestamp: number; +}; + +const PERSISTED_ECHO_TTL_MS = 2 * 60 * 1000; +const MAX_PERSISTED_ECHO_ENTRIES = 256; + +// sent-echoes.jsonl carries scope keys + outbound message text + messageIds. +// A hostile same-UID process could otherwise (a) read the file to enumerate +// active conversations and outbound content, or (b) inject lines so a future +// inbound dedupe call wrongly suppresses a legitimate inbound message. Owner- +// only mode on both the directory and file closes that vector — defaults are +// 0755/0644 which are world-readable on a multi-user Mac. +const PERSISTED_ECHO_DIR_MODE = 0o700; +const PERSISTED_ECHO_FILE_MODE = 0o600; + +function resolvePersistedEchoPath(): string { + return path.join(resolveStateDir(), "imessage", "sent-echoes.jsonl"); +} + +function clampPersistedEchoModes(filePath: string): void { + // mkdirSync's mode is masked by umask and only applies on creation. If the + // dir or file already exists from an older gateway version, clamp now. + try { + fs.chmodSync(path.dirname(filePath), PERSISTED_ECHO_DIR_MODE); + fs.chmodSync(filePath, PERSISTED_ECHO_FILE_MODE); + } catch { + // best-effort — fs may not support chmod on every platform + } +} + +function normalizeText(text: string | undefined): string | undefined { + const normalized = text?.replace(/\r\n?/g, "\n").trim(); + return normalized || undefined; +} + +function normalizeMessageId(messageId: string | undefined): string | undefined { + const normalized = messageId?.trim(); + if (!normalized || normalized === "ok" || normalized === "unknown") { + return undefined; + } + return normalized; +} + +function parseEntry(line: string): PersistedEchoEntry | null { + try { + const parsed = JSON.parse(line) as Partial; + if (typeof parsed.scope !== "string" || typeof parsed.timestamp !== "number") { + return null; + } + return { + scope: parsed.scope, + text: typeof parsed.text === "string" ? parsed.text : undefined, + messageId: typeof parsed.messageId === "string" ? parsed.messageId : undefined, + timestamp: parsed.timestamp, + }; + } catch { + return null; + } +} + +// In-memory mirror of the persisted file. The echo cache is consulted on +// every inbound message; without a cache, group-chat bursts trigger a +// readFileSync + JSON.parse for every member's reply. The mirror is +// invalidated by file mtime so concurrent gateway processes (rare) and +// post-restart hydrate still see fresh data. +let mirror: { entries: PersistedEchoEntry[]; mtimeMs: number } | null = null; +let persistenceFailureLogged = false; +function reportFailure(scope: string, err: unknown): void { + if (persistenceFailureLogged) { + return; + } + persistenceFailureLogged = true; + logVerbose(`imessage echo-cache: ${scope} disabled after first failure: ${String(err)}`); +} + +function loadMirrorIfStale(): void { + const filePath = resolvePersistedEchoPath(); + let mtimeMs: number; + try { + mtimeMs = fs.statSync(filePath).mtimeMs; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + reportFailure("stat", err); + } + mirror = { entries: [], mtimeMs: 0 }; + return; + } + if (mirror && mirror.mtimeMs === mtimeMs) { + return; + } + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch (err) { + reportFailure("read", err); + mirror = { entries: [], mtimeMs }; + return; + } + const cutoff = Date.now() - PERSISTED_ECHO_TTL_MS; + const entries = raw + .split(/\n+/) + .map(parseEntry) + .filter((entry): entry is PersistedEchoEntry => Boolean(entry && entry.timestamp >= cutoff)) + .slice(-MAX_PERSISTED_ECHO_ENTRIES); + mirror = { entries, mtimeMs }; +} + +function readRecentEntries(): PersistedEchoEntry[] { + loadMirrorIfStale(); + return mirror?.entries ?? []; +} + +// Trigger compaction once the on-disk file grows past 2x the cap or holds +// stale entries beyond the TTL window. Until then, every remember is an +// O(1) append rather than a full rewrite — group-chat bursts that send 5+ +// outbound messages back-to-back used to write the entire file 5+ times. +const COMPACT_AT_ENTRY_COUNT = MAX_PERSISTED_ECHO_ENTRIES * 2; + +function compactRecentEntries(entries: PersistedEchoEntry[]): void { + const filePath = resolvePersistedEchoPath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: PERSISTED_ECHO_DIR_MODE }); + fs.writeFileSync( + filePath, + entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : ""), + { encoding: "utf8", mode: PERSISTED_ECHO_FILE_MODE }, + ); + clampPersistedEchoModes(filePath); + } catch (err) { + reportFailure("compact", err); + // Persistence failed; don't update the in-memory mirror so the next + // read still reflects what's actually on disk. + return; + } + // Update mirror to reflect what we just wrote, so the next has() call + // doesn't re-read the file we just authored. + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(filePath).mtimeMs; + } catch { + // ignore — stale mirror will refresh on next access + } + mirror = { entries: [...entries], mtimeMs }; +} + +function appendEntry(entry: PersistedEchoEntry): void { + const filePath = resolvePersistedEchoPath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: PERSISTED_ECHO_DIR_MODE }); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, { + encoding: "utf8", + mode: PERSISTED_ECHO_FILE_MODE, + }); + // Always clamp — appendFileSync's `mode` only applies on creation, and + // an older gateway version may have left an existing 0644 file behind. + // chmod is microseconds; doing it every append keeps the security + // guarantee monotonic instead of conditional on creation order. + clampPersistedEchoModes(filePath); + } catch (err) { + reportFailure("append", err); + return; + } + // Mirror stays in sync without re-reading the file: append our entry to + // the in-memory copy and bump the mtime to whatever the FS reports now. + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(filePath).mtimeMs; + } catch { + // ignore + } + if (mirror) { + mirror = { entries: [...mirror.entries, entry], mtimeMs }; + } else { + mirror = { entries: [entry], mtimeMs }; + } +} + +export function rememberPersistedIMessageEcho(params: { + scope: string; + text?: string; + messageId?: string; +}): void { + const entry: PersistedEchoEntry = { + scope: params.scope, + text: normalizeText(params.text), + messageId: normalizeMessageId(params.messageId), + timestamp: Date.now(), + }; + if (!entry.text && !entry.messageId) { + return; + } + // Make sure the mirror reflects whatever's on disk before we decide + // whether a compaction is due. + loadMirrorIfStale(); + appendEntry(entry); + const total = mirror?.entries.length ?? 0; + const cutoff = Date.now() - PERSISTED_ECHO_TTL_MS; + const oldestStale = mirror?.entries[0] && mirror.entries[0].timestamp < cutoff; + if (total > COMPACT_AT_ENTRY_COUNT || oldestStale) { + const fresh = (mirror?.entries ?? []).filter((e) => e.timestamp >= cutoff); + compactRecentEntries(fresh.slice(-MAX_PERSISTED_ECHO_ENTRIES)); + } +} + +export function hasPersistedIMessageEcho(params: { + scope: string; + text?: string; + messageId?: string; +}): boolean { + const text = normalizeText(params.text); + const messageId = normalizeMessageId(params.messageId); + if (!text && !messageId) { + return false; + } + for (const entry of readRecentEntries()) { + if (entry.scope !== params.scope) { + continue; + } + if (messageId && entry.messageId === messageId) { + return true; + } + if (text && entry.text === text) { + return true; + } + } + return false; +} diff --git a/extensions/imessage/src/monitor/reflection-guard.test.ts b/extensions/imessage/src/monitor/reflection-guard.test.ts index d7156b93da5..d6596ca22ec 100644 --- a/extensions/imessage/src/monitor/reflection-guard.test.ts +++ b/extensions/imessage/src/monitor/reflection-guard.test.ts @@ -104,4 +104,20 @@ describe("detectReflectedContent", () => { const result = detectReflectedContent("This is a { + const result = detectReflectedContent( + "ACP error (ACP_SESSION_INIT_FAILED): ACP metadata is missing for agent:codex", + ); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("acp-error"); + }); + + it("detects reflected gateway auth failure replies", () => { + const result = detectReflectedContent( + "⚠️ Missing API key for OpenAI on the gateway. Use `openai-codex/gpt-5.5`, or set `OPENAI_API_KEY`, then try again.", + ); + expect(result.isReflection).toBe(true); + expect(result.matchedLabels).toContain("gateway-missing-api-key"); + }); }); diff --git a/extensions/imessage/src/monitor/reflection-guard.ts b/extensions/imessage/src/monitor/reflection-guard.ts index 706743be17d..1e66778ae61 100644 --- a/extensions/imessage/src/monitor/reflection-guard.ts +++ b/extensions/imessage/src/monitor/reflection-guard.ts @@ -13,6 +13,8 @@ const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]* const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i; // Require closing `>` to avoid false-positives on phrases like "". const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i; +const ACP_ERROR_RE = /\bACP error\s*\(\s*ACP_[A-Z0-9_]+\s*\):/i; +const GATEWAY_MISSING_API_KEY_RE = /\bMissing API key for\b.+\bon the gateway\b/i; const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [ { re: INTERNAL_SEPARATOR_RE, label: "internal-separator" }, @@ -20,6 +22,8 @@ const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [ { re: THINKING_TAG_RE, label: "thinking-tag" }, { re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" }, { re: FINAL_TAG_RE, label: "final-tag" }, + { re: ACP_ERROR_RE, label: "acp-error" }, + { re: GATEWAY_MISSING_API_KEY_RE, label: "gateway-missing-api-key" }, ]; type ReflectionDetection = { diff --git a/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts new file mode 100644 index 00000000000..c78369ebc65 --- /dev/null +++ b/extensions/imessage/src/probe.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { imessageRpcSupportsMethod } from "./probe.js"; + +describe("imessageRpcSupportsMethod", () => { + it("returns false when the bridge is not available", () => { + expect( + imessageRpcSupportsMethod( + { + available: false, + v2Ready: false, + selectors: {}, + rpcMethods: ["typing", "read"], + }, + "typing", + ), + ).toBe(false); + }); + + it("returns false when status is undefined", () => { + expect(imessageRpcSupportsMethod(undefined, "typing")).toBe(false); + }); + + it("returns true when the requested method is in the explicit rpcMethods list", () => { + expect( + imessageRpcSupportsMethod( + { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["chats.list", "send", "typing", "read"], + }, + "typing", + ), + ).toBe(true); + }); + + it("returns false for a method not in the explicit rpcMethods list", () => { + expect( + imessageRpcSupportsMethod( + { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["chats.list", "send"], + }, + "typing", + ), + ).toBe(false); + }); + + it("falls back to the foundational set when rpcMethods is empty (older imsg builds)", () => { + // Older imsg builds shipped chats.list/send/watch.*/messages.history + // before the rpc_methods capability list existed. Without this fallback + // we'd silently break send() on every gateway running an older imsg. + const oldBuild = { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: [], + }; + for (const method of [ + "chats.list", + "messages.history", + "watch.subscribe", + "watch.unsubscribe", + "send", + ]) { + expect(imessageRpcSupportsMethod(oldBuild, method)).toBe(true); + } + }); + + it("gates newer methods off when rpcMethods is empty (forces upgrade for typing/read/group)", () => { + const oldBuild = { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: [], + }; + for (const method of [ + "typing", + "read", + "chats.create", + "chats.delete", + "chats.markUnread", + "group.rename", + "group.setIcon", + "group.addParticipant", + "group.removeParticipant", + "group.leave", + ]) { + expect(imessageRpcSupportsMethod(oldBuild, method)).toBe(false); + } + }); +}); diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 055f86768b3..0d24aec419b 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -13,6 +13,13 @@ export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; export type IMessageProbe = BaseProbeResult & { fatal?: boolean; + privateApi?: { + available: boolean; + v2Ready: boolean; + selectors: Record; + rpcMethods: string[]; + error?: string; + }; }; export type IMessageProbeOptions = { @@ -28,7 +35,21 @@ type RpcSupportResult = { fatal?: boolean; }; -const rpcSupportCache = new Map(); +// 5-minute TTL on the rpc-support cache lets us cope with `brew upgrade imsg` +// happening mid-process without forcing a gateway restart. +const RPC_SUPPORT_CACHE_TTL_MS = 5 * 60 * 1000; +// 10-second negative TTL on the private-api status cache lets a flurry of +// agent actions during a bridge outage avoid serializing on probe RPC. +const PRIVATE_API_NEGATIVE_TTL_MS = 10 * 1000; + +type RpcSupportCacheEntry = { result: RpcSupportResult; expiresAt: number }; +type PrivateApiCacheEntry = { + status: NonNullable; + expiresAt: number; +}; + +const rpcSupportCache = new Map(); +const bridgeStatusCache = new Map(); function isDefaultLocalIMessageCliPath(cliPath: string): boolean { const trimmed = cliPath.trim(); @@ -47,8 +68,8 @@ export function resolveIMessageNonMacHostError( async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise { const cached = rpcSupportCache.get(cliPath); - if (cached) { - return cached; + if (cached && cached.expiresAt > Date.now()) { + return cached.result; } try { const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs }); @@ -60,12 +81,18 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise | null; + firstLineSnippet?: string; +} { + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines.toReversed()) { + try { + const value = JSON.parse(line); + if (value && typeof value === "object" && !Array.isArray(value)) { + return { payload: value as Record }; + } + } catch { + // Continue scanning earlier JSONL records. + } + } + // No JSONL line parsed. Surface a small snippet of the first non-empty + // line so the operator can grep imsg release notes if the status output + // schema has shifted. + const snippet = lines[0]?.slice(0, 120); + return { payload: null, firstLineSnippet: snippet }; +} + +function selectorsFromPayload(payload: Record): Record { + const raw = payload.selectors; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const selectors: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "boolean") { + selectors[key] = value; + } + } + return selectors; +} + +function rpcMethodsFromPayload(payload: Record): string[] { + const raw = payload.rpc_methods; + if (!Array.isArray(raw)) { + return []; + } + return raw.filter((entry): entry is string => typeof entry === "string"); +} + +// Methods that have always existed on imsg's rpc surface, before the +// `rpc_methods` capability list was added. An older imsg build that +// reports `available: true` but ships no rpc_methods array is assumed to +// support these — gating them off would silently break the integration +// for everyone who hasn't upgraded yet. +const FOUNDATIONAL_RPC_METHODS = new Set([ + "chats.list", + "messages.history", + "watch.subscribe", + "watch.unsubscribe", + "send", +]); + +export function imessageRpcSupportsMethod( + status: IMessageProbe["privateApi"] | undefined, + method: string, +): boolean { + if (!status?.available) { + return false; + } + if (status.rpcMethods.length === 0) { + // Older imsg builds (pre-rpc_methods): assume the foundational set, + // gate every newer method off until the user upgrades. This keeps + // chats.list/send/watch working while making typing/read/group.* etc. + // explicit-upgrade-required. + return FOUNDATIONAL_RPC_METHODS.has(method); + } + return status.rpcMethods.includes(method); +} + +export function getCachedIMessagePrivateApiStatus( + cliPath?: string | null, +): IMessageProbe["privateApi"] | undefined { + const key = cliPath?.trim() || "imsg"; + const entry = bridgeStatusCache.get(key); + if (!entry) { + return undefined; + } + // Negative cache entries expire so a flurry of agent actions during a + // bridge outage don't all serialize on a re-probe. + if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) { + bridgeStatusCache.delete(key); + return undefined; + } + return entry.status; +} + +export function clearIMessagePrivateApiCache(cliPath?: string): void { + if (cliPath) { + const key = cliPath.trim() || "imsg"; + bridgeStatusCache.delete(key); + rpcSupportCache.delete(key); + } else { + bridgeStatusCache.clear(); + rpcSupportCache.clear(); + } +} + +export async function probeIMessagePrivateApi( + cliPath: string, + timeoutMs: number, + options: { forceRefresh?: boolean } = {}, +): Promise> { + const key = cliPath.trim() || "imsg"; + if (!options.forceRefresh) { + const entry = bridgeStatusCache.get(key); + if (entry) { + if (entry.status.available) { + return entry.status; + } + if (entry.expiresAt > Date.now()) { + return entry.status; + } + } + } + try { + const result = await runCommandWithTimeout([key, "status", "--json"], { timeoutMs }); + const combined = `${result.stdout}\n${result.stderr}`.trim(); + const { payload, firstLineSnippet } = parseStatusPayload(result.stdout); + const selectors = payload ? selectorsFromPayload(payload) : {}; + const rpcMethods = payload ? rpcMethodsFromPayload(payload) : []; + const advancedFeatures = payload?.advanced_features === true; + const v2Ready = payload?.v2_ready === true; + const status: NonNullable = { + available: result.code === 0 && advancedFeatures && v2Ready, + v2Ready, + selectors, + rpcMethods, + ...(result.code === 0 + ? !payload && firstLineSnippet + ? { + error: + `imsg status --json returned no parseable JSONL ` + + `(first line: "${firstLineSnippet}") — output schema may have changed`, + } + : {} + : { error: combined || `imsg status --json failed (code ${String(result.code)})` }), + }; + bridgeStatusCache.set(key, { + status, + expiresAt: status.available ? 0 : Date.now() + PRIVATE_API_NEGATIVE_TTL_MS, + }); + return status; + } catch (err) { + const status: NonNullable = { + available: false, + v2Ready: false, + selectors: {}, + rpcMethods: [], + error: String(err), + }; + bridgeStatusCache.set(key, { + status, + expiresAt: Date.now() + PRIVATE_API_NEGATIVE_TTL_MS, + }); + return status; + } +} + /** * Probe iMessage RPC availability. * @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default. @@ -112,6 +305,8 @@ export async function probeIMessage( }; } + const privateApi = await probeIMessagePrivateApi(cliPath, effectiveTimeout); + const client = await createIMessageRpcClient({ cliPath, dbPath, @@ -119,9 +314,9 @@ export async function probeIMessage( }); try { await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout }); - return { ok: true }; + return { ok: true, privateApi }; } catch (err) { - return { ok: false, error: String(err) }; + return { ok: false, error: String(err), privateApi }; } finally { await client.stop(); } diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 227e7d6cbf7..6ab2edf5985 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -13,6 +13,9 @@ import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; +import { extractMarkdownFormatRuns } from "./markdown-format.js"; +import { rememberIMessageReplyCache } from "./monitor-reply-cache.js"; +import { rememberPersistedIMessageEcho } from "./monitor/persisted-echo-cache.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; type IMessageSendOpts = { @@ -140,6 +143,22 @@ function createIMessageSendReceipt(params: { return createMessageReceiptFromOutboundResults(receiptParams); } +function resolveOutboundEchoScope(params: { + accountId: string; + target: ReturnType; +}): string | null { + if (params.target.kind === "chat_id") { + return `${params.accountId}:${formatIMessageChatTarget(params.target.chatId)}`; + } + if (params.target.kind === "chat_guid") { + return `${params.accountId}:chat_guid:${params.target.chatGuid}`; + } + if (params.target.kind === "chat_identifier") { + return `${params.accountId}:chat_identifier:${params.target.chatIdentifier}`; + } + return `${params.accountId}:imessage:${params.target.to}`; +} + export async function sendMessageIMessage( to: string, text: string, @@ -194,6 +213,17 @@ export async function sendMessageIMessage( if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } + // Extract markdown bold/italic/underline/strikethrough into typed-run + // ranges that the imsg bridge applies via attributedBody. macOS 15+ + // recipients render the runs natively; earlier macOS recipients still + // see the marker-stripped text without literal asterisks. + const formatted = message.trim() + ? extractMarkdownFormatRuns(message) + : { text: message, ranges: [] }; + message = formatted.text; + if (!message.trim() && !filePath) { + throw new Error("iMessage send requires text or media"); + } const resolvedReplyToId = sanitizeReplyToId(opts.replyToId); const params: Record = { text: message, @@ -203,6 +233,9 @@ export async function sendMessageIMessage( if (resolvedReplyToId) { params.reply_to = resolvedReplyToId; } + if (formatted.ranges.length > 0) { + params.formatting = formatted.ranges; + } if (filePath) { params.file = filePath; } @@ -229,6 +262,34 @@ export async function sendMessageIMessage( }); const resolvedId = resolveMessageId(result); const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown"); + const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target }); + if (echoScope) { + rememberPersistedIMessageEcho({ + scope: echoScope, + text: message, + messageId: resolvedId ?? undefined, + }); + } + // Record the outbound message in the reply cache with isFromMe=true so + // edit/unsend actions can verify the agent actually sent the message + // before dispatching. Inbound recording (in monitor/inbound-processing) + // sets isFromMe=false, so the cache distinguishes own-sent from received. + if (resolvedId) { + rememberIMessageReplyCache({ + accountId: account.accountId, + messageId: resolvedId, + chatGuid: target.kind === "chat_guid" ? target.chatGuid : undefined, + chatIdentifier: + target.kind === "chat_identifier" + ? target.chatIdentifier + : target.kind === "handle" + ? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}` + : undefined, + chatId: target.kind === "chat_id" ? target.chatId : undefined, + timestamp: Date.now(), + isFromMe: true, + }); + } return { messageId, sentText: message, diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index c8d1ca3488e..0383d846573 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -171,6 +171,7 @@ export const imessageCompletionNote = { lines: [ "Run OpenClaw on the Mac signed into Messages, or set cliPath to an SSH wrapper that runs imsg on that Mac.", "Linux/Windows hosts cannot run the default local imsg path directly.", + "Run `imsg launch`, then `openclaw channels status --probe` to verify private API actions.", "Ensure OpenClaw has Full Disk Access to Messages DB.", "Grant Automation permission for Messages when prompted.", "List chats with: imsg chats --limit 20", diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index d5d0a49bc97..b6aff0a77c0 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -166,4 +166,12 @@ describe("createIMessageTestPlugin", () => { }, }); }); + + it("exposes seeded private API actions for binding contract tests", () => { + const plugin = createIMessageTestPlugin(); + + expect(plugin.actions?.describeMessageTool({} as never)?.actions).toEqual( + expect.arrayContaining(["react", "edit", "unsend", "reply", "sendWithEffect", "upload-file"]), + ); + }); }); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index a892eba3d27..722081005db 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -15,17 +15,17 @@ const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ 'ntQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"model":{"type":"string","minLength":1},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const"', ':4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"dm.policy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"] (legacy: channels.discord.dm.allowFrom)."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Discord Retry Attempts","help":"Max retry attempts for outbound Discord API calls (default: 3)."},"retry.minDelayMs":{"label":"Discord Retry Min Delay (ms)","help":"Minimum retry delay in ms for Discord outbound calls."},"retry.maxDelayMs":{"label":"Discord Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Discord outbound calls."},"retry.jitter":{"label":"Discord Retry Jitter","help":"Jitter factor (0-1) applied to Discord retry delays."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"eventQueue.listenerTimeout":{"label":"Discord EventQueue Listener Timeout (ms)","help":"Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout."},"eventQueue.maxQueueSize":{"label":"Discord EventQueue Max Queue Size","help":"Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize."},"eventQueue.maxConcurrency":{"label":"Discord EventQueue Max Concurrency","help":"Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"gatewayInfoTimeoutMs":{"label":"Discord Gateway Metadata Timeout (ms)","help":"Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset."},"gatewayReadyTimeoutMs":{"label":"Discord Gateway READY Timeout (ms)","help":"Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset."},"gatewayRuntimeReadyTimeoutMs":{"label":"Discord Gateway Runtime READY Timeout (ms)","help":"Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses (for example openai/gpt-5.4-mini). Leave unset to inherit the routed agent model."},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationId":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pa', 'ttern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"required":["domain","connectionMode","webhookPath","dmPolicy","groupPolicy","reactionNotifications","typingIndicator","resolveSenderNames"],"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockS', - 'treaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"iMessage via the imsg CLI on a signed-in Mac or SSH wrapper.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChar', - 's":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress', - '.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"', - 'integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"arra', - 'y","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"objec', - 't","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0', - ',"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false', - '}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"def', - 'ault":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)","help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message u', - 'sing LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid ', - 'consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + 'treaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{', + '"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"n', + 'umber"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentenc', + 'e"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLengt', + 'h":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":fal', + 'se},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provide', + 'r":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":', + '"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"', + 'type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)",', + '"help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":', + '{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index c30e653b9e2..42ef86fcae4 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -29,6 +29,20 @@ export type IMessageAccountConfig = { dbPath?: string; /** Remote SSH host token for SCP attachment fetches (`host` or `user@host`). */ remoteHost?: string; + /** Enable or disable private API message actions. */ + actions?: { + reactions?: boolean; + edit?: boolean; + unsend?: boolean; + reply?: boolean; + sendWithEffect?: boolean; + renameGroup?: boolean; + setGroupIcon?: boolean; + addParticipant?: boolean; + removeParticipant?: boolean; + leaveGroup?: boolean; + sendAttachment?: boolean; + }; /** Optional default send service (imessage|sms|auto). */ service?: "imessage" | "sms" | "auto"; /** Optional default region (used when sending SMS). */ @@ -73,6 +87,18 @@ export type IMessageAccountConfig = { blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** When private API is available, mark inbound chats read before dispatch (default: true). */ + sendReadReceipts?: boolean; + /** + * Merge consecutive same-sender DM rows from `chat.db` into a single agent + * turn. Mirrors `channels.bluebubbles.coalesceSameSenderDms` so Apple's + * split-send (` ` arriving as two separate rows ~0.8-2.0 s + * apart) lands as one merged message. DM-only — group chats keep instant + * per-message dispatch. Widens the default inbound debounce window to + * 2500 ms when enabled without an explicit + * `messages.inbound.byChannel.imessage`. Default: `false`. + */ + coalesceSameSenderDms?: boolean; groups?: Record< string, { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index b12b0ef4d4b..3a6b59e8ca1 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -1350,6 +1350,23 @@ export const IrcConfigSchema = IrcAccountSchemaBase.extend({ } }); +const IMessageActionSchema = z + .object({ + reactions: z.boolean().optional(), + edit: z.boolean().optional(), + unsend: z.boolean().optional(), + reply: z.boolean().optional(), + sendWithEffect: z.boolean().optional(), + renameGroup: z.boolean().optional(), + setGroupIcon: z.boolean().optional(), + addParticipant: z.boolean().optional(), + removeParticipant: z.boolean().optional(), + leaveGroup: z.boolean().optional(), + sendAttachment: z.boolean().optional(), + }) + .strict() + .optional(); + export const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), @@ -1363,6 +1380,7 @@ export const IMessageAccountSchemaBase = z .string() .refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)") .optional(), + actions: IMessageActionSchema, service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), region: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), @@ -1382,10 +1400,13 @@ export const IMessageAccountSchemaBase = z .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root")) .optional(), mediaMaxMb: z.number().int().positive().optional(), + probeTimeoutMs: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(), chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + sendReadReceipts: z.boolean().optional(), + coalesceSameSenderDms: z.boolean().optional(), groups: z .record( z.string(),