重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,11 @@
.DS_Store
dist/.boundary-tsc.tsbuildinfo
**/*.map
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/*.test-helpers.ts
**/*.test-harness.ts
src/test-*.ts
src/test-support/

View file

@ -0,0 +1,198 @@
# Changelog
## 2026.4.20
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.4.19-beta.1
### Changes
- Version alignment with core OpenClaw release numbers.
This file tracks Matrix-related release notes for the local `@openclaw/matrix`
plugin since the `matrix-js-sdk` migration. Source release notes live in
`../../changelog.md`; exact repeated entries inside the same version are
collapsed here.
## 2026.4.15-beta.2
### Fixes
- Matrix/pairing: block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
## 2026.4.15-beta.1
### Changes
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
### Fixes
- Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) Thanks @ly85206559.
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
- Matrix/security: block DM pairing-store entries from authorizing room control commands. (#67294) Thanks @pgondhi987.
## 2026.4.12
### Changes
- Matrix/partial streaming: add MSC4357 live markers to draft preview sends and edits so supporting Matrix clients can render a live/typewriter animation and stop it when the final edit lands. (#63513) Thanks @TigerInYourDream.
### Fixes
- Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys.
- Channels/replay dedupe: standardize replay claims, retryable-failure release, and post-success commit behavior across Telegram, Discord, Slack, Mattermost, WhatsApp, Matrix, LINE, Feishu, Zalo, Nextcloud Talk, TLON, Nostr, Voice Call, and shared plugin interactive callbacks so duplicate deliveries stay reply-once after success but retry cleanly after pre-delivery failures. Thanks @vincentkoc.
## 2026.4.10
### Changes
- QA/Matrix: add a live `openclaw qa matrix` lane backed by a disposable Matrix homeserver, shared live-transport seams, and Matrix-specific transport coverage for threading, reactions, restart, and allowlist behavior. (#64489) Thanks @gumadeiras.
- Matrix/partial streaming: add MSC4357 live markers to draft preview sends and edits so supporting Matrix clients can render a live/typewriter animation and stop it when the final edit lands. (#63513) Thanks @TigerInYourDream.
### Fixes
- Gateway/thread routing: preserve Slack, Telegram, Mattermost, Matrix, ACP, restart-sentinel, and agent announce delivery targets so subagent, cron, stream-relay, session fallback, and restart messages land back in the originating thread, topic, or room casing. (#54840, #57056, #63143, #63228, #63506, #64343, #64391)
- Matrix: keep multi-account room scoping consistent, keep packaged crypto migrations warning-only when appropriate, preserve ordered block streaming, add explicit Matrix block-streaming opt-in, and resolve verification/bootstrap from the packaged runtime entry. (#58449, #59249, #59266, #64373) Thanks @gumadeiras.
- Matrix/migration: keep packaged warning-only crypto migrations from being misclassified as actionable when only helper chunks are present, so startup and doctor stay on the warning-only path instead of creating unnecessary migration snapshots. (#64373) Thanks @gumadeiras.
- Matrix/ACP thread bindings: preserve canonical room casing and parent conversation routing during ACP session spawn so mixed-case room ids bind correctly from top-level rooms and existing Matrix threads. (#64343) Thanks @gumadeiras.
## 2026.4.9
### Fixes
- Matrix/gateway: wait for Matrix sync readiness before marking startup successful, keep Matrix background handler failures contained, and route fatal Matrix sync stops through channel-level restart handling instead of crashing the whole gateway. (#62779) Thanks @gumadeiras.
- Matrix/doctor: migrate legacy `channels.matrix.dm.policy: "trusted"` configs back to compatible DM policies during `openclaw doctor --fix`, preserving explicit `allowFrom` boundaries as `allowlist` and defaulting empty legacy configs to `pairing`. (#62942) Thanks @lukeboyett.
## 2026.4.8
### Fixes
- Bundled channels/setup: load shared secret contracts through packaged top-level sidecars across BlueBubbles, Feishu, Google Chat, IRC, Matrix, Mattermost, Microsoft Teams, Nextcloud Talk, Slack, and Zalo so installed npm builds no longer rely on missing `dist/extensions/*/src/*` files during gateway startup.
## 2026.4.7
### Fixes
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Matrix/agents: hide owner-only `set-profile` from embedded agent channel-action discovery so non-owner runs stop advertising profile updates they cannot execute. (#62662) Thanks @eleqtrizit.
## 2026.4.5
### Changes
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Matrix/exec approvals: clarify unavailable-approval replies so Matrix no longer claims chat approvals are unsupported when native exec approvals are merely unconfigured. (#61424) Thanks @gumadeiras.
### Fixes
- Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.
- Matrix/DM sessions: add `channels.matrix.dm.sessionScope`, shared-session collision notices, and aligned outbound session reuse so separate Matrix DM rooms can keep distinct context when configured. (#61373) Thanks @gumadeiras.
- Matrix: move legacy top-level `avatarUrl` into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras.
- Matrix/streaming: add a quiet preview mode for streamed Matrix replies, keep legacy `partial` preview-first behavior, and finalize quiet media captions correctly so previews stop notifying early without dropping final text semantics. (#61450) Thanks @gumadeiras.
- Matrix: keep direct transport requests on the pinned dispatcher by routing them through undici runtime fetch, so Matrix clients resume syncing on newer runtimes without dropping the validated address binding. (#61595) Thanks @gumadeiras.
- Matrix: avoid failing startup when token auth already knows the user ID but still needs optional device metadata, retry transient auth bootstrap requests, and backfill missing device IDs after startup while keeping unknown-device storage reuse conservative until metadata is repaired. (#61383) Thanks @gumadeiras.
- Matrix: pass configured `deviceId` through health probes and keep probe-only client setup out of durable Matrix storage, so health checks preserve the correct device identity without rewriting `storage-meta.json` or related probe state on disk. (#61581) Thanks @MoerAI.
- Matrix/plugin loading: ship and source-load the crypto bootstrap runtime sidecar correctly so current `main` stops warning about failed Matrix bootstrap loads and `matrix/index` plugin-id mismatches on every invocation. (#53298) thanks @keithce.
- Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras.
- Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras.
- Matrix/delivery recovery: treat Synapse `User not in room` replay failures as permanent during startup recovery so poisoned queued messages move to `failed/` instead of crash-looping Matrix after restart. (#57426) thanks @dlardo.
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
- Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
- Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss.
- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras.
## 2026.4.2
### Changes
- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.
## 2026.4.1-beta.1
### Notes
- Matrix/onboarding: restore guided setup in `openclaw channels add` and `openclaw configure --section channels`, while keeping custom plugin wizards on the shared `setupWizard` seam. (#59462) Thanks @gumadeiras.
- Matrix/streaming: keep live partial previews for the current assistant block while preserving completed block updates as separate messages when `channels.matrix.blockStreaming` is enabled. (#59384) Thanks @gumadeiras.
## 2026.3.31
### Changes
- Matrix/history: add optional room history context for Matrix group triggers via `channels.matrix.historyLimit`, with per-agent watermarks and retry-safe snapshots so failed trigger retries do not drift into newer room messages. (#57022) thanks @chain710.
- Matrix/network: add explicit `channels.matrix.proxy` config for routing Matrix traffic through an HTTP(S) proxy, including account-level overrides and matching probe/runtime behavior. (#56931) thanks @patrick-yingxi-pan.
- Matrix/streaming: add draft streaming so partial Matrix replies update the same message in place instead of sending a new message for each chunk. (#56387) Thanks @jrusz.
- Matrix/threads: add per-DM `threadReplies` overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.
### Fixes
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
## 2026.3.31-beta.1
### Fixes
- Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
- Matrix/context: filter fetched room context by sender allowlists so reply and thread context lookup no longer pulls non-allowlisted messages into agent context. (#58376) Thanks @jacobtomlinson.
- Matrix/delivery recovery: treat Synapse `User not in room` replay failures as permanent during startup recovery so poisoned queued messages move to `failed/` instead of crash-looping Matrix after restart. (#57426) thanks @dlardo.
- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras.
- Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss.
- Matrix/DM threads: keep strict unnamed fresh-invite rooms promotable even when Matrix omits the optional direct hint, preserve repair-failed local DM promotions while still revalidating later room metadata, and keep both bound and thread-isolated Matrix sessions reporting the correct route policy. (#58099) Thanks @gumadeiras.
- Matrix/plugin loading: ship and source-load the crypto bootstrap runtime sidecar correctly so current `main` stops warning about failed Matrix bootstrap loads and `matrix/index` plugin-id mismatches on every invocation. (#53298) thanks @keithce.
- Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras.
- Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras.
## 2026.3.28
### Changes
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
### Fixes
- Matrix/replies: include quoted poll question/options in inbound reply context so the agent sees the original poll content when users reply to Matrix poll messages. (#55056) Thanks @alberthild.
- Matrix/plugins: keep plugin bootstrap from crashing when built runtime mixes bare and deep `matrix-js-sdk` entrypoints, so unrelated channels do not get taken down during plugin load. (#56273) Thanks @aquaright1.
- Matrix: keep separate 2-person rooms out of DM routing after `m.direct` seeds successfully, while still honoring explicit `is_direct` state and startup fallback recovery. (#54890) thanks @private-peter
- Plugins/Matrix: preserve sender filenames for inbound media by forwarding `originalFilename` to `saveMediaBuffer`. (#55692) thanks @esrehmki.
- Matrix/mentions: recognize `matrix.to` mentions whose visible label uses the bot's room display name, so `requireMention: true` rooms respond correctly in modern Matrix clients. (#55393) thanks @nickludlam.
- Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras
- Plugins/Matrix: resolve env-backed `accessToken` and `password` SecretRefs against the active Matrix config env path during startup, and officially accept SecretRef `accessToken` config values. (#54980) thanks @kakahu2015.
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
## 2026.3.23
### Fixes
- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
## 2026.3.22
### Breaking
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
- Plugins/Matrix: stop mention-gated or otherwise dropped room chatter from refreshing focused thread bindings before the message is actually routed, so idle ACP and session bindings can still expire normally in mention-required rooms. Thanks @vincentkoc, @dinakars777 and @mvanhorn.
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
### Changes
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
### Fixes
- Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc.
- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc.
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Plugins/Matrix: accept shared send-tool media aliases (`mediaUrl`, `filePath`, `path`) and preserve `asVoice` / `audioAsVoice` through Matrix action dispatch so media-only sends and voice-message intents reach the plugin send layer correctly. Thanks @psacc and @vincentkoc.

View file

@ -0,0 +1,116 @@
# Matrix Spec Support
Current Matrix spec/event support tracked for the bundled Matrix plugin.
Scope:
- code-backed today-state only
- plugin behavior only; not a claim of full Matrix spec coverage
- update this file when adding or removing Matrix event/spec support
Legend:
- `in`: inbound handling
- `out`: outbound send/edit/emit
- `tools`: CLI/action/runtime tooling built on that surface
## Support Matrix
| Surface | Spec / event ids | Support | Notes | Evidence |
| ------------------------ | -------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| Core messages | `m.room.message` | in/out/tools | base text/media message surface | `src/matrix/send/types.ts`, `src/matrix/actions/messages.ts` |
| Rich text | `org.matrix.custom.html` | out | Markdown rendered to Matrix HTML | `src/matrix/send/formatting.ts` |
| Replies | `m.in_reply_to` | in/out | reply relation support | `src/matrix/send/formatting.ts` |
| Edits | `m.replace` | in/out/tools | Matrix edit flow; edit body uses `* <text>` fallback format | `src/matrix/send/types.ts`, `src/matrix/draft-stream.test.ts` |
| Threads | `m.thread` | in/out/tools | thread send, routing, session/thread policy | `src/matrix/send/types.ts`, `src/matrix/monitor/types.ts`, `src/matrix/monitor/threads.test.ts` |
| Direct rooms | `m.direct` | in/out/tools | DM routing, cache, repair, outbound target selection | `src/matrix/send/types.ts`, `src/matrix/sdk.ts`, `src/matrix/send/targets.test.ts`, `src/matrix/direct-management.test.ts` |
| Reactions | `m.reaction`, `m.annotation` | in/out/tools | send, summarize, inbound reaction routing | `src/matrix/reaction-common.ts`, `src/matrix/monitor/reaction-events.test.ts` |
| Read receipts | `m.read` | out | sent on inbound message receipt | `src/matrix/sdk.ts`, `src/matrix/monitor/handler.ts` |
| Typing | typing API | out | typing keepalive while reply runs | `src/matrix/sdk.ts`, `src/matrix/send.ts`, `src/matrix/monitor/handler.ts` |
| Mentions | `m.mentions` | in/out/tools | stable mention metadata on sends/edits/media captions/poll fallback text | `src/matrix/send/formatting.ts`, `src/matrix/monitor/mentions.ts`, `src/matrix/send.test.ts` |
| Mention link compat | `https://matrix.to/#/...` | in/out | emit + validate visible-label mentions in formatted HTML | `src/matrix/format.ts`, `src/matrix/monitor/mentions.ts` |
| Polls | `m.poll.start`, `m.poll.response`, `m.poll.end` | in/out/tools | stable poll create/read/vote/summary flow | `src/matrix/poll-types.ts`, `src/matrix/actions/polls.ts`, `src/matrix/poll-summary.ts` |
| Poll compat | `org.matrix.msc3381.poll.start`, `org.matrix.msc3381.poll.response`, `org.matrix.msc3381.poll.end` | in/out | unstable poll compat still emitted/parsed where needed | `src/matrix/poll-types.ts`, `src/matrix/send.test.ts` |
| Poll relations | `m.reference` | in/out/tools | poll votes/results linked to root poll event | `src/matrix/poll-types.ts`, `src/matrix/actions/polls.ts`, `src/matrix/poll-summary.ts` |
| Extensible text fallback | `org.matrix.msc1767.text` | in/out | poll text fallback/compat | `src/matrix/poll-types.ts` |
| Voice messages | `org.matrix.msc3245.voice` | out | voice-bubble marker on compatible audio sends | `src/matrix/send/media.ts`, `src/matrix/send/types.ts` |
| Voice audio metadata | `org.matrix.msc1767.audio` | out | duration metadata for voice sends | `src/matrix/send/media.ts`, `src/matrix/send/types.ts` |
| Location | `m.location`, `geo:` | in | inbound parse to text/context; no outbound location send tracked here | `src/matrix/monitor/types.ts`, `src/matrix/monitor/location.ts`, `src/matrix/monitor/handler.ts` |
| E2EE room events | `m.room.encrypted` | in/out/tools | encrypted event hydration, decrypt, encrypted media send | `src/matrix/monitor/types.ts`, `src/matrix/sdk.ts`, `docs/channels/matrix.md` |
| Encrypted media previews | `file`, `thumbnail_file` | out | encrypted thumbnails for encrypted image events | `src/matrix/send/media.ts`, `docs/channels/matrix.md` |
| Device verification | `m.key.verification.*`, `m.key.verification.request` | in/tools | request/ready/start/SAS/done/cancel notices and CLI flows | `src/matrix/monitor/verification-utils.ts`, `src/matrix/monitor/events.test.ts`, `docs/channels/matrix.md` |
| Streaming/live markers | `org.matrix.msc4357.live` | out | live draft/edit markers for partial streaming | `src/matrix/send/types.ts`, `src/matrix/send.ts`, `src/matrix/draft-stream.ts` |
## Explicit MSCs In Use
These MSCs are explicitly referenced in the plugin today:
- `MSC3381`: polls
- `MSC1767`: extensible-events fallback fields used for polls and voice metadata
- `MSC3245`: voice message marker
- `MSC4357`: live streaming marker
Evidence:
- `src/matrix/poll-types.ts`
- `src/matrix/send/media.ts`
- `src/matrix/send/types.ts`
- `src/matrix/draft-stream.ts`
## Non-goals
This file does not claim:
- full client-server API coverage
- full room-state/event coverage inherited from `matrix-js-sdk`
- outbound support for any surface not listed above
If a new Matrix feature lands, add a row with:
1. exact event/spec id
2. support shape (`in`, `out`, `tools`)
3. at least one code path proving it
## Missing / Candidate Specs
Recommended next additions, prioritized by user-visible value and closeness to current code:
| Priority | Surface | Spec / event ids | Why add it | Current gap |
| -------- | ----------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| high | Sticker messages | `m.sticker` | stable spec; common client surface; we already classify sticker attachments on read paths | no explicit inbound `m.sticker` event handling and no outbound sticker send path |
| high | Outbound location messages | `m.location`, `geo_uri` | stable spec; docs already say Matrix supports location, but current implementation is inbound-only | no outbound location send/action API |
| medium | Private + richer read markers | `m.read.private`, `m.fully_read` | stable spec; better privacy and cleaner read-state behavior than public-only `m.read` | current code only sends public `m.read` receipts |
| medium | Thread-aware receipts | `m.read` with `thread_id` | stable spec; better alignment with Matrix thread UX | current receipt send path has no `thread_id` support |
| medium | Unread markers | `m.marked_unread` | stable spec; useful for operator workflows and room triage tooling | no room account-data support for unread markers |
| low | Emote messages | `m.emote` | stable spec; easy parity win for bots/action tools | no explicit send/action support |
| low | Poll close parity | `m.poll.end`, `org.matrix.msc3381.poll.end` | completes the poll lifecycle on a surface we already parse | plugin parses poll-end events but does not expose an outbound close flow |
| low | Animated media metadata | `info.is_animated` on `m.image` / `m.sticker` | newer stable metadata; helps clients decide whether to fetch/render animated originals | media info builders do not populate animation metadata |
### Why these are listed
- `m.sticker` is a stable spec surface in the latest Matrix Client-Server API, and the plugin already has partial sticker awareness in message summarization.
- `m.location` is a stable spec surface in the latest Matrix Client-Server API. The plugin currently parses inbound location events from `geo_uri`, but does not send them.
- `m.read.private`, `m.fully_read`, and threaded receipts are stable read-marker/read-receipt surfaces in the latest Matrix Client-Server API. The plugin currently posts plain `m.read` receipts only.
- `m.marked_unread` is a stable room account-data surface in the latest Matrix Client-Server API and would be useful if Matrix actions grow more operator/client-like room triage controls.
- `m.emote` is a stable message type and a relatively small addition compared with the items above.
- `m.poll.end` is already represented in local poll types, so exposing a close-poll send/tool flow is a contained follow-up.
- `info.is_animated` is a smaller metadata-parity item, but easy to miss once sticker support exists.
### Probably not worth prioritizing here
- VoIP / `m.call.*`: valid Matrix spec area and a plausible future direction here, just not a near-term priority relative to messaging, receipt, and room-state gaps.
### Gap Evidence
- sticker partial only: `src/matrix/media-text.ts`
- location inbound only: `src/matrix/monitor/location.ts`, `src/matrix/monitor/handler.ts`
- public receipt only: `src/matrix/sdk.ts`, `src/matrix/send.ts`, `src/matrix/monitor/handler.ts`
- poll-end constants only: `src/matrix/poll-types.ts`
- no animation metadata emit: `src/matrix/send/media.ts`
### External Spec References
- latest Matrix Client-Server API: <https://spec.matrix.org/latest/client-server-api/index.html>
- `m.sticker`: <https://spec.matrix.org/latest/client-server-api/#msticker>
- `m.location`: <https://spec.matrix.org/latest/client-server-api/#mlocation>
- receipts and read markers (`m.read.private`, `m.fully_read`, `m.marked_unread`): <https://spec.matrix.org/latest/client-server-api/#receipts>

View file

@ -0,0 +1,18 @@
export { matrixPlugin } from "./src/channel.js";
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";
export * from "./src/account-selection.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";
export {
createMatrixThreadBindingManager,
getMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./src/matrix/thread-bindings-shared.js";
export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js";
export const matrixSessionBindingAdapterChannels = ["matrix"] as const;

View file

@ -0,0 +1,56 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import {
resolveMatrixCredentialsDir,
resolveMatrixCredentialsFilename,
} from "./src/storage-paths.js";
type MatrixAuthPresenceParams =
| {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}
| OpenClawConfig;
function listMatrixCredentialPaths(
_cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): readonly string[] {
const credentialsDir = resolveMatrixCredentialsDir(resolveStateDir(env, os.homedir));
const paths = new Set<string>([
resolveMatrixCredentialsFilename(),
resolveMatrixCredentialsFilename("default"),
]);
try {
const entries = fs.readdirSync(credentialsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && /^credentials(?:-[a-z0-9._-]+)?\.json$/i.test(entry.name)) {
paths.add(entry.name);
}
}
} catch {
// Missing credentials directories mean no persisted Matrix auth state.
}
return [...paths].map((filename) => path.join(credentialsDir, filename));
}
export function hasAnyMatrixAuth(
params: MatrixAuthPresenceParams,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const cfg = params && typeof params === "object" && "cfg" in params ? params.cfg : params;
const resolvedEnv =
params && typeof params === "object" && "cfg" in params ? (params.env ?? env) : env;
return listMatrixCredentialPaths(cfg, resolvedEnv).some((filePath) => {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
});
}

View file

@ -0,0 +1,3 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not pull the broad Matrix API barrel into lightweight plugin loads.
export { matrixPlugin } from "./src/channel.js";

View file

@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerMatrixCliMetadata } from "./src/cli-metadata.js";
export { registerMatrixCliMetadata } from "./src/cli-metadata.js";
export default definePluginEntry({
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)",
register: registerMatrixCliMetadata,
});

View file

@ -0,0 +1,17 @@
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./src/setup-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { matrixSetupAdapter } from "./src/setup-core.js";
export { matrixSetupWizard } from "./src/setup-surface.js";

View file

@ -0,0 +1,3 @@
export * from "./src/account-selection.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";

View file

@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
import entry, { registerMatrixFullRuntime } from "./index.js";
const cliMocks = vi.hoisted(() => ({
registerMatrixCli: vi.fn(),
}));
const runtimeMocks = vi.hoisted(() => ({
ensureMatrixCryptoRuntime: vi.fn(async () => {}),
handleVerificationBootstrap: vi.fn(async () => {}),
handleVerificationStatus: vi.fn(async () => {}),
handleVerifyRecoveryKey: vi.fn(async () => {}),
setMatrixRuntime: vi.fn(),
}));
vi.mock("./src/cli.js", () => {
return {
registerMatrixCli: cliMocks.registerMatrixCli,
};
});
vi.mock("./plugin-entry.handlers.runtime.js", () => runtimeMocks);
vi.mock("./runtime-api.js", () => ({ setMatrixRuntime: runtimeMocks.setMatrixRuntime }));
describe("matrix plugin", () => {
it("registers matrix CLI through a descriptor-backed lazy registrar", async () => {
const registerCli = vi.fn();
const registerGatewayMethod = vi.fn();
const api = createTestPluginApi({
id: "matrix",
name: "Matrix",
source: "test",
config: {},
runtime: {} as never,
registrationMode: "cli-metadata",
registerCli,
registerGatewayMethod,
});
registerMatrixCliMetadata(api);
const registrar = registerCli.mock.calls[0]?.[0];
expect(registerCli).toHaveBeenCalledWith(expect.any(Function), {
descriptors: [
{
name: "matrix",
description: "Manage Matrix accounts, verification, devices, and profile state",
hasSubcommands: true,
},
],
});
expect(typeof registrar).toBe("function");
expect(cliMocks.registerMatrixCli).not.toHaveBeenCalled();
const program = { command: vi.fn() };
const result = registrar?.({ program } as never);
await result;
expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program });
expect(registerGatewayMethod).not.toHaveBeenCalled();
});
it("keeps runtime bootstrap and CLI metadata out of setup-only registration", () => {
expect(entry.kind).toBe("bundled-channel-entry");
expect(entry.id).toBe("matrix");
expect(entry.name).toBe("Matrix");
});
it("registers subagent lifecycle hooks during full runtime registration", () => {
const on = vi.fn();
const registerGatewayMethod = vi.fn();
const api = createTestPluginApi({
id: "matrix",
name: "Matrix",
source: "test",
config: {},
runtime: {} as never,
registrationMode: "full",
on,
registerGatewayMethod,
});
registerMatrixFullRuntime(api);
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_spawning",
"subagent_ended",
"subagent_delivery_target",
]);
for (const [, handler] of on.mock.calls) {
expect(handler).toEqual(expect.any(Function));
}
});
});

View file

@ -0,0 +1,85 @@
import {
defineBundledChannelEntry,
type OpenClawPluginApi,
} from "openclaw/plugin-sdk/channel-entry-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
type MatrixHandlersRuntimeModule = typeof import("./plugin-entry.handlers.runtime.js");
type MatrixSubagentHooksModule = typeof import("./src/matrix/subagent-hooks.js");
let matrixHandlersRuntimePromise: Promise<MatrixHandlersRuntimeModule> | null = null;
let matrixSubagentHooksPromise: Promise<MatrixSubagentHooksModule> | null = null;
function loadMatrixHandlersRuntimeModule() {
matrixHandlersRuntimePromise ??= import("./plugin-entry.handlers.runtime.js");
return matrixHandlersRuntimePromise;
}
function loadMatrixSubagentHooksModule() {
matrixSubagentHooksPromise ??= import("./src/matrix/subagent-hooks.js");
return matrixSubagentHooksPromise;
}
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
void loadMatrixHandlersRuntimeModule()
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await loadMatrixHandlersRuntimeModule();
await handleVerifyRecoveryKey(ctx);
});
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
const { handleVerificationBootstrap } = await loadMatrixHandlersRuntimeModule();
await handleVerificationBootstrap(ctx);
});
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
const { handleVerificationStatus } = await loadMatrixHandlersRuntimeModule();
await handleVerificationStatus(ctx);
});
api.on("subagent_spawning", async (event) => {
const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
return await handleMatrixSubagentSpawning(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
await handleMatrixSubagentEnded(event);
});
api.on("subagent_delivery_target", async (event) => {
const { handleMatrixSubagentDeliveryTarget } = await loadMatrixSubagentHooksModule();
return handleMatrixSubagentDeliveryTarget(event);
});
}
export default defineBundledChannelEntry({
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "matrixPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setMatrixRuntime",
},
registerCliMetadata: registerMatrixCliMetadata,
registerFull: registerMatrixFullRuntime,
});

View file

@ -0,0 +1,23 @@
{
"id": "matrix",
"channels": ["matrix"],
"channelEnvVars": {
"matrix": [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_ID",
"MATRIX_DEVICE_NAME",
"MATRIX_OPS_HOMESERVER",
"MATRIX_OPS_ACCESS_TOKEN",
"MATRIX_OPS_DEVICE_ID",
"MATRIX_OPS_DEVICE_NAME"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,55 @@
{
"name": "@openclaw/matrix",
"version": "2026.4.20",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@matrix-org/matrix-sdk-crypto-wasm": "18.1.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.1",
"matrix-js-sdk": "41.3.0",
"music-metadata": "^11.12.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true,
"persistedAuthState": {
"specifier": "./auth-presence",
"exportName": "hasAnyMatrixAuth"
}
},
"install": {
"npmSpec": "@openclaw/matrix",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
},
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View file

@ -0,0 +1 @@
export * from "./src/plugin-entry.runtime.ts";

View file

@ -0,0 +1,54 @@
// Keep the external runtime API light so Jiti callers can resolve Matrix config
// helpers without traversing the full plugin-sdk/runtime graph or bootstrapping
// matrix-js-sdk during plain runtime-api import.
export * from "./src/auth-precedence.js";
export {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "./src/account-selection.js";
export * from "./src/account-selection.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";
export { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./src/matrix/deps.js";
export {
assertHttpUrlTargetsPrivateNetwork,
closeDispatcher,
createPinnedDispatcher,
resolvePinnedHostnameWithPolicy,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
ssrfPolicyFromAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
export {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./src/matrix/thread-bindings-shared.js";
export { setMatrixRuntime } from "./src/runtime.js";
export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
export type {
ChannelDirectoryEntry,
ChannelMessageActionContext,
OpenClawConfig,
PluginRuntime,
RuntimeLogger,
RuntimeEnv,
WizardPrompter,
} from "openclaw/plugin-sdk/matrix-runtime-shared";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
export function chunkTextForOutbound(text: string, limit: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const splitAt = Math.max(window.lastIndexOf("\n"), window.lastIndexOf(" "));
const breakAt = splitAt > 0 ? splitAt : limit;
chunks.push(remaining.slice(0, breakAt).trimEnd());
remaining = remaining.slice(breakAt).trimStart();
}
if (remaining.length > 0 || text.length === 0) {
chunks.push(remaining);
}
return chunks;
}

View file

@ -0,0 +1 @@
export * from "./src/matrix-migration.runtime.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,17 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "matrixPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setMatrixRuntime",
},
});

View file

@ -0,0 +1,141 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
describe("matrix account selection", () => {
it("resolves configured account ids from non-canonical account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
});
it("matches the default account against normalized Matrix account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
defaultAccount: "Team Ops",
accounts: {
"Ops Bot": { homeserver: "https://matrix.example.org" },
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: { homeserver: "https://matrix.example.org" },
alerts: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
});
it('uses a named "default" Matrix account when defaultAccount is unset', () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
default: { homeserver: "https://matrix.example.org" },
ops: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("default");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("finds the raw Matrix account entry by normalized account id", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
},
},
},
},
};
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
});
});
it("discovers env-backed named Matrix accounts during enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it('uses the "default" Matrix account when mixed default and named env-backed accounts exist', () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-secret",
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "team-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("discovers default Matrix accounts backed only by global env vars", () => {
const cfg: OpenClawConfig = {};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
});
});

View file

@ -0,0 +1,223 @@
import {
listCombinedAccountIds,
listConfiguredAccountIds,
resolveListedDefaultAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-core";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveMatrixAccountStringValues,
type MatrixResolvedStringField,
} from "./auth-precedence.js";
import { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds } from "./env-vars.js";
import { isRecord } from "./record-shared.js";
type MatrixTopologyStringSources = Partial<Record<MatrixResolvedStringField, string>>;
function readConfiguredMatrixString(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function readConfiguredMatrixSecretSource(value: unknown): string {
return hasConfiguredSecretInput(value) ? "configured" : "";
}
function resolveMatrixChannelStringSources(
entry: Record<string, unknown> | null,
): MatrixTopologyStringSources {
if (!entry) {
return {};
}
return {
homeserver: readConfiguredMatrixString(entry.homeserver),
userId: readConfiguredMatrixString(entry.userId),
accessToken: readConfiguredMatrixSecretSource(entry.accessToken),
password: readConfiguredMatrixSecretSource(entry.password),
deviceId: readConfiguredMatrixString(entry.deviceId),
deviceName: readConfiguredMatrixString(entry.deviceName),
};
}
function readEnvMatrixString(env: NodeJS.ProcessEnv, key: string): string {
return normalizeOptionalString(env[key]) ?? "";
}
function resolveScopedMatrixEnvStringSources(
accountId: string,
env: NodeJS.ProcessEnv,
): MatrixTopologyStringSources {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: readEnvMatrixString(env, keys.homeserver),
userId: readEnvMatrixString(env, keys.userId),
accessToken: readEnvMatrixString(env, keys.accessToken),
password: readEnvMatrixString(env, keys.password),
deviceId: readEnvMatrixString(env, keys.deviceId),
deviceName: readEnvMatrixString(env, keys.deviceName),
};
}
function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopologyStringSources {
return {
homeserver: readEnvMatrixString(env, "MATRIX_HOMESERVER"),
userId: readEnvMatrixString(env, "MATRIX_USER_ID"),
accessToken: readEnvMatrixString(env, "MATRIX_ACCESS_TOKEN"),
password: readEnvMatrixString(env, "MATRIX_PASSWORD"),
deviceId: readEnvMatrixString(env, "MATRIX_DEVICE_ID"),
deviceName: readEnvMatrixString(env, "MATRIX_DEVICE_NAME"),
};
}
function hasUsableResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
}): boolean {
// Account discovery must keep homeserver+userId shapes because auth can still
// resolve through cached Matrix credentials even when no fresh token/password
// is present in config or env.
return Boolean(values.homeserver && (values.accessToken || values.userId));
}
function hasFreshResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
password: string;
}): boolean {
return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password)));
}
function resolveEffectiveMatrixAccountSources(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): ReturnType<typeof resolveMatrixAccountStringValues> {
const normalizedAccountId = normalizeAccountId(params.accountId);
return resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env),
channel: resolveMatrixChannelStringSources(params.channel),
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
});
}
function hasUsableEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasUsableResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasFreshEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasFreshResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasConfiguredDefaultMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
env: NodeJS.ProcessEnv;
}): boolean {
return hasFreshEffectiveMatrixAccountSource({
channel: params.channel,
accountId: DEFAULT_ACCOUNT_ID,
env: params.env,
});
}
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
export function findMatrixAccountEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return null;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
if (!accounts) {
return null;
}
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
return isRecord(entry) ? entry : null;
}
export function resolveConfiguredMatrixAccountIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
const configuredAccountIds = listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
});
if (hasConfiguredDefaultMatrixAccountSource({ channel, env })) {
configuredAccountIds.push(DEFAULT_ACCOUNT_ID);
}
const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) =>
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID
? hasConfiguredDefaultMatrixAccountSource({ channel, env })
: hasUsableEffectiveMatrixAccountSource({ channel, accountId, env }),
);
return listCombinedAccountIds({
configuredAccountIds,
additionalAccountIds: readyEnvAccountIds,
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveMatrixDefaultOrOnlyAccountId(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return DEFAULT_ACCOUNT_ID;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
return resolveListedDefaultAccountId({
accountIds: configuredAccountIds,
configuredDefaultAccountId: configuredDefault,
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
});
}
export function requiresExplicitMatrixDefaultAccount(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredAccountIds.length <= 1) {
return false;
}
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}

View file

@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessageActionContext } from "../runtime-api.js";
import type { CoreConfig } from "./types.js";
const mocks = vi.hoisted(() => ({
handleMatrixAction: vi.fn(),
}));
vi.mock("./tool-actions.js", () => ({
handleMatrixAction: mocks.handleMatrixAction,
}));
const { matrixMessageActions } = await import("./actions.js");
const profileAction = "set-profile" as ChannelMessageActionContext["action"];
function createContext(
overrides: Partial<ChannelMessageActionContext>,
): ChannelMessageActionContext {
return {
channel: "matrix",
action: "send",
cfg: {
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
},
} as CoreConfig,
params: {},
...overrides,
};
}
describe("matrixMessageActions account propagation", () => {
beforeEach(() => {
mocks.handleMatrixAction.mockReset().mockResolvedValue({
ok: true,
output: "",
details: { ok: true },
});
});
it("forwards accountId for send actions", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
message: "hello",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
accountId: "ops",
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
it("forwards accountId for permissions actions", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "permissions",
accountId: "ops",
params: {
operation: "verification-list",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "verificationList",
accountId: "ops",
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
it("forwards accountId for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: true,
accountId: "ops",
params: {
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "setProfile",
accountId: "ops",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
it("rejects self-profile updates for non-owner callers", async () => {
await expect(
matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: false,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
),
).rejects.toMatchObject({
name: "ToolAuthorizationError",
message: "Matrix profile updates require owner access.",
});
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
});
it("rejects self-profile updates when owner status is unknown", async () => {
await expect(
matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
),
).rejects.toMatchObject({
name: "ToolAuthorizationError",
message: "Matrix profile updates require owner access.",
});
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
});
it("forwards local avatar paths for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: true,
accountId: "ops",
params: {
path: "/tmp/avatar.jpg",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "setProfile",
accountId: "ops",
avatarPath: "/tmp/avatar.jpg",
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
it("forwards mediaLocalRoots for media sends", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
params: {
to: "room:!room:example",
message: "hello",
media: "file:///tmp/photo.png",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
accountId: "ops",
mediaUrl: "file:///tmp/photo.png",
}),
expect.any(Object),
{ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] },
);
});
it("allows media-only sends without requiring a message body", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
media: "file:///tmp/photo.png",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
accountId: "ops",
content: undefined,
mediaUrl: "file:///tmp/photo.png",
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
it("accepts shared media aliases and forwards voice-send intent", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "send",
accountId: "ops",
params: {
to: "room:!room:example",
filePath: "/tmp/clip.mp3",
asVoice: true,
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
accountId: "ops",
content: undefined,
mediaUrl: "/tmp/clip.mp3",
audioAsVoice: true,
}),
expect.any(Object),
{ mediaLocalRoots: undefined },
);
});
});

