Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 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,375 @@
# Changelog
## 2026.6.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.9
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.8
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.6.1
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.31
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.28
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.27
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.26
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.24
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.22
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.21
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.20
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.19
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.17
### Changes
- Version alignment with core OpenClaw release numbers.
### Fixes
- Matrix/E2EE: stop requesting MSC4222 `state_after` sync responses so homeservers with incomplete state-after data do not leave fresh encrypted rooms without outbound room encryptors. Fixes #82515. Thanks @nickdecooman.
## 2026.5.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.14
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.8
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.6
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.4
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.5.2
### Changes
- Version alignment with core OpenClaw release numbers.
## Unreleased
### Changes
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
### Fixes
- Matrix/E2EE: close the owner-side device verification loop when SAS lands via the CLI. `verify confirm-sas` now (1) awaits the rust-crypto verifier promise so the done-exchange and any cross-signing uploads triggered by `crossSignDevice` settle before the verb returns, (2) cross-signs the bot device on the auto-confirmed inbound SAS path (previously skipped), and (3) calls `trustOwnIdentityAfterSelfVerification` from the standalone `confirmMatrixVerificationSas` action so the operator's Element X clears the "Verify" prompt without waiting for a passive sync tick [AI-assisted]. Thanks @nklock.
- Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.
## 2026.4.25
### Changes
- Version alignment with core OpenClaw release numbers.
## 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 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 source loaders 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,11 @@
# OpenClaw Matrix
Official OpenClaw channel plugin for Matrix rooms and direct messages.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/matrix
```
Configure the Matrix homeserver and bot credentials in OpenClaw. The plugin lets agents join configured rooms, receive messages, and reply through Matrix.

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>

39
extensions/matrix/api.ts Normal file
View File

@@ -0,0 +1,39 @@
// Matrix API module exposes the plugin public contract.
export { matrixPlugin } from "./src/channel.js";
export { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./src/setup-core.js";
export { matrixOnboardingAdapter } from "./src/setup-surface.js";
export {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./src/account-selection.js";
export {
getMatrixScopedEnvVarNames,
listMatrixEnvAccountIds,
resolveMatrixEnvAccountToken,
} from "./src/env-vars.js";
export {
hashMatrixAccessToken,
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsDir,
resolveMatrixCredentialsFilename,
resolveMatrixCredentialsPath,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoragePaths,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} 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,57 @@
// Matrix plugin module implements auth presence behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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,12 @@
// Matrix plugin module implements cli metadata behavior.
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,8 @@
// Matrix API module exposes the plugin public contract.
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./src/setup-contract.js";
export { matrixSetupAdapter } from "./src/setup-core.js";
export { matrixSetupWizard } from "./src/setup-surface.js";

View File

@@ -0,0 +1,319 @@
// Matrix tests cover doctor contract state migrations.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { PluginDoctorStateMigrationContext } from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import { SqliteBackedMatrixSyncStore } from "./src/matrix/client/file-sync-store.js";
import { openMatrixStorageMetaStoreOptions } from "./src/matrix/client/storage.js";
import {
readMatrixIdbSnapshotJson,
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
} from "./src/matrix/crypto-state-store.js";
import { installMatrixTestRuntime } from "./src/test-runtime.js";
function createContext(): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore: <T>(options: OpenKeyedStoreOptions): PluginStateKeyedStore<T> =>
createPluginStateKeyedStoreForTests<T>("matrix", options),
};
}
function createMigrationParams(stateDir: string) {
return {
config: {} as OpenClawConfig,
env: { OPENCLAW_STATE_DIR: stateDir },
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createContext(),
};
}
function migrationById(id: string) {
const migration = stateMigrations.find((entry) => entry.id === id);
if (!migration) {
throw new Error(`missing migration ${id}`);
}
return migration;
}
describe("matrix doctor contract state migrations", () => {
const tempDirs: string[] = [];
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
resetPluginStateStoreForTests();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("migrates legacy sync cache JSON to SQLite plugin state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "bot-storage.json"),
JSON.stringify({
version: 1,
savedSync: {
nextBatch: "legacy-token",
accountData: [],
roomsData: {
join: {},
invite: {},
leave: {},
knock: {},
},
},
cleanShutdown: true,
}),
);
const migration = migrationById("matrix-sync-cache-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix sync cache JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix sync cache JSON to SQLite for ${storageRootDir}`,
`Archived Matrix sync cache legacy source -> ${path.join(storageRootDir, "bot-storage.json")}.migrated`,
],
warnings: [],
});
const store = new SqliteBackedMatrixSyncStore(storageRootDir);
expect(store.hasSavedSync()).toBe(true);
expect(store.hasSavedSyncFromCleanShutdown()).toBe(true);
await expect(store.getSavedSyncToken()).resolves.toBe("legacy-token");
expect(fs.existsSync(path.join(storageRootDir, "bot-storage.json"))).toBe(false);
});
it("migrates Matrix storage metadata JSON to SQLite plugin state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "storage-meta.json"),
JSON.stringify({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accountId: "default",
accessTokenHash: "token-hash",
deviceId: "DEVICE",
currentTokenStateClaimed: true,
}),
);
const migration = migrationById("matrix-storage-meta-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix storage metadata JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix storage metadata JSON to SQLite for ${storageRootDir}`,
`Archived Matrix storage metadata legacy source -> ${path.join(storageRootDir, "storage-meta.json")}.migrated`,
],
warnings: [],
});
const store = createPluginStateKeyedStoreForTests<Record<string, unknown>>(
"matrix",
openMatrixStorageMetaStoreOptions(storageRootDir),
);
await expect(store.lookup("current")).resolves.toMatchObject({
deviceId: "DEVICE",
currentTokenStateClaimed: true,
});
expect(fs.existsSync(path.join(storageRootDir, "storage-meta.json"))).toBe(false);
});
it("does not archive the legacy flat sync cache into an unread SQLite root", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const flatRoot = path.join(stateDir, "matrix");
fs.mkdirSync(flatRoot, { recursive: true });
fs.writeFileSync(
path.join(flatRoot, "bot-storage.json"),
JSON.stringify({
next_batch: "flat-token",
rooms: { join: {} },
account_data: { events: [] },
}),
);
const migration = migrationById("matrix-sync-cache-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toBeNull();
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [],
warnings: [],
});
expect(fs.existsSync(path.join(flatRoot, "bot-storage.json"))).toBe(true);
});
it("migrates Matrix recovery-key JSON to SQLite plugin state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "recovery-key.json"),
JSON.stringify({
version: 1,
createdAt: "2026-03-12T00:00:00.000Z",
keyId: "SSSS",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
);
const migration = migrationById("matrix-recovery-key-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix recovery-key JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix recovery-key JSON to SQLite for ${storageRootDir}`,
`Archived Matrix recovery key legacy source -> ${path.join(storageRootDir, "recovery-key.json")}.migrated`,
],
warnings: [],
});
expect(readMatrixRecoveryKeyState(storageRootDir)?.keyId).toBe("SSSS");
expect(fs.existsSync(path.join(storageRootDir, "recovery-key.json"))).toBe(false);
});
it("migrates Matrix IndexedDB snapshot JSON to SQLite plugin state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
const snapshot = [
{
name: "openclaw-matrix::matrix-sdk-crypto",
version: 1,
stores: [
{
name: "sessions",
keyPath: null,
autoIncrement: false,
indexes: [],
records: [{ key: "room-1", value: { session: "abc123" } }],
},
],
},
];
fs.writeFileSync(
path.join(storageRootDir, "crypto-idb-snapshot.json"),
JSON.stringify(snapshot),
);
const migration = migrationById("matrix-idb-snapshot-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix IndexedDB snapshot JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix IndexedDB snapshot JSON to SQLite for ${storageRootDir}`,
`Archived Matrix IndexedDB snapshot legacy source -> ${path.join(storageRootDir, "crypto-idb-snapshot.json")}.migrated`,
],
warnings: [],
});
expect(JSON.parse(readMatrixIdbSnapshotJson(storageRootDir) ?? "null")).toEqual(snapshot);
expect(fs.existsSync(path.join(storageRootDir, "crypto-idb-snapshot.json"))).toBe(false);
});
it("migrates Matrix legacy crypto migration JSON to SQLite plugin state", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-doctor-"));
tempDirs.push(stateDir);
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "legacy-crypto-migration.json"),
JSON.stringify({
version: 1,
source: "matrix-bot-sdk-rust",
accountId: "default",
deviceId: "DEVICE",
roomKeyCounts: { total: 2, backedUp: 2 },
backupVersion: "1",
decryptionKeyImported: true,
restoreStatus: "pending",
detectedAt: "2026-03-12T00:00:00.000Z",
lastError: null,
}),
);
const migration = migrationById("matrix-legacy-crypto-migration-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix legacy crypto migration JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix legacy crypto migration JSON to SQLite for ${storageRootDir}`,
`Archived Matrix legacy crypto migration legacy source -> ${path.join(storageRootDir, "legacy-crypto-migration.json")}.migrated`,
],
warnings: [],
});
expect(readMatrixLegacyCryptoMigrationState(storageRootDir)?.restoreStatus).toBe("pending");
expect(fs.existsSync(path.join(storageRootDir, "legacy-crypto-migration.json"))).toBe(false);
});
});

View File