View file

@ -0,0 +1,254 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import { matrixMessageActions } from "./actions.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
const profileAction = "set-profile" as const;
const runtimeStub = {
config: {
loadConfig: () => ({}),
},
media: {
loadWebMedia: async () => {
throw new Error("not used");
},
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: async () => null,
resizeToJpeg: async () => Buffer.from(""),
},
state: {
resolveStateDir: () => "/tmp/openclaw-matrix-test",
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
function createConfiguredMatrixConfig(): CoreConfig {
return {
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
},
} as CoreConfig;
}
describe("matrixMessageActions", () => {
beforeEach(() => {
setMatrixRuntime(runtimeStub);
});
it("exposes poll create but only handles poll votes inside the plugin", () => {
const describeMessageTool = matrixMessageActions.describeMessageTool;
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
expect(describeMessageTool).toBeTypeOf("function");
expect(supportsAction).toBeTypeOf("function");
const discovery = describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toContain("poll");
expect(actions).toContain("poll-vote");
expect(supportsAction({ action: "poll" } as never)).toBe(false);
expect(supportsAction({ action: "poll-vote" } as never)).toBe(true);
});
it("exposes and describes self-profile updates", () => {
const describeMessageTool = matrixMessageActions.describeMessageTool;
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
const discovery = describeMessageTool({
cfg: createConfiguredMatrixConfig(),
senderIsOwner: true,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
const schema = discovery.schema;
if (!schema) {
throw new Error("matrix schema missing");
}
const properties = (schema as { properties?: Record<string, unknown> }).properties ?? {};
expect(actions).toContain(profileAction);
expect(supportsAction({ action: profileAction } as never)).toBe(true);
expect(discovery.mediaSourceParams).toEqual({
"set-profile": ["avatarUrl", "avatarPath"],
});
expect(properties.displayName).toBeDefined();
expect(properties.avatarUrl).toBeDefined();
expect(properties.avatarPath).toBeDefined();
});
it("hides self-profile updates for non-owner discovery", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
senderIsOwner: false,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
expect(discovery.actions).not.toContain(profileAction);
expect(discovery.schema).toBeNull();
});
it("hides self-profile updates when owner status is unknown", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
expect(discovery.actions).not.toContain(profileAction);
expect(discovery.schema).toBeNull();
});
it("hides gated actions when the default Matrix account disables them", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
},
},
},
},
},
} as CoreConfig,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toEqual(["poll", "poll-vote"]);
});
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
const actions = discovery.actions;
expect(actions).toEqual([]);
});
it("honors the selected Matrix account during discovery", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@assistant:example.org",
accessToken: "assistant-token",
actions: {
messages: true,
reactions: false,
},
},
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
actions: {
messages: true,
reactions: true,
},
},
},
},
},
} as CoreConfig;
const describeMessageTool = matrixMessageActions.describeMessageTool;
if (!describeMessageTool) {
throw new Error("matrix message action discovery is unavailable");
}
const assistantDiscovery = describeMessageTool({
cfg,
accountId: "assistant",
} as never);
const opsDiscovery = describeMessageTool({
cfg,
accountId: "ops",
} as never);
if (!assistantDiscovery || !opsDiscovery) {
throw new Error("matrix action discovery returned null");
}
const assistantActions = assistantDiscovery.actions;
const opsActions = opsDiscovery.actions;
expect(assistantActions).not.toContain("react");
expect(assistantActions).not.toContain("reactions");
expect(opsActions).toContain("react");
expect(opsActions).toContain("reactions");
});
});

View file

@ -0,0 +1,343 @@
import { Type } from "@sinclair/typebox";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
import {
createActionGate,
readNumberParam,
readStringParam,
ToolAuthorizationError,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelMessageToolDiscovery,
} from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"poll-vote",
"react",
"reactions",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"set-profile",
"member-info",
"channel-info",
"permissions",
]);
const MATRIX_PROFILE_MEDIA_PROPERTIES = {
avatarUrl: Type.Optional(
Type.String({
description:
"Profile avatar URL for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatar_url: Type.Optional(
Type.String({
description:
"snake_case alias of avatarUrl for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatarPath: Type.Optional(
Type.String({
description:
"Local avatar file path for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
avatar_path: Type.Optional(
Type.String({
description:
"snake_case alias of avatarPath for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
} as const;
const MATRIX_PROFILE_MEDIA_SOURCE_PARAMS = Object.freeze(["avatarUrl", "avatarPath"]);
function createMatrixExposedActions(params: {
gate: ReturnType<typeof createActionGate>;
encryptionEnabled: boolean;
senderIsOwner?: boolean;
}) {
const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
if (params.gate("messages")) {
actions.add("send");
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (params.gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (params.gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (params.gate("profile") && params.senderIsOwner === true) {
actions.add("set-profile");
}
if (params.gate("memberInfo")) {
actions.add("member-info");
}
if (params.gate("channelInfo")) {
actions.add("channel-info");
}
if (params.encryptionEnabled && params.gate("verification")) {
actions.add("permissions");
}
return actions;
}
function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery["schema"]> {
return {
properties: {
displayName: Type.Optional(
Type.String({
description: "Profile display name for Matrix self-profile update actions.",
}),
),
display_name: Type.Optional(
Type.String({
description: "snake_case alias of displayName for Matrix self-profile update actions.",
}),
),
...MATRIX_PROFILE_MEDIA_PROPERTIES,
},
};
}
export const matrixMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
const resolvedCfg = cfg as CoreConfig;
if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
return { actions: [], capabilities: [] };
}
const account = resolveMatrixAccount({
cfg: resolvedCfg,
accountId: accountId ?? resolveDefaultMatrixAccountId(resolvedCfg),
});
if (!account.enabled || !account.configured) {
return { actions: [], capabilities: [] };
}
const gate = createActionGate(account.config.actions);
const actions = createMatrixExposedActions({
gate,
encryptionEnabled: account.config.encryption === true,
senderIsOwner,
});
const listedActions = Array.from(actions);
return {
actions: listedActions,
capabilities: [],
schema: listedActions.includes("set-profile") ? buildMatrixProfileToolSchema() : null,
mediaSourceParams: listedActions.includes("set-profile")
? { "set-profile": MATRIX_PROFILE_MEDIA_SOURCE_PARAMS }
: null,
};
},
supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async (ctx: ChannelMessageActionContext) => {
const { handleMatrixAction } = await import("./tool-actions.runtime.js");
const { action, params, cfg, accountId, mediaLocalRoots } = ctx;
const dispatch = async (actionParams: Record<string, unknown>) =>
await handleMatrixAction(
{
...actionParams,
...(accountId ? { accountId } : {}),
},
cfg as CoreConfig,
{ mediaLocalRoots },
);
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "mediaUrl", { trim: false }) ??
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false });
const content = readStringParam(params, "message", {
required: !mediaUrl,
allowEmpty: true,
});
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const audioAsVoice =
typeof params.asVoice === "boolean"
? params.asVoice
: typeof params.audioAsVoice === "boolean"
? params.audioAsVoice
: undefined;
return await dispatch({
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyTo ?? undefined,
threadId: threadId ?? undefined,
audioAsVoice,
});
}
if (action === "poll-vote") {
return await dispatch({
...params,
action: "pollVote",
});
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", { required: true });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await dispatch({
action: "react",
roomId: resolveRoomId(),
messageId,
emoji,
remove,
});
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await dispatch({
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
});
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await dispatch({
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
});
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await dispatch({
action: "editMessage",
roomId: resolveRoomId(),
messageId,
content,
});
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await dispatch({
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await dispatch({
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "set-profile") {
if (ctx.senderIsOwner !== true) {
throw new ToolAuthorizationError("Matrix profile updates require owner access.");
}
const avatarPath =
readStringParam(params, "avatarPath") ??
readStringParam(params, "path") ??
readStringParam(params, "filePath");
return await dispatch({
action: "setProfile",
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
avatarPath,
});
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await dispatch({
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
});
}
if (action === "channel-info") {
return await dispatch({
action: "channelInfo",
roomId: resolveRoomId(),
});
}
if (action === "permissions") {
const operation = normalizeLowercaseStringOrEmpty(
readStringParam(params, "operation") ??
readStringParam(params, "mode") ??
"verification-list",
);
const operationToAction: Record<string, string> = {
"encryption-status": "encryptionStatus",
"verification-status": "verificationStatus",
"verification-bootstrap": "verificationBootstrap",
"verification-recovery-key": "verificationRecoveryKey",
"verification-backup-status": "verificationBackupStatus",
"verification-backup-restore": "verificationBackupRestore",
"verification-list": "verificationList",
"verification-request": "verificationRequest",
"verification-accept": "verificationAccept",
"verification-cancel": "verificationCancel",
"verification-start": "verificationStart",
"verification-generate-qr": "verificationGenerateQr",
"verification-scan-qr": "verificationScanQr",
"verification-sas": "verificationSas",
"verification-confirm": "verificationConfirm",
"verification-mismatch": "verificationMismatch",
"verification-confirm-qr": "verificationConfirmQr",
};
const resolvedAction = operationToAction[operation];
if (!resolvedAction) {
throw new Error(
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
operationToAction,
).join(", ")}`,
);
}
return await dispatch({
...params,
action: resolvedAction,
});
}
throw new Error(`Action ${action} is not supported for provider matrix.`);
},
};

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { matrixApprovalAuth } from "./approval-auth.js";
describe("matrixApprovalAuth", () => {
it("normalizes Matrix user ids before authorizing", () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@Owner:Example.org"] },
},
},
};
expect(
matrixApprovalAuth.authorizeActorAction({
cfg,
senderId: "@owner:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
});
});

View file

@ -0,0 +1,25 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
export function getMatrixApprovalAuthApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params);
return resolveApprovalApprovers({
allowFrom: account.config.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
export const matrixApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Matrix",
resolveApprovers: ({ cfg, accountId }) =>
getMatrixApprovalAuthApprovers({ cfg: cfg as CoreConfig, accountId }),
normalizeSenderId: (value) => normalizeMatrixApproverId(value),
});

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("matrixApprovalNativeRuntime", () => {
it("uses a longer code fence when resolved commands contain triple backticks", async () => {
const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
resolved: {
id: "req-1",
decision: "allow-once",
ts: 0,
},
view: {
approvalKind: "exec",
approvalId: "req-1",
decision: "allow-once",
commandText: "echo ```danger```",
} as never,
entry: {} as never,
});
expect(result).toEqual({
kind: "update",
payload: [
"Exec approval: Allowed once",
"",
"Command",
"````",
"echo ```danger```",
"````",
].join("\n"),
});
});
});

View file

@ -0,0 +1,400 @@
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
ResolvedApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixAnyApprovalClientEnabled,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js";
import { repairMatrixDirectRooms } from "./matrix/direct-management.js";
import type { MatrixClient } from "./matrix/sdk.js";
import { reactMatrixMessage, sendMessageMatrix } from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
type MatrixRawApprovalTarget = {
to: string;
threadId?: string | number | null;
};
type MatrixPrepareTargetParams = {
cfg: CoreConfig;
accountId?: string | null;
gatewayUrl?: string;
context?: unknown;
rawTarget: MatrixRawApprovalTarget;
};
export type MatrixApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
export type MatrixApprovalHandlerContext = {
client: MatrixClient;
deps?: MatrixApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: MatrixApprovalHandlerContext;
} | null {
const context = params.context as MatrixApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.client || !accountId) {
return null;
}
return { accountId, context };
}
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
async function prepareTarget(
params: MatrixPrepareTargetParams,
): Promise<PreparedMatrixTarget | null> {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return null;
}
const target = resolveMatrixTargetIdentity(params.rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeThreadId(params.rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: params.cfg,
accountId: resolved.accountId,
});
const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms;
const repaired = await repairDirectRooms({
client: resolved.context.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
});
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
function buildPendingApprovalContent(params: {
view: PendingApprovalView;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions = params.view.actions.map((action) => action.decision);
const payload =
params.view.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: {
id: params.view.approvalId,
request: {
title: params.view.title,
description: params.view.description ?? "",
severity: params.view.severity,
toolName: params.view.toolName ?? undefined,
pluginId: params.view.pluginId ?? undefined,
agentId: params.view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: params.view.expiresAtMs,
} satisfies PluginApprovalRequest,
nowMs: params.nowMs,
allowedDecisions,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.view.approvalId,
approvalSlug: params.view.approvalId.slice(0, 8),
approvalCommandId: params.view.approvalId,
ask: params.view.ask ?? undefined,
agentId: params.view.agentId ?? undefined,
allowedDecisions,
command: params.view.commandText,
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
sessionKey: params.view.sessionKey ?? undefined,
expiresAtMs: params.view.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.view.approvalId,
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
};
}
function buildResolvedApprovalText(view: ResolvedApprovalView): string {
if (view.approvalKind === "plugin") {
return (
buildPluginApprovalResolvedReplyPayload({
resolved: {
id: view.approvalId,
decision: view.decision,
resolvedBy: view.resolvedBy ?? undefined,
ts: 0,
},
}).text ?? ""
);
}
const decisionLabel =
view.decision === "allow-once"
? "Allowed once"
: view.decision === "allow-always"
? "Allowed always"
: "Denied";
return [
`Exec approval: ${decisionLabel}`,
"",
"Command",
buildMarkdownCodeBlock(view.commandText),
].join("\n");
}
function buildMarkdownCodeBlock(text: string): string {
const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0);
const fence = "`".repeat(Math.max(3, longestFence + 1));
return [fence, text, fence].join("\n");
}
export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
PendingApprovalContent,
PreparedMatrixTarget,
PendingMessage,
ReactionTargetRef,
string
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ cfg, accountId, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return isMatrixAnyApprovalClientEnabled({
cfg,
accountId: resolved.accountId,
});
},
shouldHandle: ({ cfg, accountId, request, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return shouldHandleMatrixApprovalRequest({
cfg,
accountId: resolved.accountId,
request: request as ExecApprovalRequest | PluginApprovalRequest,
});
},
},
presentation: {
buildPendingPayload: ({ view, nowMs }) =>
buildPendingApprovalContent({
view,
nowMs,
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: buildResolvedApprovalText(view),
}),
buildExpiredResult: () => ({ kind: "delete" }),
},
transport: {
prepareTarget: ({ cfg, accountId, context, plannedTarget }) => {
return prepareTarget({
cfg,
accountId,
context,
rawTarget: plannedTarget.target,
}).then((preparedTarget) =>
preparedTarget
? {
dedupeKey: buildChannelApprovalNativeTargetKey({
to: preparedTarget.roomId,
threadId: preparedTarget.threadId,
}),
target: preparedTarget,
}
: null,
);
},
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage;
const result = await sendMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
});
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map(
async ({ emoji }) => {
await reactMessage(result.roomId, reactionEventId, emoji, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
});
},
),
);
return {
roomId: result.roomId,
messageIds,
reactionEventId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage;
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
const text = payload;
await Promise.allSettled([
editMessage(entry.roomId, primaryMessageId, text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
}),
...staleMessageIds.map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: "approval resolved",
});
}),
]);
},
deleteEntry: async ({ cfg, accountId, context, entry, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: phase === "expired" ? "approval expired" : "approval resolved",
});
}),
);
},
},
interactions: {
bindPending: ({ entry, pendingPayload }) => {
const target = normalizeReactionTargetRef({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
roomId: target.roomId,
eventId: target.eventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
});
return target;
},
unbindPending: ({ binding }) => {
const target = normalizeReactionTargetRef(binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
},
});

View file

@ -0,0 +1,6 @@
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}

View file

@ -0,0 +1,329 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { matrixApprovalCapability } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
...overrides,
},
},
} as OpenClawConfig;
}
describe("matrix approval capability", () => {
it("describes the correct Matrix exec-approval setup path", () => {
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
channel: "matrix",
channelLabel: "Matrix",
});
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
expect(text).toContain("`channels.matrix.dm.allowFrom`");
});
it("describes the named-account Matrix exec-approval setup path", () => {
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
channel: "matrix",
channelLabel: "Matrix",
accountId: "work",
});
expect(text).toContain("`channels.matrix.accounts.work.execApprovals.approvers`");
expect(text).toContain("`channels.matrix.accounts.work.dm.allowFrom`");
expect(text).not.toContain("`channels.matrix.execApprovals.approvers`");
});
it("describes native matrix approval delivery capabilities", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("resolves origin targets from matrix turn source", async () => {
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!ops:example.org",
threadId: "$thread",
});
});
it("resolves approver dm targets", async () => {
const targets = await matrixApprovalCapability.native?.resolveApproverDmTargets?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
});
it("suppresses same-channel plugin forwarding when Matrix native delivery is available", () => {
const shouldSuppress = matrixApprovalCapability.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("delivery suppression helper unavailable");
}
expect(
shouldSuppress({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
approvalKind: "plugin",
target: {
channel: "matrix",
to: "room:!ops:example.org",
accountId: "default",
},
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin action",
pluginId: "git-tools",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
} as never),
).toBe(true);
});
it("preserves room-id case when matching Matrix origin targets", async () => {
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "matrix",
turnSourceTo: "room:!Ops:Example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
sessionKey: "agent:main:matrix:channel:!Ops:Example.org",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!Ops:Example.org",
threadId: "$thread",
});
});
it("keeps plugin approval auth independent from exec approvers", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: true,
approvers: ["@exec:example.org"],
target: "both",
},
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@owner:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve plugin requests on Matrix.",
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
it("requires Matrix DM approvers before enabling plugin approval auth", () => {
const cfg = buildConfig({
dm: { allowFrom: [] },
execApprovals: {
enabled: true,
approvers: ["@exec:example.org"],
target: "both",
},
});
expect(
matrixApprovalCapability.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "@exec:example.org",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
});
});
it("reports exec initiating-surface availability independently from plugin auth", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: false,
approvers: [],
target: "both",
},
});
expect(
matrixApprovalCapability.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ kind: "enabled" });
expect(
matrixApprovalCapability.getExecInitiatingSurfaceState?.({
cfg,
accountId: "default",
action: "approve",
}),
).toEqual({ kind: "disabled" });
});
it("enables matrix-native plugin approval delivery when DM approvers are configured", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("keeps matrix-native plugin approval delivery disabled without DM approvers", () => {
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: false,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View file

@ -0,0 +1,345 @@
import {
createChannelApprovalCapability,
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import {
getMatrixApprovalApprovers,
getMatrixExecApprovalApprovers,
isMatrixAnyApprovalClientEnabled,
isMatrixApprovalClientEnabled,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalAuthorizedSender,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { listMatrixAccountIds } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type MatrixOriginTarget = { to: string; threadId?: string };
function normalizeComparableTarget(value: string): string {
const target = resolveMatrixTargetIdentity(value);
if (!target) {
return normalizeLowercaseStringOrEmpty(value);
}
if (target.kind === "user") {
return `user:${normalizeMatrixUserId(target.id)}`;
}
return `${normalizeLowercaseStringOrEmpty(target.kind)}:${target.id}`;
}
function resolveMatrixNativeTarget(raw: string): string | null {
const target = resolveMatrixTargetIdentity(raw);
if (!target) {
return null;
}
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
}
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
const target = resolveMatrixNativeTarget(turnSourceTo);
if (turnSourceChannel !== "matrix" || !target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
};
}
function resolveSessionMatrixOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): MatrixOriginTarget | null {
const target = resolveMatrixNativeTarget(sessionTarget.to);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
};
}
function matrixTargetsMatch(a: MatrixOriginTarget, b: MatrixOriginTarget): boolean {
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
(a.threadId ?? "") === (b.threadId ?? "")
);
}
function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string | null }): boolean {
return getMatrixApprovalAuthApprovers(params).length > 0;
}
function availabilityState(enabled: boolean) {
return enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
}
function hasMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
return (
getMatrixApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
}).length > 0
);
}
function hasAnyMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): boolean {
return (
getMatrixExecApprovalApprovers(params).length > 0 ||
getMatrixApprovalAuthApprovers(params).length > 0
);
}
function isMatrixPluginAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalAuthApprovers(params).includes(normalizedSenderId);
}
function resolveSuppressionAccountId(params: {
target: { accountId?: string | null };
request: { request: { turnSourceAccountId?: string | null } };
}): string | undefined {
return (
params.target.accountId?.trim() ||
params.request.request.turnSourceAccountId?.trim() ||
undefined
);
}
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
channel: "matrix",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
resolveSessionTarget: resolveSessionMatrixOriginTarget,
targetsMatch: matrixTargetsMatch,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "matrix",
});
if (!sessionConversation) {
return null;
}
const target = resolveMatrixNativeTarget(sessionConversation.id);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionConversation.threadId),
};
},
});
function resolveMatrixApproverDmTargets(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): { to: string }[] {
if (!shouldHandleMatrixApprovalRequest(params)) {
return [];
}
return getMatrixApprovalApprovers(params)
.map((approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
})
.filter((target): target is { to: string } => target !== null);
}
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "matrix",
channelLabel: "Matrix",
describeExecApprovalSetup: ({
accountId,
}: Parameters<NonNullable<ChannelApprovalCapability["describeExecApprovalSetup"]>>[0]) => {
const prefix =
accountId && accountId !== "default"
? `channels.matrix.accounts.${accountId}`
: "channels.matrix";
return `Approve it from the Web UI or terminal UI for now. Matrix supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`${prefix}.dm.allowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
},
listAccountIds: listMatrixAccountIds,
hasApprovers: ({ cfg, accountId }) =>
hasAnyMatrixApprovalApprovers({
cfg: cfg as CoreConfig,
accountId,
}),
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixPluginAuthorizedSender({
cfg: cfg as CoreConfig,
accountId,
senderId,
}),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveMatrixExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId,
resolveOriginTarget: resolveMatrixOriginTarget,
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isMatrixAnyApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.matrixApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
const splitMatrixApprovalCapability = splitChannelApprovalCapability(
matrixNativeApprovalCapability,
);
const matrixBaseNativeApprovalAdapter = splitMatrixApprovalCapability.native;
const matrixBaseDeliveryAdapter = splitMatrixApprovalCapability.delivery;
type MatrixForwardingSuppressionParams = Parameters<
NonNullable<NonNullable<typeof matrixBaseDeliveryAdapter>["shouldSuppressForwardingFallback"]>
>[0];
const matrixDeliveryAdapter = matrixBaseDeliveryAdapter && {
...matrixBaseDeliveryAdapter,
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) => {
const accountId = resolveSuppressionAccountId(params);
if (
!hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId,
approvalKind: params.approvalKind,
})
) {
return false;
}
return matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false;
},
};
const matrixNativeAdapter = matrixBaseNativeApprovalAdapter && {
describeDeliveryCapabilities: (
params: Parameters<typeof matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities>[0],
) => {
const capabilities = matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params);
const hasApprovers = hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
const clientEnabled = isMatrixApprovalClientEnabled({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
return {
...capabilities,
enabled: capabilities.enabled && hasApprovers && clientEnabled,
};
},
resolveOriginTarget: matrixBaseNativeApprovalAdapter.resolveOriginTarget,
resolveApproverDmTargets: matrixBaseNativeApprovalAdapter.resolveApproverDmTargets,
};
export const matrixApprovalCapability = createChannelApprovalCapability({
authorizeActorAction: (
params: Parameters<NonNullable<ChannelApprovalCapability["authorizeActorAction"]>>[0],
) => {
if (params.approvalKind !== "plugin") {
return matrixNativeApprovalCapability.authorizeActorAction?.(params) ?? { authorized: true };
}
if (
!hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
})
) {
return {
authorized: false,
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
} as const;
}
return matrixApprovalAuth.authorizeActorAction(params);
},
getActionAvailabilityState: (
params: Parameters<NonNullable<ChannelApprovalCapability["getActionAvailabilityState"]>>[0],
) => {
if (params.approvalKind === "plugin") {
return availabilityState(
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
}),
);
}
return (
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ?? {
kind: "disabled",
}
);
},
getExecInitiatingSurfaceState: (
params: Parameters<NonNullable<ChannelApprovalCapability["getExecInitiatingSurfaceState"]>>[0],
) =>
matrixNativeApprovalCapability.getExecInitiatingSurfaceState?.(params) ??
({ kind: "disabled" } as const),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixNativeApprovalCapability.nativeRuntime,
native: matrixNativeAdapter,
render: matrixNativeApprovalCapability.render,
});

View file

@ -0,0 +1,45 @@
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type MatrixApprovalReactionKind = "exec" | "plugin";
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
return normalized === "*" ? undefined : normalized;
}
function getMatrixApprovalReactionApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): string[] {
const account = resolveMatrixAccount(params).config;
if (params.approvalKind === "plugin") {
return resolveApprovalApprovers({
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixApproverId,
});
}
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixExecApproverId,
});
}
export function isMatrixApprovalReactionAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
approvalKind: MatrixApprovalReactionKind;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalReactionApprovers(params).includes(normalizedSenderId);
}

View file

@ -0,0 +1,107 @@
import { afterEach, describe, expect, it } from "vitest";
import {
buildMatrixApprovalReactionHint,
clearMatrixApprovalReactionTargetsForTest,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
afterEach(() => {
clearMatrixApprovalReactionTargetsForTest();
});
describe("matrix approval reactions", () => {
it("lists reactions in stable decision order", () => {
expect(listMatrixApprovalReactionBindings(["allow-once", "deny", "allow-always"])).toEqual([
{ decision: "allow-once", emoji: "✅", label: "Allow once" },
{ decision: "allow-always", emoji: "♾️", label: "Allow always" },
{ decision: "deny", emoji: "❌", label: "Deny" },
]);
});
it("builds a compact reaction hint", () => {
expect(buildMatrixApprovalReactionHint(["allow-once", "deny"])).toBe(
"React here: ✅ Allow once, ❌ Deny",
);
});
it("resolves a registered approval anchor event back to an approval decision", () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-once",
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-always",
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "❌",
}),
).toEqual({
approvalId: "req-123",
decision: "deny",
});
});
it("ignores reactions that are not allowed on the registered approval anchor event", () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toBeNull();
});
it("stops resolving reactions after the approval anchor event is unregistered", () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
unregisterMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toBeNull();
});
});

View file

@ -0,0 +1,158 @@
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
const MATRIX_APPROVAL_REACTION_META = {
"allow-once": {
emoji: "✅",
label: "Allow once",
},
"allow-always": {
emoji: "♾️",
label: "Allow always",
},
deny: {
emoji: "❌",
label: "Deny",
},
} satisfies Record<ExecApprovalReplyDecision, { emoji: string; label: string }>;
const MATRIX_APPROVAL_REACTION_ORDER = [
"allow-once",
"allow-always",
"deny",
] as const satisfies readonly ExecApprovalReplyDecision[];
export type MatrixApprovalReactionBinding = {
decision: ExecApprovalReplyDecision;
emoji: string;
label: string;
};
export type MatrixApprovalReactionResolution = {
approvalId: string;
decision: ExecApprovalReplyDecision;
};
type MatrixApprovalReactionTarget = {
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
const matrixApprovalReactionTargets = new Map<string, MatrixApprovalReactionTarget>();
function buildReactionTargetKey(roomId: string, eventId: string): string | null {
const normalizedRoomId = roomId.trim();
const normalizedEventId = eventId.trim();
if (!normalizedRoomId || !normalizedEventId) {
return null;
}
return `${normalizedRoomId}:${normalizedEventId}`;
}
export function listMatrixApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): MatrixApprovalReactionBinding[] {
const allowed = new Set(allowedDecisions);
return MATRIX_APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)).map(
(decision) => ({
decision,
emoji: MATRIX_APPROVAL_REACTION_META[decision].emoji,
label: MATRIX_APPROVAL_REACTION_META[decision].label,
}),
);
}
export function buildMatrixApprovalReactionHint(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): string | null {
const bindings = listMatrixApprovalReactionBindings(allowedDecisions);
if (bindings.length === 0) {
return null;
}
return `React here: ${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join(", ")}`;
}
export function resolveMatrixApprovalReactionDecision(
reactionKey: string,
allowedDecisions: readonly ExecApprovalReplyDecision[],
): ExecApprovalReplyDecision | null {
const normalizedReaction = reactionKey.trim();
if (!normalizedReaction) {
return null;
}
const allowed = new Set(allowedDecisions);
for (const decision of MATRIX_APPROVAL_REACTION_ORDER) {
if (!allowed.has(decision)) {
continue;
}
if (MATRIX_APPROVAL_REACTION_META[decision].emoji === normalizedReaction) {
return decision;
}
}
return null;
}
export function registerMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
const approvalId = params.approvalId.trim();
const allowedDecisions = Array.from(
new Set(
params.allowedDecisions.filter(
(decision): decision is ExecApprovalReplyDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
),
),
);
if (!key || !approvalId || allowedDecisions.length === 0) {
return;
}
matrixApprovalReactionTargets.set(key, {
approvalId,
allowedDecisions,
});
}
export function unregisterMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return;
}
matrixApprovalReactionTargets.delete(key);
}
export function resolveMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
reactionKey: string;
}): MatrixApprovalReactionResolution | null {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
const target = matrixApprovalReactionTargets.get(key);
if (!target) {
return null;
}
const decision = resolveMatrixApprovalReactionDecision(
params.reactionKey,
target.allowedDecisions,
);
if (!decision) {
return null;
}
return {
approvalId: target.approvalId,
decision,
};
}
export function clearMatrixApprovalReactionTargetsForTest(): void {
matrixApprovalReactionTargets.clear();
}

View file

@ -0,0 +1,61 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
export type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
export function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}

View file

@ -0,0 +1,92 @@
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import type { PinnedDispatcherPolicy, SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import type { MatrixProbe } from "./matrix/probe.js";
import type { CoreConfig } from "./types.js";
type ResolveMatrixAuth = (params: { cfg: CoreConfig; accountId?: string }) => Promise<{
homeserver: string;
accessToken: string;
userId: string;
deviceId?: string;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}>;
type ProbeMatrix = (params: {
homeserver: string;
accessToken: string;
userId: string;
deviceId?: string;
timeoutMs: number;
accountId?: string;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}) => Promise<MatrixProbe>;
type SendMessageMatrix = (
to: string,
message: string,
options?: { accountId?: string },
) => Promise<unknown>;
export function createMatrixProbeAccount(params: {
resolveMatrixAuth: ResolveMatrixAuth;
probeMatrix: ProbeMatrix;
}) {
return async ({
account,
timeoutMs,
cfg,
}: {
account: { accountId?: string };
timeoutMs?: number;
cfg: unknown;
}): Promise<MatrixProbe> => {
try {
const auth = await params.resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await params.probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: auth.deviceId,
timeoutMs: timeoutMs ?? 5_000,
accountId: account.accountId,
allowPrivateNetwork: auth.allowPrivateNetwork,
ssrfPolicy: auth.ssrfPolicy,
dispatcherPolicy: auth.dispatcherPolicy,
});
} catch (err) {
return {
ok: false,
error: formatMatrixErrorMessage(err),
elapsedMs: 0,
};
}
};
}
export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) {
return {
idLabel: "matrixUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i),
notify: async ({
id,
message,
accountId,
}: {
id: string;
message: string;
accountId?: string;
}) => {
await sendMessageMatrix(`user:${id}`, message, accountId ? { accountId } : {});
},
};
}

View file

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
const probeMatrixMock = vi.hoisted(() => vi.fn());
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
vi.mock("./matrix/send.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./matrix/send.js");
return {
...actual,
sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args),
};
});
vi.mock("./matrix/probe.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/probe.js")>("./matrix/probe.js");
return {
...actual,
probeMatrix: (...args: unknown[]) => probeMatrixMock(...args),
};
});
vi.mock("./matrix/client.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/client.js")>("./matrix/client.js");
return {
...actual,
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
};
});
describe("matrix account path propagation", () => {
beforeEach(() => {
vi.clearAllMocks();
sendMessageMatrixMock.mockResolvedValue({
messageId: "$sent",
roomId: "!room:example.org",
});
probeMatrixMock.mockResolvedValue({
ok: true,
error: null,
status: null,
elapsedMs: 5,
userId: "@poe:example.org",
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "poe",
homeserver: "https://matrix.example.org",
userId: "@poe:example.org",
accessToken: "poe-token",
deviceId: "POEDEVICE",
});
});
it("forwards accountId when notifying pairing approval", async () => {
const pairingText = createMatrixPairingText(sendMessageMatrixMock);
expect(pairingText.normalizeAllowEntry(" matrix:@user:example.org ")).toBe(
"@user:example.org",
);
await pairingText.notify({
id: "@user:example.org",
message: pairingText.message,
accountId: "poe",
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
"user:@user:example.org",
expect.any(String),
{ accountId: "poe" },
);
});
it("forwards accountId and deviceId to matrix probes", async () => {
const probeAccount = createMatrixProbeAccount({
resolveMatrixAuth: resolveMatrixAuthMock,
probeMatrix: probeMatrixMock,
});
await probeAccount({
cfg: {} as never,
timeoutMs: 500,
account: {
accountId: "poe",
} as never,
});
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: {},
accountId: "poe",
});
expect(probeMatrixMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
accessToken: "poe-token",
userId: "@poe:example.org",
deviceId: "POEDEVICE",
timeoutMs: 500,
accountId: "poe",
});
});
});

View file

@ -0,0 +1,601 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { RuntimeEnv } from "../runtime-api.js";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix directory", () => {
const runtimeEnv: RuntimeEnv = createRuntimeEnv();
beforeEach(() => {
installMatrixTestRuntime();
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
matrix: {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("only exposes real Matrix thread ids in tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: undefined,
hasRepliedRef: { value: false },
});
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: true },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: "$thread",
hasRepliedRef: { value: true },
});
});
it("exposes Matrix direct user id in dm tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
From: "matrix:@alice:example.org",
To: "room:!dm:example.org",
ChatType: "direct",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!dm:example.org",
currentThreadTs: "$thread",
currentDirectUserId: "@alice:example.org",
hasRepliedRef: { value: false },
});
});
it("accepts raw room ids when inferring Matrix direct user ids", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
cfg: {} as CoreConfig,
context: {
From: "user:@alice:example.org",
To: "!dm:example.org",
ChatType: "direct",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "!dm:example.org",
currentThreadTs: undefined,
currentDirectUserId: "@alice:example.org",
hasRepliedRef: { value: false },
});
});
it("resolves group mention policy from account config", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!room:example.org": { requireMention: true },
},
accounts: {
Assistant: {
groups: {
"!room:example.org": { requireMention: false },
},
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
true,
);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "!room:example.org",
}),
).toBe(false);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "matrix:room:!room:example.org",
}),
).toBe(false);
});
it("matches prefixed Matrix aliases in group context", () => {
const cfg = {
channels: {
matrix: {
groups: {
"#ops:example.org": { requireMention: false },
},
},
},
} as unknown as CoreConfig;
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
groupId: "matrix:room:!room:example.org",
groupChannel: "matrix:channel:#ops:example.org",
}),
).toBe(false);
});
it("reports room access warnings against the active Matrix config path", () => {
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
},
},
} as CoreConfig,
accountId: "default",
}),
}),
).toEqual([
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
]);
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
groupPolicy: "open",
},
},
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
accounts: {
assistant: {
groupPolicy: "open",
},
},
},
},
} as CoreConfig,
accountId: "assistant",
}),
}),
).toEqual([
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.accounts.assistant.groupPolicy="allowlist" + channels.matrix.accounts.assistant.groups (and optionally channels.matrix.accounts.assistant.groupAllowFrom) to restrict rooms.',
]);
});
it("reports invite auto-join warnings only when explicitly enabled", () => {
expect(
matrixPlugin.security?.collectWarnings?.({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
autoJoin: "always",
},
},
} as CoreConfig,
account: resolveMatrixAccount({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
autoJoin: "always",
},
},
} as CoreConfig,
accountId: "default",
}),
}),
).toEqual([
'- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set channels.matrix.autoJoin="allowlist" + channels.matrix.autoJoinAllowlist (or channels.matrix.autoJoin="off") to restrict joins.',
]);
});
it("writes matrix non-default account credentials under channels.matrix.accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://default.example.org",
accessToken: "default-token",
deviceId: "DEFAULTDEVICE",
avatarUrl: "mxc://server/avatar",
encryption: true,
threadReplies: "inbound",
groups: {
"!room:example.org": { requireMention: true },
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix"]?.deviceId).toBeUndefined();
expect(updated.channels?.["matrix"]?.avatarUrl).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({
accessToken: "default-token",
homeserver: "https://default.example.org",
deviceId: "DEFAULTDEVICE",
avatarUrl: "mxc://server/avatar",
encryption: true,
threadReplies: "inbound",
groups: {
"!room:example.org": { requireMention: true },
},
});
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
});
expect(resolveMatrixConfigForAccount(updated, "ops", {})).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: undefined,
});
});
it("writes default matrix account credentials under channels.matrix.accounts.default", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
});
expect(updated.channels?.["matrix"]?.accounts).toBeUndefined();
});
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
const envKeys = [
"MATRIX_OPS_HOMESERVER",
"MATRIX_OPS_USER_ID",
"MATRIX_OPS_ACCESS_TOKEN",
"MATRIX_OPS_PASSWORD",
] as const;
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
for (const key of envKeys) {
delete process.env[key];
}
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBe(
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
);
} finally {
for (const key of envKeys) {
if (previousEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = previousEnv[key];
}
}
}
});
it("accepts --use-env for non-default account when scoped env vars are present", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBeNull();
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("clears stored auth fields when switching a Matrix account to env-backed auth", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_DEVICE_ID: process.env.MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_DEVICE_NAME: process.env.MATRIX_OPS_DEVICE_NAME,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.env.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token";
process.env.MATRIX_OPS_DEVICE_ID = "OPSENVDEVICE";
process.env.MATRIX_OPS_DEVICE_NAME = "Ops Env Device";
try {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.inline.example.org",
userId: "@ops:inline.example.org",
accessToken: "ops-inline-token",
password: "ops-inline-password", // pragma: allowlist secret
deviceId: "OPSINLINEDEVICE",
deviceName: "Ops Inline Device",
encryption: true,
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
useEnv: true,
name: "Ops",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
encryption: true,
});
expect(updated.channels?.["matrix"]?.accounts?.ops?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.ops?.userId).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.ops?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.ops?.password).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.ops?.deviceId).toBeUndefined();
expect(updated.channels?.["matrix"]?.accounts?.ops?.deviceName).toBeUndefined();
expect(resolveMatrixConfigForAccount(updated, "ops", process.env)).toMatchObject({
homeserver: "https://ops.env.example.org",
accessToken: "ops-env-token",
deviceId: "OPSENVDEVICE",
deviceName: "Ops Env Device",
});
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("resolves account id from input name when explicit account id is missing", () => {
const accountId = matrixPlugin.setup!.resolveAccountId?.({
cfg: {} as CoreConfig,
accountId: undefined,
input: { name: "Main Bot" },
});
expect(accountId).toBe("main-bot");
});
it("resolves binding account id from agent id when omitted", () => {
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
cfg: {} as CoreConfig,
agentId: "Ops",
accountId: undefined,
});
expect(accountId).toBe("ops");
});
it("clears stale access token when switching an account to password auth", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "old-token",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "new-password", // pragma: allowlist secret
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBe("new-password");
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBeUndefined();
});
it("clears stale password when switching an account to token auth", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "old-password", // pragma: allowlist secret
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
accessToken: "new-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBe("new-token");
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBeUndefined();
});
});