@@ -0,0 +1,416 @@
import type { Dirent } from "node:fs";
// Matrix API module exposes the plugin public contract.
import fs from "node:fs/promises";
import path from "node:path";
import {
legacyStateFileExists,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
hasMatrixSyncCacheStateInStore,
openMatrixSyncCacheStoreOptions,
readLegacyMatrixSyncCacheState,
writeMatrixSyncCacheStateToStore,
type MatrixSyncCacheRecord,
} from "./src/matrix/client/file-sync-store.js";
import {
hasMatrixStorageMetaStateInStore,
normalizeMatrixStorageMetadata,
openMatrixStorageMetaStoreOptions,
writeMatrixStorageMetaStateToStore,
type MatrixStorageMetadata,
} from "./src/matrix/client/storage.js";
import {
MATRIX_IDB_SNAPSHOT_FILENAME,
MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
MATRIX_RECOVERY_KEY_FILENAME,
hasMatrixIdbSnapshotStateInStore,
hasMatrixLegacyCryptoMigrationStateInStore,
hasMatrixRecoveryKeyStateInStore,
openMatrixIdbSnapshotStoreOptions,
openMatrixLegacyCryptoMigrationStoreOptions,
openMatrixRecoveryKeyStoreOptions,
readLegacyMatrixLegacyCryptoMigrationState,
readLegacyMatrixRecoveryKeyState,
writeMatrixIdbSnapshotJsonToStore,
writeMatrixLegacyCryptoMigrationStateToStore,
writeMatrixRecoveryKeyStateToStore,
type MatrixIdbSnapshotRecord,
type MatrixLegacyCryptoMigrationState,
} from "./src/matrix/crypto-state-store.js";
import { readLegacyMatrixIdbSnapshotState } from "./src/matrix/sdk/idb-persistence.js";
import type { MatrixStoredRecoveryKey } from "./src/matrix/sdk/types.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
const MATRIX_SYNC_CACHE_FILENAME = "bot-storage.json";
const MATRIX_STORAGE_META_FILENAME = "storage-meta.json";
async function collectLegacyMatrixStateRoots(
stateDir: string,
filename: string,
): Promise<string[]> {
const matrixRoot = path.join(stateDir, "matrix");
const roots: string[] = [];
async function visit(dir: string): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === filename) {
roots.push(dir);
continue;
}
if (entry.isDirectory()) {
await visit(entryPath);
}
}
}
await visit(matrixRoot);
return roots.filter((root) => path.resolve(root) !== path.resolve(matrixRoot)).toSorted();
}
async function collectLegacySyncCacheRoots(stateDir: string): Promise<string[]> {
return collectLegacyMatrixStateRoots(stateDir, MATRIX_SYNC_CACHE_FILENAME);
}
async function readLegacyMatrixStorageMetadata(
storageRootDir: string,
): Promise<MatrixStorageMetadata | null> {
try {
return normalizeMatrixStorageMetadata(
JSON.parse(
await fs.readFile(path.join(storageRootDir, MATRIX_STORAGE_META_FILENAME), "utf8"),
),
);
} catch {
return null;
}
}
async function archiveLegacySyncCache(params: {
storageRootDir: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const sourcePath = path.join(params.storageRootDir, MATRIX_SYNC_CACHE_FILENAME);
const archivedPath = `${sourcePath}.migrated`;
if (await legacyStateFileExists(archivedPath)) {
params.warnings.push(
`Left migrated Matrix sync cache in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(sourcePath, archivedPath);
params.changes.push(`Archived Matrix sync cache legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving Matrix sync cache legacy source: ${String(err)}`);
}
}
async function archiveLegacyMatrixStateFile(params: {
storageRootDir: string;
filename: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const sourcePath = path.join(params.storageRootDir, params.filename);
const archivedPath = `${sourcePath}.migrated`;
if (await legacyStateFileExists(archivedPath)) {
params.warnings.push(
`Left migrated ${params.label} in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(sourcePath, archivedPath);
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
}
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "matrix-storage-meta-json-to-plugin-state",
label: "Matrix storage metadata",
async detectLegacyState(params) {
const previews: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_STORAGE_META_FILENAME,
)) {
if (!(await readLegacyMatrixStorageMetadata(storageRootDir))) {
continue;
}
previews.push(`Matrix storage metadata JSON can migrate to SQLite: ${storageRootDir}`);
}
return previews.length > 0 ? { preview: previews } : null;
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_STORAGE_META_FILENAME,
)) {
const payload = await readLegacyMatrixStorageMetadata(storageRootDir);
if (!payload) {
continue;
}
const store = params.context.openPluginStateKeyedStore<MatrixStorageMetadata>(
openMatrixStorageMetaStoreOptions(storageRootDir),
);
if (await hasMatrixStorageMetaStateInStore({ store })) {
warnings.push(
`Skipped Matrix storage metadata import for ${storageRootDir} because SQLite already has metadata`,
);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_STORAGE_META_FILENAME,
label: "Matrix storage metadata",
changes,
warnings,
});
continue;
}
await writeMatrixStorageMetaStateToStore({ payload, store });
changes.push(`Migrated Matrix storage metadata JSON to SQLite for ${storageRootDir}`);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_STORAGE_META_FILENAME,
label: "Matrix storage metadata",
changes,
warnings,
});
}
return { changes, warnings };
},
},
{
id: "matrix-sync-cache-json-to-plugin-state",
label: "Matrix sync cache",
async detectLegacyState(params) {
const previews: string[] = [];
for (const storageRootDir of await collectLegacySyncCacheRoots(params.stateDir)) {
const persisted = await readLegacyMatrixSyncCacheState(storageRootDir);
if (!persisted) {
continue;
}
previews.push(`Matrix sync cache JSON can migrate to SQLite: ${storageRootDir}`);
}
return previews.length > 0 ? { preview: previews } : null;
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
for (const storageRootDir of await collectLegacySyncCacheRoots(params.stateDir)) {
const persisted = await readLegacyMatrixSyncCacheState(storageRootDir);
if (!persisted) {
continue;
}
const store = params.context.openPluginStateKeyedStore<MatrixSyncCacheRecord>(
openMatrixSyncCacheStoreOptions(storageRootDir),
);
if (await hasMatrixSyncCacheStateInStore({ storageRootDir, store })) {
warnings.push(
`Skipped Matrix sync cache import for ${storageRootDir} because SQLite already has sync cache state`,
);
await archiveLegacySyncCache({ storageRootDir, changes, warnings });
continue;
}
await writeMatrixSyncCacheStateToStore({
storageRootDir,
payload: persisted,
store,
});
changes.push(`Migrated Matrix sync cache JSON to SQLite for ${storageRootDir}`);
await archiveLegacySyncCache({ storageRootDir, changes, warnings });
}
return { changes, warnings };
},
},
{
id: "matrix-recovery-key-json-to-plugin-state",
label: "Matrix recovery key",
async detectLegacyState(params) {
const previews: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_RECOVERY_KEY_FILENAME,
)) {
if (!readLegacyMatrixRecoveryKeyState(storageRootDir)) {
continue;
}
previews.push(`Matrix recovery-key JSON can migrate to SQLite: ${storageRootDir}`);
}
return previews.length > 0 ? { preview: previews } : null;
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_RECOVERY_KEY_FILENAME,
)) {
const payload = readLegacyMatrixRecoveryKeyState(storageRootDir);
if (!payload) {
continue;
}
const store = params.context.openPluginStateKeyedStore<MatrixStoredRecoveryKey>(
openMatrixRecoveryKeyStoreOptions(storageRootDir),
);
if (await hasMatrixRecoveryKeyStateInStore({ store })) {
warnings.push(
`Skipped Matrix recovery-key import for ${storageRootDir} because SQLite already has recovery-key state`,
);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_RECOVERY_KEY_FILENAME,
label: "Matrix recovery key",
changes,
warnings,
});
continue;
}
await writeMatrixRecoveryKeyStateToStore({ payload, store });
changes.push(`Migrated Matrix recovery-key JSON to SQLite for ${storageRootDir}`);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_RECOVERY_KEY_FILENAME,
label: "Matrix recovery key",
changes,
warnings,
});
}
return { changes, warnings };
},
},
{
id: "matrix-idb-snapshot-json-to-plugin-state",
label: "Matrix IndexedDB snapshot",
async detectLegacyState(params) {
const previews: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_IDB_SNAPSHOT_FILENAME,
)) {
const snapshot = await readLegacyMatrixIdbSnapshotState(storageRootDir);
if (!snapshot) {
continue;
}
previews.push(`Matrix IndexedDB snapshot JSON can migrate to SQLite: ${storageRootDir}`);
}
return previews.length > 0 ? { preview: previews } : null;
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_IDB_SNAPSHOT_FILENAME,
)) {
const snapshot = await readLegacyMatrixIdbSnapshotState(storageRootDir);
if (!snapshot) {
continue;
}
const store = params.context.openPluginStateKeyedStore<MatrixIdbSnapshotRecord>(
openMatrixIdbSnapshotStoreOptions(storageRootDir),
);
if (await hasMatrixIdbSnapshotStateInStore({ store })) {
warnings.push(
`Skipped Matrix IndexedDB snapshot import for ${storageRootDir} because SQLite already has snapshot state`,
);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_IDB_SNAPSHOT_FILENAME,
label: "Matrix IndexedDB snapshot",
changes,
warnings,
});
continue;
}
await writeMatrixIdbSnapshotJsonToStore({
snapshotJson: JSON.stringify(snapshot),
databaseCount: snapshot.length,
store,
});
changes.push(`Migrated Matrix IndexedDB snapshot JSON to SQLite for ${storageRootDir}`);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_IDB_SNAPSHOT_FILENAME,
label: "Matrix IndexedDB snapshot",
changes,
warnings,
});
}
return { changes, warnings };
},
},
{
id: "matrix-legacy-crypto-migration-json-to-plugin-state",
label: "Matrix legacy crypto migration",
async detectLegacyState(params) {
const previews: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
)) {
if (!readLegacyMatrixLegacyCryptoMigrationState(storageRootDir)) {
continue;
}
previews.push(
`Matrix legacy crypto migration JSON can migrate to SQLite: ${storageRootDir}`,
);
}
return previews.length > 0 ? { preview: previews } : null;
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
for (const storageRootDir of await collectLegacyMatrixStateRoots(
params.stateDir,
MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
)) {
const state = readLegacyMatrixLegacyCryptoMigrationState(storageRootDir);
if (!state) {
continue;
}
const store = params.context.openPluginStateKeyedStore<MatrixLegacyCryptoMigrationState>(
openMatrixLegacyCryptoMigrationStoreOptions(storageRootDir),
);
if (await hasMatrixLegacyCryptoMigrationStateInStore({ store })) {
warnings.push(
`Skipped Matrix legacy crypto migration import for ${storageRootDir} because SQLite already has migration state`,
);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
label: "Matrix legacy crypto migration",
changes,
warnings,
});
continue;
}
await writeMatrixLegacyCryptoMigrationStateToStore({ state, store });
changes.push(
`Migrated Matrix legacy crypto migration JSON to SQLite for ${storageRootDir}`,
);
await archiveLegacyMatrixStateFile({
storageRootDir,
filename: MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
label: "Matrix legacy crypto migration",
changes,
warnings,
});
}
return { changes, warnings };
},
},
];

View File

@@ -0,0 +1,4 @@
// Matrix API module exposes the plugin public contract.
export * from "./src/account-selection.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";

View File

@@ -0,0 +1,150 @@
// Matrix tests cover index plugin behavior.
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
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 () => {}),
handleMatrixSubagentDeliveryTarget: vi.fn(() => "delivery-target"),
handleMatrixSubagentEnded: vi.fn(async () => {}),
handleMatrixSubagentSpawning: vi.fn(async () => "spawned"),
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-setter-api.js", () => ({ setMatrixRuntime: runtimeMocks.setMatrixRuntime }));
vi.mock("./src/matrix/subagent-hooks.js", () => runtimeMocks);
function requireFirstCliRegistration(mock: ReturnType<typeof vi.fn>) {
const [call] = mock.mock.calls;
if (!call || typeof call[0] !== "function") {
throw new Error("expected Matrix CLI registration");
}
return call as [(ctx: unknown) => unknown, unknown];
}
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);
expect(registerCli).toHaveBeenCalledTimes(1);
const [registrar, options] = requireFirstCliRegistration(registerCli);
expect(typeof registrar).toBe("function");
expect(options).toEqual({
descriptors: [
{
name: "matrix",
description: "Manage Matrix accounts, verification, devices, and profile state",
hasSubcommands: true,
},
],
});
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");
if (!entry.setChannelRuntime) {
throw new Error("expected Matrix runtime setter");
}
entry.setChannelRuntime({ marker: "runtime" } as never);
expect(runtimeMocks.setMatrixRuntime).not.toHaveBeenCalled();
});
it("wires CLI metadata through the bundled entry", () => {
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,
});
entry.register(api);
expect(registerCli).toHaveBeenCalledTimes(1);
const [registrar, options] = requireFirstCliRegistration(registerCli);
expect(typeof registrar).toBe("function");
expect(options).toEqual({
descriptors: [
{
name: "matrix",
description: "Manage Matrix accounts, verification, devices, and profile state",
hasSubcommands: true,
},
],
});
expect(registerGatewayMethod).not.toHaveBeenCalled();
});
it("registers subagent lifecycle hooks during full runtime registration", async () => {
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(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_ended",
"subagent_delivery_target",
]);
const handlers = Object.fromEntries(on.mock.calls);
await expect(handlers.subagent_ended({ id: "ended" })).resolves.toBeUndefined();
await expect(handlers.subagent_delivery_target({ id: "target" })).resolves.toBe(
"delivery-target",
);
expect(runtimeMocks.handleMatrixSubagentEnded).toHaveBeenCalledWith({ id: "ended" });
expect(runtimeMocks.handleMatrixSubagentDeliveryTarget).toHaveBeenCalledWith({ id: "target" });
});
});

View File

@@ -0,0 +1,52 @@
// Matrix plugin entrypoint registers its OpenClaw integration.
import {
defineBundledChannelEntry,
type OpenClawPluginApi,
} from "openclaw/plugin-sdk/channel-entry-contract";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
import { registerMatrixSubagentHooks } from "./subagent-hooks-api.js";
const loadMatrixHandlersRuntimeModule = createLazyRuntimeModule(
() => import("./plugin-entry.handlers.runtime.js"),
);
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
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);
});
registerMatrixSubagentHooks(api);
}
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-setter-api.js",
exportName: "setMatrixRuntime",
},
registerCliMetadata: registerMatrixCliMetadata,
registerFull: registerMatrixFullRuntime,
});