View file

@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createNonExitingRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock("./resolver.runtime.js", () => ({
matrixResolverRuntime: {
resolveMatrixTargets: resolveMatrixTargetsMock,
},
}));
import { matrixResolverAdapter } from "./resolver.js";
describe("matrix resolver adapter", () => {
beforeEach(() => {
resolveMatrixTargetsMock.mockClear();
});
it("forwards accountId into Matrix target resolution", async () => {
await matrixResolverAdapter.resolveTargets({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: createNonExitingRuntimeEnv(),
});
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: expect.objectContaining({
log: expect.any(Function),
error: expect.any(Function),
exit: expect.any(Function),
}),
});
});
});

View file

@ -0,0 +1,16 @@
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
export const matrixChannelRuntime = {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
matrixOutbound,
probeMatrix,
resolveMatrixAuth,
resolveMatrixTargets,
sendMessageMatrix,
};

View file

@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime-api.js";
const verificationMocks = vi.hoisted(() => ({
bootstrapMatrixVerification: vi.fn(),
}));
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
}));
import { matrixConfigAdapter } from "./config-adapter.js";
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix setup post-write bootstrap", () => {
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
});
const encryptedDefaultCfg = {
channels: {
matrix: {
encryption: true,
},
},
} as CoreConfig;
const defaultPasswordInput = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
password: "secret", // pragma: allowlist secret
} as const;
const runtime: RuntimeEnv = {
log,
error,
exit,
};
function applyAccountConfig(params: {
previousCfg: CoreConfig;
accountId: string;
input: Record<string, unknown>;
}) {
return {
previousCfg: params.previousCfg,
accountId: params.accountId,
input: params.input,
nextCfg: matrixSetupAdapter.applyAccountConfig({
cfg: params.previousCfg,
accountId: params.accountId,
input: params.input,
}) as CoreConfig,
};
}
function applyDefaultAccountConfig(input: Record<string, unknown> = defaultPasswordInput) {
return applyAccountConfig({
previousCfg: encryptedDefaultCfg,
accountId: "default",
input,
});
}
function mockBootstrapResult(params: {
success: boolean;
backupVersion?: string | null;
error?: string;
}) {
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
success: params.success,
...(params.error ? { error: params.error } : {}),
verification: {
backupVersion: params.backupVersion ?? null,
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
}
async function runAfterAccountConfigWritten(params: {
previousCfg: CoreConfig;
nextCfg: CoreConfig;
accountId: string;
input: Record<string, unknown>;
}) {
await runMatrixSetupBootstrapAfterConfigWrite({
previousCfg: params.previousCfg,
cfg: params.nextCfg,
accountId: params.accountId,
runtime,
});
}
async function withSavedEnv<T>(
values: Record<string, string | undefined>,
run: () => Promise<T> | T,
) {
const previousEnv = Object.fromEntries(
Object.keys(values).map((key) => [key, process.env[key]]),
) as Record<string, string | undefined>;
for (const [key, value] of Object.entries(values)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await run();
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
beforeEach(() => {
verificationMocks.bootstrapMatrixVerification.mockReset();
log.mockClear();
error.mockClear();
exit.mockClear();
installMatrixTestRuntime();
});
it("bootstraps verification for newly added encrypted accounts", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({ success: true, backupVersion: "7" });
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
expect(error).not.toHaveBeenCalled();
});
it("does not bootstrap verification for already configured accounts", async () => {
const previousCfg = {
channels: {
matrix: {
accounts: {
flurry: {
encryption: true,
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
},
},
},
},
} as CoreConfig;
const input = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "new-token",
};
const { nextCfg, accountId } = applyAccountConfig({
previousCfg,
accountId: "flurry",
input,
});
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("logs a warning when verification bootstrap fails", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({
success: false,
error: "no room-key backup exists on the homeserver",
});
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(error).toHaveBeenCalledWith(
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
);
});
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
await withSavedEnv(
{
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "env-token",
},
async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig({
useEnv: true,
});
mockBootstrapResult({ success: true, backupVersion: "9" });
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
},
);
});
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
return withSavedEnv(
{
MATRIX_HOMESERVER: undefined,
MATRIX_USER_ID: undefined,
MATRIX_ACCESS_TOKEN: undefined,
MATRIX_PASSWORD: undefined,
MATRIX_DEFAULT_HOMESERVER: undefined,
MATRIX_DEFAULT_USER_ID: undefined,
MATRIX_DEFAULT_ACCESS_TOKEN: undefined,
MATRIX_DEFAULT_PASSWORD: undefined,
},
() => {
expect(
matrixSetupAdapter.validateInput?.({
cfg: {} as CoreConfig,
accountId: "default",
input: { useEnv: true },
}),
).toContain("Set Matrix env vars for the default account");
},
);
});
it("clears allowPrivateNetwork and proxy when deleting the default Matrix account config", () => {
const updated = matrixConfigAdapter.deleteAccount?.({
cfg: {
channels: {
matrix: {
homeserver: "http://localhost.localdomain:8008",
network: {
dangerouslyAllowPrivateNetwork: true,
},
proxy: "http://127.0.0.1:7890",
accounts: {
ops: {
enabled: true,
},
},
},
},
} as CoreConfig,
accountId: "default",
}) as CoreConfig;
expect(updated.channels?.matrix).toEqual({
accounts: {
ops: {
enabled: true,
},
},
});
});
});

View file

@ -0,0 +1,554 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createAllowlistProviderOpenWarningCollector,
projectAccountConfigWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
import {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import { matrixMessageActions } from "./actions.js";
import { matrixApprovalCapability } from "./approval-native.js";
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
import { DEFAULT_ACCOUNT_ID, matrixConfigAdapter } from "./config-adapter.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { matrixDoctor } from "./doctor.js";
import { shouldSuppressLocalMatrixExecApprovalPrompt } from "./exec-approvals.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
} from "./group-mentions.js";
import {
resolveMatrixAccount,
resolveMatrixAccountConfig,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import type { MatrixProbe } from "./matrix/probe.js";
import {
normalizeMatrixMessagingTarget,
resolveMatrixDirectUserId,
resolveMatrixTargetIdentity,
} from "./matrix/target-ids.js";
import {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./matrix/thread-bindings-shared.js";
import { matrixResolverAdapter } from "./resolver.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./setup-contract.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
import { resolveMatrixInboundConversation } from "./thread-binding-api.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
);
const meta = {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
};
const listMatrixDirectoryPeersFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = normalizeLowercaseStringOrEmpty(raw);
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
const listMatrixDirectoryGroupsFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [Object.keys(account.config.groups ?? account.config.rooms ?? {})],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = normalizeLowercaseStringOrEmpty(raw);
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
});
function projectMatrixConversationBinding(binding: {
boundAt: number;
metadata?: {
lastActivityAt?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
}) {
return {
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.metadata?.lastActivityAt === "number"
? binding.metadata.lastActivityAt
: binding.boundAt,
idleTimeoutMs:
typeof binding.metadata?.idleTimeoutMs === "number"
? binding.metadata.idleTimeoutMs
: undefined,
maxAgeMs:
typeof binding.metadata?.maxAgeMs === "number" ? binding.metadata.maxAgeMs : undefined,
};
}
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
channelKey: "matrix",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
});
const collectMatrixSecurityWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedMatrixAccount>({
providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Matrix rooms",
openBehavior: "allows any room to trigger (mention-gated)",
remediation:
'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms',
},
});
function resolveMatrixAccountConfigPath(accountId: string, field: string): string {
return accountId === DEFAULT_ACCOUNT_ID
? `channels.matrix.${field}`
: `channels.matrix.accounts.${accountId}.${field}`;
}
function collectMatrixSecurityWarningsForAccount(params: {
account: ResolvedMatrixAccount;
cfg: CoreConfig;
}): string[] {
const warnings = collectMatrixSecurityWarnings(params);
if (params.account.accountId !== DEFAULT_ACCOUNT_ID) {
const groupPolicyPath = resolveMatrixAccountConfigPath(params.account.accountId, "groupPolicy");
const groupsPath = resolveMatrixAccountConfigPath(params.account.accountId, "groups");
const groupAllowFromPath = resolveMatrixAccountConfigPath(
params.account.accountId,
"groupAllowFrom",
);
return warnings.map((warning) =>
warning
.replace("channels.matrix.groupPolicy", groupPolicyPath)
.replace("channels.matrix.groups", groupsPath)
.replace("channels.matrix.groupAllowFrom", groupAllowFromPath),
);
}
if (params.account.config.autoJoin !== "always") {
return warnings;
}
const autoJoinPath = resolveMatrixAccountConfigPath(params.account.accountId, "autoJoin");
const autoJoinAllowlistPath = resolveMatrixAccountConfigPath(
params.account.accountId,
"autoJoinAllowlist",
);
return [
...warnings,
`- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set ${autoJoinPath}="allowlist" + ${autoJoinAllowlistPath} (or ${autoJoinPath}="off") to restrict joins.`,
];
}
function normalizeMatrixAcpConversationId(conversationId: string) {
const target = resolveMatrixTargetIdentity(conversationId);
if (!target || target.kind !== "room") {
return null;
}
return { conversationId: target.id };
}
function matchMatrixAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
parentConversationId?: string;
}) {
const binding = normalizeMatrixAcpConversationId(params.bindingConversationId);
if (!binding) {
return null;
}
if (binding.conversationId === params.conversationId) {
return { conversationId: params.conversationId, matchPriority: 2 };
}
if (
params.parentConversationId &&
params.parentConversationId !== params.conversationId &&
binding.conversationId === params.parentConversationId
) {
return {
conversationId: params.parentConversationId,
matchPriority: 1,
};
}
return null;
}
function resolveMatrixCommandConversation(params: {
threadId?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const parentConversationId = [params.originatingTo, params.commandTo, params.fallbackTo]
.map((candidate) => {
const trimmed = candidate?.trim();
if (!trimmed) {
return undefined;
}
const target = resolveMatrixTargetIdentity(trimmed);
return target?.kind === "room" ? target.id : undefined;
})
.find((candidate): candidate is string => Boolean(candidate));
if (params.threadId) {
return {
conversationId: params.threadId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
return parentConversationId ? { conversationId: parentConversationId } : null;
}
function resolveMatrixDeliveryTarget(params: {
conversationId: string;
parentConversationId?: string;
}) {
const parentConversationId = params.parentConversationId?.trim();
if (parentConversationId && parentConversationId !== params.conversationId.trim()) {
const parentTarget = resolveMatrixTargetIdentity(parentConversationId);
if (parentTarget?.kind === "room") {
return {
to: `room:${parentTarget.id}`,
threadId: params.conversationId.trim(),
};
}
}
const conversationTarget = resolveMatrixTargetIdentity(params.conversationId);
if (conversationTarget?.kind === "room") {
return { to: `room:${conversationTarget.id}` };
}
return null;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
},
approvalCapability: matrixApprovalCapability,
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "child",
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setMatrixThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
idleTimeoutMs,
}).map(projectMatrixConversationBinding),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setMatrixThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
maxAgeMs,
}).map(projectMatrixConversationBinding),
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
resolveInboundConversation: ({ to, conversationId, threadId }) =>
resolveMatrixInboundConversation({ to, conversationId, threadId }),
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
resolveMatrixDeliveryTarget({ conversationId, parentConversationId }),
resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
return true;
}
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => {
const entries = await listMatrixDirectoryPeersFromConfig(params);
return entries.map((entry) => {
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return incomplete
? Object.assign({}, entry, { name: `incomplete id; expected @user:server` })
: entry;
});
},
listGroups: async (params) => await listMatrixDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMatrixChannelRuntime,
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive,
}),
}),
resolver: matrixResolverAdapter,
actions: matrixMessageActions,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: {
...matrixSetupAdapter,
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeMatrixAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
matchMatrixAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo }) =>
resolveMatrixCommandConversation({
threadId,
originatingTo,
commandTo,
fallbackTo,
}),
},
status: createComputedAccountStatusAdapter<ResolvedMatrixAccount, MatrixProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs, cfg }) =>
await createMatrixProbeAccount({
resolveMatrixAuth: async ({ cfg, accountId }) =>
(await loadMatrixChannelRuntime()).resolveMatrixAuth({
cfg,
accountId,
}),
probeMatrix: async (params) =>
await (await loadMatrixChannelRuntime()).probeMatrix(params),
})({
account,
timeoutMs,
cfg,
}),
resolveAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
lastProbeAt: runtime?.lastProbeAt ?? null,
...buildTrafficStatusSummary(runtime),
},
}),
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.homeserver,
});
ctx.log?.info(
`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`,
);
// Serialize startup: wait for any previous startup to complete import phase.
// This works around a race condition with concurrent dynamic imports.
//
// INVARIANT: The import() below cannot hang because:
// 1. It only loads local ESM modules with no circular awaits
// 2. Module initialization is synchronous (no top-level await in ./matrix/monitor/index.js)
// 3. The lock only serializes the import phase, not the provider startup
const previousLock = matrixStartupLock;
let releaseLock: () => void = () => {};
matrixStartupLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
await previousLock;
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
// Wrap in try/finally to ensure lock is released even if import fails.
let monitorMatrixProvider: typeof import("./matrix/monitor/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/monitor/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
setStatus: ctx.setStatus,
});
},
},
doctor: matrixDoctor,
lifecycle: {
runStartupMaintenance: runMatrixStartupMaintenance,
},
},
security: {
resolveDmPolicy: resolveMatrixDmPolicy,
collectWarnings: projectAccountConfigWarningCollector(
(cfg) => cfg as CoreConfig,
collectMatrixSecurityWarningsForAccount,
),
},
pairing: {
text: createMatrixPairingText(
async (to, message, options) =>
await (await loadMatrixChannelRuntime()).sendMessageMatrix(to, message, options),
),
},
threading: {
resolveReplyToMode: createScopedAccountReplyToModeResolver<
ReturnType<typeof resolveMatrixAccountConfig>
>({
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccountConfig),
resolveReplyToMode: (account) => account.replyToMode,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: normalizeOptionalString(currentTarget),
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
currentDirectUserId: resolveMatrixDirectUserId({
from: context.From,
to: context.To,
chatType: context.ChatType,
}),
hasRepliedRef,
};
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",
},
sendMedia: {
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
unavailableMessage: "Matrix outbound media delivery is unavailable",
},
sendPoll: {
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
unavailableMessage: "Matrix outbound poll delivery is unavailable",
},
}),
},
});

View file

@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
export function registerMatrixCliMetadata(api: OpenClawPluginApi) {
api.registerCli(
async ({ program }) => {
const { registerMatrixCli } = await import("./cli.js");
registerMatrixCli({ program });
},
{
descriptors: [
{
name: "matrix",
description: "Manage Matrix accounts, verification, devices, and profile state",
hasSubcommands: true,
},
],
},
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
listMatrixAccountIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
resolveMatrixAccountConfig,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { normalizeMatrixAllowList } from "./matrix/monitor/allowlist.js";
export { DEFAULT_ACCOUNT_ID };
export const matrixConfigAdapter = createScopedChannelConfigAdapter<
ResolvedMatrixAccount,
ReturnType<typeof resolveMatrixAccountConfig>
>({
sectionKey: "matrix",
listAccountIds: listMatrixAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveAccessorAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg, accountId }),
defaultAccountId: resolveDefaultMatrixAccountId,
clearBaseFields: [
"name",
"homeserver",
"network",
"proxy",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
resolveAllowFrom: (account) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
});

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { MatrixConfigSchema } from "./config-schema.js";
describe("MatrixConfigSchema SecretInput", () => {
it("accepts SecretRef accessToken at top-level", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
});
expect(result.success).toBe(true);
});
it("accepts SecretRef password at top-level", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
});
expect(result.success).toBe(true);
});
it("accepts dm threadReplies overrides", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dm: {
policy: "pairing",
threadReplies: "off",
},
});
expect(result.success).toBe(true);
});
it("accepts dm sessionScope overrides", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dm: {
policy: "pairing",
sessionScope: "per-room",
},
});
expect(result.success).toBe(true);
});
it("accepts room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
groups: {
"!room:example.org": {
enabled: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.groups?.["!room:example.org"]?.account).toBe("axis");
});
it("accepts legacy room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
rooms: {
"!room:example.org": {
enabled: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.rooms?.["!room:example.org"]?.account).toBe("axis");
});
it("accepts quiet Matrix streaming mode", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: "quiet",
});
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,114 @@
import {
AllowFromListSchema,
buildNestedDmConfigSchema,
ContextVisibilityModeSchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "openclaw/plugin-sdk/zod";
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
profile: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),
})
.optional();
const matrixThreadBindingsSchema = z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.optional();
const matrixExecApprovalsSchema = z
.object({
enabled: z.boolean().optional(),
approvers: AllowFromListSchema,
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
account: z.string().optional(),
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: AllowFromListSchema,
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
const matrixNetworkSchema = z
.object({
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
defaultAccount: z.string().optional(),
accounts: z.record(z.string(), z.unknown()).optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
network: matrixNetworkSchema,
proxy: z.string().optional(),
userId: z.string().optional(),
accessToken: buildSecretInputSchema().optional(),
password: buildSecretInputSchema().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
avatarUrl: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
groupPolicy: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
blockStreaming: z.boolean().optional(),
streaming: z.union([z.enum(["partial", "quiet", "off"]), z.boolean()]).optional(),
replyToMode: z.enum(["off", "first", "all", "batched"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
.optional(),
reactionNotifications: z.enum(["off", "own"]).optional(),
threadBindings: matrixThreadBindingsSchema,
startupVerification: z.enum(["off", "if-unverified"]).optional(),
startupVerificationCooldownHours: z.number().optional(),
mediaMaxMb: z.number().optional(),
historyLimit: z.number().int().min(0).optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: AllowFromListSchema,
groupAllowFrom: AllowFromListSchema,
dm: buildNestedDmConfigSchema({
sessionScope: z.enum(["per-user", "per-room"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
}),
execApprovals: matrixExecApprovalsSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View file

@ -0,0 +1,200 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { matrixAuthedHttpClientCtorMock, requestJsonMock } = vi.hoisted(() => ({
matrixAuthedHttpClientCtorMock: vi.fn(),
requestJsonMock: vi.fn(),
}));
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
vi.mock("./matrix/sdk/http-client.js", () => ({
MatrixAuthedHttpClient: class {
constructor(params: unknown) {
matrixAuthedHttpClientCtorMock(params);
}
requestJson(params: unknown) {
return requestJsonMock(params);
}
},
}));
let listMatrixDirectoryGroupsLive: typeof import("./directory-live.js").listMatrixDirectoryGroupsLive;
let listMatrixDirectoryPeersLive: typeof import("./directory-live.js").listMatrixDirectoryPeersLive;
let resolveMatrixAuth: typeof import("./matrix/client.js").resolveMatrixAuth;
describe("matrix directory live", () => {
const cfg = { channels: { matrix: {} } };
beforeAll(async () => {
({ listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } =
await import("./directory-live.js"));
({ resolveMatrixAuth } = await import("./matrix/client.js"));
});
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
accountId: "assistant",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
});
matrixAuthedHttpClientCtorMock.mockReset();
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ results: [] });
});
it("passes accountId to peer directory auth resolution", async () => {
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes accountId to group directory auth resolution", async () => {
await listMatrixDirectoryGroupsLive({
cfg,
accountId: "assistant",
query: "channel:#room:example.org",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes dispatcherPolicy through to the live directory client", async () => {
vi.mocked(resolveMatrixAuth).mockResolvedValue({
accountId: "assistant",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8080",
},
});
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
});
expect(matrixAuthedHttpClientCtorMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
accessToken: "test-token",
ssrfPolicy: undefined,
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.internal:8080",
},
});
});
it("returns no peer results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryPeersLive({
cfg,
query: " ",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryGroupsLive({
cfg,
query: "",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("preserves query casing when searching the Matrix user directory", async () => {
await listMatrixDirectoryPeersLive({
cfg,
query: "Alice",
limit: 3,
});
expect(requestJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
timeoutMs: 10_000,
body: {
search_term: "Alice",
limit: 3,
},
}),
);
});
it("accepts prefixed fully qualified user ids without hitting Matrix", async () => {
const results = await listMatrixDirectoryPeersLive({
cfg,
query: "matrix:user:@Alice:Example.org",
});
expect(results).toEqual([
{
kind: "user",
id: "@Alice:Example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("resolves prefixed room aliases through the hardened Matrix HTTP client", async () => {
requestJsonMock.mockResolvedValueOnce({
room_id: "!team:example.org",
});
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "channel:#Team:Example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "#Team:Example.org",
handle: "#Team:Example.org",
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
timeoutMs: 10_000,
}),
);
});
it("accepts prefixed room ids without additional Matrix lookups", async () => {
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "matrix:room:!team:example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "!team:example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,238 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveMatrixAuth } from "./matrix/client.js";
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
import type { ChannelDirectoryEntry } from "./runtime-api.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
type MatrixDirectoryLiveParams = {
cfg: unknown;
accountId?: string | null;
query?: string | null;
limit?: number | null;
};
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
const MATRIX_DIRECTORY_TIMEOUT_MS = 10_000;
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && Number.isFinite(limit) && limit > 0
? Math.max(1, Math.floor(limit))
: 20;
}
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
return new MatrixAuthedHttpClient({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
ssrfPolicy: auth.ssrfPolicy,
dispatcherPolicy: auth.dispatcherPolicy,
});
}
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
auth: MatrixResolvedAuth;
client: MatrixAuthedHttpClient;
query: string;
queryLower: string;
} | null> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return {
auth,
client: createMatrixDirectoryClient(auth),
query,
queryLower: normalizeLowercaseStringOrEmpty(query),
};
}
function createGroupDirectoryEntry(params: {
id: string;
name: string;
handle?: string;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name,
handle: params.handle,
} satisfies ChannelDirectoryEntry;
}
async function requestMatrixJson<T>(
client: MatrixAuthedHttpClient,
params: {
method: "GET" | "POST";
endpoint: string;
body?: unknown;
},
): Promise<T> {
return (await client.requestJson({
method: params.method,
endpoint: params.endpoint,
body: params.body,
timeoutMs: MATRIX_DIRECTORY_TIMEOUT_MS,
})) as T;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return [];
}
const directUserId = normalizeMatrixMessagingTarget(query);
if (directUserId && isMatrixQualifiedUserId(directUserId)) {
return [{ kind: "user", id: directUserId }];
}
const context = await resolveMatrixDirectoryContext({
...params,
query,
});
if (!context) {
return [];
}
const res = await requestMatrixJson<MatrixUserDirectoryResponse>(context.client, {
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
body: {
search_term: context.query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = normalizeOptionalString(entry.user_id);
if (!userId) {
return null;
}
const displayName = normalizeOptionalString(entry.display_name);
return {
kind: "user",
id: userId,
name: displayName,
handle: displayName ? `@${displayName}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
client: MatrixAuthedHttpClient,
alias: string,
): Promise<string | null> {
try {
const res = await requestMatrixJson<MatrixAliasLookup>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return normalizeOptionalString(res.room_id) ?? null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
client: MatrixAuthedHttpClient,
roomId: string,
): Promise<string | null> {
try {
const res = await requestMatrixJson<MatrixRoomNameState>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return normalizeOptionalString(res.name) ?? null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const query = normalizeOptionalString(params.query) ?? "";
if (!query) {
return [];
}
const directTarget = normalizeMatrixMessagingTarget(query);
if (directTarget?.startsWith("!")) {
return [createGroupDirectoryEntry({ id: directTarget, name: directTarget })];
}
const context = await resolveMatrixDirectoryContext({
...params,
query,
});
if (!context) {
return [];
}
const { client, queryLower } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
if (directTarget?.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(client, directTarget);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: directTarget, handle: directTarget })];
}
const joined = await requestMatrixJson<MatrixJoinedRoomsResponse>(client, {
method: "GET",
endpoint: "/_matrix/client/v3/joined_rooms",
});
const rooms = (joined.joined_rooms ?? [])
.map((roomId) => normalizeOptionalString(roomId))
.filter((roomId): roomId is string => Boolean(roomId));
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(client, roomId);
if (!name || !normalizeLowercaseStringOrEmpty(name).includes(queryLower)) {
continue;
}
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) {
break;
}
}
return results;
}

View file

@ -0,0 +1,287 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord } from "./record-shared.js";
function hasLegacyMatrixRoomAllowAlias(value: unknown): boolean {
const room = isRecord(value) ? value : null;
return Boolean(room && typeof room.allow === "boolean");
}
function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean {
const rooms = isRecord(value) ? value : null;
return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room)));
}
function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => {
if (!isRecord(account)) {
return false;
}
return (
hasLegacyMatrixRoomMapAllowAliases(account.groups) ||
hasLegacyMatrixRoomMapAllowAliases(account.rooms)
);
});
}
function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
);
}
function hasLegacyTrustedDmPolicy(value: unknown): boolean {
const root = isRecord(value) ? value : null;
if (!root) {
return false;
}
const dm = isRecord(root.dm) ? root.dm : null;
return dm?.policy === "trusted";
}
function hasLegacyMatrixAccountTrustedDmPolicies(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyTrustedDmPolicy(account));
}
function migrateLegacyTrustedDmPolicy(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
const dm = isRecord(params.entry.dm) ? params.entry.dm : null;
if (!dm || dm.policy !== "trusted") {
return { entry: params.entry, changed: false };
}
const allowFromRaw = dm.allowFrom;
// Trim before counting: downstream allowlist normalization drops whitespace-only
// entries, so a config like [" "] must still fall back to "pairing"
// instead of becoming an effectively empty allowlist.
const allowFromEntries = Array.isArray(allowFromRaw)
? allowFromRaw.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
).length
: 0;
// Preserve the operator's existing trust boundary when an explicit allowFrom
// list is present; only fall back to pairing when the effective allowlist is
// empty.
const nextPolicy: "allowlist" | "pairing" = allowFromEntries > 0 ? "allowlist" : "pairing";
const nextDm = { ...dm, policy: nextPolicy };
params.changes.push(
`Migrated ${params.pathPrefix}.dm.policy "trusted" → "${nextPolicy}" (legacy alias removed; ` +
`${allowFromEntries > 0 ? `preserved ${allowFromEntries} ${params.pathPrefix}.dm.allowFrom ${allowFromEntries === 1 ? "entry" : "entries"}` : "no allowFrom entries present, defaulting to pairing for safety"}).`,
);
return { entry: { ...params.entry, dm: nextDm }, changed: true };
}
function normalizeMatrixRoomAllowAliases(params: {
rooms: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { rooms: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextRooms: Record<string, unknown> = { ...params.rooms };
for (const [roomId, roomValue] of Object.entries(params.rooms)) {
const room = isRecord(roomValue) ? roomValue : null;
if (!room || typeof room.allow !== "boolean") {
continue;
}
const nextRoom = { ...room };
if (typeof nextRoom.enabled !== "boolean") {
nextRoom.enabled = room.allow;
}
delete nextRoom.allow;
nextRooms[roomId] = nextRoom;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`,
);
}
return { rooms: nextRooms, changed };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "matrix"],
message:
'channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:
'channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
'channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountRoomAllowAliases,
},
{
path: ["channels", "matrix"],
message:
'channels.matrix.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
match: hasLegacyTrustedDmPolicy,
},
{
path: ["channels", "matrix", "accounts"],
message:
'channels.matrix.accounts.<id>.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
match: hasLegacyMatrixAccountTrustedDmPolicies,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const matrix = isRecord(channels?.matrix) ? channels.matrix : null;
if (!matrix) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedMatrix: Record<string, unknown> = matrix;
let changed = false;
const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedMatrix,
pathPrefix: "channels.matrix",
changes,
});
updatedMatrix = topLevelPrivateNetwork.entry;
changed = changed || topLevelPrivateNetwork.changed;
const topLevelTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
entry: updatedMatrix,
pathPrefix: "channels.matrix",
changes,
});
updatedMatrix = topLevelTrustedDmPolicy.entry;
changed = changed || topLevelTrustedDmPolicy.changed;
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
if (!rooms) {
return;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.${key}`,
changes,
});
if (normalized.changed) {
updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms };
changed = true;
}
};
normalizeTopLevelRoomScope("groups");
normalizeTopLevelRoomScope("rooms");
const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
let nextAccount: Record<string, unknown> = account;
let accountChanged = false;
const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: nextAccount,
pathPrefix: `channels.matrix.accounts.${accountId}`,
changes,
});
if (privateNetworkMigration.changed) {
nextAccount = privateNetworkMigration.entry;
accountChanged = true;
}
const accountTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
entry: nextAccount,
pathPrefix: `channels.matrix.accounts.${accountId}`,
changes,
});
if (accountTrustedDmPolicy.changed) {
nextAccount = accountTrustedDmPolicy.entry;
accountChanged = true;
}
for (const key of ["groups", "rooms"] as const) {
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
if (!rooms) {
continue;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.accounts.${accountId}.${key}`,
changes,
});
if (normalized.changed) {
nextAccount = { ...nextAccount, [key]: normalized.rooms };
accountChanged = true;
}
}
if (accountChanged) {
nextAccounts[accountId] = nextAccount;
accountsChanged = true;
}
}
if (accountsChanged) {
updatedMatrix = { ...updatedMatrix, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
matrix: updatedMatrix as NonNullable<OpenClawConfig["channels"]>["matrix"],
},
},
changes,
};
}

View file

@ -0,0 +1,440 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
applyMatrixDoctorRepair,
cleanStaleMatrixPluginConfig,
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
matrixDoctor,
runMatrixDoctorSequence,
} from "./doctor.js";
vi.mock("./matrix-migration.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./matrix-migration.runtime.js")>(
"./matrix-migration.runtime.js",
);
return {
...actual,
maybeCreateMatrixMigrationSnapshot: vi.fn(),
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
resolveMatrixMigrationStatus: vi.fn(() => ({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: false,
actionable: false,
})),
};
});
describe("matrix doctor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("formats state and crypto previews", () => {
expect(
formatMatrixLegacyStatePreview({
accountId: "default",
legacyStoragePath: "/tmp/legacy-sync.json",
targetStoragePath: "/tmp/new-sync.json",
legacyCryptoPath: "/tmp/legacy-crypto.json",
targetCryptoPath: "/tmp/new-crypto.json",
selectionNote: "Picked the newest account.",
targetRootDir: "/tmp/account-root",
}),
).toContain("Matrix plugin upgraded in place.");
const previews = formatMatrixLegacyCryptoPreview({
inspectorAvailable: true,
warnings: ["matrix warning"],
plans: [
{
accountId: "default",
rootDir: "/tmp/account-root",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
legacyCryptoPath: "/tmp/legacy-crypto.json",
recoveryKeyPath: "/tmp/recovery-key.txt",
statePath: "/tmp/state.json",
},
],
});
expect(previews[0]).toBe("- matrix warning");
expect(previews[1]).toContain("/tmp/recovery-key.txt");
});
it("warns on stale custom Matrix plugin paths and cleans them", async () => {
const missingPath = path.join(tmpdir(), `openclaw-matrix-missing-${Date.now()}`);
await fs.rm(missingPath, { recursive: true, force: true });
const warnings = await collectMatrixInstallPathWarnings({
plugins: {
installs: {
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
},
},
});
expect(warnings[0]).toContain("custom path that no longer exists");
const cleaned = await cleanStaleMatrixPluginConfig({
plugins: {
installs: {
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
},
load: { paths: [missingPath, "/other/path"] },
allow: ["matrix", "other-plugin"],
},
});
expect(cleaned.changes[0]).toContain("Removed stale Matrix plugin references");
expect(cleaned.config.plugins?.load?.paths).toEqual(["/other/path"]);
expect(cleaned.config.plugins?.allow).toEqual(["other-plugin"]);
});
it("surfaces matrix sequence warnings and repair changes", async () => {
const runtimeApi = await import("./matrix-migration.runtime.js");
vi.mocked(runtimeApi.resolveMatrixMigrationStatus).mockReturnValue({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: true,
actionable: true,
});
vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
archivePath: "/tmp/matrix-backup.tgz",
created: true,
markerPath: "/tmp/marker.json",
});
vi.mocked(runtimeApi.autoMigrateLegacyMatrixState).mockResolvedValue({
migrated: true,
changes: ["Migrated legacy sync state"],
warnings: [],
});
vi.mocked(runtimeApi.autoPrepareLegacyMatrixCrypto).mockResolvedValue({
migrated: true,
changes: ["Prepared recovery key export"],
warnings: [],
});
const cfg = {
channels: {
matrix: {},
},
} as never;
const repair = await applyMatrixDoctorRepair({ cfg, env: process.env });
expect(repair.changes.join("\n")).toContain("Matrix migration snapshot");
const sequence = await runMatrixDoctorSequence({
cfg,
env: process.env,
shouldRepair: true,
});
expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot");
});
it("normalizes legacy Matrix room allow aliases to enabled", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
groups: {
"!ops:example.org": {
allow: true,
},
},
accounts: {
work: {
rooms: {
"!legacy:example.org": {
allow: false,
},
},
},
},
},
},
} as never,
});
const matrixConfig = result.config.channels?.matrix as
| {
groups?: Record<string, unknown>;
accounts?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
const workAccount = matrixConfig?.accounts?.work as
| {
rooms?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
expect(matrixConfig?.groups?.["!ops:example.org"]).toEqual({
enabled: true,
});
expect(workAccount?.rooms?.["!legacy:example.org"]).toEqual({
enabled: false,
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (true).",
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (false).",
]),
);
});
it("normalizes legacy Matrix private-network aliases", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
allowPrivateNetwork: true,
accounts: {
work: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
const matrixConfig = result.config.channels?.matrix as
| {
accounts?: Record<string, unknown>;
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
const workAccount = matrixConfig?.accounts?.work as
| {
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
| undefined;
expect(matrixConfig?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(workAccount?.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
"Moved channels.matrix.accounts.work.allowPrivateNetwork → channels.matrix.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
]),
);
});
it("migrates legacy channels.matrix.dm.policy 'trusted' with allowFrom to 'allowlist'", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "trusted",
allowFrom: ["@alice:example.org", "@bob:example.org"],
},
},
},
} as never,
});
const matrixDm = (
result.config.channels?.matrix as { dm?: { policy?: string; allowFrom?: string[] } }
)?.dm;
expect(matrixDm?.policy).toBe("allowlist");
expect(matrixDm?.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]);
expect(result.changes).toEqual(
expect.arrayContaining([
expect.stringContaining('Migrated channels.matrix.dm.policy "trusted" → "allowlist"'),
expect.stringContaining("preserved 2 channels.matrix.dm.allowFrom entries"),
]),
);
});
it("migrates legacy 'trusted' policy with whitespace-only allowFrom entries to 'pairing'", () => {
// Whitespace-only entries are dropped by downstream allowlist normalization,
// so they must not count toward the allowFrom population check — otherwise
// the migration would emit policy="allowlist" with an effectively empty
// allowlist, silently blocking all DMs.
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "trusted",
allowFrom: [" ", "\t", ""],
},
},
},
} as never,
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expect(result.changes).toEqual(
expect.arrayContaining([
expect.stringContaining('Migrated channels.matrix.dm.policy "trusted" → "pairing"'),
]),
);
});
it("migrates legacy channels.matrix.dm.policy 'trusted' without allowFrom to 'pairing'", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "trusted",
},
},
},
} as never,
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expect(result.changes).toEqual(
expect.arrayContaining([
expect.stringContaining('Migrated channels.matrix.dm.policy "trusted" → "pairing"'),
]),
);
});
it("migrates legacy per-account channels.matrix.accounts.<id>.dm.policy 'trusted'", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
accounts: {
work: {
dm: {
enabled: true,
policy: "trusted",
allowFrom: ["@boss:example.org"],
},
},
personal: {
dm: {
enabled: true,
policy: "trusted",
},
},
},
},
},
} as never,
});
const accounts = (
result.config.channels?.matrix as {
accounts?: Record<string, { dm?: { policy?: string; allowFrom?: string[] } }>;
}
)?.accounts;
expect(accounts?.work?.dm?.policy).toBe("allowlist");
expect(accounts?.work?.dm?.allowFrom).toEqual(["@boss:example.org"]);
expect(accounts?.personal?.dm?.policy).toBe("pairing");
expect(result.changes).toEqual(
expect.arrayContaining([
expect.stringContaining(
'Migrated channels.matrix.accounts.work.dm.policy "trusted" → "allowlist"',
),
expect.stringContaining(
'Migrated channels.matrix.accounts.personal.dm.policy "trusted" → "pairing"',
),
]),
);
});
it("leaves modern dm.policy values untouched", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
accounts: {
work: {
dm: { enabled: true, policy: "pairing" },
},
},
},
},
} as never,
});
expect(result.changes).toEqual([]);
expect(result.config).toEqual({
channels: {
matrix: {
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
accounts: {
work: {
dm: { enabled: true, policy: "pairing" },
},
},
},
},
});
});
});

View file

@ -0,0 +1,262 @@
import { type ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
removePluginFromConfig,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
} from "./doctor-contract.js";
import {
autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
detectLegacyMatrixState,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
import { isRecord } from "./record-shared.js";
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
return isRecord(channels?.matrix);
}
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.installs?.matrix ||
cfg.plugins?.entries?.matrix ||
cfg.plugins?.allow?.includes("matrix") ||
cfg.plugins?.deny?.includes("matrix"),
);
}
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
return Object.entries(env).some(
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
);
}
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return (
hasConfiguredMatrixChannel(cfg) ||
hasConfiguredMatrixPluginSurface(cfg) ||
hasConfiguredMatrixEnv(env)
);
}
export function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
export function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
}).map((entry) => `- ${entry}`);
}
export async function cleanStaleMatrixPluginConfig(cfg: OpenClawConfig) {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue || issue.kind !== "missing-path") {
return { config: cfg, changes: [] };
}
const { config, actions } = removePluginFromConfig(cfg, "matrix");
const removed: string[] = [];
if (actions.install) {
removed.push("install record");
}
if (actions.loadPath) {
removed.push("load path");
}
if (actions.entry) {
removed.push("plugin entry");
}
if (actions.allowlist) {
removed.push("allowlist entry");
}
if (removed.length === 0) {
return { config: cfg, changes: [] };
}
return {
config,
changes: [
`Removed stale Matrix plugin references (${removed.join(", ")}). The previous install path no longer exists: ${issue.path}`,
],
};
}
export async function applyMatrixDoctorRepair(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
let matrixSnapshotReady = true;
if (migrationStatus.actionable) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: params.env,
});
changes.push(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
);
} catch (error) {
matrixSnapshotReady = false;
warnings.push(
`- Failed creating a Matrix migration snapshot before repair: ${String(error)}`,
);
warnings.push(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
);
}
} else if (migrationStatus.pending) {
warnings.push(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
);
}
if (!matrixSnapshotReady) {
return { changes, warnings };
}
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: params.cfg,
env: params.env,
});
if (matrixStateRepair.changes.length > 0) {
changes.push(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
);
}
if (matrixStateRepair.warnings.length > 0) {
warnings.push(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
});
if (matrixCryptoRepair.changes.length > 0) {
changes.push(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
);
}
if (matrixCryptoRepair.warnings.length > 0) {
warnings.push(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
return { changes, warnings };
}
export async function runMatrixDoctorSequence(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
shouldRepair: boolean;
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
const warningNotes: string[] = [];
const changeNotes: string[] = [];
const installWarnings = await collectMatrixInstallPathWarnings(params.cfg);
if (installWarnings.length > 0) {
warningNotes.push(installWarnings.join("\n"));
}
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
return { changeNotes, warningNotes };
}
if (params.shouldRepair) {
const repair = await applyMatrixDoctorRepair({
cfg: params.cfg,
env: params.env,
});
changeNotes.push(...repair.changes);
warningNotes.push(...repair.warnings);
} else {
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
if (migrationStatus.legacyState) {
if ("warning" in migrationStatus.legacyState) {
warningNotes.push(`- ${migrationStatus.legacyState.warning}`);
} else {
warningNotes.push(formatMatrixLegacyStatePreview(migrationStatus.legacyState));
}
}
if (
migrationStatus.legacyCrypto.warnings.length > 0 ||
migrationStatus.legacyCrypto.plans.length > 0
) {
warningNotes.push(...formatMatrixLegacyCryptoPreview(migrationStatus.legacyCrypto));
}
}
return { changeNotes, warningNotes };
}
export const matrixDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) => await cleanStaleMatrixPluginConfig(cfg),
};

View file

@ -0,0 +1,92 @@
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
const MATRIX_SCOPED_ENV_SUFFIXES = [
"HOMESERVER",
"USER_ID",
"ACCESS_TOKEN",
"PASSWORD",
"DEVICE_ID",
"DEVICE_NAME",
] as const;
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
export function resolveMatrixEnvAccountToken(accountId: string): string {
return Array.from(normalizeAccountId(accountId))
.map((char) =>
/[a-z0-9]/.test(char)
? char.toUpperCase()
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
)
.join("");
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
function decodeMatrixEnvAccountToken(token: string): string | undefined {
let decoded = "";
for (let index = 0; index < token.length; ) {
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
if (hexEscape) {
const hex = hexEscape[1];
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
if (!Number.isFinite(codePoint)) {
return undefined;
}
const char = String.fromCodePoint(codePoint);
decoded += char;
index += hexEscape[0].length;
continue;
}
const char = token[index];
if (!char || !/[A-Z0-9]/.test(char)) {
return undefined;
}
decoded += char.toLowerCase();
index += 1;
}
const normalized = normalizeOptionalAccountId(decoded);
if (!normalized) {
return undefined;
}
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
}
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
const ids = new Set<string>();
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
if (typeof env[key] === "string" && env[key]?.trim()) {
ids.add(normalizeAccountId("default"));
break;
}
}
for (const key of Object.keys(env)) {
const match = MATRIX_SCOPED_ENV_RE.exec(key);
if (!match) {
continue;
}
const accountId = decodeMatrixEnvAccountToken(match[1]);
if (accountId) {
ids.add(accountId);
}
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
}

View file

@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveMatrixApproval", () => {
beforeEach(() => {
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("submits exec approval resolutions through the shared gateway resolver", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("passes plugin approval ids through unchanged", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("recognizes structured approval-not-found errors", async () => {
const { isApprovalNotFoundError } = await import("./exec-approval-resolver.js");
const err = new Error("approval not found");
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).gatewayCode =
"INVALID_REQUEST";
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).details = {
reason: "APPROVAL_NOT_FOUND",
};
expect(isApprovalNotFoundError(err)).toBe(true);
});
});

View file

@ -0,0 +1,23 @@
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveMatrixApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
});
}

View file

@ -0,0 +1,483 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
getMatrixExecApprovalApprovers,
isMatrixExecApprovalApprover,
isMatrixExecApprovalAuthorizedSender,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalTargetRecipient,
normalizeMatrixApproverId,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
shouldSuppressLocalMatrixExecApprovalPrompt,
} from "./exec-approvals.js";
import type { MatrixAccountConfig, MatrixExecApprovalConfig } from "./types.js";
const tempDirs: string[] = [];
type MatrixExecApprovalRequest = Parameters<
typeof shouldHandleMatrixExecApprovalRequest
>[0]["request"];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function createTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-exec-approvals-"));
tempDirs.push(dir);
return dir;
}
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>["execApprovals"],
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
...channelOverrides,
execApprovals,
},
},
} as OpenClawConfig;
}
function matrixAccount(
accountId: string,
execApprovals: MatrixExecApprovalConfig,
overrides: Partial<MatrixAccountConfig> = {},
): MatrixAccountConfig {
return {
homeserver: "https://matrix.example.org",
userId: `@bot-${accountId}:example.org`,
accessToken: `tok-${accountId}`,
...overrides,
execApprovals,
};
}
function buildMultiAccountMatrixConfig(params: {
sessionStorePath?: string;
defaultExecApprovals?: MatrixExecApprovalConfig;
opsExecApprovals?: MatrixExecApprovalConfig;
defaultOverrides?: Partial<MatrixAccountConfig>;
opsOverrides?: Partial<MatrixAccountConfig>;
}): OpenClawConfig {
return {
...(params.sessionStorePath ? { session: { store: params.sessionStorePath } } : {}),
channels: {
matrix: {
accounts: {
default: matrixAccount(
"default",
params.defaultExecApprovals ?? {
enabled: true,
approvers: ["@owner:example.org"],
},
params.defaultOverrides,
),
ops: matrixAccount(
"ops",
params.opsExecApprovals ?? {
enabled: true,
approvers: ["@owner:example.org"],
},
params.opsOverrides,
),
},
},
},
} as OpenClawConfig;
}
function makeForeignChannelApprovalRequest(params: {
id: string;
sessionKey?: string;
agentId?: string;
}): MatrixExecApprovalRequest {
return {
id: params.id,
request: {
command: "echo hi",
agentId: params.agentId ?? "ops-agent",
sessionKey: params.sessionKey ?? "agent:ops-agent:missing",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
describe("matrix exec approvals", () => {
it("auto-enables when approvers resolve and disables only when forced off", () => {
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig(undefined, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(true);
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(true);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe(true);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["user:@override:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
);
expect(getMatrixExecApprovalApprovers({ cfg })).toEqual(["@override:example.org"]);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@override:example.org" })).toBe(true);
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@owner:example.org" })).toBe(false);
});
it("ignores wildcard allowlist entries when inferring exec approvers", () => {
const cfg = buildConfig({ enabled: true }, { dm: { allowFrom: ["*"] } });
expect(getMatrixExecApprovalApprovers({ cfg })).toEqual([]);
expect(isMatrixExecApprovalClientEnabled({ cfg })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveMatrixExecApprovalTarget({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
}),
).toBe("dm");
});
it("matches matrix target recipients from generic approval forwarding targets", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "matrix", to: "user:@target:example.org" },
{ channel: "matrix", to: "room:!ops:example.org" },
],
},
},
} as OpenClawConfig;
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@other:example.org" })).toBe(
false,
);
expect(isMatrixExecApprovalAuthorizedSender({ cfg, senderId: "@target:example.org" })).toBe(
true,
);
});
it("suppresses local prompts only when the native client is enabled", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
payload,
}),
).toBe(true);
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig(),
payload,
}),
).toBe(false);
});
it("keeps local prompts when filters exclude the request", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
agentId: "other-agent",
sessionKey: "agent:other-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
}),
payload,
}),
).toBe(false);
});
it("suppresses local prompts for generic exec payloads when metadata matches filters", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
approvalKind: "exec",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
sessionFilter: ["matrix:channel:"],
}),
payload,
}),
).toBe(true);
});
it("suppresses local prompts for plugin approval payloads when DM approvers are configured", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "plugin:req-1",
approvalSlug: "plugin:r",
approvalKind: "plugin",
},
},
};
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig(
{ enabled: true, approvers: ["@owner:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
),
payload,
}),
).toBe(true);
});
it("normalizes prefixed approver ids", () => {
expect(normalizeMatrixApproverId("matrix:@owner:example.org")).toBe("@owner:example.org");
expect(normalizeMatrixApproverId("user:@owner:example.org")).toBe("@owner:example.org");
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
sessionFilter: ["matrix:channel:", "ops$"],
});
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:matrix:channel:!room:example.org:ops",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
it("scopes non-matrix turn sources to the stored matrix account", () => {
const tmpDir = createTempDir();
const storePath = path.join(tmpDir, "sessions.json");
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:ops-agent:matrix:channel:!room:example.org": {
sessionId: "main",
updatedAt: 1,
origin: {
provider: "matrix",
accountId: "ops",
},
lastChannel: "slack",
lastTo: "channel:C999",
lastAccountId: "work",
},
}),
"utf-8",
);
const cfg = buildMultiAccountMatrixConfig({ sessionStorePath: storePath });
const request = makeForeignChannelApprovalRequest({
id: "req-3",
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
});
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(false);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(true);
});
it("rejects unbound foreign-channel approvals in multi-account matrix configs", () => {
const cfg = buildMultiAccountMatrixConfig({});
const request = makeForeignChannelApprovalRequest({ id: "req-4" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(false);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("allows unbound foreign-channel approvals when only one matrix account can handle them", () => {
const cfg = buildMultiAccountMatrixConfig({
opsExecApprovals: {
enabled: false,
approvers: ["@owner:example.org"],
},
});
const request = makeForeignChannelApprovalRequest({ id: "req-5" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("uses request filters when checking foreign-channel matrix ambiguity", () => {
const cfg = buildMultiAccountMatrixConfig({
defaultExecApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["ops-agent"],
},
opsExecApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
agentFilter: ["other-agent"],
},
});
const request = makeForeignChannelApprovalRequest({ id: "req-6" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
it("ignores disabled matrix accounts when checking foreign-channel ambiguity", () => {
const cfg = buildMultiAccountMatrixConfig({
opsOverrides: { enabled: false },
});
const request = makeForeignChannelApprovalRequest({ id: "req-7" });
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "default",
request,
}),
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
});
});

View file

@ -0,0 +1,290 @@
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import {
createChannelExecApprovalProfile,
getExecApprovalReplyMetadata,
isChannelExecApprovalClientEnabledFromConfig,
isChannelExecApprovalTargetRecipient,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
export { normalizeMatrixApproverId };
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
return normalized === "*" ? undefined : normalized;
}
function resolveMatrixExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const account = resolveMatrixAccount(params);
const config = account.config.execApprovals;
if (!config) {
return undefined;
}
return {
...config,
enabled: account.enabled && account.configured ? config.enabled : false,
};
}
function countMatrixExecApprovalEligibleAccounts(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): number {
return listMatrixAccountIds(params.cfg).filter((accountId) => {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
if (!account.enabled || !account.configured) {
return false;
}
const config = resolveMatrixExecApprovalConfig({
cfg: params.cfg,
accountId,
});
const filters = config?.enabled
? {
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
}
: {
agentFilter: undefined,
sessionFilter: undefined,
};
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
cfg: params.cfg,
accountId,
approvalKind: params.approvalKind,
}).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: filters.agentFilter,
sessionFilter: filters.sessionFilter,
})
);
}).length;
}
function matchesMatrixRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
channel: "matrix",
});
if (turnSourceChannel && turnSourceChannel !== "matrix" && !boundAccountId) {
return (
countMatrixExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
approvalKind: params.approvalKind,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
export function getMatrixExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveMatrixAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.dm?.allowFrom,
normalizeApprover: normalizeMatrixExecApproverId,
});
}
function resolveMatrixApprovalKind(request: ApprovalRequest): ApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
export function getMatrixApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): string[] {
if (params.approvalKind === "plugin") {
return getMatrixApprovalAuthApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
});
}
return getMatrixExecApprovalApprovers(params);
}
export function isMatrixExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
return isChannelExecApprovalTargetRecipient({
...params,
channel: "matrix",
normalizeSenderId: normalizeMatrixApproverId,
matchTarget: ({ target, normalizedSenderId }) =>
normalizeMatrixApproverId(target.to) === normalizedSenderId,
});
}
const matrixExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: resolveMatrixExecApprovalConfig,
resolveApprovers: getMatrixExecApprovalApprovers,
normalizeSenderId: normalizeMatrixApproverId,
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
matchesRequestAccount: (params) =>
matchesMatrixRequestAccount({
...params,
approvalKind: "exec",
}),
});
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
export const isMatrixExecApprovalApprover = matrixExecApprovalProfile.isApprover;
export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.isAuthorizedSender;
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
export function isMatrixApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
if (params.approvalKind === "exec") {
return isMatrixExecApprovalClientEnabled(params);
}
const config = resolveMatrixExecApprovalConfig(params);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers(params).length,
});
}
export function isMatrixAnyApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isMatrixApprovalClientEnabled({
...params,
approvalKind: "exec",
}) ||
isMatrixApprovalClientEnabled({
...params,
approvalKind: "plugin",
})
);
}
export function shouldHandleMatrixApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const approvalKind = resolveMatrixApprovalKind(params.request);
if (
!matchesMatrixRequestAccount({
...params,
approvalKind,
})
) {
return false;
}
const config = resolveMatrixExecApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
...params,
approvalKind,
}).length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}
function buildFilterCheckRequest(params: {
metadata: NonNullable<ReturnType<typeof getExecApprovalReplyMetadata>>;
}): ApprovalRequest {
if (params.metadata.approvalKind === "plugin") {
return {
id: params.metadata.approvalId,
request: {
title: "Plugin Approval Required",
description: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
return {
id: params.metadata.approvalId,
request: {
command: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
export function shouldSuppressLocalMatrixExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
if (!matrixExecApprovalProfile.shouldSuppressLocalPrompt(params)) {
return false;
}
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata) {
return false;
}
const request = buildFilterCheckRequest({
metadata,
});
return shouldHandleMatrixApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
request,
});
}

View file

@ -0,0 +1,41 @@
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import { normalizeMatrixResolvableTarget } from "./matrix/target-ids.js";
import type { ChannelGroupContext, GroupToolPolicyConfig } from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
const roomId = normalizeMatrixResolvableTarget(params.groupId?.trim() ?? "");
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [normalizeMatrixResolvableTarget(groupChannel)] : [];
const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
return resolveMatrixRoomConfig({
rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId,
aliases,
}).config;
}
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveMatrixRoomConfigForGroup(params);
if (resolved) {
if (resolved.autoReply === true) {
return false;
}
if (resolved.autoReply === false) {
return true;
}
if (typeof resolved.requireMention === "boolean") {
return resolved.requireMention;
}
}
return true;
}
export function resolveMatrixGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const resolved = resolveMatrixRoomConfigForGroup(params);
return resolved?.tools;
}