557
extensions/matrix/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,557 @@
{
"name": "@openclaw/matrix",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/matrix",
"version": "2026.6.11",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.1",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.1",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.3.0",
"matrix-js-sdk": "41.9.0-rc.0",
"music-metadata": "11.13.0",
"typebox": "1.3.3",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz",
"integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==",
"license": "MIT"
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-nodejs": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.6.1.tgz",
"integrity": "sha512-xDulwTJfgHtA0rx/bi8JPc0m/hvImiuixfOoomUQYicBAi59xe8Cqc04K2GLQA+yc6JuqXfg6vLsNSgrStpLhw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"https-proxy-agent": "^7.0.5",
"node-downloader-helper": "^2.1.9"
},
"engines": {
"node": ">= 24"
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/events": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/another-json": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
"license": "Apache-2.0"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/file-type": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz",
"integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/is-network-error": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/matrix-events-sdk": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "41.9.0-rc.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.9.0-rc.0.tgz",
"integrity": "sha512-W3KPvaRhmvZxsnOrpsnpyboirfjQDaIZ8K0aJrw5wdkD9YSK02NutaFRG9UFAEOolG0ythj6dQIeUS/icZKzCQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^8.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz",
"integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Borewit"
},
{
"type": "buymeacoffee",
"url": "https://buymeacoffee.com/borewit"
}
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.2",
"@tokenizer/token": "^0.3.0",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"file-type": "^21.3.4",
"media-typer": "^2.0.0",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/node-downloader-helper": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.11.tgz",
"integrity": "sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==",
"license": "MIT",
"bin": {
"ndh": "bin/ndh"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/oidc-client-ts": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/p-retry": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz",
"integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.3.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/sdp-transform": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unhomoglyph": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,31 @@
{
"id": "matrix",
"name": "Matrix",
"description": "OpenClaw Matrix channel plugin for rooms and direct messages.",
"icon": "https://cdn.simpleicons.org/matrix",
"commandAliases": [{ "name": "matrix" }],
"activation": {
"onStartup": false,
"onCommands": ["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,101 @@
{
"name": "@openclaw/matrix",
"version": "2026.6.11",
"description": "OpenClaw Matrix channel plugin for rooms and direct messages.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.1",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.1",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.3.0",
"matrix-js-sdk": "41.9.0-rc.0",
"music-metadata": "11.13.0",
"typebox": "1.3.3",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"markdownCapable": true,
"quickstartAllowFrom": true,
"doctorCapabilities": {
"dmAllowFromMode": "nestedOnly",
"groupModel": "sender",
"groupAllowFromFallbackToAllowFrom": false,
"warnOnEmptyGroupSenderAllowlist": true
},
"cliAddOptions": [
{
"flags": "--homeserver <url>",
"description": "Matrix homeserver URL"
},
{
"flags": "--user-id <id>",
"description": "Matrix user ID"
},
{
"flags": "--access-token <token>",
"description": "Matrix access token"
},
{
"flags": "--device-name <name>",
"description": "Matrix device name"
},
{
"flags": "--initial-sync-limit <n>",
"description": "Matrix initial sync limit"
}
],
"persistedAuthState": {
"specifier": "./auth-presence",
"exportName": "hasAnyMatrixAuth"
}
},
"install": {
"clawhubSpec": "clawhub:@openclaw/matrix",
"npmSpec": "@openclaw/matrix",
"defaultChoice": "clawhub",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,2 @@
// Matrix plugin module implements plugin entry.handlers behavior.
export * from "./src/plugin-entry.runtime.ts";

View File

@@ -0,0 +1,73 @@
// Matrix API module exposes the plugin public contract.
export {
type MatrixResolvedStringField,
type MatrixResolvedStringValues,
resolveMatrixAccountStringValues,
} from "./src/auth-precedence.js";
export {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "./src/account-selection.js";
export {
findMatrixAccountEntry,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
} from "./src/account-selection.js";
export {
getMatrixScopedEnvVarNames,
listMatrixEnvAccountIds,
resolveMatrixEnvAccountToken,
} from "./src/env-vars.js";
export {
hashMatrixAccessToken,
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsDir,
resolveMatrixCredentialsFilename,
resolveMatrixCredentialsPath,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoragePaths,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} 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,
} from "openclaw/plugin-sdk/channel-contract";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/time-runtime";
export type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
export type { WizardPrompter } from "openclaw/plugin-sdk/setup";
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,2 @@
// Matrix API module exposes the plugin public contract.
export * from "./src/matrix-migration.runtime.js";

View File

@@ -0,0 +1,3 @@
// Narrow entry point for setMatrixRuntime. The full runtime-api barrel is kept
// for external/runtime callers, but bundled plugin register only needs this.
export { setMatrixRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,6 @@
// Matrix API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,6 @@
// Matrix API module exposes the plugin public contract.
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,18 @@
// Matrix plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "matrixSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-setter-api.js",
exportName: "setMatrixRuntime",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Matrix runtime plugin surface.
export { matrixSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,142 @@
// Matrix tests cover account selection plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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,224 @@
// Matrix plugin module implements account selection behavior.
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-contracts";
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,238 @@
// Matrix tests cover actions.account propagation plugin behavior.
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 matrixActionCall() {
const call = mocks.handleMatrixAction.mock.calls[0];
if (!call) {
throw new Error("expected handleMatrixAction call");
}
return {
input: call[0] as Record<string, unknown>,
cfg: call[1],
options: call[2],
};
}
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",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("forwards accountId for permissions actions", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "permissions",
accountId: "ops",
params: {
operation: "verification-list",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("verificationList");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("forwards accountId for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("setProfile");
expect(call.input.accountId).toBe("ops");
expect(call.input.displayName).toBe("Ops Bot");
expect(call.input.avatarUrl).toBe("mxc://example/avatar");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("rejects self-profile updates without sender owner context", async () => {
await expect(
matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
),
).rejects.toThrow("Matrix profile updates require owner access.");
});
it("dispatches self-profile updates with sender owner context", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
},
}),
);
const call = matrixActionCall();
expect(call.input).toMatchObject({
action: "setProfile",
accountId: "ops",
displayName: "Ops Bot",
});
});
it("forwards local avatar paths for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
path: "/tmp/avatar.jpg",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("setProfile");
expect(call.input.accountId).toBe("ops");
expect(call.input.avatarPath).toBe("/tmp/avatar.jpg");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ 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",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ 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",
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.content).toBeUndefined();
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ 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,
},
}),
);
const call = matrixActionCall();
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.input.content).toBeUndefined();
expect(call.input.mediaUrl).toBe("/tmp/clip.mp3");
expect(call.input.audioAsVoice).toBe(true);
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
});

View File