View file

@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const availabilityState = vi.hoisted(() => ({
currentFilePath: "/virtual/dist/matrix-migration.runtime.js",
existingPaths: new Set<string>(),
dirEntries: [] as Array<{ name: string; isFile: () => boolean }>,
}));
vi.mock("node:fs", async () => {
const { mockNodeBuiltinModule } = await import("../../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs")>("node:fs"),
{
existsSync: (candidate: unknown) => availabilityState.existingPaths.has(String(candidate)),
readdirSync: () => availabilityState.dirEntries as never,
},
{ mirrorToDefault: true },
);
});
vi.mock("node:url", async () => {
const actual = await vi.importActual<typeof import("node:url")>("node:url");
return {
...actual,
fileURLToPath: () => availabilityState.currentFilePath,
};
});
const { isMatrixLegacyCryptoInspectorAvailable } =
await import("./legacy-crypto-inspector-availability.js");
describe("isMatrixLegacyCryptoInspectorAvailable", () => {
beforeEach(() => {
availabilityState.currentFilePath = "/virtual/dist/matrix-migration.runtime.js";
availabilityState.existingPaths.clear();
availabilityState.dirEntries = [];
});
it("detects the source inspector module directly", () => {
availabilityState.currentFilePath =
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js";
availabilityState.existingPaths.add(
"/virtual/extensions/matrix/src/matrix/legacy-crypto-inspector.ts",
);
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("detects hashed built inspector chunks", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("does not confuse the availability helper artifact with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
it("does not confuse hashed availability helper chunks with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
});

View file

@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const LEGACY_CRYPTO_INSPECTOR_FILE = "legacy-crypto-inspector.js";
const LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX = "legacy-crypto-inspector-";
const LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX = "availability-";
const JAVASCRIPT_MODULE_SUFFIX = ".js";
function isLegacyCryptoInspectorArtifactName(name: string): boolean {
if (name === LEGACY_CRYPTO_INSPECTOR_FILE) {
return true;
}
if (
!name.startsWith(LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX) ||
!name.endsWith(JAVASCRIPT_MODULE_SUFFIX)
) {
return false;
}
const chunkSuffix = name.slice(
LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX.length,
-JAVASCRIPT_MODULE_SUFFIX.length,
);
return (
chunkSuffix.length > 0 &&
chunkSuffix !== "availability" &&
!chunkSuffix.startsWith(LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX)
);
}
function hasSourceInspectorArtifact(currentDir: string): boolean {
return [
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
].some((candidate) => fs.existsSync(candidate));
}
function hasBuiltInspectorArtifact(currentDir: string): boolean {
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
return true;
}
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
return true;
}
return fs
.readdirSync(currentDir, { withFileTypes: true })
.some((entry) => entry.isFile() && isLegacyCryptoInspectorArtifactName(entry.name));
}
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
if (hasSourceInspectorArtifact(currentDir)) {
return true;
}
try {
return hasBuiltInspectorArtifact(currentDir);
} catch {
return false;
}
}

View file

@ -0,0 +1,234 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
import {
MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeFile,
writeMatrixCredentials,
} from "./test-helpers.js";
function createDefaultMatrixConfig(): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
},
},
};
}
function writeDefaultLegacyCryptoFixture(home: string) {
const stateDir = path.join(home, ".openclaw");
const cfg = createDefaultMatrixConfig();
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
});
writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
return { cfg, rootDir };
}
function createOpsLegacyCryptoFixture(params: {
home: string;
accessToken?: string;
includeStoredCredentials?: boolean;
}) {
const stateDir = path.join(params.home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
if (params.includeStoredCredentials) {
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: MATRIX_OPS_DEVICE_ID,
});
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
return { rootDir };
}
describe("matrix legacy encrypted-state migration", () => {
afterEach(() => {
legacyCryptoInspectorAvailability.available = true;
});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(true);
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
});
});
it("skips migration when no legacy Matrix plans exist", async () => {
await withTempHome(async () => {
const result = await autoPrepareLegacyMatrixCrypto({
cfg: createDefaultMatrixConfig(),
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [],
});
});
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as { restoreStatus: string };
expect(state.restoreStatus).toBe("manual-action-required");
});
});
it("prefers stored credentials for named accounts when config is token-only", async () => {
await withTempHome(async (home) => {
const { rootDir } = createOpsLegacyCryptoFixture({
home,
includeStoredCredentials: true,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 1, backedUp: 1 },
backupVersion: "1",
decryptionKeyBase64: "b3Bz",
}),
},
});
expect(result.migrated).toBe(true);
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
});
});
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
const { cfg } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(false);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
],
});
});
});
});

View file

@ -0,0 +1,549 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoMigrationState = {
version: 1;
source: "matrix-bot-sdk-rust";
accountId: string;
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyImported: boolean;
restoreStatus: "pending" | "completed" | "manual-action-required";
detectedAt: string;
restoredAt?: string;
importedCount?: number;
totalCount?: number;
lastError?: string | null;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
inspectorAvailable: boolean;
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: MatrixLegacyCryptoInspector;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
};
type MatrixLegacyCryptoInspectorParams = {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
};
type MatrixLegacyCryptoInspectorResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
}
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
if ("warning" in target) {
return target;
}
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
try {
if (!fs.existsSync(metadataPath)) {
return fallback;
}
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
deviceId?: unknown;
};
return {
deviceId:
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
};
} catch {
return fallback;
}
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Omit<MatrixLegacyCryptoDetection, "inspectorAvailable"> {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}
return { plans, warnings };
}
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
} catch {
return null;
}
}
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
} catch {
return null;
}
}
async function persistLegacyMigrationState(params: {
filePath: string;
state: MatrixLegacyCryptoMigrationState;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
}): Promise<void> {
await params.writeJsonFileAtomically(params.filePath, params.state);
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const detection = resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
const inspectorAvailable =
detection.plans.length === 0 || isMatrixLegacyCryptoInspectorAvailable();
if (!inspectorAvailable && detection.plans.length > 0) {
return {
inspectorAvailable,
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
};
}
return {
inspectorAvailable,
plans: detection.plans,
warnings: detection.warnings,
};
}
export async function autoPrepareLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
const env = params.env ?? process.env;
const detection = params.deps?.inspectLegacyStore
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const inspectorAvailable =
"inspectorAvailable" in detection ? detection.inspectorAvailable : true;
const warnings = [...detection.warnings];
const changes: string[] = [];
const writeJsonFileAtomically =
params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl;
if (detection.plans.length === 0) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
if (!params.deps?.inspectLegacyStore && !inspectorAvailable) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
} catch (err) {
const message = formatMatrixErrorMessage(err);
if (!warnings.includes(message)) {
warnings.push(message);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
}
if (!inspectLegacyStore) {
return {
migrated: false,
changes,
warnings,
};
}
for (const plan of detection.plans) {
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
if (existingState?.version === 1) {
continue;
}
if (!plan.deviceId) {
warnings.push(
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
);
continue;
}
let summary: MatrixLegacyCryptoSummary;
try {
summary = await inspectLegacyStore({
cryptoRootDir: plan.legacyCryptoPath,
userId: plan.userId,
deviceId: plan.deviceId,
log: params.log?.info,
});
} catch (err) {
warnings.push(
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
);
continue;
}
let decryptionKeyImported = false;
if (summary.decryptionKeyBase64) {
const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`,
);
}
} else {
decryptionKeyImported = true;
}
}
const localOnlyKeys =
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
: 0;
if (localOnlyKeys > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
);
}
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
if (
summary.decryptionKeyBase64 &&
!decryptionKeyImported &&
!loadStoredRecoveryKey(plan.recoveryKeyPath)
) {
continue;
}
const state: MatrixLegacyCryptoMigrationState = {
version: 1,
source: "matrix-bot-sdk-rust",
accountId: plan.accountId,
deviceId: summary.deviceId,
roomKeyCounts: summary.roomKeyCounts,
backupVersion: summary.backupVersion,
decryptionKeyImported,
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
detectedAt: new Date().toISOString(),
lastError: null,
};
try {
await persistLegacyMigrationState({
filePath: plan.statePath,
state,
writeJsonFileAtomically,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`,
);
}
}
if (changes.length > 0) {
params.log?.info?.(
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View file

@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected a migratable Matrix legacy state plan");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-from-cache",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected cached credentials to make Matrix migration resolvable");
}
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
});
});
});

View file

@ -0,0 +1,156 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
const legacy = resolveLegacyMatrixPaths(params.env);
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
if ("warning" in target) {
return target;
}
return {
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}
export function detectLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
return resolveMatrixMigrationPlan({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
function moveLegacyPath(params: {
sourcePath: string;
targetPath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
params.warnings.push(
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
);
return;
}
try {
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.renameSync(params.sourcePath, params.targetPath);
params.changes.push(
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
);
} catch (err) {
params.warnings.push(
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
);
}
}
export async function autoMigrateLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixLegacyStateMigrationResult> {
const env = params.env ?? process.env;
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
if (!detection) {
return { migrated: false, changes: [], warnings: [] };
}
if ("warning" in detection) {
params.log?.warn?.(`matrix: ${detection.warning}`);
return { migrated: false, changes: [], warnings: [detection.warning] };
}
const changes: string[] = [];
const warnings: string[] = [];
moveLegacyPath({
sourcePath: detection.legacyStoragePath,
targetPath: detection.targetStoragePath,
label: "sync store",
changes,
warnings,
});
moveLegacyPath({
sourcePath: detection.legacyCryptoPath,
targetPath: detection.targetCryptoPath,
label: "crypto store",
changes,
warnings,
});
if (changes.length > 0) {
const details = [
...changes.map((entry) => `- ${entry}`),
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
"- No user action required.",
];
params.log?.info?.(
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View file

@ -0,0 +1,22 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type MatrixPackageManifest = {
dependencies?: Record<string, string>;
openclaw?: {
bundle?: {
stageRuntimeDependencies?: boolean;
};
};
};
describe("matrix package manifest", () => {
it("opts into staging bundled runtime dependencies", () => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as MatrixPackageManifest;
expect(packageJson.dependencies?.["fake-indexeddb"]).toBeDefined();
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
});
});

View file

@ -0,0 +1,9 @@
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./migration-snapshot.js";
export { maybeCreateMatrixMigrationSnapshot } from "./migration-snapshot-backup.js";

View file

@ -0,0 +1,175 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
listConfiguredAccountIds,
resolveMergedAccountConfig,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
return cfg.channels?.matrix ?? {};
}
function resolveMatrixAccountsMap(cfg: CoreConfig): Readonly<Record<string, MatrixAccountConfig>> {
const accounts = resolveMatrixBaseConfig(cfg).accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
return accounts;
}
function selectInheritedMatrixRoomEntries(params: {
entries: MatrixRoomEntries | undefined;
accountId: string;
}): MatrixRoomEntries | undefined {
const entries = params.entries;
if (!entries) {
return undefined;
}
const selected = Object.fromEntries(
Object.entries(entries).filter(([, value]) => {
const scopedAccount =
typeof value?.account === "string" ? normalizeAccountId(value.account) : undefined;
return scopedAccount === undefined || scopedAccount === params.accountId;
}),
) as MatrixRoomEntries;
return Object.keys(selected).length > 0 ? selected : undefined;
}
function mergeMatrixRoomEntries(
inherited: MatrixRoomEntries | undefined,
accountEntries: MatrixRoomEntries | undefined,
hasAccountOverride: boolean,
): MatrixRoomEntries | undefined {
if (!inherited && !accountEntries) {
return undefined;
}
if (hasAccountOverride && Object.keys(accountEntries ?? {}).length === 0) {
return undefined;
}
const merged: MatrixRoomEntries = {
...inherited,
};
for (const [key, value] of Object.entries(accountEntries ?? {})) {
const inheritedValue = merged[key];
merged[key] =
inheritedValue && value
? {
...inheritedValue,
...value,
}
: (value ?? inheritedValue);
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
return listConfiguredAccountIds({
accounts: resolveMatrixAccountsMap(cfg),
normalizeAccountId,
});
}
export function findMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
return resolveNormalizedAccountEntry(
resolveMatrixAccountsMap(cfg),
accountId,
normalizeAccountId,
);
}
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
const normalized = normalizeAccountId(accountId);
if (findMatrixAccountConfig(cfg, normalized)) {
return true;
}
if (normalized !== DEFAULT_ACCOUNT_ID) {
return false;
}
const matrix = resolveMatrixBaseConfig(cfg);
return (
typeof matrix.enabled === "boolean" ||
typeof matrix.name === "string" ||
typeof matrix.homeserver === "string" ||
typeof matrix.userId === "string" ||
hasConfiguredSecretInput(matrix.accessToken) ||
hasConfiguredSecretInput(matrix.password) ||
typeof matrix.deviceId === "string" ||
typeof matrix.deviceName === "string" ||
typeof matrix.avatarUrl === "string"
);
}
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const base = resolveMatrixBaseConfig(params.cfg);
const merged = resolveMergedAccountConfig<MatrixConfig>({
channelConfig: base,
accounts: params.cfg.channels?.matrix?.accounts as
| Record<string, Partial<MatrixConfig>>
| undefined,
accountId,
normalizeAccountId,
nestedObjectKeys: ["dm", "actions", "execApprovals"],
});
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const groups = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.groups,
accountId,
}),
accountConfig?.groups,
Boolean(accountConfig && Object.hasOwn(accountConfig, "groups")),
);
const rooms = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.rooms,
accountId,
}),
accountConfig?.rooms,
Boolean(accountConfig && Object.hasOwn(accountConfig, "rooms")),
);
// Room maps need custom scoping, so keep the generic merge for all other fields.
const { groups: _ignoredGroups, rooms: _ignoredRooms, ...rest } = merged;
return {
...rest,
...(groups ? { groups } : {}),
...(rooms ? { rooms } : {}),
};
}
export function resolveMatrixAccountAllowlistConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): {
dmAllowFrom?: NonNullable<MatrixConfig["dm"]>["allowFrom"];
groupAllowFrom?: MatrixConfig["groupAllowFrom"];
} {
const accountId = normalizeAccountId(params.accountId);
const base = resolveMatrixBaseConfig(params.cfg);
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const accountDm = accountConfig?.dm;
let dmAllowFrom = base.dm?.allowFrom;
if (accountDm && Object.hasOwn(accountDm, "allowFrom")) {
dmAllowFrom = accountDm.allowFrom;
}
let groupAllowFrom = base.groupAllowFrom;
if (accountConfig && Object.hasOwn(accountConfig, "groupAllowFrom")) {
groupAllowFrom = accountConfig.groupAllowFrom;
}
return { dmAllowFrom, groupAllowFrom };
}

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
describe("resolveMatrixAccount readiness", () => {
it("does not treat inherited base auth as configured for named accounts", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "base-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
},
},
},
},
};
installMatrixTestRuntime({ cfg });
expect(resolveMatrixAccount({ cfg, accountId: "default" }).configured).toBe(true);
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).configured).toBe(false);
});
});

View file

@ -0,0 +1,757 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
import type { CoreConfig } from "../types.js";
import {
listMatrixAccountIds,
resolveConfiguredMatrixBotUserIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
} from "./accounts.js";
import type { MatrixStoredCredentials } from "./credentials-read.js";
const loadMatrixCredentialsMock = vi.hoisted(() =>
vi.fn<(env?: NodeJS.ProcessEnv, accountId?: string | null) => MatrixStoredCredentials | null>(
() => null,
),
);
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: (env?: NodeJS.ProcessEnv, accountId?: string | null) =>
loadMatrixCredentialsMock(env, accountId),
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
"MATRIX_DEFAULT_HOMESERVER",
"MATRIX_DEFAULT_ACCESS_TOKEN",
getMatrixScopedEnvVarNames("team-ops").homeserver,
getMatrixScopedEnvVarNames("team-ops").accessToken,
];
type MatrixRoomScopeKey = "groups" | "rooms";
function createMatrixAccountConfig(accessToken: string) {
return {
homeserver: "https://matrix.example.org",
accessToken,
};
}
function createMatrixScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
},
accounts: {
default: createMatrixAccountConfig("default-token"),
axis: createMatrixAccountConfig("axis-token"),
},
},
},
} as unknown as CoreConfig;
}
function createMatrixTopLevelDefaultScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
...createMatrixAccountConfig("default-token"),
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: createMatrixAccountConfig("ops-token"),
},
},
},
} as unknown as CoreConfig;
}
function expectMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
accountId: string,
expected: Record<string, { enabled: true; account?: string }>,
): void {
expect(resolveMatrixAccount({ cfg, accountId }).config[scopeKey]).toEqual(expected);
}
function expectMultiAccountMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "axis", {
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
}
function expectTopLevelDefaultMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "ops", {
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
}
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
loadMatrixCredentialsMock.mockReset().mockReturnValue(null);
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats SecretRef access-token config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats accounts.default SecretRef access-token config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("treats accounts.default SecretRef password config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: { source: "file", provider: "matrix-file", id: "value" },
},
},
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-password",
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("normalizes and de-duplicates configured account ids", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "Main Bot",
accounts: {
"Main Bot": {
homeserver: "https://matrix.example.org",
accessToken: "main-token",
},
"main-bot": {
homeserver: "https://matrix.example.org",
accessToken: "duplicate-token",
},
OPS: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot");
});
it("returns the only named account when no explicit default is set", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://matrix.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.accountId).toBe("ops");
expect(account.homeserver).toBe("https://ops.example.org");
expect(account.configured).toBe(true);
});
it("includes env-backed named accounts in plugin account enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
process.env[keys.homeserver] = "https://matrix.example.org";
process.env[keys.accessToken] = "ops-token";
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-ops");
});
it("includes default accounts backed only by global env vars in plugin account enumeration", () => {
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
process.env.MATRIX_ACCESS_TOKEN = "default-token";
const cfg: CoreConfig = {};
expect(listMatrixAccountIds(cfg)).toEqual(["default"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("treats mixed default and named env-backed accounts as multi-account", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
process.env.MATRIX_ACCESS_TOKEN = "default-token";
process.env[keys.homeserver] = "https://matrix.example.org";
process.env[keys.accessToken] = "ops-token";
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["default", "team-ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("includes a top-level configured default account alongside named accounts", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["default", "ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
name: "Shared Defaults",
enabled: true,
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
});
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
const cfg: CoreConfig = {
channels: {
matrix: {
accounts: {
alpha: {
homeserver: "https://matrix.example.org",
accessToken: "alpha-token",
},
beta: {
homeserver: "https://matrix.example.org",
accessToken: "beta-token",
},
},
},
},
};
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("collects other configured Matrix account user ids for bot detection", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
userId: "@main:example.org",
homeserver: "https://matrix.example.org",
accessToken: "main-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts:example.org",
accessToken: "alerts-token",
},
},
},
},
};
expect(
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops" })).toSorted(),
).toEqual(["@alerts:example.org", "@main:example.org"]);
});
it("honors injected env when detecting configured bot accounts", () => {
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@main:example.org",
MATRIX_ACCESS_TOKEN: "main-token",
MATRIX_ALERTS_HOMESERVER: "https://matrix.example.org",
MATRIX_ALERTS_USER_ID: "@alerts:example.org",
MATRIX_ALERTS_ACCESS_TOKEN: "alerts-token",
} as NodeJS.ProcessEnv;
const cfg: CoreConfig = {
channels: {
matrix: {},
},
};
expect(
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops", env })).toSorted(),
).toEqual(["@alerts:example.org", "@main:example.org"]);
});
it("falls back to stored credentials when an access-token-only account omits userId", () => {
loadMatrixCredentialsMock.mockImplementation(
(env?: NodeJS.ProcessEnv, accountId?: string | null) =>
accountId === "ops"
? {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
createdAt: "2026-03-19T00:00:00.000Z",
}
: null,
);
const cfg: CoreConfig = {
channels: {
matrix: {
userId: "@main:example.org",
homeserver: "https://matrix.example.org",
accessToken: "main-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "default" }))).toEqual([
"@ops:example.org",
]);
});
it("preserves shared nested dm and actions config when an account overrides one field", () => {
const account = resolveMatrixAccount({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "main-token",
dm: {
enabled: true,
policy: "pairing",
},
actions: {
reactions: true,
messages: true,
},
accounts: {
ops: {
accessToken: "ops-token",
dm: {
allowFrom: ["@ops:example.org"],
},
actions: {
messages: false,
},
},
},
},
},
},
accountId: "ops",
});
expect(account.config.dm).toEqual({
enabled: true,
policy: "pairing",
allowFrom: ["@ops:example.org"],
});
expect(account.config.actions).toEqual({
reactions: true,
messages: false,
});
});
it("filters channel-level groups by room account in multi-account setups", () => {
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("groups"), "groups");
});
it("filters channel-level groups when the default account is configured at the top level", () => {
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("groups"),
"groups",
);
});
it("filters legacy channel-level rooms by room account in multi-account setups", () => {
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("rooms"), "rooms");
});
it("filters legacy channel-level rooms when the default account is configured at the top level", () => {
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("rooms"),
"rooms",
);
});
it("honors injected env when scoping room entries in multi-account setups", () => {
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops", env }).config.groups).toEqual({
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
});
it("keeps scoped groups bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!shared-room:example.org": {
enabled: true,
},
});
});
it("keeps scoped legacy rooms bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!shared-room:example.org": {
enabled: true,
},
});
});
it("lets an account clear inherited groups with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
groups: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toBeUndefined();
});
it("lets an account clear inherited legacy rooms with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
rooms: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toBeUndefined();
});
});

View file