@@ -0,0 +1,249 @@
// Matrix tests cover actions plugin behavior.
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: {
current: () => ({}),
},
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(Object.keys(properties).toSorted()).toEqual([
"avatarPath",
"avatarUrl",
"avatar_path",
"avatar_url",
"displayName",
"display_name",
]);
expect(properties.displayName).toHaveProperty("type", "string");
expect(properties.avatarUrl).toHaveProperty("type", "string");
expect(properties.avatarPath).toHaveProperty("type", "string");
});
it("hides self-profile updates without owner identity context", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
expect(discovery.actions).not.toContain(profileAction);
});
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).toStrictEqual([]);
});
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,352 @@
// Matrix plugin module implements actions behavior.
import {
createActionGate,
readPositiveIntegerParam,
readStringParam,
ToolAuthorizationError,
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { Type } from "typebox";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.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 {
actions: ["set-profile"],
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 = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
return await dispatch({
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
});
}
if (action === "read") {
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
return await dispatch({
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
});
}
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,24 @@
// Matrix tests cover approval auth plugin behavior.
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,26 @@
// Matrix plugin module implements approval auth behavior.
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,577 @@
// Matrix tests cover approval handler plugin behavior.
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
import {
clearMatrixApprovalReactionTargetsForTest,
resolveMatrixApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
type MatrixDeliverPendingParams = Parameters<
typeof matrixApprovalNativeRuntime.transport.deliverPending
>[0];
type MatrixPendingApprovalView = MatrixDeliverPendingParams["view"];
type MatrixPendingExecApprovalView = Extract<MatrixPendingApprovalView, { approvalKind: "exec" }>;
type MatrixPendingPluginApprovalView = Extract<
MatrixPendingApprovalView,
{ approvalKind: "plugin" }
>;
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval";
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
return actual;
}
function mockCall<T extends readonly unknown[]>(mock: { mock: { calls: T[] } }, index = 0) {
return mock.mock.calls.at(index);
}
function buildMatrixReceipt(messageIds: readonly string[], roomId = "!room:example.org") {
return {
primaryPlatformMessageId: messageIds[0],
platformMessageIds: [...messageIds],
parts: messageIds.map((messageId, index) => ({
platformMessageId: messageId,
kind: "text" as const,
index,
raw: {
channel: "matrix",
messageId,
roomId,
},
})),
sentAt: 100,
raw: messageIds.map((messageId) => ({
channel: "matrix",
messageId,
roomId,
})),
};
}
function buildMatrixApprovalRoomTarget(
roomId: string,
): MatrixDeliverPendingParams["plannedTarget"] {
return {
surface: "approver-dm",
target: {
to: `room:${roomId}`,
},
reason: "preferred",
};
}
// Pending approvals expire in the future; the reaction target store TTLs its
// memory layer from `view.expiresAtMs - now`, so epoch-past fixtures would
// evict the mapping before assertions run.
const TEST_APPROVAL_EXPIRES_AT_MS = Date.now() + 5 * 60_000;
function buildExecApprovalView(
overrides: Partial<MatrixPendingExecApprovalView> = {},
): MatrixPendingExecApprovalView {
return {
approvalKind: "exec",
approvalId: "req-1",
phase: "pending",
title: "Exec Approval Required",
description: "A command needs your approval.",
metadata: [],
ask: "on-request",
agentId: "agent-1",
commandText: "echo hi",
commandPreview: "echo hi",
cwd: "/repo",
host: "gateway",
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
{
decision: "deny",
label: "Deny",
style: "danger",
command: "/approve req-1 deny",
},
],
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
...overrides,
};
}
function buildPluginApprovalView(
overrides: Partial<MatrixPendingPluginApprovalView> = {},
): MatrixPendingPluginApprovalView {
return {
approvalKind: "plugin",
approvalId: "plugin:req-1",
phase: "pending",
title: "Plugin Approval Required",
description: "Approve the tool call.",
metadata: [],
agentId: "agent-1",
pluginId: "ops",
toolName: "deploy",
severity: "critical",
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve plugin:req-1 allow-once",
},
],
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
...overrides,
};
}
async function buildPendingPayload(view: MatrixPendingApprovalView) {
const request =
view.approvalKind === "plugin"
? ({
id: view.approvalId,
request: {
title: view.title,
description: view.description ?? "",
severity: view.severity,
toolName: view.toolName ?? undefined,
pluginId: view.pluginId ?? undefined,
agentId: view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: view.expiresAtMs,
} satisfies PluginApprovalRequest)
: ({
id: view.approvalId,
request: {
command: view.commandText,
cwd: view.cwd ?? undefined,
host: view.host ?? undefined,
agentId: view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: view.expiresAtMs,
} satisfies ExecApprovalRequest);
return await matrixApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { client: {} as never },
request,
approvalKind: view.approvalKind,
nowMs: 100,
view,
});
}
describe("matrixApprovalNativeRuntime", () => {
beforeEach(() => {
clearMatrixApprovalReactionTargetsForTest();
});
it("sends versioned Matrix approval content with pending exec approvals", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
expect(target).toBe("room:!room:example.org");
expect(String(text)).toContain("echo hi");
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
?.extraContent;
expectRecordFields(extraContent?.[MATRIX_APPROVAL_METADATA_KEY], {
version: 1,
type: "approval.request",
state: "pending",
id: "req-1",
kind: "exec",
commandText: "echo hi",
cwd: "/repo",
agentId: "agent-1",
allowedDecisions: ["allow-once", "deny"],
});
});
it("delivers Matrix approval content with plugin approval fields", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$plugin-approval",
primaryMessageId: "$plugin-approval",
receipt: buildMatrixReceipt(["$plugin-approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildPluginApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "plugin",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
expect(target).toBe("room:!room:example.org");
expect(String(text)).toContain("deploy");
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
?.extraContent;
expect(extraContent?.[MATRIX_APPROVAL_METADATA_KEY]).toEqual({
version: 1,
type: "approval.request",
state: "pending",
phase: "pending",
id: "plugin:req-1",
kind: "plugin",
title: "Plugin Approval Required",
description: "Approve the tool call.",
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
metadata: [],
allowedDecisions: ["allow-once"],
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve plugin:req-1 allow-once",
},
],
pluginId: "ops",
toolName: "deploy",
agentId: "agent-1",
severity: "critical",
});
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
expect(mockCall(reactMessage)?.[1]).toBe("$plugin-approval");
expect(mockCall(reactMessage)?.[2]).toBe("✅");
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
});
it("binds Matrix approval reactions before publishing option reactions", async () => {
const sendSingleTextMessage = vi.fn().mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockImplementation(async () => {
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$approval",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
});
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(reactMessage).toHaveBeenCalled();
});
it("retries transient Matrix approval send failures", async () => {
const sendSingleTextMessage = vi
.fn()
.mockRejectedValueOnce(new Error("transient Matrix send failure"))
.mockResolvedValue({
messageId: "$approval",
primaryMessageId: "$approval",
receipt: buildMatrixReceipt(["$approval"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView();
const pendingPayload = await buildPendingPayload(view);
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(sendSingleTextMessage).toHaveBeenCalledTimes(2);
expectRecordFields(entry, {
roomId: "!room:example.org",
platformMessageIds: ["$approval"],
});
});
it("retries transient Matrix direct-room repair failures before preparing approval DMs", async () => {
const repairDirectRooms = vi
.fn()
.mockRejectedValueOnce(new Error("direct account data not ready"))
.mockResolvedValue({
activeRoomId: "!dm:example.org",
});
const prepared = await matrixApprovalNativeRuntime.transport.prepareTarget({
cfg: {
channels: {
matrix: {
encryption: false,
},
},
} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
repairDirectRooms,
},
},
request: {} as never,
approvalKind: "exec",
view: buildExecApprovalView(),
pendingPayload: {} as never,
plannedTarget: {
surface: "approver-dm",
target: {
to: "user:@owner:example.org",
},
reason: "preferred",
},
});
expect(repairDirectRooms).toHaveBeenCalledTimes(2);
const preparedTarget = expectRecordFields(prepared, {});
expect(preparedTarget.target).toEqual({
to: "room:!dm:example.org",
roomId: "!dm:example.org",
threadId: undefined,
});
});
it("falls back to chunked Matrix delivery when approval content exceeds one event", async () => {
const sendSingleTextMessage = vi
.fn()
.mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)"));
const sendMessage = vi.fn().mockResolvedValue({
messageId: "$last",
primaryMessageId: "$legacy-primary",
receipt: buildMatrixReceipt(["$primary", "$last"]),
roomId: "!room:example.org",
});
const reactMessage = vi.fn().mockResolvedValue(undefined);
const view = buildExecApprovalView({
actions: [
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
],
});
const pendingPayload = await buildPendingPayload(view);
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
deps: {
sendSingleTextMessage,
sendMessage,
reactMessage,
},
},
request: {} as never,
approvalKind: "exec",
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
preparedTarget: {
to: "room:!room:example.org",
roomId: "!room:example.org",
},
view,
pendingPayload,
});
expect(mockCall(sendMessage)?.[0]).toBe("room:!room:example.org");
expect(mockCall(sendMessage)?.[1]).toBe(pendingPayload.text);
expectRecordFields(mockCall(sendMessage)?.[2], {
accountId: "default",
extraContent: pendingPayload.extraContent,
});
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
expect(mockCall(reactMessage)?.[1]).toBe("$primary");
expect(typeof mockCall(reactMessage)?.[2]).toBe("string");
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
expectRecordFields(entry, {
roomId: "!room:example.org",
platformMessageIds: ["$primary", "$last"],
reactionEventId: "$primary",
});
const bindPending = matrixApprovalNativeRuntime.interactions?.bindPending;
if (!bindPending) {
throw new Error("Matrix approval runtime must expose bindPending");
}
const binding = await bindPending({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {} as never,
approvalKind: "exec",
view,
pendingPayload,
entry: entry!,
});
expect(binding).toEqual({
roomId: "!room:example.org",
eventId: "$primary",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$primary",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!room:example.org",
eventId: "$last",
reactionKey: "✅",
}),
).toBeNull();
});
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,595 @@
// Matrix plugin module implements approval handler behavior.
import { setTimeout as sleep } from "node:timers/promises";
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/approval-runtime";
import {
listMessageReceiptPlatformIds,
resolveMessageReceiptPrimaryId,
} from "openclaw/plugin-sdk/channel-outbound";
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-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,
sendSingleTextMessageMatrix,
} from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
// OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback.
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const;
type PendingMessage = {
roomId: string;
platformMessageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type MatrixApprovalMetadataAction = {
decision: ExecApprovalReplyDecision;
label: string;
style: PendingApprovalView["actions"][number]["style"];
command: string;
};
type MatrixApprovalMetadataBase = {
version: 1;
type: "approval.request";
id: string;
state: "pending";
kind: PendingApprovalView["approvalKind"];
phase: "pending";
title: string;
description?: string;
expiresAtMs: number;
metadata: PendingApprovalView["metadata"];
allowedDecisions: ExecApprovalReplyDecision[];
actions: MatrixApprovalMetadataAction[];
};
type MatrixExecApprovalMetadata = MatrixApprovalMetadataBase & {
kind: "exec";
ask?: string;
agentId?: string;
commandText: string;
commandPreview?: string;
cwd?: string;
envKeys?: readonly string[];
host?: string;
nodeId?: string;
sessionKey?: string;
};
type MatrixPluginApprovalSeverity = Extract<
PendingApprovalView,
{ approvalKind: "plugin" }
>["severity"];
type MatrixPluginApprovalMetadata = MatrixApprovalMetadataBase & {
kind: "plugin";
agentId?: string;
pluginId?: string;
toolName?: string;
severity: MatrixPluginApprovalSeverity;
};
type MatrixApprovalMetadata = MatrixExecApprovalMetadata | MatrixPluginApprovalMetadata;
type MatrixApprovalExtraContent = {
[MATRIX_APPROVAL_METADATA_KEY]: MatrixApprovalMetadata;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
extraContent: MatrixApprovalExtraContent;
};
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;
};
const MATRIX_APPROVAL_DELIVERY_ATTEMPTS = 3;
const MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS = 250;
export type MatrixApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
sendSingleTextMessage?: typeof sendSingleTextMessageMatrix;
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 normalizeUniqueStringEntries(entry.platformMessageIds);
}
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;
}
function isSingleMatrixMessageLimitError(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("Matrix single-message text exceeds limit")
);
}
async function retryMatrixApprovalDelivery<T>(
operation: () => Promise<T>,
params: { shouldRetry?: (error: unknown) => boolean } = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= MATRIX_APPROVAL_DELIVERY_ATTEMPTS; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === MATRIX_APPROVAL_DELIVERY_ATTEMPTS || params.shouldRetry?.(error) === false) {
break;
}
await sleep(MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
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 retryMatrixApprovalDelivery(
async () =>
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 buildMatrixApprovalMetadata(params: {
view: PendingApprovalView;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): MatrixApprovalMetadata {
const base: MatrixApprovalMetadataBase = {
version: 1,
type: "approval.request",
id: params.view.approvalId,
state: "pending",
kind: params.view.approvalKind,
phase: params.view.phase,
title: params.view.title,
expiresAtMs: params.view.expiresAtMs,
metadata: params.view.metadata,
allowedDecisions: Array.from(params.allowedDecisions),
actions: params.view.actions.map((action) => ({
decision: action.decision,
label: action.label,
style: action.style,
command: action.command,
})),
...(params.view.description != null ? { description: params.view.description } : {}),
};
if (params.view.approvalKind === "plugin") {
return {
...base,
kind: "plugin",
severity: params.view.severity,
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
...(params.view.pluginId != null ? { pluginId: params.view.pluginId } : {}),
...(params.view.toolName != null ? { toolName: params.view.toolName } : {}),
};
}
return {
...base,
kind: "exec",
commandText: params.view.commandText,
...(params.view.ask != null ? { ask: params.view.ask } : {}),
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
...(params.view.commandPreview != null ? { commandPreview: params.view.commandPreview } : {}),
...(params.view.cwd != null ? { cwd: params.view.cwd } : {}),
...(params.view.envKeys != null ? { envKeys: params.view.envKeys } : {}),
...(params.view.host != null ? { host: params.view.host } : {}),
...(params.view.nodeId != null ? { nodeId: params.view.nodeId } : {}),
...(params.view.sessionKey != null ? { sessionKey: params.view.sessionKey } : {}),
};
}
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,
extraContent: {
[MATRIX_APPROVAL_METADATA_KEY]: buildMatrixApprovalMetadata({
view: params.view,
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, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendSingleTextMessage =
resolved.context.deps?.sendSingleTextMessage ?? sendSingleTextMessageMatrix;
const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage;
let result;
try {
result = await retryMatrixApprovalDelivery(
async () =>
await sendSingleTextMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
extraContent: pendingPayload.extraContent,
}),
{ shouldRetry: (error) => !isSingleMatrixMessageLimitError(error) },
);
} catch (error) {
if (!isSingleMatrixMessageLimitError(error)) {
throw error;
}
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
result = await retryMatrixApprovalDelivery(
async () =>
await sendMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
extraContent: pendingPayload.extraContent,
}),
);
}
const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt);
const platformMessageIds = receiptMessageIds.length
? receiptMessageIds
: [result.messageId.trim()].filter(Boolean);
const reactionEventId =
resolveMessageReceiptPrimaryId(result.receipt) ||
result.primaryMessageId?.trim() ||
platformMessageIds[0] ||
result.messageId.trim();
registerMatrixApprovalReactionTarget({
roomId: result.roomId,
eventId: reactionEventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
ttlMs: view.expiresAtMs - Date.now(),
});
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,
platformMessageIds,
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: (params) => {
const target = normalizeReactionTargetRef({
roomId: params.entry.roomId,
eventId: params.entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
roomId: target.roomId,
eventId: target.eventId,
approvalId: params.pendingPayload.approvalId,
allowedDecisions: params.pendingPayload.allowedDecisions,
ttlMs: params.view.expiresAtMs - Date.now(),
});
return target;
},
unbindPending: (params) => {
const target = normalizeReactionTargetRef(params.binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
cancelDelivered: (params) => {
const target = normalizeReactionTargetRef({
roomId: params.entry.roomId,
eventId: params.entry.reactionEventId,
});
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
},
});

View File

@@ -0,0 +1,7 @@
// Matrix plugin module implements approval ids behavior.
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,330 @@
// Matrix tests cover approval native plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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,349 @@
// Matrix plugin module implements approval native behavior.
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 {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
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 normalizeMatrixOriginTarget(target: MatrixOriginTarget): MatrixOriginTarget {
return {
...target,
to: normalizeComparableTarget(target.to),
};
}
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,
normalizeTargetForMatch: normalizeMatrixOriginTarget,
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,46 @@
// Matrix plugin module implements approval reaction auth behavior.
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,187 @@
// Matrix tests cover approval reactions plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMatrixApprovalReactionHint,
clearMatrixApprovalReactionTargetsForTest,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTargetWithPersistence,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { setMatrixRuntime } from "./runtime.js";
afterEach(() => {
clearMatrixApprovalReactionTargetsForTest();
vi.restoreAllMocks();
});
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", async () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-once",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toEqual({
approvalId: "req-123",
decision: "allow-always",
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
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", async () => {
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "♾️",
}),
).toBeNull();
});
it("stops resolving reactions after the approval anchor event is unregistered", async () => {
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(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg",
reactionKey: "✅",
}),
).toBeNull();
});
it("persists approval reaction targets when runtime state is available", async () => {
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({
version: 1,
target: { approvalId: "req-persisted", allowedDecisions: ["deny"] },
});
const openKeyedStore = vi.fn(() => ({
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
}));
setMatrixRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
ttlMs: 1000,
});
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith(
"!ops:example.org:$approval-msg-2",
{
version: 1,
target: { approvalId: "req-123", allowedDecisions: ["allow-once", "deny"] },
},
{ ttlMs: 1000 },
);
clearMatrixApprovalReactionTargetsForTest();
await expect(
resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
reactionKey: "❌",
}),
).resolves.toEqual({ approvalId: "req-persisted", decision: "deny" });
expect(openKeyedStore).toHaveBeenCalledTimes(2);
expect(lookup).toHaveBeenCalledWith("!ops:example.org:$approval-msg-2");
});
it("falls back to in-memory approval reaction targets when persistent state cannot open", async () => {
const warn = vi.fn();
setMatrixRuntime({
state: {
openKeyedStore: vi.fn(() => {
throw new Error("sqlite unavailable");
}),
},
logging: { getChildLogger: () => ({ warn }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
approvalId: "req-fallback",
allowedDecisions: ["deny"],
});
expect(
await resolveMatrixApprovalReactionTargetWithPersistence({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
reactionKey: "❌",
}),
).toEqual({ approvalId: "req-fallback", decision: "deny" });
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,208 @@
// Matrix plugin module implements approval reactions behavior.
import { createApprovalReactionTargetStore } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import { getOptionalMatrixRuntime } from "./runtime.js";
// Matrix keeps its own reaction emoji set (checkmark/cross render reliably across
// Matrix clients), so decision resolution stays local instead of using the SDK bindings.
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[];
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type MatrixApprovalReactionBinding = {
decision: ExecApprovalReplyDecision;
emoji: string;
label: string;
};
type MatrixApprovalReactionResolution = {
approvalId: string;
decision: ExecApprovalReplyDecision;
};
type MatrixApprovalReactionTarget = {
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalMatrixRuntime()
?.logging.getChildLogger({ plugin: "matrix", feature: "approval-reaction-state" })
.warn("Matrix persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Matrix reactions.
}
}
function readPersistedTarget(target: unknown): MatrixApprovalReactionTarget | null {
const value = target as Partial<MatrixApprovalReactionTarget> | null | undefined;
if (!value || typeof value.approvalId !== "string" || !Array.isArray(value.allowedDecisions)) {
return null;
}
return {
approvalId: value.approvalId,
allowedDecisions: value.allowedDecisions,
};
}
const matrixApprovalReactionTargets =
createApprovalReactionTargetStore<MatrixApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
openStore: (storeParams) => getOptionalMatrixRuntime()?.state.openKeyedStore(storeParams),
logPersistentError: reportPersistentApprovalReactionError,
readPersistedTarget,
});
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(", ")}`;
}
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[];
ttlMs?: number;
}): 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.register(
key,
{ approvalId, allowedDecisions },
{ ttlMs: params.ttlMs },
);
}
export function unregisterMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return;
}
matrixApprovalReactionTargets.delete(key);
}
function resolveTarget(params: {
target: MatrixApprovalReactionTarget | null | undefined;
reactionKey: string;
}): MatrixApprovalReactionResolution | null {
const target = params.target;
if (!target) {
return null;
}
const decision = resolveMatrixApprovalReactionDecision(
params.reactionKey,
target.allowedDecisions,
);
if (!decision) {
return null;
}
return {
approvalId: target.approvalId,
decision,
};
}
export async function resolveMatrixApprovalReactionTargetWithPersistence(params: {
roomId: string;
eventId: string;
reactionKey: string;
}): Promise<MatrixApprovalReactionResolution | null> {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
return resolveTarget({
target: await matrixApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
});
}
export function clearMatrixApprovalReactionTargetsForTest(): void {
matrixApprovalReactionTargets.clearForTest();
}

View File

@@ -0,0 +1,62 @@
// Matrix plugin module implements auth precedence behavior.
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,98 @@
// Matrix plugin module implements channel account paths behavior.
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/ssrf-dispatcher";
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: { cfg: CoreConfig; 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,
cfg,
accountId,
}: {
id: string;
message: string;
cfg: CoreConfig;
accountId?: string;
}) => {
await sendMessageMatrix(`user:${id}`, message, {
cfg,
...(accountId ? { accountId } : {}),
});
},
};
}

View File

@@ -0,0 +1,105 @@
// Matrix tests cover channel.account paths plugin behavior.
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
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({
cfg: {} as never,
id: "@user:example.org",
message: pairingText.message,
accountId: "poe",
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
"user:@user:example.org",
PAIRING_APPROVED_MESSAGE,
{ cfg: {}, 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,617 @@
// Matrix tests cover channelirectory plugin behavior.
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it } from "vitest";
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";
function requireMatrixDirectory() {
const directory = matrixPlugin.directory;
if (!directory?.listPeers || !directory.listGroups) {
throw new Error("expected Matrix directory listPeers/listGroups");
}
return {
listPeers: directory.listPeers,
listGroups: directory.listGroups,
};
}
function requireMatrixReplyToModeResolver() {
const resolveReplyToMode = matrixPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("expected Matrix replyToMode resolver");
}
return resolveReplyToMode;
}
function requireDirectoryEntry(
entries: readonly { kind: string; id: string; name?: string }[],
kind: string,
id: string,
) {
const entry = entries.find((candidate) => candidate.kind === kind && candidate.id === id);
if (!entry) {
throw new Error(`expected Matrix directory entry ${kind}:${id}`);
}
return entry;
}
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;
const directory = requireMatrixDirectory();
const peers = await directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(requireDirectoryEntry(peers, "user", "user:@alice:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(peers, "user", "bob").name).toBe(
"incomplete id; expected @user:server",
);
expect(requireDirectoryEntry(peers, "user", "user:@carol:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(peers, "user", "user:@dana:example.org").name).toBeUndefined();
const groups = await directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(requireDirectoryEntry(groups, "group", "room:!room1:example.org").name).toBeUndefined();
expect(requireDirectoryEntry(groups, "group", "#alias:example.org").name).toBeUndefined();
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
matrix: {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
const resolveReplyToMode = requireMatrixReplyToModeResolver();
expect(
resolveReplyToMode({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
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();
const defaultAccount = updated.channels?.["matrix"]?.accounts?.default;
expect(defaultAccount?.accessToken).toBe("default-token");
expect(defaultAccount?.homeserver).toBe("https://default.example.org");
expect(defaultAccount?.deviceId).toBe("DEFAULTDEVICE");
expect(defaultAccount?.avatarUrl).toBe("mxc://server/avatar");
expect(defaultAccount?.encryption).toBe(true);
expect(defaultAccount?.threadReplies).toBe("inbound");
expect(defaultAccount?.groups).toEqual({
"!room:example.org": { requireMention: true },
});
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
expect(opsAccount?.enabled).toBe(true);
expect(opsAccount?.homeserver).toBe("https://matrix.example.org");
expect(opsAccount?.userId).toBe("@ops:example.org");
expect(opsAccount?.accessToken).toBe("ops-token");
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", {});
expect(resolvedOps.homeserver).toBe("https://matrix.example.org");
expect(resolvedOps.userId).toBe("@ops:example.org");
expect(resolvedOps.accessToken).toBe("ops-token");
expect(resolvedOps.deviceId).toBeUndefined();
});
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;
const matrixConfig = updated.channels?.["matrix"];
expect(matrixConfig?.enabled).toBe(true);
expect(matrixConfig?.homeserver).toBe("https://matrix.example.org");
expect(matrixConfig?.userId).toBe("@bot:example.org");
expect(matrixConfig?.accessToken).toBe("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;
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
expect(opsAccount?.name).toBe("Ops");
expect(opsAccount?.enabled).toBe(true);
expect(opsAccount?.encryption).toBe(true);
expect(opsAccount?.homeserver).toBeUndefined();
expect(opsAccount?.userId).toBeUndefined();
expect(opsAccount?.accessToken).toBeUndefined();
expect(opsAccount?.password).toBeUndefined();
expect(opsAccount?.deviceId).toBeUndefined();
expect(opsAccount?.deviceName).toBeUndefined();
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", process.env);
expect(resolvedOps.homeserver).toBe("https://ops.env.example.org");
expect(resolvedOps.accessToken).toBe("ops-env-token");
expect(resolvedOps.deviceId).toBe("OPSENVDEVICE");
expect(resolvedOps.deviceName).toBe("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,265 @@
// Matrix tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendMessageMatrix: vi.fn(),
}));
vi.mock("./matrix/send.js", () => ({
sendMessageMatrix: mocks.sendMessageMatrix,
sendPollMatrix: vi.fn(),
sendTypingMatrix: vi.fn(),
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
import { matrixPlugin } from "./channel.js";
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
function lastMatrixSendOptions() {
const options = mocks.sendMessageMatrix.mock.lastCall?.[2];
if (!options || typeof options !== "object") {
throw new Error("Expected Matrix send options");
}
return options as Record<string, unknown>;
}
describe("matrix channel message adapter", () => {
beforeAll(async () => {
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$warmup", roomId: "!room:example" });
const sendText = matrixPlugin.message?.send?.text;
if (!sendText) {
throw new Error("Expected Matrix message adapter text sender");
}
await sendText({
cfg,
to: "room:!room:example",
text: "warmup",
accountId: "default",
});
mocks.sendMessageMatrix.mockReset();
});
it("declares Matrix markdown rendering support for shared reply payloads", () => {
expect(matrixPlugin.meta.markdownCapable).toBe(true);
});
beforeEach(() => {
mocks.sendMessageMatrix.mockReset();
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
});
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
const adapter = matrixPlugin.message;
if (!adapter?.send?.text || !adapter.send.media) {
throw new Error("Expected Matrix message adapter send capabilities.");
}
const sendText = adapter.send.text;
const sendMedia = adapter.send.media;
const proveText = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendText({
cfg,
to: "room:!room:example",
text: "hello",
accountId: "default",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("hello");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.accountId).toBe("default");
expect(result.receipt.platformMessageIds).toEqual(["$event-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendMedia({
cfg,
to: "room:!room:example",
text: "caption",
mediaUrl: "file:///tmp/cat.png",
mediaLocalRoots: ["/tmp/openclaw"],
accountId: "default",
audioAsVoice: true,
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("caption");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.mediaUrl).toBe("file:///tmp/cat.png");
expect(options.mediaLocalRoots).toEqual(["/tmp/openclaw"]);
expect(options.audioAsVoice).toBe(true);
expect(result.receipt.parts[0]?.kind).toBe("voice");
};
const proveReplyThread = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await sendText({
cfg,
to: "room:!room:example",
text: "threaded",
accountId: "default",
replyToId: "$reply",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("threaded");
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.replyToId).toBe("$reply");
expect(options.threadId).toBe("$thread");
expect(result.receipt.replyToId).toBe("$reply");
expect(result.receipt.threadId).toBe("$thread");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "matrixMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveReplyThread,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(adapter.send?.text).toBeTypeOf("function");
},
},
});
});
it("forwards presentation payload hooks through the registered outbound adapter", async () => {
const outbound = matrixPlugin.outbound;
expect(outbound?.presentationCapabilities?.supported).toBe(true);
expect(outbound?.presentationCapabilities?.buttons).toBe(true);
expect(outbound?.presentationCapabilities?.selects).toBe(true);
expect(outbound?.presentationCapabilities?.context).toBe(true);
expect(outbound?.presentationCapabilities?.divider).toBe(true);
if (!outbound?.renderPresentation || !outbound.sendPayload) {
throw new Error("Expected Matrix outbound presentation payload hooks.");
}
const presentation = {
title: "Select thinking level",
tone: "info" as const,
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Low", value: "/think low" }],
},
],
};
const rendered = await outbound.renderPresentation({
payload: { text: "fallback", presentation },
presentation,
ctx: {} as never,
});
const matrixChannelData = rendered?.channelData?.matrix as
| { extraContent?: Record<string, unknown> }
| undefined;
expect(matrixChannelData?.extraContent).toEqual({
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
});
await outbound.sendPayload({
cfg,
to: "room:!room:example",
text: rendered?.text ?? "",
payload: rendered!,
accountId: "default",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe(rendered?.text);
const options = lastMatrixSendOptions();
expect(options.cfg).toBe(cfg);
expect(options.accountId).toBe("default");
expect(options.threadId).toBe("$thread");
expect(options.extraContent).toEqual({
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = matrixPlugin.message;
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
draftPreview: () => {
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
quietFinalization: () => {
expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter!,
proofs: {
finalEdit: () => {
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(adapter!.send!.text).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
},
previewReceipt: () => {
expect(adapter!.live?.capabilities?.quietFinalization).toBe(true);
},
},
});
});
it("declares bullets as the markdown table default", () => {
expect(matrixPlugin.messaging?.defaultMarkdownTableMode).toBe("bullets");
});
});

View File

@@ -0,0 +1,50 @@
// Matrix tests cover channel.resolve plugin behavior.
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
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).toHaveBeenCalledTimes(1);
const [forwarded] = resolveMatrixTargetsMock.mock.calls.at(0) as unknown as [
{
accountId: string;
cfg: { channels: { matrix: Record<string, never> } };
inputs: string[];
kind: string;
runtime: { error: unknown; exit: unknown; log: unknown };
},
];
expect(forwarded).toEqual({
cfg: { channels: { matrix: {} } },
accountId: "ops",
inputs: ["Alice"],
kind: "user",
runtime: forwarded?.runtime,
});
expect(forwarded?.runtime.log).toBeTypeOf("function");
expect(forwarded?.runtime.error).toBeTypeOf("function");
expect(forwarded?.runtime.exit).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,18 @@
// Matrix plugin module implements channel behavior.
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix, sendTypingMatrix } 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,
sendTypingMatrix,
};

View File

@@ -0,0 +1,310 @@
// Matrix tests cover channel.setup plugin behavior.
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",
cfg: nextCfg,
});
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("bootstraps verification when setup enables encryption for an existing account", async () => {
const previousCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: false,
},
},
} as CoreConfig;
const nextCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: true,
},
},
} as CoreConfig;
mockBootstrapResult({ success: true, backupVersion: "8" });
await runAfterAccountConfigWritten({
previousCfg,
nextCfg,
accountId: "default",
input: {},
});
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 8');
});
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",
cfg: nextCfg,
});
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,49 @@
// Matrix plugin module implements channel.setup behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { matrixConfigAdapter } from "./config-adapter.js";
import { MatrixChannelConfigSchema } from "./config-schema.js";
import { resolveMatrixAccount, type ResolvedMatrixAccount } from "./matrix/accounts.js";
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard,
}));
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
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,
},
setupWizard: matrixSetupWizard,
setup: matrixSetupAdapter,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: MatrixChannelConfigSchema,
config: {
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.homeserver,
},
}),
hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured,
},
};

View File

@@ -0,0 +1,680 @@
// Matrix plugin module implements channel behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createChannelMessageAdapterFromOutbound,
createRuntimeOutboundDelegates,
} from "openclaw/plugin-sdk/channel-outbound";
import {
createAllowlistProviderOpenWarningCollector,
projectAccountConfigWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import {
createLazyRuntimeNamedExport,
createLazyRuntimeModule,
} from "openclaw/plugin-sdk/lazy-runtime";
import {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
chunkTextForOutbound,
sanitizeAssistantVisibleText,
} 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 { MatrixChannelConfigSchema } from "./config-schema.js";
import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
} from "./doctor-contract.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 { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.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 loadMatrixSetupWizard = createLazyRuntimeNamedExport(
() => import("./setup-surface.js"),
"matrixSetupWizard",
);
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
);
const loadMatrixDoctorModule = createLazyRuntimeModule(() => import("./doctor.js"));
const meta = {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
markdownCapable: true,
quickstartAllowFrom: true,
};
function buildMatrixTrafficStatusSummary(
snapshot?: {
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
} | null,
) {
return {
lastInboundAt: snapshot?.lastInboundAt ?? null,
lastOutboundAt: snapshot?.lastOutboundAt ?? null,
};
}
const matrixDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) =>
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
};
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;
}
const matrixChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
limits: {
text: {
markdownDialect: "markdown",
supportsEdit: true,
},
},
},
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
accountId,
payload,
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
renderPresentation: {
resolve: (runtime) => runtime.matrixOutbound.renderPresentation,
unavailableMessage: "Matrix outbound presentation rendering is unavailable",
},
sendPayload: {
resolve: (runtime) => runtime.matrixOutbound.sendPayload,
unavailableMessage: "Matrix outbound payload delivery is unavailable",
},
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",
},
}),
};
const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "matrix",
outbound: matrixChannelOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
quietFinalization: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
previewReceipt: true,
},
},
},
});
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
id: "matrix",
meta,
setupWizard: createMatrixSetupWizardProxy(async () => ({
matrixSetupWizard: await loadMatrixSetupWizard(),
})),
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
tts: {
voice: {
synthesisTarget: "voice-note",
},
},
},
reload: { configPrefixes: ["channels.matrix"] },
configSchema: MatrixChannelConfigSchema,
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: {
defaultMarkdownTableMode: "bullets",
targetPrefixes: ["matrix"],
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,
message: matrixMessageAdapter,
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: cfgLocal, accountId }) =>
(await loadMatrixChannelRuntime()).resolveMatrixAuth({
cfg: cfgLocal,
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,
...buildMatrixTrafficStatusSummary(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,
},
heartbeat: {
sendTyping: async ({ cfg, to, accountId }) => {
await (
await loadMatrixChannelRuntime()
).sendTypingMatrix(to, true, {
cfg: cfg as CoreConfig,
...(accountId ? { accountId } : {}),
});
},
clearTyping: async ({ cfg, to, accountId }) => {
await (
await loadMatrixChannelRuntime()
).sendTypingMatrix(to, false, {
cfg: cfg as CoreConfig,
...(accountId ? { accountId } : {}),
});
},
},
},
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: matrixChannelOutbound,
});

View File

@@ -0,0 +1,20 @@
// Matrix plugin module implements cli metadata behavior.
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

2318
extensions/matrix/src/cli.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
// Matrix helper module supports config adapter behavior.
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,128 @@
// Matrix tests cover config schema plugin behavior.
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 the Matrix name matching compatibility flag", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dangerouslyAllowNameMatching: true,
});
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);
});
it("accepts scalar progress Matrix streaming mode", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: "progress",
});
expect(result.success).toBe(true);
});
it("accepts Matrix streaming preview tool progress config", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
streaming: {
mode: "progress",
progress: {
label: "Shelling",
maxLines: 4,
toolProgress: false,
},
preview: {
toolProgress: true,
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,162 @@
// Matrix helper module supports config schema behavior.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import {
AllowFromListSchema,
buildNestedDmConfigSchema,
ContextVisibilityModeSchema,
GroupPolicySchema,
MarkdownConfigSchema,
MentionPatternsPolicySchema,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
import { matrixChannelConfigUiHints } from "./config-ui-hints.js";
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(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).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 botLoopProtectionSchema = z
.object({
enabled: z.boolean().optional(),
maxEventsPerWindow: z.number().int().positive().optional(),
windowSeconds: z.number().int().positive().optional(),
cooldownSeconds: z.number().int().positive().optional(),
})
.strict()
.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(),
botLoopProtection: botLoopProtectionSchema,
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();
const matrixStreamingSchema = z
.object({
mode: z.enum(["partial", "quiet", "progress", "off"]).optional(),
progress: z
.object({
label: z.union([z.string(), z.literal(false)]).optional(),
labels: z.array(z.string()).optional(),
maxLines: z.number().int().positive().optional(),
maxLineChars: z.number().int().positive().optional(),
toolProgress: z.boolean().optional(),
})
.strict()
.optional(),
preview: z
.object({
toolProgress: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict();
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(),
dangerouslyAllowNameMatching: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
botLoopProtection: botLoopProtectionSchema,
groupPolicy: GroupPolicySchema.optional(),
mentionPatterns: MentionPatternsPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
blockStreaming: z.boolean().optional(),
streaming: z
.union([z.enum(["partial", "quiet", "progress", "off"]), z.boolean(), matrixStreamingSchema])
.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,
});
export const MatrixChannelConfigSchema = buildChannelConfigSchema(MatrixConfigSchema, {
uiHints: matrixChannelConfigUiHints,
});

View File

@@ -0,0 +1,73 @@
// Matrix helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const matrixChannelConfigUiHints = {
mentionPatterns: {
label: "Matrix Mention Pattern Policy",
help: "Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied.",
},
"mentionPatterns.mode": {
label: "Matrix Mention Pattern Mode",
help: '"allow" enables configured regex mention patterns unless denyIn matches; "deny" disables them unless allowIn matches.',
},
"mentionPatterns.allowIn": {
label: "Matrix Mention Pattern Allowlist",
help: "Matrix room IDs where configured regex mention patterns are enabled when mode is deny.",
},
"mentionPatterns.denyIn": {
label: "Matrix Mention Pattern Denylist",
help: "Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers.",
},
allowBots: {
label: "Matrix Allow Bot Messages",
help: 'Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set "mentions" to require a visible room mention.',
},
botLoopProtection: {
label: "Matrix Bot Loop Protection",
help: "Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch.",
},
"botLoopProtection.enabled": {
label: "Matrix Bot Loop Protection Enabled",
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when configured bot messages are ignored.',
},
"botLoopProtection.maxEventsPerWindow": {
label: "Matrix Bot Loop Events per Window",
help: "Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20.",
},
"botLoopProtection.windowSeconds": {
label: "Matrix Bot Loop Window Seconds",
help: "Sliding window length for counting bot-pair messages. Default: 60.",
},
"botLoopProtection.cooldownSeconds": {
label: "Matrix Bot Loop Cooldown Seconds",
help: "How long to suppress the bot pair after it exceeds the budget. Default: 60.",
},
dangerouslyAllowNameMatching: {
label: "Matrix Display Name Matching",
help: "Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable.",
},
"streaming.progress.label": {
label: "Matrix Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "Matrix Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "Matrix Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "Matrix Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.toolProgress": {
label: "Matrix Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commandText": {
label: "Matrix Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,198 @@
// Matrix tests cover directory live plugin behavior.
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).toStrictEqual([]);
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).toStrictEqual([]);
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({
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({
method: "GET",
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
timeoutMs: 10_000,
body: undefined,
});
});
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,239 @@
// Matrix plugin module implements directory live behavior.
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,288 @@
// Matrix plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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,406 @@
// Matrix tests cover doctor plugin behavior.
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();
});
function runMatrixCompatibilityNormalize(
params: Parameters<NonNullable<typeof matrixDoctor.normalizeCompatibilityConfig>>[0],
) {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("expected Matrix doctor compatibility normalizer");
}
return normalize(params);
}
function normalizeMatrixDmConfig(dm: Record<string, unknown>) {
return runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
dm,
},
},
} as never,
});
}
function expectChangeContaining(changes: readonly string[], fragment: string): void {
expect(changes.join("\n")).toContain(fragment);
}
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");
expect(previews[1]).toContain("Recovery key state: Matrix SQLite state");
expect(previews[1]).toContain("Migration state: Matrix SQLite state");
});
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 result = runMatrixCompatibilityNormalize({
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).toContain(
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (true).",
);
expect(result.changes).toContain(
"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 result = runMatrixCompatibilityNormalize({
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).toContain(
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
);
expect(result.changes).toContain(
"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 result = runMatrixCompatibilityNormalize({
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"]);
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "allowlist"',
);
expectChangeContaining(result.changes, "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 result = normalizeMatrixDmConfig({
enabled: true,
policy: "trusted",
allowFrom: [" ", "\t", ""],
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
);
});
it("migrates legacy channels.matrix.dm.policy 'trusted' without allowFrom to 'pairing'", () => {
const result = normalizeMatrixDmConfig({
enabled: true,
policy: "trusted",
});
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
expect(matrixDm?.policy).toBe("pairing");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
);
});
it("migrates legacy per-account channels.matrix.accounts.<id>.dm.policy 'trusted'", () => {
const result = runMatrixCompatibilityNormalize({
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");
expectChangeContaining(
result.changes,
'Migrated channels.matrix.accounts.work.dm.policy "trusted" → "allowlist"',
);
expectChangeContaining(
result.changes,
'Migrated channels.matrix.accounts.personal.dm.policy "trusted" → "pairing"',
);
});
it("leaves modern dm.policy values untouched", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
channels: {
matrix: {
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
accounts: {
work: {
dm: { enabled: true, policy: "pairing" },
},
},
},
},
} as never,
});
expect(result.changes).toStrictEqual([]);
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,263 @@
// Matrix plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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}`,
`- Recovery key state: Matrix SQLite state (imports ${plan.recoveryKeyPath} if present)`,
`- Migration state: Matrix SQLite state (imports ${plan.statePath} if present)`,
'- 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,93 @@
// Matrix plugin module implements env vars behavior.
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,69 @@
// Matrix tests cover exec approval resolver plugin behavior.
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,24 @@
// Matrix plugin module implements exec approval resolver behavior.
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-contracts";
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,485 @@
// Matrix tests cover exec approvals plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-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("requires enablement and approvers before enabling the client", () => {
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isMatrixExecApprovalClientEnabled({
cfg: buildConfig(undefined, { dm: { allowFrom: ["@owner:example.org"] } }),
}),
).toBe(false);
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 })).toStrictEqual([]);
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", async () => {
const tmpDir = createTempDir();
const storePath = path.join(tmpDir, "sessions.json");
await saveSessionStore(
storePath,
{
"agent:ops-agent:matrix:channel:!room:example.org": {
sessionId: "main",
updatedAt: 1,
origin: {
provider: "matrix",
accountId: "ops",
},
lastChannel: "slack",
lastTo: "channel:C999",
lastAccountId: "work",
},
},
{ skipMaintenance: true },
);
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,294 @@
// Matrix plugin module implements exec approvals behavior.
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 {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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,30 @@
// Matrix tests cover group mentions plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveMatrixGroupToolPolicy } from "./group-mentions.js";
describe("Matrix group policy", () => {
it("resolves room tool policy from the case-preserved Matrix room id", () => {
const policy = resolveMatrixGroupToolPolicy({
accountId: "default",
cfg: {
channels: {
matrix: {
accounts: {
default: {
groups: {
"!RoomABC:example.org": {
tools: { allow: ["sessions_spawn"] },
},
},
},
},
},
},
},
groupId: "!roomabc:example.org",
groupChannel: "!RoomABC:example.org",
});
expect(policy).toEqual({ allow: ["sessions_spawn"] });
});
});

View File

@@ -0,0 +1,42 @@
// Matrix plugin module implements group mentions behavior.
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,84 @@
// Matrix tests cover legacy crypto inspector availability plugin behavior.
import path from "node:path";
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("openclaw/plugin-sdk/test-node-mocks");
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 = path.resolve(
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js",
);
availabilityState.existingPaths.add(
path.resolve("/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,61 @@
// Matrix plugin module implements legacy crypto inspector availability behavior.
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,243 @@
// Matrix tests cover legacy crypto plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
} from "./matrix/crypto-state-store.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";
import { installMatrixTestRuntime } from "./test-runtime.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", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
legacyCryptoInspectorAvailability.available = true;
resetPluginStateStoreForTests();
});
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).toStrictEqual([]);
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).toStrictEqual([]);
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("YWJjZA==");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
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.',
);
expect(readMatrixLegacyCryptoMigrationState(rootDir)?.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(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("b3Bz");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
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,502 @@
// Matrix plugin module implements legacy crypto behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { loadJsonFile } 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 {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore,
migrateLegacyMatrixRecoveryKeyFileToStore,
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
writeMatrixLegacyCryptoMigrationState,
writeMatrixRecoveryKeyState,
type MatrixLegacyCryptoMigrationState,
} from "./matrix/crypto-state-store.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import type { MatrixStoredRecoveryKey } from "./matrix/sdk/types.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 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;
};
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;
};
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 };
const parsed = loadJsonFile<{ deviceId?: unknown }>(metadataPath);
return {
deviceId:
typeof parsed?.deviceId === "string" && parsed.deviceId.trim()
? parsed.deviceId
: fallback.deviceId,
};
}
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 };
}
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[] = [];
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) {
try {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(plan.rootDir);
migrateLegacyMatrixRecoveryKeyFileToStore(plan.rootDir);
} catch (err) {
warnings.push(
`Failed migrating Matrix crypto sidecar state for account "${plan.accountId}" (${plan.rootDir}): ${String(err)}`,
);
}
const existingState = readMatrixLegacyCryptoMigrationState(plan.rootDir);
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 = readMatrixRecoveryKeyState(plan.rootDir);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but Matrix SQLite state already contains a different recovery key. Leaving the existing state unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
writeMatrixRecoveryKeyState({
storageRootDir: plan.rootDir,
payload,
});
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}" into SQLite`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" to SQLite: ${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 &&
!readMatrixRecoveryKeyState(plan.rootDir)
) {
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 {
writeMatrixLegacyCryptoMigrationState({
storageRootDir: plan.rootDir,
state,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}" in SQLite`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" to SQLite: ${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,87 @@
// Matrix tests cover legacy state plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
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).toStrictEqual([]);
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,157 @@
// Matrix plugin module implements legacy state behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
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,10 @@
// Matrix plugin module implements matrix migration behavior.
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,176 @@
// Matrix helper module supports account config behavior.
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", "botLoopProtection"],
});
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,28 @@
// Matrix tests cover accounts.readiness plugin behavior.
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,790 @@
// Matrix tests cover accounts plugin behavior.
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("merges account bot loop protection over top-level defaults field-by-field", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "top-token",
botLoopProtection: {
maxEventsPerWindow: 8,
windowSeconds: 120,
cooldownSeconds: 240,
},
accounts: {
ops: {
accessToken: "ops-token",
botLoopProtection: {
maxEventsPerWindow: 3,
},
},
},
},
},
};
const account = resolveMatrixAccount({ cfg, accountId: "ops" });
expect(account.config.botLoopProtection).toEqual({
maxEventsPerWindow: 3,
windowSeconds: 120,
cooldownSeconds: 240,
});
});
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,195 @@
// Matrix plugin module implements accounts behavior.
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,38 @@
// Matrix plugin module implements actions behavior.
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,247 @@
// Matrix tests cover client plugin behavior.
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;
const TEST_CFG = {};
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 (clientForTest, 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(
{ cfg: TEST_CFG, accountId: "default" },
async () => "ok",
);
await expectOneOffSharedMatrixClient();
expect(result).toBe("ok");
});
it("skips one-off room preparation when readiness is disabled", async () => {
await withResolvedActionClient(
{ cfg: TEST_CFG, 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({ cfg: TEST_CFG, 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(
{ cfg: TEST_CFG, 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({ cfg: TEST_CFG, 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({ cfg: loadConfigMock() as never }, 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(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("can discard read-only shared action clients without persisting crypto state", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
"discard",
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "discard");
});
it("stops shared action clients when the wrapped call throws", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedActionClient({ cfg: TEST_CFG, 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",
{ cfg: TEST_CFG, 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,32 @@
// Matrix plugin module implements client behavior.
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
import { resolveMatrixRoomId } from "../send.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
type MatrixActionClientStopMode = "stop" | "persist" | "discard";
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,221 @@
// Matrix tests cover devices plugin behavior.
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");
function expectResolvedActionClientCall(): void {
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
const call = withResolvedActionClientMock.mock.calls[0];
if (!call) {
throw new Error("Expected resolved action client call");
}
expect(call[0]).toEqual({ accountId: "poe" });
expect(call[1]).toBeTypeOf("function");
expect(withStartedActionClientMock).not.toHaveBeenCalled();
}
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" });
expectResolvedActionClientCall();
expect(result).toEqual([
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
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).toEqual({
currentDeviceId: "du314Zpw3A",
staleOpenClawDevices: [
{
deviceId: "old123",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
],
currentOpenClawDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
});
expectResolvedActionClientCall();
});
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,
},
],
}));
withResolvedActionClientMock.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).toEqual({
before: [
{
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,
},
],
staleGatewayDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
currentDeviceId: "du314Zpw3A",
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
remainingDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
});
expectResolvedActionClientCall();
});
});

View File

@@ -0,0 +1,35 @@
// Matrix plugin module implements devices behavior.
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { withResolvedActionClient } 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 withResolvedActionClient(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,16 @@
// Matrix tests cover limits plugin behavior.
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 @@
// Matrix plugin module implements limits behavior.
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
return resolveIntegerOption(raw, fallback, { min: 1 });
}

View File

@@ -0,0 +1,642 @@
// Matrix tests cover messages plugin behavior.
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";
const MATRIX_ACTION_TEST_CFG = {
channels: {
matrix: {},
},
};
function installMatrixActionTestRuntime(): void {
setMatrixRuntime({
config: {
current: () => ({}),
},
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>>;
threadRelations?: 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 ?? _events) as unknown,
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
if (params.pollRoot?.event_id === eventId) {
return params.pollRoot;
}
return null;
});
const getRelations = vi.fn(async (_roomId: string, _eventId: string, relType: string) => ({
events:
relType === "m.thread"
? (params.threadRelations ?? params.pollRelations ?? [])
: (params.pollRelations ?? []),
nextBatch: null,
prevBatch: null,
}));
return {
client: {
doRequest,
hydrateEvents,
getEvent,
getRelations,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
hydrateEvents,
getEvent,
getRelations,
};
}
function createEditClient(originalContent: Record<string, unknown>) {
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({ content: originalContent }),
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;
return { client, sendMessage };
}
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
return actual;
}
function mockCallArg(
mockFn: { mock: { calls: unknown[][] } },
callIndex: number,
argIndex: number,
) {
const call = mockFn.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected mock call ${callIndex} to exist`);
}
if (!(argIndex in call)) {
throw new Error(`Expected mock call ${callIndex} argument ${argIndex} to exist`);
}
return call[argIndex];
}
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 cfg = {} as never;
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
cfg,
timeoutMs: 12_345,
});
expect(result).toEqual({ eventId: "evt-edit" });
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
cfg,
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 { client, sendMessage } = createEditClient({
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
});
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello @alice:example.org and @bob:example.org",
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
"m.mentions": { user_ids: ["@bob:example.org"] },
});
expectRecordFields(content["m.new_content"], {
"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 { client, sendMessage } = createEditClient({
body: "hello @alice:example.org",
});
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello again @alice:example.org",
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
"m.mentions": {},
});
expectRecordFields(content["m.new_content"], {
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(mockCallArg(doRequest, 0, 0)).toBe("GET");
expect(String(mockCallArg(doRequest, 0, 1))).toContain("/rooms/!room%3Aexample.org/messages");
expectRecordFields(mockCallArg(doRequest, 0, 2), { limit: 2 });
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(result.messages).toHaveLength(2);
expectRecordFields(result.messages[0], {
eventId: "$poll",
msgtype: "m.text",
});
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
expectRecordFields(result.messages[1], {
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);
expectRecordFields(result.messages[0], { eventId: "$poll" });
expect(result.messages[0]?.body).toContain("[Poll]");
expect(getEvent).toHaveBeenCalledTimes(2);
});
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(mockCallArg(hydrateEvents, 0, 0)).toBe("!room:example.org");
expect(
(mockCallArg(hydrateEvents, 0, 1) as Array<Record<string, unknown>>).some(
(event) => event.event_id === "$enc",
),
).toBe(true);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.eventId).toBe("$poll");
});
it("filters Matrix thread events out of main-room reads", async () => {
const { client } = createMessagesClient({
chunk: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
{
event_id: "$main",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "main room",
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(result.messages.map((message) => message.eventId)).toEqual(["$main"]);
});
it("filters threaded poll roots out of main-room reads", async () => {
const threadedPollRoot = createPollStartEvent();
const threadedPollContent = threadedPollRoot.content as Record<string, unknown>;
threadedPollRoot.content = {
...threadedPollContent,
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
};
const { client, getEvent } = createMessagesClient({
chunk: [createPollResponseEvent()],
pollRoot: threadedPollRoot,
pollRelations: [createPollResponseEvent()],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(result.messages).toEqual([]);
});
it("uses the thread relations endpoint and includes the thread root once", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "thread root",
},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 5,
});
expect(doRequest).not.toHaveBeenCalled();
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 4 },
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual([
"$thread-root",
"$thread-reply",
]);
});
it("includes poll snapshots from threaded reads", async () => {
const { client, getEvent, getRelations } = createMessagesClient({
chunk: [],
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,
threadId: "$thread-root",
limit: 5,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 5 },
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
});
it("includes poll roots when reading the thread they start", async () => {
const { client, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRoot: createPollStartEvent({
includeDisclosedKind: true,
maxSelections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
}),
pollRelations: [createPollResponseEvent()],
threadRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$poll" },
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$poll",
limit: 5,
});
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$poll", "m.thread", undefined, {
dir: "b",
from: undefined,
limit: 4,
});
expect(result.messages.map((message) => message.eventId)).toEqual(["$poll", "$thread-reply"]);
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
});
it("does not summarize non-start poll events as thread roots", async () => {
const { client, getRelations } = createMessagesClient({
chunk: [],
pollRoot: createPollResponseEvent(),
threadRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$vote" },
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$vote",
limit: 5,
});
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$vote", "m.thread", undefined, {
dir: "b",
from: undefined,
limit: 5,
});
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
});
it("counts the thread root toward the requested first-page limit", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "thread root",
},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(doRequest).not.toHaveBeenCalled();
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-root"]);
expect(result.nextBatch).toEqual(
expect.stringContaining("openclaw.matrix.thread-relations-start:"),
);
const next = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
before: result.nextBatch ?? undefined,
});
expect(getRelations).toHaveBeenLastCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(next.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
});
it("does not reserve first-page thread capacity for a redacted root", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [],
pollRelations: [
{
event_id: "$thread-reply",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 20,
content: {
msgtype: "m.text",
body: "thread reply",
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
},
},
],
pollRoot: {
event_id: "$thread-root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
unsigned: { redacted_because: {} },
content: {},
},
});
const result = await readMatrixMessages("room:!room:example.org", {
client,
threadId: "$thread-root",
limit: 1,
});
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$thread-root",
"m.thread",
undefined,
{ dir: "b", from: undefined, limit: 1 },
);
expect(doRequest).not.toHaveBeenCalled();
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
expect(result.nextBatch).toBeNull();
});
});

View File

@@ -0,0 +1,261 @@
import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType, isPollStartType } 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";
const MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX = "openclaw.matrix.thread-relations-start:";
export async function sendMatrixMessage(
to: string,
content: string | undefined,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
audioAsVoice?: boolean;
} = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
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 = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
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;
threadId?: 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 rawBefore = normalizeOptionalString(opts.before);
const rawAfter = normalizeOptionalString(opts.after);
const dir = opts.after ? "f" : "b";
const threadId = normalizeOptionalString(opts.threadId);
const isThreadRelationsStartCursor = threadId
? isMatrixThreadRelationsStartCursor(rawBefore, threadId)
: false;
const token = isThreadRelationsStartCursor ? undefined : (rawBefore ?? rawAfter);
const includeThreadRoot = threadId !== undefined && !token && !isThreadRelationsStartCursor;
const threadRootSummary =
includeThreadRoot && threadId
? await fetchDisplayableThreadRootSummary(client, resolvedRoom, threadId)
: undefined;
const rootCountsTowardLimit = threadRootSummary !== undefined;
const rootFillsThreadPage = rootCountsTowardLimit && limit === 1;
const relationLimit = rootCountsTowardLimit ? Math.max(limit - 1, 1) : limit;
const seenPollRoots = new Set<string>();
const threadRootEventId = normalizeOptionalString(threadRootSummary?.eventId);
if (threadRootEventId) {
seenPollRoots.add(threadRootEventId);
}
const relationPage =
threadId && relationLimit > 0
? await client.getRelations(resolvedRoom, threadId, "m.thread", undefined, {
dir: dir as Direction,
from: token,
limit: relationLimit,
})
: null;
// Flat room history uses the low-level endpoint for compatibility; threaded reads use
// the SDK relations helper so encrypted rooms get the SDK's event-type translation.
const flatPage = threadId
? null
: ((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,
relationPage ? (rootFillsThreadPage ? [] : relationPage.events) : (flatPage?.chunk ?? []),
);
const messages: MatrixMessageSummary[] = [];
if (threadRootSummary) {
messages.push(threadRootSummary);
}
for (const event of hydratedChunk) {
if (event.unsigned?.redacted_because) {
continue;
}
if (!threadId && isMatrixThreadEvent(event)) {
continue;
}
if (event.type === EventType.RoomMessage) {
if (threadId && event.event_id === threadId) {
continue;
}
messages.push(summarizeMatrixRawEvent(event));
continue;
}
if (!isPollEventType(event.type)) {
continue;
}
const pollRootId = resolveMatrixPollRootEventId(event);
if (!pollRootId || seenPollRoots.has(pollRootId)) {
continue;
}
if (
!threadId &&
(await isMatrixPollRootThreaded({
client,
event,
pollRootId,
resolvedRoom,
}))
) {
continue;
}
seenPollRoots.add(pollRootId);
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
if (pollSummary) {
messages.push(pollSummary);
}
}
const nextBatch =
rootFillsThreadPage && threadId && relationPage?.events.length
? encodeMatrixThreadRelationsStartCursor(threadId)
: (relationPage?.nextBatch ?? flatPage?.end ?? null);
return {
messages,
nextBatch,
prevBatch: relationPage?.prevBatch ?? flatPage?.start ?? null,
};
});
}
function encodeMatrixThreadRelationsStartCursor(threadId: string): string {
const payload = Buffer.from(JSON.stringify({ v: 1, threadId }), "utf8").toString("base64url");
return `${MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX}${payload}`;
}
function isMatrixThreadRelationsStartCursor(raw: string | undefined, threadId: string): boolean {
if (!raw?.startsWith(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX)) {
return false;
}
const encoded = raw.slice(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX.length);
try {
const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as {
v?: unknown;
threadId?: unknown;
};
return decoded.v === 1 && decoded.threadId === threadId;
} catch {
return false;
}
}
async function fetchDisplayableThreadRootSummary(
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>,
resolvedRoom: string,
threadId: string,
): Promise<MatrixMessageSummary | undefined> {
const rawRootEvent = (await client
.getEvent(resolvedRoom, threadId)
.catch(() => null)) as MatrixRawEvent | null;
if (!rawRootEvent) {
return undefined;
}
const rootEvent = (await client.hydrateEvents(resolvedRoom, [rawRootEvent]))[0];
if (!rootEvent || rootEvent.unsigned?.redacted_because) {
return undefined;
}
if (rootEvent.type === EventType.RoomMessage) {
return summarizeMatrixRawEvent(rootEvent);
}
if (isPollStartType(rootEvent.type)) {
return (await fetchMatrixPollMessageSummary(client, resolvedRoom, rootEvent)) ?? undefined;
}
return undefined;
}
function isMatrixThreadEvent(event: MatrixRawEvent): boolean {
const relates = event.content?.["m.relates_to"];
if (!relates || typeof relates !== "object") {
return false;
}
return (relates as { rel_type?: unknown }).rel_type === "m.thread";
}
async function isMatrixPollRootThreaded(params: {
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>;
event: MatrixRawEvent;
pollRootId: string;
resolvedRoom: string;
}): Promise<boolean> {
if (isMatrixThreadEvent(params.event)) {
return true;
}
const rootEvent = (await params.client
.getEvent(params.resolvedRoom, params.pollRootId)
.catch(() => null)) as MatrixRawEvent | null;
if (!rootEvent) {
return false;
}
const hydratedRoot = (await params.client.hydrateEvents(params.resolvedRoom, [rootEvent]))[0];
return hydratedRoot ? isMatrixThreadEvent(hydratedRoot) : false;
}

View File

@@ -0,0 +1,80 @@
// Matrix tests cover pins plugin behavior.
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([
{
attachment: undefined,
body: "hello",
eventId: "$a",
msgtype: "m.text",
relatesTo: undefined,
sender: "@alice:example.org",
timestamp: 123,
},
]);
});
});

View File

@@ -0,0 +1,64 @@
// Matrix plugin module implements pins behavior.
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,
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, 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, 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,73 @@
// Matrix tests cover polls plugin behavior.
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", {
"m.poll.response": { answers: ["berry"] },
"m.relates_to": {
event_id: "$poll",
rel_type: "m.reference",
},
"org.matrix.msc3381.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,111 @@
// Matrix plugin module implements polls behavior.
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
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 uniqueValues(normalized);
}
function normalizeOptionIds(optionIds: string[]): string[] {
return uniqueStrings(
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,149 @@
// Matrix tests cover profile plugin behavior.
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");
function mockCallAt(
mock: { mock: { calls: Array<readonly unknown[]> } },
index: number,
label: string,
): readonly unknown[] {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
function firstMockArg(mock: { mock: { calls: Array<readonly unknown[]> } }, label: string) {
return mockCallAt(mock, 0, label)[0];
}
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 () => {
const actionClient = {
getUserId: vi.fn(async () => "@bot:example.org"),
};
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run(actionClient);
});
await updateMatrixOwnProfile({
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
});
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
const [wrapperOpts, run, mode] = mockCallAt(
withResolvedActionClientMock,
0,
"Matrix action client wrapper",
);
expect(wrapperOpts).toEqual({
accountId: "ops",
displayName: " Ops Bot ",
avatarUrl: " mxc://example/avatar ",
avatarPath: " /tmp/avatar.png ",
});
expect(typeof run).toBe("function");
expect(mode).toBe("persist");
expect(syncMatrixOwnProfileMock).toHaveBeenCalledTimes(1);
const syncCall = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") as
| {
client: unknown;
userId: string;
displayName: string;
avatarUrl: string;
avatarPath: string;
loadAvatarFromUrl: unknown;
loadAvatarFromPath: unknown;
}
| undefined;
if (!syncCall) {
throw new Error("syncMatrixOwnProfile was not called");
}
const { client, loadAvatarFromUrl, loadAvatarFromPath, ...profileFields } = syncCall;
expect(client).toBe(actionClient);
expect(typeof loadAvatarFromUrl).toBe("function");
expect(typeof loadAvatarFromPath).toBe("function");
expect(profileFields).toEqual({
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 = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") 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,38 @@
// Matrix plugin module implements profile behavior.
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,134 @@
// Matrix tests cover reactions plugin behavior.
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",
"/_matrix/client/v1/rooms/!room%3Aexample.org/relations/%24msg/m.annotation/m.reaction",
{ dir: "b", limit: 2 },
);
expect(result).toStrictEqual([
{
key: "👍",
count: 2,
users: ["@alice:example.org", "@bob:example.org"],
},
{
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).toStrictEqual([]);
});
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,56 @@
// Matrix plugin module implements reactions behavior.
import {
buildMatrixReactionRelationsPath,
selectOwnMatrixReactionEventIds,
summarizeMatrixReactionEvents,
} from "../reaction-common.js";
import { withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import type { MatrixActionClientOpts, MatrixRawEvent, 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,80 @@
// Matrix tests cover room plugin behavior.
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,72 @@
// Matrix plugin module implements room behavior.
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,101 @@
// Matrix tests cover summary plugin behavior.
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).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: undefined,
msgtype: "m.image",
attachment: {
kind: "image",
filename: "photo.jpg",
},
timestamp: 123,
relatesTo: undefined,
});
});
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).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: "can you see this?",
msgtype: "m.image",
attachment: {
kind: "image",
caption: "can you see this?",
filename: "photo.jpg",
},
timestamp: 123,
relatesTo: undefined,
});
});
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).toEqual({
eventId: "$image",
sender: "@gum:matrix.example.org",
body: "see image.png",
msgtype: "m.image",
attachment: {
kind: "image",
caption: "see image.png",
},
timestamp: 123,
relatesTo: undefined,
});
});
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();
});
});

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