@ -0,0 +1,194 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountConfig,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveGlobalMatrixEnvConfig, resolveScopedMatrixEnvConfig } from "./client/env-auth.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
};
function clean(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function resolveMatrixAccountAuthView(params: {
cfg: CoreConfig;
accountId: string;
env: NodeJS.ProcessEnv;
}): {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
} {
const normalizedAccountId = normalizeAccountId(params.accountId);
const matrix = resolveMatrixBaseConfig(params.cfg);
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account.homeserver),
userId: clean(account.userId),
accessToken: typeof account.accessToken === "string" ? clean(account.accessToken) : "",
password: typeof account.password === "string" ? clean(account.password) : "",
deviceId: clean(account.deviceId),
deviceName: clean(account.deviceName),
},
scopedEnv: resolveScopedMatrixEnvConfig(normalizedAccountId, params.env),
channel: {
homeserver: clean(matrix.homeserver),
userId: clean(matrix.userId),
accessToken: typeof matrix.accessToken === "string" ? clean(matrix.accessToken) : "",
password: typeof matrix.password === "string" ? clean(matrix.password) : "",
deviceId: clean(matrix.deviceId),
deviceName: clean(matrix.deviceName),
},
globalEnv: resolveGlobalMatrixEnvConfig(params.env),
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken || undefined,
password: resolvedStrings.password || undefined,
};
}
function resolveMatrixAccountUserId(params: {
cfg: CoreConfig;
accountId: string;
env?: NodeJS.ProcessEnv;
}): string | null {
const env = params.env ?? process.env;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId: params.accountId,
env,
});
const configuredUserId = authView.userId.trim();
if (configuredUserId) {
return configuredUserId;
}
const stored = loadMatrixCredentials(env, params.accountId);
if (!stored) {
return null;
}
if (authView.homeserver && stored.homeserver !== authView.homeserver) {
return null;
}
if (authView.accessToken && stored.accessToken !== authView.accessToken) {
return null;
}
return stored.userId.trim() || null;
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = resolveConfiguredMatrixAccountIds(cfg, process.env);
return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
}
export function resolveConfiguredMatrixBotUserIds(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): Set<string> {
const env = params.env ?? process.env;
const currentAccountId = normalizeAccountId(params.accountId);
const accountIds = new Set(resolveConfiguredMatrixAccountIds(params.cfg, env));
if (resolveMatrixAccount({ cfg: params.cfg, accountId: DEFAULT_ACCOUNT_ID, env }).configured) {
accountIds.add(DEFAULT_ACCOUNT_ID);
}
const ids = new Set<string>();
for (const accountId of accountIds) {
if (normalizeAccountId(accountId) === currentAccountId) {
continue;
}
if (!resolveMatrixAccount({ cfg: params.cfg, accountId, env }).configured) {
continue;
}
const userId = resolveMatrixAccountUserId({
cfg: params.cfg,
accountId,
env,
});
if (userId) {
ids.add(userId);
}
}
return ids;
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): ResolvedMatrixAccount {
const env = params.env ?? process.env;
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultMatrixAccountId(params.cfg),
);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId, env });
const explicitAuthConfig =
accountId === DEFAULT_ACCOUNT_ID
? base
: (findMatrixAccountConfig(params.cfg, accountId) ?? {});
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId,
env,
});
const hasHomeserver = Boolean(authView.homeserver);
const hasUserId = Boolean(authView.userId);
const hasAccessToken =
Boolean(authView.accessToken) || hasConfiguredSecretInput(explicitAuthConfig.accessToken);
const hasPassword = Boolean(authView.password);
const hasPasswordAuth =
hasUserId && (hasPassword || hasConfiguredSecretInput(explicitAuthConfig.password));
const stored = loadMatrixCredentials(env, accountId);
const hasStored =
stored && authView.homeserver
? credentialsMatchConfig(stored, {
homeserver: authView.homeserver,
userId: authView.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || hasStored);
return {
accountId,
enabled,
name: normalizeOptionalString(base.name),
configured,
homeserver: authView.homeserver || undefined,
userId: authView.userId || undefined,
config: base,
};
}
export { resolveMatrixAccountConfig } from "./account-config.js";

View file

@ -0,0 +1,37 @@
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.js";
export { voteMatrixPoll } from "./actions/polls.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { updateMatrixOwnProfile } from "./actions/profile.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
resetMatrixRoomKeyBackup,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View file

@ -0,0 +1,215 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockMatrixClient,
expectExplicitMatrixClientConfig,
expectOneOffSharedMatrixClient,
matrixClientResolverMocks,
primeMatrixClientResolverMocks,
} from "../client-resolver.test-helpers.js";
const resolveMatrixRoomIdMock = vi.fn();
const {
loadConfigMock,
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: getActiveMatrixClientMock,
}));
vi.mock("../client.js", () => ({
acquireSharedMatrixClient: acquireSharedMatrixClientMock,
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("../client/shared.js", () => ({
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
}));
vi.mock("../send.js", () => ({
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
}));
let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient;
let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction;
let withStartedActionClient: typeof import("./client.js").withStartedActionClient;
describe("action client helpers", () => {
beforeAll(async () => {
({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
await import("./client.js"));
});
beforeEach(() => {
primeMatrixClientResolverMocks();
resolveMatrixRoomIdMock
.mockReset()
.mockImplementation(async (_client, roomId: string) => roomId);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("stops one-off shared clients when no active monitor client is registered", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
await expectOneOffSharedMatrixClient();
expect(result).toBe("ok");
});
it("skips one-off room preparation when readiness is disabled", async () => {
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
expect(sharedClient.start).not.toHaveBeenCalled();
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("starts one-off clients when started readiness is required", async () => {
await withStartedActionClient({ accountId: "default" }, async () => {});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.start).toHaveBeenCalledTimes(1);
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "persist");
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
return "ok";
});
expect(result).toBe("ok");
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
expect(activeClient.stop).not.toHaveBeenCalled();
});
it("starts active clients when started readiness is required", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
await withStartedActionClient({ accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
});
expect(activeClient.start).toHaveBeenCalledTimes(1);
expect(activeClient.prepareForOneOff).not.toHaveBeenCalled();
expect(activeClient.stop).not.toHaveBeenCalled();
expect(activeClient.stopAndPersist).not.toHaveBeenCalled();
});
it("uses the implicit resolved account id for active client lookup and storage", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
},
},
},
});
resolveMatrixAuthContextMock.mockReturnValue({
cfg: loadConfigMock(),
env: process.env,
accountId: "ops",
resolved: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
},
});
await withResolvedActionClient({}, async () => {});
await expectOneOffSharedMatrixClient({
cfg: loadConfigMock(),
accountId: "ops",
});
});
it("uses explicit cfg instead of loading runtime config", async () => {
const explicitCfg = {
channels: {
matrix: {
defaultAccount: "ops",
},
},
};
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
expectExplicitMatrixClientConfig({
cfg: explicitCfg,
accountId: "ops",
});
});
it("stops shared action clients after wrapped calls succeed", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
expect(client).toBe(sharedClient);
return "ok";
});
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("stops shared action clients when the wrapped call throws", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedActionClient({ accountId: "default" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("resolves room ids before running wrapped room actions", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
resolveMatrixRoomIdMock.mockResolvedValue("!room:example.org");
const result = await withResolvedRoomAction(
"room:#ops:example.org",
{ accountId: "default" },
async (client, resolvedRoom) => {
expect(client).toBe(sharedClient);
return resolvedRoom;
},
);
expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(sharedClient, "room:#ops:example.org");
expect(result).toBe("!room:example.org");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
});

View file

@ -0,0 +1,31 @@
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
import { resolveMatrixRoomId } from "../send.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
type MatrixActionClientStopMode = "stop" | "persist";
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
return await withResolvedRuntimeMatrixClient(opts, run, mode);
}
export async function withStartedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist");
}
export async function withResolvedRoomAction<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}

View file

@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withResolvedActionClientMock = vi.fn();
const withStartedActionClientMock = vi.fn();
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
}));
const { getMatrixDeviceHealth, listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } =
await import("./devices.js");
describe("matrix device actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists own devices without starting a sync client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
]),
});
});
const result = await listMatrixOwnDevices({ accountId: "poe" });
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
{ accountId: "poe" },
expect.any(Function),
);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
expect(result).toEqual([
expect.objectContaining({
deviceId: "A7hWrQ70ea",
current: true,
}),
]);
});
it("computes device health without starting a sync client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "old123",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]),
});
});
const result = await getMatrixDeviceHealth({ accountId: "poe" });
expect(result.staleOpenClawDevices).toEqual([
expect.objectContaining({
deviceId: "old123",
}),
]);
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
{ accountId: "poe" },
expect.any(Function),
);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
it("prunes stale OpenClaw-managed devices but preserves the current device", async () => {
const deleteOwnDevices = vi.fn(async () => ({
currentDeviceId: "du314Zpw3A",
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
remainingDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "G6NJU9cTgs",
displayName: "OpenClaw Debug",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "My3T0hkTE0",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "phone123",
displayName: "Element iPhone",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]),
deleteOwnDevices,
});
});
const result = await pruneMatrixStaleGatewayDevices({ accountId: "poe" });
expect(deleteOwnDevices).toHaveBeenCalledWith(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.staleGatewayDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.deletedDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.remainingDevices).toEqual([
expect.objectContaining({
deviceId: "du314Zpw3A",
current: true,
}),
]);
});
});

View file

@ -0,0 +1,34 @@
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function listMatrixOwnDevices(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) => await client.listOwnDevices());
}
export async function pruneMatrixStaleGatewayDevices(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => {
const devices = await client.listOwnDevices();
const health = summarizeMatrixDeviceHealth(devices);
const staleGatewayDeviceIds = health.staleOpenClawDevices.map((device) => device.deviceId);
const deleted =
staleGatewayDeviceIds.length > 0
? await client.deleteOwnDevices(staleGatewayDeviceIds)
: {
currentDeviceId: devices.find((device) => device.current)?.deviceId ?? null,
deletedDeviceIds: [] as string[],
remainingDevices: devices,
};
return {
before: devices,
staleGatewayDeviceIds,
...deleted,
};
});
}
export async function getMatrixDeviceHealth(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) =>
summarizeMatrixDeviceHealth(await client.listOwnDevices()),
);
}

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixActionLimit } from "./limits.js";
describe("resolveMatrixActionLimit", () => {
it("uses fallback for non-finite values", () => {
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
});
it("normalizes finite numbers to positive integers", () => {
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
});
});

View file

@ -0,0 +1,6 @@
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallback;
}
return Math.max(1, Math.floor(raw));
}

View file

@ -0,0 +1,289 @@
import { describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import * as sendModule from "../send.js";
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
function installMatrixActionTestRuntime(): void {
setMatrixRuntime({
config: {
loadConfig: () => ({}),
},
channel: {
text: {
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as import("../../runtime-api.js").PluginRuntime);
}
function createPollResponseEvent(): Record<string, unknown> {
return {
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
};
}
function createPollStartEvent(params?: {
answers?: Array<Record<string, unknown>>;
includeDisclosedKind?: boolean;
maxSelections?: number;
}): Record<string, unknown> {
return {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
...(params?.includeDisclosedKind ? { kind: "m.poll.disclosed" } : {}),
...(params?.maxSelections !== undefined ? { max_selections: params.maxSelections } : {}),
answers: params?.answers ?? [{ id: "a1", "m.text": "Apple" }],
},
},
};
}
function createMessagesClient(params: {
chunk: Array<Record<string, unknown>>;
hydratedChunk?: Array<Record<string, unknown>>;
pollRoot?: Record<string, unknown>;
pollRelations?: Array<Record<string, unknown>>;
}) {
const doRequest = vi.fn(async () => ({
chunk: params.chunk,
start: "start-token",
end: "end-token",
}));
const hydrateEvents = vi.fn(
async (_roomId: string, _events: Array<Record<string, unknown>>) =>
(params.hydratedChunk ?? params.chunk) as unknown,
);
const getEvent = vi.fn(async () => params.pollRoot ?? null);
const getRelations = vi.fn(async () => ({
events: params.pollRelations ?? [],
nextBatch: null,
prevBatch: null,
}));
return {
client: {
doRequest,
hydrateEvents,
getEvent,
getRelations,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
hydrateEvents,
getEvent,
getRelations,
};
}
describe("matrix message actions", () => {
it("forwards timeoutMs to the shared Matrix edit helper", async () => {
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
try {
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
timeoutMs: 12_345,
});
expect(result).toEqual({ eventId: "evt-edit" });
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
cfg: undefined,
accountId: undefined,
client: undefined,
timeoutMs: 12_345,
});
} finally {
editSpy.mockRestore();
}
});
it("routes edits through the shared Matrix edit helper so mentions are preserved", async () => {
installMatrixActionTestRuntime();
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({
content: {
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
},
}),
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
sendMessage,
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello @alice:example.org and @bob:example.org",
{ client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(sendMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({
"m.mentions": { user_ids: ["@bob:example.org"] },
"m.new_content": expect.objectContaining({
"m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
}),
}),
);
});
it("does not re-notify legacy mentions when action edits target pre-m.mentions messages", async () => {
installMatrixActionTestRuntime();
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({
content: {
body: "hello @alice:example.org",
},
}),
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
sendMessage,
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello again @alice:example.org",
{ client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(sendMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({
"m.mentions": {},
"m.new_content": expect.objectContaining({
body: "hello again @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
}),
}),
);
});
it("includes poll snapshots when reading message history", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [
createPollResponseEvent(),
{
event_id: "$msg",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "hello",
},
},
],
pollRoot: createPollStartEvent({
includeDisclosedKind: true,
maxSelections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
}),
pollRelations: [createPollResponseEvent()],
});
const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/messages"),
expect.objectContaining({ limit: 2 }),
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(result.messages).toEqual([
expect.objectContaining({
eventId: "$poll",
body: expect.stringContaining("1. Apple (1 vote)"),
msgtype: "m.text",
}),
expect.objectContaining({
eventId: "$msg",
body: "hello",
}),
]);
});
it("dedupes multiple poll events for the same poll within one read page", async () => {
const { client, getEvent } = createMessagesClient({
chunk: [createPollResponseEvent(), createPollStartEvent()],
pollRoot: createPollStartEvent(),
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toEqual(
expect.objectContaining({
eventId: "$poll",
body: expect.stringContaining("[Poll]"),
}),
);
expect(getEvent).toHaveBeenCalledTimes(1);
});
it("uses hydrated history events so encrypted poll entries can be read", async () => {
const { client, hydrateEvents } = createMessagesClient({
chunk: [
{
event_id: "$enc",
sender: "@bob:example.org",
type: "m.room.encrypted",
origin_server_ts: 20,
content: {},
},
],
hydratedChunk: [createPollResponseEvent()],
pollRoot: createPollStartEvent(),
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(hydrateEvents).toHaveBeenCalledWith(
"!room:example.org",
expect.arrayContaining([expect.objectContaining({ event_id: "$enc" })]),
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.eventId).toBe("$poll");
});
});

View file

@ -0,0 +1,123 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType } from "../poll-types.js";
import { editMessageMatrix, sendMessageMatrix } from "../send.js";
import { withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
} from "./types.js";
export async function sendMatrixMessage(
to: string,
content: string | undefined,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
audioAsVoice?: boolean;
} = {},
) {
return await sendMessageMatrix(to, content, {
cfg: opts.cfg,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
replyToId: opts.replyToId,
threadId: opts.threadId,
audioAsVoice: opts.audioAsVoice,
accountId: opts.accountId ?? undefined,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
const eventId = await editMessageMatrix(roomId, messageId, trimmed, {
cfg: opts.cfg,
accountId: opts.accountId ?? undefined,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
return { eventId: eventId || null };
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
await client.redactEvent(resolvedRoom, messageId, opts.reason);
});
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const limit = resolveMatrixActionLimit(opts.limit, 20);
const token = normalizeOptionalString(opts.before) ?? normalizeOptionalString(opts.after);
const dir = opts.after ? "f" : "b";
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const hydratedChunk = await client.hydrateEvents(resolvedRoom, res.chunk);
const seenPollRoots = new Set<string>();
const messages: MatrixMessageSummary[] = [];
for (const event of hydratedChunk) {
if (event.unsigned?.redacted_because) {
continue;
}
if (event.type === EventType.RoomMessage) {
messages.push(summarizeMatrixRawEvent(event));
continue;
}
if (!isPollEventType(event.type)) {
continue;
}
const pollRootId = resolveMatrixPollRootEventId(event);
if (!pollRootId || seenPollRoots.has(pollRootId)) {
continue;
}
seenPollRoots.add(pollRootId);
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
if (pollSummary) {
messages.push(pollSummary);
}
}
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
});
}

View file

@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
let pinned = [...seedPinned];
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
const sendStateEvent = vi.fn(
async (_roomId: string, _type: string, _key: string, payload: unknown) => {
pinned = [...((payload as { pinned: string[] }).pinned ?? [])];
},
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
const body = knownBodies[eventId];
if (!body) {
throw new Error("missing");
}
return {
event_id: eventId,
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 123,
content: { msgtype: "m.text", body },
};
});
return {
client: {
getRoomStateEvent,
sendStateEvent,
getEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getPinned: () => pinned,
sendStateEvent,
};
}
describe("matrix pins actions", () => {
it("pins a message once even when asked twice", async () => {
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
expect(first.pinned).toEqual(["$a", "$b"]);
expect(second.pinned).toEqual(["$a", "$b"]);
expect(getPinned()).toEqual(["$a", "$b"]);
expect(sendStateEvent).toHaveBeenCalledTimes(2);
});
it("unpinds only the selected message id", async () => {
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
expect(result.pinned).toEqual(["$a", "$c"]);
expect(getPinned()).toEqual(["$a", "$c"]);
});
it("lists pinned ids and summarizes only resolvable events", async () => {
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
const result = await listMatrixPins("!room:example.org", { client });
expect(result.pinned).toEqual(["$a", "$missing"]);
expect(result.events).toEqual([
expect.objectContaining({
eventId: "$a",
body: "hello",
}),
]);
});
});

View file

@ -0,0 +1,64 @@
import { withResolvedRoomAction } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
async function updateMatrixPins(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts,
update: (current: string[]) => string[],
): Promise<{ pinned: string[] }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const current = await readPinnedEvents(client, resolvedRoom);
const next = update(current);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
});
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.includes(messageId) ? current : [...current, messageId],
);
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.filter((id) => id !== messageId),
);
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
});
}

View file

@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { voteMatrixPoll } from "./polls.js";
function createPollClient(pollContent?: Record<string, unknown>) {
const getEvent = vi.fn(async () => ({
type: "m.poll.start",
content: pollContent ?? {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
max_selections: 1,
answers: [
{ id: "apple", "m.text": "Apple" },
{ id: "berry", "m.text": "Berry" },
],
},
},
}));
const sendEvent = vi.fn(async () => "$vote1");
return {
client: {
getEvent,
sendEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getEvent,
sendEvent,
};
}
describe("matrix poll actions", () => {
it("votes by option index against the resolved room id", async () => {
const { client, getEvent, sendEvent } = createPollClient();
const result = await voteMatrixPoll("room:!room:example.org", "$poll", {
client,
optionIndex: 2,
});
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(sendEvent).toHaveBeenCalledWith(
"!room:example.org",
"m.poll.response",
expect.objectContaining({
"m.poll.response": { answers: ["berry"] },
}),
);
expect(result).toEqual({
eventId: "$vote1",
roomId: "!room:example.org",
pollId: "$poll",
answerIds: ["berry"],
labels: ["Berry"],
maxSelections: 1,
});
});
it("rejects option indexes that are outside the poll range", async () => {
const { client, sendEvent } = createPollClient();
await expect(
voteMatrixPoll("room:!room:example.org", "$poll", {
client,
optionIndex: 3,
}),
).rejects.toThrow("out of range");
expect(sendEvent).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,109 @@
import {
buildPollResponseContent,
isPollStartType,
parsePollStart,
type PollStartContent,
} from "../poll-types.js";
import { withResolvedRoomAction } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
function normalizeOptionIndexes(indexes: number[]): number[] {
const normalized = indexes
.map((index) => Math.trunc(index))
.filter((index) => Number.isFinite(index) && index > 0);
return Array.from(new Set(normalized));
}
function normalizeOptionIds(optionIds: string[]): string[] {
return Array.from(
new Set(optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0)),
);
}
function resolveSelectedAnswerIds(params: {
optionIds?: string[];
optionIndexes?: number[];
pollContent: PollStartContent;
}): { answerIds: string[]; labels: string[]; maxSelections: number } {
const parsed = parsePollStart(params.pollContent);
if (!parsed) {
throw new Error("Matrix poll vote requires a valid poll start event.");
}
const selectedById = normalizeOptionIds(params.optionIds ?? []);
const selectedByIndex = normalizeOptionIndexes(params.optionIndexes ?? []).map((index) => {
const answer = parsed.answers[index - 1];
if (!answer) {
throw new Error(
`Matrix poll option index ${index} is out of range for a poll with ${parsed.answers.length} options.`,
);
}
return answer.id;
});
const answerIds = normalizeOptionIds([...selectedById, ...selectedByIndex]);
if (answerIds.length === 0) {
throw new Error("Matrix poll vote requires at least one poll option id or index.");
}
if (answerIds.length > parsed.maxSelections) {
throw new Error(
`Matrix poll allows at most ${parsed.maxSelections} selection${parsed.maxSelections === 1 ? "" : "s"}.`,
);
}
const answerMap = new Map(parsed.answers.map((answer) => [answer.id, answer.text] as const));
const labels = answerIds.map((answerId) => {
const label = answerMap.get(answerId);
if (!label) {
throw new Error(
`Matrix poll option id "${answerId}" is not valid for poll ${parsed.question}.`,
);
}
return label;
});
return {
answerIds,
labels,
maxSelections: parsed.maxSelections,
};
}
export async function voteMatrixPoll(
roomId: string,
pollId: string,
opts: MatrixActionClientOpts & {
optionId?: string;
optionIds?: string[];
optionIndex?: number;
optionIndexes?: number[];
} = {},
) {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const pollEvent = await client.getEvent(resolvedRoom, pollId);
const eventType = typeof pollEvent.type === "string" ? pollEvent.type : "";
if (!isPollStartType(eventType)) {
throw new Error(`Event ${pollId} is not a Matrix poll start event.`);
}
const { answerIds, labels, maxSelections } = resolveSelectedAnswerIds({
optionIds: [...(opts.optionIds ?? []), ...(opts.optionId ? [opts.optionId] : [])],
optionIndexes: [
...(opts.optionIndexes ?? []),
...(opts.optionIndex !== undefined ? [opts.optionIndex] : []),
],
pollContent: pollEvent.content as PollStartContent,
});
const content = buildPollResponseContent(pollId, answerIds);
const eventId = await client.sendEvent(resolvedRoom, "m.poll.response", content);
return {
eventId: eventId ?? null,
roomId: resolvedRoom,
pollId,
answerIds,
labels,
maxSelections,
};
});
}

View file

@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadWebMediaMock = vi.fn();
const syncMatrixOwnProfileMock = vi.fn();
const withResolvedActionClientMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
},
}),
}));
vi.mock("../profile.js", () => ({
syncMatrixOwnProfile: (...args: unknown[]) => syncMatrixOwnProfileMock(...args),
}));
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
}));
const { updateMatrixOwnProfile } = await import("./profile.js");
describe("matrix profile actions", () => {
beforeEach(() => {
vi.clearAllMocks();
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("avatar"),
contentType: "image/png",
fileName: "avatar.png",
});
syncMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://example/avatar",
convertedAvatarFromHttp: true,
uploadedAvatarSource: "http",
});
});
it("trims profile fields and persists through the action client wrapper", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
await updateMatrixOwnProfile({
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
});
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
{
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
},
expect.any(Function),
"persist",
);
expect(syncMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: "@bot:example.org",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
avatarPath: "/tmp/avatar.png",
}),
);
});
it("bridges avatar loaders through Matrix runtime media helpers", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
await updateMatrixOwnProfile({
avatarUrl: "https://cdn.example.org/avatar.png",
avatarPath: "/tmp/avatar.png",
});
const call = syncMatrixOwnProfileMock.mock.calls[0]?.[0] as
| {
loadAvatarFromUrl: (url: string, maxBytes: number) => Promise<unknown>;
loadAvatarFromPath: (path: string, maxBytes: number) => Promise<unknown>;
}
| undefined;
if (!call) {
throw new Error("syncMatrixOwnProfile was not called");
}
await call.loadAvatarFromUrl("https://cdn.example.org/avatar.png", 123);
await call.loadAvatarFromPath("/tmp/avatar.png", 456);
expect(loadWebMediaMock).toHaveBeenNthCalledWith(1, "https://cdn.example.org/avatar.png", 123);
expect(loadWebMediaMock).toHaveBeenNthCalledWith(2, "/tmp/avatar.png", {
maxBytes: 456,
localRoots: undefined,
});
});
});

View file

@ -0,0 +1,37 @@
import { getMatrixRuntime } from "../../runtime.js";
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function updateMatrixOwnProfile(
opts: MatrixActionClientOpts & {
displayName?: string;
avatarUrl?: string;
avatarPath?: string;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.trim();
const avatarPath = opts.avatarPath?.trim();
const runtime = getMatrixRuntime();
return await withResolvedActionClient(
opts,
async (client) => {
const userId = await client.getUserId();
return await syncMatrixOwnProfile({
client,
userId,
displayName: displayName || undefined,
avatarUrl: avatarUrl || undefined,
avatarPath: avatarPath || undefined,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
loadAvatarFromPath: async (path, maxBytes) =>
await runtime.media.loadWebMedia(path, {
maxBytes,
localRoots: opts.mediaLocalRoots,
}),
});
},
"persist",
);
}

View file

@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
function createReactionsClient(params: {
chunk: Array<{
event_id?: string;
sender?: string;
key?: string;
}>;
userId?: string | null;
}) {
const doRequest = vi.fn(async (_method: string, _path: string, _query: unknown) => ({
chunk: params.chunk.map((item) => ({
event_id: item.event_id ?? "",
sender: item.sender ?? "",
content: item.key
? {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$target",
key: item.key,
},
}
: {},
})),
}));
const getUserId = vi.fn(async () => params.userId ?? null);
const redactEvent = vi.fn(async () => undefined);
return {
client: {
doRequest,
getUserId,
redactEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
redactEvent,
};
}
describe("matrix reaction actions", () => {
it("aggregates reactions by key and unique sender", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
{ event_id: "$4", sender: "@bot:example.org" },
],
userId: "@bot:example.org",
});
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
expect.objectContaining({ limit: 2 }),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "👍",
count: 2,
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
}),
expect.objectContaining({
key: "👎",
count: 1,
users: ["@alice:example.org"],
}),
]),
);
});
it("removes only current-user reactions matching emoji filter", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
],
userId: "@me:example.org",
});
const result = await removeMatrixReactions("!room:example.org", "$msg", {
client,
emoji: "👍",
});
expect(result).toEqual({ removed: 1 });
expect(redactEvent).toHaveBeenCalledTimes(1);
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
});
it("returns removed=0 when current user id is unavailable", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
userId: null,
});
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual({ removed: 0 });
expect(redactEvent).not.toHaveBeenCalled();
});
it("returns an empty list when the relations response is malformed", async () => {
const doRequest = vi.fn(async () => ({ chunk: null }));
const client = {
doRequest,
getUserId: vi.fn(async () => "@me:example.org"),
redactEvent: vi.fn(async () => undefined),
stop: vi.fn(),
} as unknown as MatrixClient;
const result = await listMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual([]);
});
it("rejects blank message ids before querying Matrix relations", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [],
userId: "@me:example.org",
});
await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow(
"messageId",
);
expect(doRequest).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
import {
buildMatrixReactionRelationsPath,
selectOwnMatrixReactionEventIds,
summarizeMatrixReactionEvents,
} from "../reaction-common.js";
import { withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import {
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
} from "./types.js";
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
async function listMatrixReactionEvents(
client: ActionClient,
roomId: string,
messageId: string,
limit: number,
): Promise<MatrixRawEvent[]> {
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
dir: "b",
limit,
})) as { chunk?: MatrixRawEvent[] };
return Array.isArray(res.chunk) ? res.chunk : [];
}
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const limit = resolveMatrixActionLimit(opts.limit, 100);
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
return summarizeMatrixReactionEvents(chunk);
});
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
const userId = await client.getUserId();
if (!userId) {
return { removed: 0 };
}
const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji);
if (toRemove.length === 0) {
return { removed: 0 };
}
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
});
}

View file

@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { getMatrixMemberInfo, getMatrixRoomInfo } from "./room.js";
function createRoomClient() {
const getRoomStateEvent = vi.fn(async (_roomId: string, eventType: string) => {
switch (eventType) {
case "m.room.name":
return { name: "Ops Room" };
case "m.room.topic":
return { topic: "Incidents" };
case "m.room.canonical_alias":
return { alias: "#ops:example.org" };
default:
throw new Error(`unexpected state event ${eventType}`);
}
});
const getJoinedRoomMembers = vi.fn(async () => [
{ user_id: "@alice:example.org" },
{ user_id: "@bot:example.org" },
]);
const getUserProfile = vi.fn(async () => ({
displayname: "Alice",
avatar_url: "mxc://example.org/alice",
}));
return {
client: {
getRoomStateEvent,
getJoinedRoomMembers,
getUserProfile,
stop: vi.fn(),
} as unknown as MatrixClient,
getRoomStateEvent,
getJoinedRoomMembers,
getUserProfile,
};
}
describe("matrix room actions", () => {
it("returns room details from the resolved Matrix room id", async () => {
const { client, getJoinedRoomMembers, getRoomStateEvent } = createRoomClient();
const result = await getMatrixRoomInfo("room:!ops:example.org", { client });
expect(getRoomStateEvent).toHaveBeenCalledWith("!ops:example.org", "m.room.name", "");
expect(getJoinedRoomMembers).toHaveBeenCalledWith("!ops:example.org");
expect(result).toEqual({
roomId: "!ops:example.org",
name: "Ops Room",
topic: "Incidents",
canonicalAlias: "#ops:example.org",
altAliases: [],
memberCount: 2,
});
});
it("resolves optional room ids when looking up member info", async () => {
const { client, getUserProfile } = createRoomClient();
const result = await getMatrixMemberInfo("@alice:example.org", {
client,
roomId: "room:!ops:example.org",
});
expect(getUserProfile).toHaveBeenCalledWith("@alice:example.org");
expect(result).toEqual({
userId: "@alice:example.org",
profile: {
displayName: "Alice",
avatarUrl: "mxc://example.org/alice",
},
membership: null,
powerLevel: null,
displayName: "Alice",
roomId: "!ops:example.org",
});
});
});

View file

@ -0,0 +1,71 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getUserProfile(userId);
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
});
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
});
}

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { summarizeMatrixRawEvent } from "./summary.js";
describe("summarizeMatrixRawEvent", () => {
it("replaces bare media filenames with a media marker", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "photo.jpg",
},
});
expect(summary).toMatchObject({
eventId: "$image",
msgtype: "m.image",
attachment: {
kind: "image",
filename: "photo.jpg",
},
});
expect(summary.body).toBeUndefined();
});
it("preserves captions while marking media summaries", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "can you see this?",
filename: "photo.jpg",
},
});
expect(summary).toMatchObject({
body: "can you see this?",
attachment: {
kind: "image",
caption: "can you see this?",
filename: "photo.jpg",
},
});
});
it("does not treat a sentence ending in a file extension as a bare filename", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$image",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.image",
body: "see image.png",
},
});
expect(summary).toMatchObject({
body: "see image.png",
attachment: {
kind: "image",
caption: "see image.png",
},
});
});
it("leaves text messages unchanged", () => {
const summary = summarizeMatrixRawEvent({
event_id: "$text",
sender: "@gum:matrix.example.org",
type: "m.room.message",
origin_server_ts: 123,
content: {
msgtype: "m.text",
body: "hello",
},
});
expect(summary.body).toBe("hello");
expect(summary.attachment).toBeUndefined();
});
});

View file

@ -0,0 +1,88 @@
import { isMatrixNotFoundError } from "../errors.js";
import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js";
import { fetchMatrixPollMessageSummary } from "../poll-summary.js";
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: resolveMatrixMessageBody({
body: content.body,
filename: content.filename,
msgtype: content.msgtype,
}),
msgtype: content.msgtype,
attachment: resolveMatrixMessageAttachment({
body: content.body,
filename: content.filename,
msgtype: content.msgtype,
}),
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
if (isMatrixNotFoundError(err)) {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
if (raw.unsigned?.redacted_because) {
return null;
}
const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw);
if (pollSummary) {
return pollSummary;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View file

@ -0,0 +1,82 @@
import type { CoreConfig } from "../../types.js";
import {
MATRIX_ANNOTATION_RELATION_TYPE,
MATRIX_REACTION_EVENT_TYPE,
type MatrixReactionEventContent,
} from "../reaction-common.js";
import type { MatrixClient, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export type { MatrixReactionSummary } from "../reaction-common.js";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: MATRIX_REACTION_EVENT_TYPE,
} as const;
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = MatrixReactionEventContent;
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
cfg?: CoreConfig;
mediaLocalRoots?: readonly string[];
timeoutMs?: number;
accountId?: string | null;
readiness?: "none" | "prepared" | "started";
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
attachment?: MatrixMessageAttachmentSummary;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixMessageAttachmentKind = "audio" | "file" | "image" | "sticker" | "video";
export type MatrixMessageAttachmentSummary = {
kind: MatrixMessageAttachmentKind;
caption?: string;
filename?: string;
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

View file

@ -0,0 +1,204 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const withResolvedActionClientMock = vi.fn();
const withStartedActionClientMock = vi.fn();
const loadConfigMock = vi.fn(() => ({
channels: {
matrix: {},
},
}));
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: loadConfigMock,
},
}),
}));
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
}));
let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifications;
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
describe("matrix verification actions", () => {
beforeAll(async () => {
({
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
listMatrixVerifications,
} = await import("./verification.js"));
});
beforeEach(() => {
vi.clearAllMocks();
loadConfigMock.mockReturnValue({
channels: {
matrix: {},
},
});
});
it("points encryption guidance at the selected Matrix account", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
encryption: false,
},
},
},
},
});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});
it("uses the resolved default Matrix account when accountId is omitted", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
defaultAccount: "ops",
accounts: {
ops: {
encryption: false,
},
},
},
},
});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications()).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});
it("uses explicit cfg instead of runtime config when crypto is unavailable", async () => {
const explicitCfg = {
channels: {
matrix: {
accounts: {
ops: {
encryption: false,
},
},
},
},
};
loadConfigMock.mockImplementation(() => {
throw new Error("verification actions should not reload runtime config when cfg is provided");
});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications({ cfg: explicitCfg, accountId: "ops" })).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
expect(loadConfigMock).not.toHaveBeenCalled();
});
it("resolves verification status without starting the Matrix client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
crypto: {
listVerifications: vi.fn(async () => []),
getRecoveryKey: vi.fn(async () => ({
encodedPrivateKey: "rec-key",
})),
},
getOwnDeviceVerificationStatus: vi.fn(async () => ({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
recoveryKeyStored: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: "SSSS",
backupVersion: "11",
backup: {
serverVersion: "11",
activeVersion: "11",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
})),
});
});
const status = await getMatrixVerificationStatus({ includeRecoveryKey: true });
expect(status).toMatchObject({
verified: true,
pendingVerifications: 0,
recoveryKey: "rec-key",
});
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
it("resolves encryption and backup status without starting the Matrix client", async () => {
withResolvedActionClientMock
.mockImplementationOnce(async (_opts, run) => {
return await run({
crypto: {
getRecoveryKey: vi.fn(async () => ({
encodedPrivateKey: "rec-key",
createdAt: "2026-01-01T00:00:00.000Z",
})),
listVerifications: vi.fn(async () => [{ id: "req-1" }]),
},
});
})
.mockImplementationOnce(async (_opts, run) => {
return await run({
getRoomKeyBackupStatus: vi.fn(async () => ({
serverVersion: "11",
activeVersion: "11",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
})),
});
});
const encryption = await getMatrixEncryptionStatus({ includeRecoveryKey: true });
const backup = await getMatrixRoomKeyBackupStatus();
expect(encryption).toMatchObject({
encryptionEnabled: true,
recoveryKeyStored: true,
recoveryKey: "rec-key",
pendingVerifications: 1,
});
expect(backup).toMatchObject({
serverVersion: "11",
trusted: true,
});
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,237 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
function requireCrypto(
client: import("../sdk.js").MatrixClient,
opts: MatrixActionClientOpts,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
}
return client.crypto;
}
function resolveVerificationId(input: string): string {
const normalized = input.trim();
if (!normalized) {
throw new Error("Matrix verification request id is required");
}
return normalized;
}
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.listVerifications();
});
}
export async function requestMatrixVerification(
params: MatrixActionClientOpts & {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
} = {},
) {
return await withStartedActionClient(params, async (client) => {
const crypto = requireCrypto(client, params);
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
return await crypto.requestVerification({
ownUser,
userId: normalizeOptionalString(params.userId),
deviceId: normalizeOptionalString(params.deviceId),
roomId: normalizeOptionalString(params.roomId),
});
});
}
export async function acceptMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.acceptVerification(resolveVerificationId(requestId));
});
}
export async function cancelMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: normalizeOptionalString(opts.reason),
code: normalizeOptionalString(opts.code),
});
});
}
export async function startMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { method?: "sas" } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
});
}
export async function generateMatrixVerificationQr(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
});
}
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
}
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
});
}
export async function getMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
});
}
export async function mismatchMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationReciprocateQr(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
});
}
export async function getMatrixEncryptionStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
const recoveryKey = await crypto.getRecoveryKey();
return {
encryptionEnabled: true,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
pendingVerifications: (await crypto.listVerifications()).length,
};
});
}
export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
...status,
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
};
if (!opts.includeRecoveryKey) {
return payload;
}
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
return {
...payload,
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
};
});
}
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
opts,
async (client) => await client.getRoomKeyBackupStatus(),
);
}
export async function verifyMatrixRecoveryKey(
recoveryKey: string,
opts: MatrixActionClientOpts = {},
) {
return await withStartedActionClient(
opts,
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
);
}
export async function restoreMatrixRoomKeyBackup(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
} = {},
) {
return await withStartedActionClient(
opts,
async (client) =>
await client.restoreRoomKeyBackup({
recoveryKey: normalizeOptionalString(opts.recoveryKey),
}),
);
}
export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup());
}
export async function bootstrapMatrixVerification(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
forceResetCrossSigning?: boolean;
} = {},
) {
return await withStartedActionClient(
opts,
async (client) =>
await client.bootstrapOwnDeviceVerification({
recoveryKey: normalizeOptionalString(opts.recoveryKey),
forceResetCrossSigning: opts.forceResetCrossSigning === true,
}),
);
}

View file

@ -0,0 +1,26 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MatrixClient } from "./sdk.js";
const activeClients = new Map<string, MatrixClient>();
function resolveAccountKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized || DEFAULT_ACCOUNT_ID;
}
export function setActiveMatrixClient(
client: MatrixClient | null,
accountId?: string | null,
): void {
const key = resolveAccountKey(accountId);
if (!client) {
activeClients.delete(key);
return;
}
activeClients.set(key, client);
}
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
const key = resolveAccountKey(accountId);
return activeClients.get(key) ?? null;
}

View file

@ -0,0 +1,18 @@
export type AsyncLock = <T>(fn: () => Promise<T>) => Promise<T>;
export function createAsyncLock(): AsyncLock {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
};
}

View file

@ -0,0 +1,124 @@
export type MatrixRoomKeyBackupStatusLike = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
export type MatrixRoomKeyBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
export type MatrixRoomKeyBackupIssue = {
code: MatrixRoomKeyBackupIssueCode;
summary: string;
message: string | null;
};
export function resolveMatrixRoomKeyBackupIssue(
backup: MatrixRoomKeyBackupStatusLike,
): MatrixRoomKeyBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
export function resolveMatrixRoomKeyBackupReadinessError(
backup: MatrixRoomKeyBackupStatusLike,
opts: {
allowUntrustedMatchingKey?: boolean;
requireServerBackup: boolean;
},
): string | null {
const issue = resolveMatrixRoomKeyBackupIssue(backup);
if (issue.code === "missing-server-backup") {
return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null;
}
if (issue.code === "ok") {
return null;
}
if (
issue.code === "untrusted-signature" &&
opts.allowUntrustedMatchingKey === true &&
backup.matchesDecryptionKey === true &&
backup.decryptionKeyCached === true
) {
return null;
}
if (issue.message) {
return `Matrix room key backup is not usable: ${issue.message}.`;
}
return "Matrix room key backup is not usable on this device.";
}

View file

@ -0,0 +1,84 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockMatrixClient,
matrixClientResolverMocks,
primeMatrixClientResolverMocks,
} from "./client-resolver.test-helpers.js";
const {
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
vi.mock("../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
vi.mock("./active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
vi.mock("./client.js", () => ({
acquireSharedMatrixClient: (...args: unknown[]) => acquireSharedMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("./client/shared.js", () => ({
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
}));
let resolveRuntimeMatrixClientWithReadiness: typeof import("./client-bootstrap.js").resolveRuntimeMatrixClientWithReadiness;
let withResolvedRuntimeMatrixClient: typeof import("./client-bootstrap.js").withResolvedRuntimeMatrixClient;
describe("client bootstrap", () => {
beforeAll(async () => {
({ resolveRuntimeMatrixClientWithReadiness, withResolvedRuntimeMatrixClient } =
await import("./client-bootstrap.js"));
});
beforeEach(() => {
primeMatrixClientResolverMocks({ resolved: {} });
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("releases leased shared clients when readiness setup fails", async () => {
const sharedClient = createMockMatrixClient();
vi.mocked(sharedClient.prepareForOneOff).mockRejectedValue(new Error("prepare failed"));
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
resolveRuntimeMatrixClientWithReadiness({
accountId: "default",
readiness: "prepared",
}),
).rejects.toThrow("prepare failed");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("releases leased shared clients when the wrapped action throws during readiness", async () => {
const sharedClient = createMockMatrixClient();
vi.mocked(sharedClient.start).mockRejectedValue(new Error("start failed"));
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedRuntimeMatrixClient(
{
accountId: "default",
readiness: "started",
},
async () => "ok",
),
).rejects.toThrow("start failed");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
});

View file

@ -0,0 +1,164 @@
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import { isBunRuntime } from "./client/runtime.js";
import type { MatrixClient } from "./sdk.js";
type ResolvedRuntimeMatrixClient = {
client: MatrixClient;
stopOnDone: boolean;
cleanup?: (mode: ResolvedRuntimeMatrixClientStopMode) => Promise<void>;
};
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
type MatrixResolvedClientHook = (
client: MatrixClient,
context: { preparedByDefault: boolean },
) => Promise<void> | void;
type MatrixSharedClientRuntimeDeps = Pick<
typeof import("./client.js"),
"acquireSharedMatrixClient" | "resolveMatrixAuthContext"
> &
Pick<typeof import("./client/shared.js"), "releaseSharedClientInstance">;
let matrixSharedClientRuntimeDepsPromise: Promise<MatrixSharedClientRuntimeDeps> | undefined;
async function loadMatrixSharedClientRuntimeDeps(): Promise<MatrixSharedClientRuntimeDeps> {
matrixSharedClientRuntimeDepsPromise ??= Promise.all([
import("./client.js"),
import("./client/shared.js"),
]).then(([clientModule, sharedModule]) => ({
acquireSharedMatrixClient: clientModule.acquireSharedMatrixClient,
resolveMatrixAuthContext: clientModule.resolveMatrixAuthContext,
releaseSharedClientInstance: sharedModule.releaseSharedClientInstance,
}));
return await matrixSharedClientRuntimeDepsPromise;
}
async function ensureResolvedClientReadiness(params: {
client: MatrixClient;
readiness?: MatrixRuntimeClientReadiness;
preparedByDefault: boolean;
}): Promise<void> {
if (params.readiness === "started") {
await params.client.start();
return;
}
if (params.readiness === "prepared" || (!params.readiness && params.preparedByDefault)) {
await params.client.prepareForOneOff();
}
}
function ensureMatrixNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
async function resolveRuntimeMatrixClient(opts: {
client?: MatrixClient;
cfg?: CoreConfig;
timeoutMs?: number;
accountId?: string | null;
onResolved?: MatrixResolvedClientHook;
}): Promise<ResolvedRuntimeMatrixClient> {
ensureMatrixNodeRuntime();
if (opts.client) {
await opts.onResolved?.(opts.client, { preparedByDefault: false });
return { client: opts.client, stopOnDone: false };
}
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
await loadMatrixSharedClientRuntimeDeps();
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
});
const active = getActiveMatrixClient(authContext.accountId);
if (active) {
await opts.onResolved?.(active, { preparedByDefault: false });
return { client: active, stopOnDone: false };
}
const client = await acquireSharedMatrixClient({
cfg,
timeoutMs: opts.timeoutMs,
accountId: authContext.accountId,
startClient: false,
});
try {
await opts.onResolved?.(client, { preparedByDefault: true });
} catch (err) {
await releaseSharedClientInstance(client, "stop");
throw err;
}
return {
client,
stopOnDone: true,
cleanup: async (mode) => {
await releaseSharedClientInstance(client, mode);
},
};
}
export async function resolveRuntimeMatrixClientWithReadiness(opts: {
client?: MatrixClient;
cfg?: CoreConfig;
timeoutMs?: number;
accountId?: string | null;
readiness?: MatrixRuntimeClientReadiness;
}): Promise<ResolvedRuntimeMatrixClient> {
return await resolveRuntimeMatrixClient({
client: opts.client,
cfg: opts.cfg,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
onResolved: async (client, context) => {
await ensureResolvedClientReadiness({
client,
readiness: opts.readiness,
preparedByDefault: context.preparedByDefault,
});
},
});
}
export async function stopResolvedRuntimeMatrixClient(
resolved: ResolvedRuntimeMatrixClient,
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
): Promise<void> {
if (!resolved.stopOnDone) {
return;
}
if (resolved.cleanup) {
await resolved.cleanup(mode);
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedRuntimeMatrixClient<T>(
opts: {
client?: MatrixClient;
cfg?: CoreConfig;
timeoutMs?: number;
accountId?: string | null;
readiness?: MatrixRuntimeClientReadiness;
},
run: (client: MatrixClient) => Promise<T>,
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
): Promise<T> {
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
try {
return await run(resolved.client);
} finally {
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
}
}

View file

@ -0,0 +1,147 @@
import { expect, vi, type Mock } from "vitest";
import type { MatrixClient } from "./sdk.js";
type MatrixClientResolverMocks = {
loadConfigMock: Mock<() => unknown>;
getMatrixRuntimeMock: Mock<() => unknown>;
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
acquireSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
releaseSharedClientInstanceMock: Mock<(...args: unknown[]) => Promise<boolean>>;
isBunRuntimeMock: Mock<() => boolean>;
resolveMatrixAuthContextMock: Mock<
(params: { cfg: unknown; accountId?: string | null }) => unknown
>;
};
export const matrixClientResolverMocks: MatrixClientResolverMocks = {
loadConfigMock: vi.fn(() => ({})),
getMatrixRuntimeMock: vi.fn(),
getActiveMatrixClientMock: vi.fn(),
acquireSharedMatrixClientMock: vi.fn(),
releaseSharedClientInstanceMock: vi.fn(),
isBunRuntimeMock: vi.fn(() => false),
resolveMatrixAuthContextMock: vi.fn(),
};
export function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
export function primeMatrixClientResolverMocks(params?: {
cfg?: unknown;
accountId?: string;
resolved?: Record<string, unknown>;
auth?: Record<string, unknown>;
client?: MatrixClient;
}): MatrixClient {
const {
loadConfigMock,
getMatrixRuntimeMock,
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const cfg = params?.cfg ?? {};
const accountId = params?.accountId ?? "default";
const defaultResolved = {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
};
const client = params?.client ?? createMockMatrixClient();
vi.clearAllMocks();
loadConfigMock.mockReturnValue(cfg);
getMatrixRuntimeMock.mockReturnValue({
config: {
loadConfig: loadConfigMock,
},
});
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
releaseSharedClientInstanceMock.mockReset().mockResolvedValue(true);
resolveMatrixAuthContextMock.mockImplementation(
({
cfg: explicitCfg,
accountId: explicitAccountId,
}: {
cfg: unknown;
accountId?: string | null;
}) => ({
cfg: explicitCfg,
env: process.env,
accountId: explicitAccountId ?? accountId,
resolved: {
...defaultResolved,
...params?.resolved,
},
}),
);
acquireSharedMatrixClientMock.mockResolvedValue(client);
return client;
}
export async function expectOneOffSharedMatrixClient(params?: {
cfg?: unknown;
accountId?: string;
timeoutMs?: number;
prepareForOneOffCalls?: number;
startCalls?: number;
releaseMode?: "persist" | "stop";
}) {
const {
getActiveMatrixClientMock,
acquireSharedMatrixClientMock,
releaseSharedClientInstanceMock,
} = matrixClientResolverMocks;
const accountId = params?.accountId ?? "default";
const prepareForOneOffCalls = params?.prepareForOneOffCalls ?? 1;
const startCalls = params?.startCalls ?? 0;
const releaseMode = params?.releaseMode ?? "stop";
expect(getActiveMatrixClientMock).toHaveBeenCalledWith(accountId);
expect(acquireSharedMatrixClientMock).toHaveBeenCalledTimes(1);
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
cfg: params?.cfg ?? {},
timeoutMs: params?.timeoutMs,
accountId,
startClient: false,
});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(prepareForOneOffCalls);
expect(sharedClient.start).toHaveBeenCalledTimes(startCalls);
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, releaseMode);
return sharedClient;
}
export function expectExplicitMatrixClientConfig(params: { cfg: unknown; accountId?: string }) {
const { getMatrixRuntimeMock, resolveMatrixAuthContextMock, acquireSharedMatrixClientMock } =
matrixClientResolverMocks;
const accountId = params.accountId ?? "default";
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
cfg: params.cfg,
accountId,
});
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
cfg: params.cfg,
timeoutMs: undefined,
accountId,
startClient: false,
});
}

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