Matrix-js: sync with main plugin-loading standards

This commit is contained in:
Gustavo Madeira Santana
2026-03-04 10:17:22 -05:00
parent 115f24819e
commit 5fddbc1d9b
140 changed files with 21472 additions and 0 deletions

305
docs/channels/matrix-js.md Normal file
View File

@@ -0,0 +1,305 @@
---
summary: "Matrix-js support status, setup, and configuration examples"
read_when:
- Setting up Matrix-js in OpenClaw
- Configuring Matrix E2EE and verification
title: "Matrix-js"
---
# Matrix-js (plugin)
Matrix-js is the current Matrix channel plugin for OpenClaw.
It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE.
For new setups, use Matrix-js.
If you need legacy compatibility with `@vector-im/matrix-bot-sdk`, use [Matrix (legacy)](/channels/matrix).
## Plugin required
Matrix-js is a plugin and is not bundled with core OpenClaw.
Install from npm:
```bash
openclaw plugins install @openclaw/matrix-js
```
Install from a local checkout:
```bash
openclaw plugins install ./extensions/matrix-js
```
See [Plugins](/tools/plugin) for plugin behavior and install rules.
## Setup
1. Install the plugin.
2. Create a Matrix account on your homeserver.
3. Configure `channels["matrix-js"]` with either:
- `homeserver` + `accessToken`, or
- `homeserver` + `userId` + `password`.
4. Restart the gateway.
5. Start a DM with the bot or invite it to a room.
Minimal token-based setup:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
dm: { policy: "pairing" },
},
},
}
```
Password-based setup (token is cached after login):
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "replace-me",
deviceName: "OpenClaw Gateway",
},
},
}
```
Environment variable equivalents (used when the config key is not set):
- `MATRIX_HOMESERVER`
- `MATRIX_ACCESS_TOKEN`
- `MATRIX_USER_ID`
- `MATRIX_PASSWORD`
- `MATRIX_DEVICE_ID`
- `MATRIX_DEVICE_NAME`
For non-default accounts, use account-scoped env vars:
- `MATRIX_<ACCOUNT_ID>_HOMESERVER`
- `MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN`
- `MATRIX_<ACCOUNT_ID>_USER_ID`
- `MATRIX_<ACCOUNT_ID>_PASSWORD`
- `MATRIX_<ACCOUNT_ID>_DEVICE_ID`
- `MATRIX_<ACCOUNT_ID>_DEVICE_NAME`
Example for account `ops`:
- `MATRIX_OPS_HOMESERVER`
- `MATRIX_OPS_ACCESS_TOKEN`
## Configuration example
This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: {
policy: "pairing",
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
autoJoin: "allowlist",
autoJoinAllowlist: ["!roomid:example.org"],
threadReplies: "inbound",
replyToMode: "off",
},
},
}
```
## E2EE setup
Enable encryption:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: { policy: "pairing" },
},
},
}
```
Check verification status:
```bash
openclaw matrix-js verify status
```
Verbose status (full diagnostics):
```bash
openclaw matrix-js verify status --verbose
```
Bootstrap cross-signing and verification state:
```bash
openclaw matrix-js verify bootstrap
```
Verbose bootstrap diagnostics:
```bash
openclaw matrix-js verify bootstrap --verbose
```
Verify this device with a recovery key:
```bash
openclaw matrix-js verify device "<your-recovery-key>"
```
Verbose device verification details:
```bash
openclaw matrix-js verify device "<your-recovery-key>" --verbose
```
Check room-key backup health:
```bash
openclaw matrix-js verify backup status
```
Verbose backup health diagnostics:
```bash
openclaw matrix-js verify backup status --verbose
```
Restore room keys from server backup:
```bash
openclaw matrix-js verify backup restore
```
Verbose restore diagnostics:
```bash
openclaw matrix-js verify backup restore --verbose
```
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
Use `--json` for full machine-readable output when scripting.
## Automatic verification notices
Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
That includes:
- verification request notices
- verification ready notices (with explicit "Verify by emoji" guidance)
- verification start and completion notices
- SAS details (emoji and decimal) when available
Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match"
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
## DM and room policy example
```json5
{
channels: {
"matrix-js": {
dm: {
policy: "allowlist",
allowFrom: ["@admin:example.org"],
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
},
},
}
```
See [Groups](/channels/groups) for mention-gating and allowlist behavior.
## Multi-account example
```json5
{
channels: {
"matrix-js": {
enabled: true,
dm: { policy: "pairing" },
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "syt_assistant_xxx",
encryption: true,
},
alerts: {
homeserver: "https://matrix.example.org",
accessToken: "syt_alerts_xxx",
dm: {
policy: "allowlist",
allowFrom: ["@ops:example.org"],
},
},
},
},
},
}
```
## Configuration reference
- `enabled`: enable or disable the channel.
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
- `userId`: full Matrix user ID, for example `@bot:example.org`.
- `accessToken`: access token for token-based auth.
- `password`: password for password-based login.
- `deviceId`: explicit Matrix device ID.
- `deviceName`: device display name for password login.
- `initialSyncLimit`: startup sync event limit.
- `encryption`: enable E2EE.
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
- `groupPolicy`: `open`, `allowlist`, or `disabled`.
- `groupAllowFrom`: allowlist of user IDs for room traffic.
- `replyToMode`: `off`, `first`, or `all`.
- `threadReplies`: `off`, `inbound`, or `always`.
- `textChunkLimit`: outbound message chunk size.
- `chunkMode`: `length` or `newline`.
- `responsePrefix`: optional message prefix for outbound replies.
- `mediaMaxMb`: outbound media size cap in MB.
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`).
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`.
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
- `groups`: per-room policy map.
- `rooms`: legacy alias for `groups`.
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`).

View File

@@ -166,6 +166,7 @@ authoring plugins:
`openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`,
`openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`,
`openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`,
`openclaw/plugin-sdk/matrix-js`,
`openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`,
`openclaw/plugin-sdk/memory-lancedb`,
`openclaw/plugin-sdk/minimax-portal-auth`,

View File

@@ -0,0 +1,120 @@
# Legacy Matrix Parity Gap Audit
Audit date: February 23, 2026
Scope:
- Baseline spec: `<repo-root>/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md`
- Compared implementations:
- Legacy: `<repo-root>/extensions/matrix`
- New: `<repo-root>/extensions/matrix-js`
Method:
- Static code comparison and targeted file inspection.
- Runtime validation executed for matrix-js test suites and project build.
Status legend:
- `PASS (static)` = code-level parity confirmed.
- `NEEDS UPDATING` = concrete parity/coexistence gap found.
- `UNVERIFIED (runtime)` = requires executing tests/integration flows.
## Summary
- Overall feature parity with legacy behavior: strong at code level.
- Previously identified dual-plugin coexistence blockers are resolved in code.
- Matrix-js regression tests pass (`27` files, `112` tests).
- Full repository build passes after the matrix-js namespace/storage changes.
- Remaining runtime validation gap: explicit side-by-side legacy `matrix` + `matrix-js` integration run.
## Coexistence Gaps (Current Status)
1. `PASS (static)`: Channel identity is consistent as `matrix-js` across metadata and runtime registration.
- Evidence:
- `<repo-root>/extensions/matrix-js/index.ts:7`
- `<repo-root>/extensions/matrix-js/openclaw.plugin.json:2`
- `<repo-root>/extensions/matrix-js/src/channel.ts:41`
- `<repo-root>/extensions/matrix-js/src/channel.ts:99`
2. `PASS (static)`: Config namespace is consistently `channels.matrix-js`.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/channel.ts:116`
- `<repo-root>/extensions/matrix-js/src/channel.ts:125`
- `<repo-root>/extensions/matrix-js/src/channel.ts:319`
- `<repo-root>/extensions/matrix-js/src/onboarding.ts:17`
- `<repo-root>/extensions/matrix-js/src/onboarding.ts:174`
- `<repo-root>/extensions/matrix-js/src/matrix/send/client.ts:22`
- `<repo-root>/extensions/matrix-js/src/matrix/client/config.ts:125`
3. `PASS (static)`: Outbound/inbound channel tags and routing context emit `matrix-js`.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/outbound.ts:20`
- `<repo-root>/extensions/matrix-js/src/outbound.ts:36`
- `<repo-root>/extensions/matrix-js/src/outbound.ts:49`
- `<repo-root>/extensions/matrix-js/src/matrix/send.ts:55`
- `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts:496`
- `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts:509`
4. `PASS (static)`: Matrix-js now uses isolated storage namespace/prefixes.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/matrix/credentials.ts:31`
- `<repo-root>/extensions/matrix-js/src/matrix/client/storage.ts:42`
- `<repo-root>/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts:127`
- `<repo-root>/extensions/matrix-js/src/matrix/client/create-client.ts:43`
## Parity Matrix (Spec Section 16, Pre-Filled)
| Check | Status | Evidence |
| ---------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Config schema keys and defaults are equivalent | PASS (static) | `<repo-root>/extensions/matrix/src/config-schema.ts` vs `<repo-root>/extensions/matrix-js/src/config-schema.ts` (no semantic diffs) |
| Auth precedence (config/env/token/cache/password/register) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/config.ts` |
| Bun runtime rejection behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/runtime.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/index.ts` |
| Startup/shutdown lifecycle and status updates match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/channel.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/index.ts` |
| DM detection heuristics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/direct.ts` |
| DM/group allowlist + pairing flow matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/allowlist.ts` |
| Mention detection (`m.mentions`, formatted_body links, regex) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/mentions.ts` |
| Control-command authorization gate behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts` |
| Inbound poll normalization matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/poll-types.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts` |
| Inbound location normalization matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/location.ts` |
| Inbound media download/decrypt/size-limit behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/media.ts` |
| Reply dispatch + typing + ack reaction + read receipts match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/replies.ts` |
| Thread handling (`threadReplies`) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/threads.ts` |
| `replyToMode` handling for single/multi reply flows matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/replies.ts` |
| Outbound text chunking, markdown, and formatting behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send.ts`, `<repo-root>/extensions/matrix-js/src/matrix/send/formatting.ts` |
| Outbound media encryption/voice/thumbnail/duration behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send/media.ts` |
| Outbound poll payload behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send.ts`, `<repo-root>/extensions/matrix-js/src/matrix/poll-types.ts` |
| Action gating and action semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/actions.ts`, `<repo-root>/extensions/matrix-js/src/tool-actions.ts`, `<repo-root>/extensions/matrix-js/src/matrix/actions/*` |
| Verification action flow and summary semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/actions/verification.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/verification-manager.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts` |
| Directory live lookup + target resolution ambiguity handling matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/directory-live.ts`, `<repo-root>/extensions/matrix-js/src/resolve-targets.ts` |
| Probe/status reporting fields match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/probe.ts`, `<repo-root>/extensions/matrix-js/src/channel.ts` |
| Storage layout and credential persistence semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/storage.ts`, `<repo-root>/extensions/matrix-js/src/matrix/credentials.ts` |
| HTTP hardening and decrypt retry behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/sdk/http-client.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk.ts` |
## Runtime Validation Status
- `PASS (runtime)`: matrix-js regression run succeeded via `pnpm test extensions/matrix-js/src` (`27` files, `112` tests).
- `PASS (runtime)`: build/type pipeline succeeded via `pnpm build`.
- `UNVERIFIED (runtime)`: side-by-side load of legacy `matrix` plus `matrix-js` with independent config.
Recommended commands for final coexistence sign-off:
```bash
pnpm test extensions/matrix/src
pnpm test extensions/matrix-js/src
pnpm build
```
## Suggested Next Fix Batch
1. Add explicit coexistence integration tests:
- Load both legacy `matrix` and `matrix-js` in one runtime with independent config + pairing state.
2. Validate state migration behavior (if required by product decision):
- Decide whether `matrix-js` should intentionally read legacy `channels.matrix`/`credentials/matrix` during transition or stay fully isolated.

View File

@@ -0,0 +1,397 @@
# Legacy Matrix Plugin Parity Spec
This document defines the expected behavior of the **legacy Matrix plugin** (`extensions/matrix`) so the new **matrix-js plugin** (`extensions/matrix-js`) can be verified for feature parity.
## 1. Scope
- Legacy source of truth:
- `extensions/matrix/index.ts`
- `extensions/matrix/src/channel.ts`
- `extensions/matrix/src/**/*.ts`
- New implementation under test:
- `extensions/matrix-js/**`
- Goal: matrix-js should preserve user-visible and operator-visible behavior unless explicitly changed.
## 2. Parity Levels
- `MUST`: required parity for GA.
- `SHOULD`: desirable parity; acceptable temporary delta if documented.
- `NICE`: optional parity.
## 3. Channel + Plugin Contract (MUST)
- Plugin id remains `matrix`; channel id exposed to runtime is `matrix` in legacy.
- Channel metadata parity:
- label/selection/docs path/blurb/order/quickstart allowFrom behavior.
- Channel capabilities parity:
- `chatTypes`: direct, group, thread
- `polls`: true
- `reactions`: true
- `threads`: true
- `media`: true
- Reload behavior parity:
- config prefixes include `channels.matrix`.
- Pairing behavior parity:
- pairing id label, allow-entry normalization, approval notification message behavior.
## 4. Configuration Contract (MUST)
Legacy schema lives in `extensions/matrix/src/config-schema.ts` and `extensions/matrix/src/types.ts`.
### 4.1 Core fields
- `enabled?: boolean`
- Auth: `homeserver`, `userId`, `accessToken`, `password`, `register`, `deviceId`, `deviceName`
- Sync/runtime: `initialSyncLimit`, `encryption`
- Access control:
- `allowlistOnly`
- `groupPolicy`: `open|allowlist|disabled`
- `groupAllowFrom`
- `dm.policy`: `pairing|allowlist|open|disabled`
- `dm.allowFrom`
- Room policy:
- `groups` (preferred) and `rooms` (legacy alias)
- room fields: `enabled`, `allow`, `requireMention`, `tools`, `autoReply`, `users`, `skills`, `systemPrompt`
- Reply/thread behavior:
- `replyToMode`: `off|first|all`
- `threadReplies`: `off|inbound|always`
- Output shaping:
- `markdown`, `textChunkLimit`, `chunkMode`, `responsePrefix`
- Media + invites:
- `mediaMaxMb`
- `autoJoin`: `always|allowlist|off`
- `autoJoinAllowlist`
- Action gates:
- `actions.reactions|messages|pins|memberInfo|channelInfo|verification`
### 4.2 Defaults and effective behavior
- DM default policy: `pairing`.
- Group mention default: mention required in rooms unless room override allows auto-reply.
- `replyToMode` default: `off`.
- `threadReplies` default: `inbound`.
- `autoJoin` default: `always`.
- Legacy global hard text max remains 4000 chars per chunk for matrix sends/replies.
- When `allowlistOnly=true`, policies are effectively tightened:
- group `open` behaves as `allowlist`
- DM policy behaves as `allowlist` unless explicitly disabled.
## 5. Account Model + Resolution (MUST)
- Account listing/resolution behavior in `extensions/matrix/src/matrix/accounts.ts`:
- supports top-level single account fallback (`default` account semantics).
- supports per-account map and normalized account IDs.
- per-account config deep-merges known nested sections (`dm`, `actions`) over base config.
- Account configured state logic parity:
- configured when homeserver exists and one of:
- access token
- userId+password
- matching stored credentials.
## 6. Auth + Client Bootstrap (MUST)
Legacy auth behavior in `extensions/matrix/src/matrix/client/config.ts`:
- Config/env resolution precedence:
- config values override env values.
- env vars: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN`, `MATRIX_PASSWORD`, `MATRIX_REGISTER`, `MATRIX_DEVICE_ID`, `MATRIX_DEVICE_NAME`.
- Token-first behavior:
- with access token, `whoami` resolves missing `userId` and/or `deviceId`.
- Credential cache behavior:
- reuses cached credentials when config matches homeserver+user (or homeserver-only token flow).
- updates `lastUsedAt` when reused.
- Password login behavior:
- login with `m.login.password` when no token.
- Register mode behavior:
- if login fails and `register=true`, attempts registration and then login-equivalent token flow.
- registration mode prepares backup snapshot and finalizes config by turning off `register` and removing stale inline token.
- Bun runtime must be rejected (Node required).
## 7. Runtime/Connection Lifecycle (MUST)
- Gateway startup path (`channel.ts` + `monitor/index.ts`) must:
- resolve auth,
- resolve shared client,
- attach monitor handlers,
- start sync,
- report runtime status fields.
- Shutdown behavior:
- client is stopped on abort,
- active client reference cleared.
- Startup lock behavior:
- startup import race is serialized via lock in `channel.ts`.
## 8. Inbound Event Processing (MUST)
Legacy handler logic: `extensions/matrix/src/matrix/monitor/handler.ts`.
### 8.1 Event eligibility
- Processes:
- `m.room.message`
- poll start events (`m.poll.start` + MSC aliases)
- location events (`m.location` and location msgtype)
- Ignores:
- redacted events
- self-sent events
- old pre-startup events
- edit relation events (`m.replace`)
- encrypted raw payloads (expects decrypted bridge events)
### 8.2 DM/group detection
- DM detection chain (`monitor/direct.ts`):
- `m.direct` cache,
- member-count heuristic (2 users),
- `is_direct` member-state fallback.
### 8.3 Access control + allowlists
- DM policy behavior:
- `disabled`: no DM processing.
- `open`: process all DMs.
- `allowlist`: process only matching allowlist.
- `pairing`: create pairing request/code for unauthorized sender and send approval instructions.
- Group policy behavior:
- `disabled`: ignore rooms.
- `allowlist`: room must exist in allowlisted rooms map (or wildcard) and pass optional sender constraints.
- `open`: allow rooms, still mention-gated by default.
- Group sender gating:
- room-level `users` allowlist if configured.
- `groupAllowFrom` fallback when room users list not set.
### 8.4 Mention + command gate behavior
- Mention detection parity:
- `m.mentions.user_ids`
- `m.mentions.room`
- `formatted_body` matrix.to links (plain and URL-encoded)
- mention regex patterns from core mention config
- Default room behavior requires mention unless room policy overrides.
- Control command bypass behavior:
- unauthorized control commands are dropped in group contexts.
### 8.5 Input normalization
- Poll start events converted to normalized text payload.
- Location events converted to normalized location text + context fields.
- mxc media downloaded (and decrypted when file payload present) with max-byte enforcement.
### 8.6 Context/session/routing
- Builds context with matrix-specific fields:
- From/To/SessionKey/MessageSid/ReplyToId/MessageThreadId/MediaPath/etc.
- Resolves per-agent route via core routing.
- Persists inbound session metadata and updates last-route for DM contexts.
### 8.7 Reply delivery
- Typing indicators start/stop around reply dispatch.
- Reply prefix/model-selection behavior uses core reply options.
- Room-level `skills` filter and `systemPrompt` are applied.
- Reply delivery semantics:
- `replyToMode` controls how often replyTo is used (`off|first|all`).
- thread target suppresses plain replyTo fallback.
- chunking and markdown-table conversion parity required.
### 8.8 Side effects
- Optional ack reaction based on `messages.ackReaction` + scope rules.
- Read receipt sent for inbound event IDs.
- System event enqueued after successful reply.
## 9. Outbound Sending Contract (MUST)
Legacy send behavior: `extensions/matrix/src/matrix/send.ts` and `send/*`.
### 9.1 Text
- Requires text or media; empty text without media is error.
- Resolves target IDs from `matrix:/room:/channel:/user:/@user/#alias` forms.
- Markdown tables converted via core table mode.
- Markdown converted to Matrix HTML formatting.
- Chunking respects configured limit but hard-caps at 4000.
- Thread relation behavior:
- `threadId` -> `m.thread` relation.
- otherwise optional reply relation.
### 9.2 Media
- Loads media via core media loader with size limits.
- Upload behavior:
- encrypts media in encrypted rooms when crypto available.
- otherwise plain upload.
- Includes metadata:
- mimetype/size/duration,
- image dimensions/thumbnail when available.
- Voice behavior:
- if `audioAsVoice=true` and compatible audio, send as voice payload (`org.matrix.msc3245.voice`).
- Caption/follow-up behavior:
- first chunk is caption,
- remaining text chunks become follow-up messages.
### 9.3 Polls
- Supports `sendPoll` with MSC3381 payload (`m.poll.start`) + fallback text.
- Supports thread relation for polls when thread ID present.
### 9.4 Reactions + receipts + typing
- Supports sending reactions (`m.reaction` annotation).
- Supports typing state and read receipts.
## 10. Tool/Action Contract (MUST)
Legacy action adapter: `src/actions.ts`, `src/tool-actions.ts`, `src/matrix/actions/*`.
### 10.1 Action availability gates
- Baseline actions include `send` and poll path support.
- Optional gated actions:
- reactions: `react`, `reactions`
- messages: `read`, `edit`, `delete`
- pins: `pin`, `unpin`, `list-pins`
- member info: `member-info`
- channel info: `channel-info`
- verification: `permissions` (only with encryption enabled + gate enabled)
### 10.2 Action semantics
- Send/edit/delete/read messages behavior parity:
- edit uses `m.replace` + `m.new_content` conventions.
- read uses `/rooms/{room}/messages` with before/after pagination tokens.
- Reaction semantics parity:
- list aggregates count per emoji and unique users.
- remove only current-user reactions (optional emoji filter).
- Pin semantics parity:
- state event `m.room.pinned_events` update/read.
- list includes resolvable summarized events.
- Member info semantics parity:
- profile display name/avatar available,
- membership/power currently returned as `null` placeholders.
- Room info semantics parity:
- includes name/topic/canonicalAlias/memberCount where retrievable.
- Verification semantics parity:
- status/list/request/accept/cancel/start/generate-qr/scan-qr/sas/confirm/mismatch/confirm-qr flows.
## 11. Directory + Target Resolution (MUST)
### 11.1 Live directory
- Peer lookup uses Matrix user directory search endpoint.
- Group lookup behavior:
- alias input (`#...`) resolves via directory API,
- room ID input (`!...`) is accepted directly,
- otherwise scans joined rooms by room name.
### 11.2 Resolver behavior
- User resolver rules:
- full user IDs resolve directly,
- otherwise requires exact unique match from live directory.
- Group resolver rules:
- prefers exact match; otherwise first candidate with note.
- Room config key normalization behavior:
- supports `matrix:`/`room:`/`channel:` prefixes and canonical IDs.
## 12. Status + Probing (MUST)
- Probe behavior (`matrix/probe.ts`):
- validates homeserver + token,
- initializes client,
- resolves user via client and returns elapsed time/status.
- Channel status snapshot includes:
- configured/baseUrl/running/last start-stop/error/probe/last probe/inbound/outbound fields.
## 13. Storage + Security + E2EE (MUST)
### 13.1 Credential/state paths
- Credentials persisted in state dir under `credentials/matrix`.
- Per-account credential filename semantics preserved.
- Matrix storage paths include account key + homeserver key + user key + token hash.
- Legacy storage migration behavior preserved.
### 13.2 HTTP hardening
- Matrix HTTP client behavior parity:
- blocks unexpected absolute endpoints,
- blocks cross-protocol redirects,
- strips auth headers on cross-origin redirect,
- supports request timeout.
### 13.3 Encryption
- Rust crypto initialization and bootstrap behavior preserved.
- Decryption bridge behavior preserved:
- encrypted event handling,
- failed decrypt retries,
- retry caps and signal-driven retry.
- Recovery key behavior preserved:
- persisted securely (0600),
- reused for secret storage callbacks,
- handles default key rebind and recreation when needed.
## 14. Onboarding UX Contract (SHOULD)
Legacy onboarding (`src/onboarding.ts`) should remain equivalent:
- checks matrix SDK availability and offers install flow,
- supports env-detected quick setup,
- supports token/password/register auth choice,
- validates homeserver URL and user ID format,
- supports DM policy and allowFrom prompt with user resolution,
- supports optional group policy and group room selection.
## 15. Known Legacy Quirks To Track (NEEDS UPDATING)
These should be explicitly reviewed during parity auditing (either preserve intentionally or fix intentionally):
- `supportsAction`/`poll` behavior in action adapter is non-obvious and should be validated end-to-end.
- Some account-aware callsites pass `accountId` through paths where underlying helpers may not consistently consume it.
- Legacy room/member info actions include placeholder/null fields (`altAliases`, `membership`, `powerLevel`).
## 16. Parity Test Matrix
Use this checklist while validating `extensions/matrix-js`:
- [ ] Config schema keys and defaults are equivalent.
- [ ] Auth precedence (config/env/token/cache/password/register) matches legacy.
- [ ] Bun runtime rejection behavior matches legacy.
- [ ] Startup/shutdown lifecycle and status updates match legacy.
- [ ] DM detection heuristics match legacy.
- [ ] DM/group allowlist + pairing flow matches legacy.
- [ ] Mention detection (`m.mentions`, formatted_body links, regex) matches legacy.
- [ ] Control-command authorization gate behavior matches legacy.
- [ ] Inbound poll normalization matches legacy.
- [ ] Inbound location normalization matches legacy.
- [ ] Inbound media download/decrypt/size-limit behavior matches legacy.
- [ ] Reply dispatch + typing + ack reaction + read receipts match legacy.
- [ ] Thread handling (`threadReplies`) matches legacy.
- [ ] `replyToMode` handling for single/multi reply flows matches legacy.
- [ ] Outbound text chunking, markdown, and formatting behavior matches legacy.
- [ ] Outbound media encryption/voice/thumbnail/duration behavior matches legacy.
- [ ] Outbound poll payload behavior matches legacy.
- [ ] Action gating and action semantics match legacy.
- [ ] Verification action flow and summary semantics match legacy.
- [ ] Directory live lookup + target resolution ambiguity handling matches legacy.
- [ ] Probe/status reporting fields match legacy.
- [ ] Storage layout and credential persistence semantics match legacy.
- [ ] HTTP hardening and decrypt retry behavior matches legacy.
## 17. Minimum Regression Commands
Run at least:
```bash
pnpm vitest extensions/matrix/src/**/*.test.ts
pnpm vitest extensions/matrix-js/src/**/*.test.ts
pnpm build
```
If behavior differs intentionally, document the delta under this spec with:
- reason,
- user impact,
- migration note,
- tests proving new intended behavior.

View File

@@ -0,0 +1,98 @@
import type {
GatewayRequestHandlerOptions,
OpenClawPluginApi,
} from "openclaw/plugin-sdk/matrix-js";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix-js";
import { matrixPlugin } from "./src/channel.js";
import { registerMatrixJsCli } from "./src/cli.js";
import {
bootstrapMatrixVerification,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "./src/matrix/actions/verification.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
id: "matrix-js",
name: "Matrix-js",
description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: err instanceof Error ? err.message : String(err) });
};
api.registerGatewayMethod(
"matrix-js.verify.recoveryKey",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const key = typeof params?.key === "string" ? params.key : "";
if (!key.trim()) {
respond(false, { error: "key required" });
return;
}
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const result = await verifyMatrixRecoveryKey(key, { accountId });
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.bootstrap",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const recoveryKey =
typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined;
const forceResetCrossSigning = params?.forceResetCrossSigning === true;
const result = await bootstrapMatrixVerification({
accountId,
recoveryKey,
forceResetCrossSigning,
});
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const includeRecoveryKey = params?.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey });
respond(true, status);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerCli(
({ program }) => {
registerMatrixJsCli({ program });
},
{ commands: ["matrix-js"] },
);
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "matrix-js",
"channels": ["matrix-js"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@openclaw/matrix-js",
"version": "2026.2.22",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "matrix-js",
"label": "Matrix-js",
"selectionLabel": "Matrix-js (plugin)",
"docsPath": "/channels/matrix-js",
"docsLabel": "matrix-js",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/matrix-js",
"localPath": "extensions/matrix-js",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,104 @@
import { sendMatrixMessage } from "../src/matrix/actions.js";
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function main() {
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
});
const targetUserId = process.argv[2]?.trim() || "@user:example.org";
const stamp = new Date().toISOString();
try {
const dmRoomCreate = (await client.doRequest(
"POST",
"/_matrix/client/v3/createRoom",
undefined,
{
is_direct: true,
invite: [targetUserId],
preset: "trusted_private_chat",
name: `OpenClaw DM Test ${stamp}`,
topic: "matrix-js basic DM messaging test",
},
)) as { room_id?: string };
const dmRoomId = dmRoomCreate.room_id?.trim() ?? "";
if (!dmRoomId) {
throw new Error("Failed to create DM room");
}
const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ??
{}) as Record<string, string[]>;
const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : [];
await client.setAccountData("m.direct", {
...currentDirect,
[targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)],
});
const dmByUserTarget = await sendMatrixMessage(
targetUserId,
`Matrix-js basic DM test (user target) ${stamp}`,
{ client },
);
const dmByRoomTarget = await sendMatrixMessage(
dmRoomId,
`Matrix-js basic DM test (room target) ${stamp}`,
{ client },
);
const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, {
invite: [targetUserId],
preset: "private_chat",
name: `OpenClaw Room Test ${stamp}`,
topic: "matrix-js basic room messaging test",
})) as { room_id?: string };
const roomId = roomCreate.room_id?.trim() ?? "";
if (!roomId) {
throw new Error("Failed to create room chat room");
}
const roomSend = await sendMatrixMessage(roomId, `Matrix-js basic room test ${stamp}`, {
client,
});
process.stdout.write(
`${JSON.stringify(
{
homeserver: base.homeserver,
senderUserId: base.userId,
targetUserId,
dm: {
roomId: dmRoomId,
userTargetMessageId: dmByUserTarget.messageId,
roomTargetMessageId: dmByRoomTarget.messageId,
},
room: {
roomId,
messageId: roomSend.messageId,
},
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(`BASIC_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,145 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { setMatrixRuntime } from "../src/runtime.js";
type EnvMap = Record<string, string>;
function loadEnvFile(filePath: string): EnvMap {
const out: EnvMap = {};
if (!fs.existsSync(filePath)) {
return out;
}
const raw = fs.readFileSync(filePath, "utf8");
for (const lineRaw of raw.split(/\r?\n/)) {
const line = lineRaw.trim();
if (!line || line.startsWith("#")) {
continue;
}
const idx = line.indexOf("=");
if (idx <= 0) {
continue;
}
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
function normalizeHomeserver(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
function chunkText(text: string, limit: number): string[] {
if (!text) {
return [];
}
if (text.length <= limit) {
return [text];
}
const out: string[] = [];
for (let i = 0; i < text.length; i += limit) {
out.push(text.slice(i, i + limit));
}
return out;
}
export type LiveHarnessConfig = {
homeserver: string;
userId: string;
password: string;
};
export function resolveLiveHarnessConfig(): LiveHarnessConfig {
const envFromFile = loadEnvFile(path.join(os.homedir(), ".openclaw", ".env"));
const homeserver = normalizeHomeserver(
process.env.MATRIX_HOMESERVER ?? envFromFile.MATRIX_HOMESERVER ?? "",
);
const userId = process.env.MATRIX_USER_ID ?? envFromFile.MATRIX_USER_ID ?? "";
const password = process.env.MATRIX_PASSWORD ?? envFromFile.MATRIX_PASSWORD ?? "";
if (!homeserver || !userId || !password) {
throw new Error("Missing MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_PASSWORD");
}
return {
homeserver,
userId,
password,
};
}
export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): {
channels: {
"matrix-js": {
homeserver: string;
userId: string;
password: string;
encryption: false;
};
};
} {
const pluginCfg = {
channels: {
"matrix-js": {
homeserver: cfg.homeserver,
userId: cfg.userId,
password: cfg.password,
encryption: false as const,
},
},
};
setMatrixRuntime({
config: {
loadConfig: () => pluginCfg,
},
state: {
resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-js-live-harness-state"),
},
channel: {
text: {
resolveMarkdownTableMode: () => "off",
convertMarkdownTables: (text: string) => text,
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "off",
chunkMarkdownTextWithMode: (text: string, limit: number) => chunkText(text, limit),
},
},
media: {
mediaKindFromMime: (mime: string) => {
const value = (mime || "").toLowerCase();
if (value.startsWith("image/")) {
return "image";
}
if (value.startsWith("audio/")) {
return "audio";
}
if (value.startsWith("video/")) {
return "video";
}
return "document";
},
isVoiceCompatibleAudio: () => false,
loadWebMedia: async () => ({
buffer: Buffer.from("matrix-js harness media payload\n", "utf8"),
contentType: "text/plain",
fileName: "matrix-js-harness.txt",
kind: "document" as const,
}),
},
} as never);
return pluginCfg;
}

View File

@@ -0,0 +1,126 @@
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
type MatrixCryptoProbe = {
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
bootstrapCrossSigning?: (opts: {
setupNewCrossSigning?: boolean;
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
}) => Promise<void>;
};
async function main() {
const base = resolveLiveHarnessConfig();
const cfg = installLiveHarnessRuntime(base);
(cfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: cfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
});
const initCrypto = (client as unknown as { initializeCryptoIfNeeded?: () => Promise<void> })
.initializeCryptoIfNeeded;
if (typeof initCrypto === "function") {
await initCrypto.call(client);
}
const inner = (client as unknown as { client?: { getCrypto?: () => unknown } }).client;
const crypto = (inner?.getCrypto?.() ?? null) as MatrixCryptoProbe | null;
const userId = auth.userId;
const password = auth.password;
const out: Record<string, unknown> = {
userId,
hasCrypto: Boolean(crypto),
readyBefore: null,
hasKeysBefore: null,
bootstrap: "skipped",
readyAfter: null,
hasKeysAfter: null,
queryHasMaster: null,
queryHasSelfSigning: null,
queryHasUserSigning: null,
};
if (!crypto || !crypto.bootstrapCrossSigning) {
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
return;
}
if (typeof crypto.isCrossSigningReady === "function") {
out.readyBefore = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`);
}
if (typeof crypto.userHasCrossSigningKeys === "function") {
out.hasKeysBefore = await crypto
.userHasCrossSigningKeys(userId, true)
.catch((err) => `error:${String(err)}`);
}
const authUploadDeviceSigningKeys = async <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
): Promise<T> => {
try {
return await makeRequest(null);
} catch {
try {
return await makeRequest({ type: "m.login.dummy" });
} catch {
if (!password?.trim()) {
throw new Error("Missing password for m.login.password fallback");
}
return await makeRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: userId },
password,
});
}
}
};
try {
await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys });
out.bootstrap = "ok";
} catch (err) {
out.bootstrap = "error";
out.bootstrapError = err instanceof Error ? err.message : String(err);
}
if (typeof crypto.isCrossSigningReady === "function") {
out.readyAfter = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`);
}
if (typeof crypto.userHasCrossSigningKeys === "function") {
out.hasKeysAfter = await crypto
.userHasCrossSigningKeys(userId, true)
.catch((err) => `error:${String(err)}`);
}
const query = (await client.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, {
device_keys: { [userId]: [] },
})) as {
master_keys?: Record<string, unknown>;
self_signing_keys?: Record<string, unknown>;
user_signing_keys?: Record<string, unknown>;
};
out.queryHasMaster = Boolean(query.master_keys?.[userId]);
out.queryHasSelfSigning = Boolean(query.self_signing_keys?.[userId]);
out.queryHasUserSigning = Boolean(query.user_signing_keys?.[userId]);
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
client.stop();
}
main().catch((err) => {
process.stderr.write(
`CROSS_SIGNING_PROBE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});

View File

@@ -0,0 +1,28 @@
import { bootstrapMatrixVerification } from "../src/matrix/actions/verification.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function main() {
const recoveryKeyArg = process.argv[2];
const forceResetCrossSigning = process.argv.includes("--force-reset-cross-signing");
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const result = await bootstrapMatrixVerification({
recoveryKey: recoveryKeyArg?.trim() || undefined,
forceResetCrossSigning,
});
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (!result.success) {
process.exitCode = 1;
}
}
main().catch((err) => {
process.stderr.write(
`E2EE_BOOTSTRAP_ERROR: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});

View File

@@ -0,0 +1,65 @@
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function main() {
const roomId = process.argv[2]?.trim();
const eventId = process.argv[3]?.trim();
if (!roomId) {
throw new Error(
"Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-room-state.ts <roomId> [eventId]",
);
}
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
});
try {
const encryptionState = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
)) as { algorithm?: string; rotation_period_ms?: number; rotation_period_msgs?: number };
let eventType: string | null = null;
if (eventId) {
const event = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`,
)) as { type?: string };
eventType = event.type ?? null;
}
process.stdout.write(
`${JSON.stringify(
{
roomId,
encryptionState,
eventId: eventId ?? null,
eventType,
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(
`E2EE_ROOM_STATE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});

View File

@@ -0,0 +1,99 @@
import { sendMatrixMessage } from "../src/matrix/actions.js";
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
const roomId = process.argv[2]?.trim();
const useFullBootstrap = process.argv.includes("--full-bootstrap");
const startupTimeoutMs = 45_000;
const settleMsRaw = Number.parseInt(process.argv[3] ?? "4000", 10);
const settleMs = Number.isFinite(settleMsRaw) && settleMsRaw >= 0 ? settleMsRaw : 4000;
if (!roomId) {
throw new Error(
"Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-send-room.ts <roomId> [settleMs] [--full-bootstrap]",
);
}
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
});
const stamp = new Date().toISOString();
try {
if (!useFullBootstrap) {
const bootstrapper = (
client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise<void> } }
).cryptoBootstrapper;
if (bootstrapper?.bootstrap) {
bootstrapper.bootstrap = async () => {};
}
}
await Promise.race([
client.start(),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`,
),
);
}, startupTimeoutMs);
}),
]);
if (settleMs > 0) {
await delay(settleMs);
}
const sent = await sendMatrixMessage(
roomId,
`Matrix-js E2EE existing-room test ${stamp} (settleMs=${settleMs})`,
{ client },
);
const event = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(sent.messageId)}`,
)) as { type?: string };
process.stdout.write(
`${JSON.stringify(
{
roomId,
messageId: sent.messageId,
storedEventType: event.type ?? null,
fullBootstrap: useFullBootstrap,
settleMs,
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(
`E2EE_SEND_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});

View File

@@ -0,0 +1,169 @@
import { sendMatrixMessage } from "../src/matrix/actions.js";
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
const MEGOLM_ALG = "m.megolm.v1.aes-sha2";
type MatrixEventLike = {
type?: string;
};
async function main() {
const targetUserId = process.argv[2]?.trim() || "@user:example.org";
const useFullBootstrap = process.argv.includes("--full-bootstrap");
const startupTimeoutMs = 45_000;
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
// Enable encryption for this run only.
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
});
const stamp = new Date().toISOString();
try {
if (!useFullBootstrap) {
const bootstrapper = (
client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise<void> } }
).cryptoBootstrapper;
if (bootstrapper?.bootstrap) {
bootstrapper.bootstrap = async () => {};
}
}
await Promise.race([
client.start(),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`,
),
);
}, startupTimeoutMs);
}),
]);
const dmRoomCreate = (await client.doRequest(
"POST",
"/_matrix/client/v3/createRoom",
undefined,
{
is_direct: true,
invite: [targetUserId],
preset: "trusted_private_chat",
name: `OpenClaw E2EE DM ${stamp}`,
topic: "matrix-js E2EE DM test",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: MEGOLM_ALG,
},
},
],
},
)) as { room_id?: string };
const dmRoomId = dmRoomCreate.room_id?.trim() ?? "";
if (!dmRoomId) {
throw new Error("Failed to create encrypted DM room");
}
const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ??
{}) as Record<string, string[]>;
const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : [];
await client.setAccountData("m.direct", {
...currentDirect,
[targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)],
});
const dmSend = await sendMatrixMessage(
dmRoomId,
`Matrix-js E2EE DM test ${stamp}\nPlease reply here so I can validate decrypt/read.`,
{
client,
},
);
const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, {
invite: [targetUserId],
preset: "private_chat",
name: `OpenClaw E2EE Room ${stamp}`,
topic: "matrix-js E2EE room test",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: MEGOLM_ALG,
},
},
],
})) as { room_id?: string };
const roomId = roomCreate.room_id?.trim() ?? "";
if (!roomId) {
throw new Error("Failed to create encrypted room chat");
}
const roomSend = await sendMatrixMessage(
roomId,
`Matrix-js E2EE room test ${stamp}\nPlease reply here too.`,
{
client,
},
);
const dmRaw = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(dmRoomId)}/event/${encodeURIComponent(dmSend.messageId)}`,
)) as MatrixEventLike;
const roomRaw = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(roomSend.messageId)}`,
)) as MatrixEventLike;
process.stdout.write(
`${JSON.stringify(
{
homeserver: base.homeserver,
senderUserId: base.userId,
targetUserId,
encryptionAlgorithm: MEGOLM_ALG,
fullBootstrap: useFullBootstrap,
dm: {
roomId: dmRoomId,
messageId: dmSend.messageId,
storedEventType: dmRaw.type ?? null,
},
room: {
roomId,
messageId: roomSend.messageId,
storedEventType: roomRaw.type ?? null,
},
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(`E2EE_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,57 @@
import {
getMatrixEncryptionStatus,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "../src/matrix/actions.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function main() {
const includeRecoveryKey = process.argv.includes("--include-recovery-key");
const verifyStoredRecoveryKey = process.argv.includes("--verify-stored-recovery-key");
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const verification = await getMatrixVerificationStatus({
includeRecoveryKey,
});
const encryption = await getMatrixEncryptionStatus({
includeRecoveryKey,
});
let recoveryVerificationResult: unknown = null;
if (verifyStoredRecoveryKey) {
const key =
verification && typeof verification === "object" && "recoveryKey" in verification
? (verification as { recoveryKey?: string | null }).recoveryKey
: null;
if (key?.trim()) {
recoveryVerificationResult = await verifyMatrixRecoveryKey(key);
} else {
recoveryVerificationResult = {
success: false,
error: "No stored recovery key returned (use --include-recovery-key)",
};
}
}
process.stdout.write(
`${JSON.stringify(
{
homeserver: base.homeserver,
userId: base.userId,
verification,
encryption,
recoveryVerificationResult,
},
null,
2,
)}\n`,
);
}
main().catch((err) => {
process.stderr.write(`E2EE_STATUS_ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,122 @@
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
type MatrixRawEvent = {
event_id?: string;
type?: string;
sender?: string;
room_id?: string;
origin_server_ts?: number;
content?: {
body?: string;
msgtype?: string;
};
};
async function main() {
const roomId = process.argv[2]?.trim();
const targetUserId = process.argv[3]?.trim() || "@user:example.org";
const timeoutSecRaw = Number.parseInt(process.argv[4] ?? "120", 10);
const timeoutMs =
(Number.isFinite(timeoutSecRaw) && timeoutSecRaw > 0 ? timeoutSecRaw : 120) * 1000;
const useFullBootstrap = process.argv.includes("--full-bootstrap");
const startupTimeoutMs = 45_000;
if (!roomId) {
throw new Error(
"Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-wait-reply.ts <roomId> [targetUserId] [timeoutSec] [--full-bootstrap]",
);
}
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
});
try {
if (!useFullBootstrap) {
const bootstrapper = (
client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise<void> } }
).cryptoBootstrapper;
if (bootstrapper?.bootstrap) {
bootstrapper.bootstrap = async () => {};
}
}
await Promise.race([
client.start(),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`,
),
);
}, startupTimeoutMs);
}),
]);
const found = await new Promise<MatrixRawEvent | null>((resolve) => {
const timer = setTimeout(() => {
resolve(null);
}, timeoutMs);
client.on("room.message", (eventRoomId, event) => {
const rid = String(eventRoomId || "");
const raw = event as MatrixRawEvent;
if (rid !== roomId) {
return;
}
if ((raw.sender ?? "").trim() !== targetUserId) {
return;
}
if ((raw.type ?? "").trim() !== "m.room.message") {
return;
}
clearTimeout(timer);
resolve(raw);
});
});
process.stdout.write(
`${JSON.stringify(
{
roomId,
targetUserId,
timeoutMs,
found: Boolean(found),
message: found
? {
eventId: found.event_id ?? null,
type: found.type ?? null,
sender: found.sender ?? null,
timestamp: found.origin_server_ts ?? null,
text: found.content?.body ?? null,
msgtype: found.content?.msgtype ?? null,
}
: null,
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(
`E2EE_WAIT_REPLY_ERROR: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});

View File

@@ -0,0 +1,56 @@
import { readMatrixMessages } from "../src/matrix/actions.js";
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
async function main() {
const roomId = process.argv[2]?.trim();
if (!roomId) {
throw new Error("Usage: bun extensions/matrix-js/scripts/live-read-room.ts <roomId> [limit]");
}
const requestedLimit = Number.parseInt(process.argv[3] ?? "30", 10);
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : 30;
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
});
try {
const result = await readMatrixMessages(roomId, { client, limit });
const compact = result.messages.map((msg) => ({
id: msg.eventId,
sender: msg.sender,
ts: msg.timestamp,
text: msg.body ?? "",
}));
process.stdout.write(
`${JSON.stringify(
{
roomId,
count: compact.length,
messages: compact,
nextBatch: result.nextBatch ?? null,
prevBatch: result.prevBatch ?? null,
},
null,
2,
)}\n`,
);
} finally {
client.stop();
}
}
main().catch((err) => {
process.stderr.write(`READ_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,242 @@
import {
createActionGate,
readNumberParam,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelToolSend,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { handleMatrixAction } from "./tool-actions.js";
import type { CoreConfig } from "./types.js";
export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions);
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
if (gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (gate("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (gate("memberInfo")) {
actions.add("member-info");
}
if (gate("channelInfo")) {
actions.add("channel-info");
}
if (account.config.encryption === true && gate("verification")) {
actions.add("permissions");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "poll",
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
return { to };
},
handleAction: async (ctx: ChannelMessageActionContext) => {
const { action, params, cfg } = ctx;
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
return await handleMatrixAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyTo ?? undefined,
threadId: threadId ?? undefined,
},
cfg as CoreConfig,
);
}
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 handleMatrixAction(
{
action: "react",
roomId: resolveRoomId(),
messageId,
emoji,
remove,
},
cfg as CoreConfig,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleMatrixAction(
{
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
},
cfg as CoreConfig,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleMatrixAction(
{
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
},
cfg as CoreConfig,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await handleMatrixAction(
{
action: "editMessage",
roomId: resolveRoomId(),
messageId,
content,
},
cfg as CoreConfig,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleMatrixAction(
{
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
},
cfg as CoreConfig,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleMatrixAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
roomId: resolveRoomId(),
messageId,
},
cfg as CoreConfig,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await handleMatrixAction(
{
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
},
cfg as CoreConfig,
);
}
if (action === "channel-info") {
return await handleMatrixAction(
{
action: "channelInfo",
roomId: resolveRoomId(),
},
cfg as CoreConfig,
);
}
if (action === "permissions") {
const operation = (
readStringParam(params, "operation") ??
readStringParam(params, "mode") ??
"verification-list"
)
.trim()
.toLowerCase();
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 handleMatrixAction(
{
...params,
action: resolvedAction,
},
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider matrix-js.`);
},
};

View File

@@ -0,0 +1,397 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "./channel.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix directory", () => {
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
"matrix-js": {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
"matrix-js": {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("resolves group mention policy from account config", () => {
const cfg = {
channels: {
"matrix-js": {
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);
});
it("writes matrix-js non-default account credentials under channels.matrix-js.accounts", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://default.example.org",
accessToken: "default-token",
},
},
} 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-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "default-token",
homeserver: "https://default.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
});
});
it("writes default matrix-js account credentials under channels.matrix-js.accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
});
});
it("migrates legacy top-level matrix-js credentials into accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
register: false,
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.encryption).toBeUndefined();
expect((updated.channels?.["matrix-js"] as Record<string, unknown>)?.register).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
});
});
it("merges top-level object defaults into accounts.default during migration", () => {
const cfg = {
channels: {
"matrix-js": {
dm: {
policy: "allowlist",
allowFrom: ["@legacy:example.org"],
},
accounts: {
default: {
dm: {
policy: "pairing",
},
},
},
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.dm).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default?.dm).toMatchObject({
policy: "pairing",
allowFrom: ["@legacy:example.org"],
});
});
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("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-js": {
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",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBe("new-password");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBeUndefined();
});
it("clears stale password when switching an account to token auth", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "old-password",
},
},
},
},
} 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-js"]?.accounts?.default?.accessToken).toBe("new-token");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
});
});

View File

@@ -0,0 +1,490 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelSetupInput,
type ChannelPlugin,
} from "openclaw/plugin-sdk/matrix-js";
import { matrixMessageActions } from "./actions.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
} from "./group-mentions.js";
import {
listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixAuth,
resolveScopedMatrixEnvConfig,
} from "./matrix/client.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.js";
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.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 meta = {
id: "matrix-js",
label: "Matrix-js",
selectionLabel: "Matrix-js (plugin)",
docsPath: "/channels/matrix-js",
docsLabel: "matrix-js",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
};
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim();
if (!normalized) {
return undefined;
}
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim();
}
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
return stripped || undefined;
}
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
const trimmed = avatarUrl?.trim();
return trimmed ? trimmed : undefined;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix-js",
meta,
onboarding: matrixOnboardingAdapter,
pairing: {
idLabel: "matrixUserId",
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix-js"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
clearBaseFields: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => {
const accountId = account.accountId;
const prefix =
accountId && accountId !== "default"
? `channels.matrix-js.accounts.${accountId}.dm`
: "channels.matrix-js.dm";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${prefix}.policy`,
allowFromPath: `${prefix}.allowFrom`,
approveHint: formatPairingApproveHint("matrix-js"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: (cfg as CoreConfig).channels?.["matrix-js"] !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}
return [
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix-js.groupPolicy="allowlist" + channels.matrix-js.groups (and optionally channels.matrix-js.groupAllowFrom) to restrict rooms.',
];
},
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: currentTarget?.trim() || undefined,
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
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: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) {
return `user:${cleaned}`;
}
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
if (raw.startsWith("!")) {
return `room:${raw}`;
}
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId, input }) =>
normalizeAccountId(accountId?.trim() || input?.name?.trim()),
resolveBindingAccountId: ({ agentId, accountId }) =>
normalizeAccountId(accountId?.trim() || agentId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix-js",
accountId,
name,
alwaysUseAccounts: true,
}),
validateInput: ({ accountId, input }) => {
const avatarUrl = resolveAvatarInput(input);
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
}
if (input.useEnv) {
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
const keys = getMatrixScopedEnvVarNames(accountId);
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
}
return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) {
return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig);
const namedConfig = applyAccountNameToChannelSection({
cfg: migratedConfig,
channelKey: "matrix-js",
accountId,
name: input.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "matrix-js",
});
if (input.useEnv) {
return setAccountEnabledInConfigSection({
cfg: next as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled: true,
}) as CoreConfig;
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
return updateMatrixAccountConfig(next as CoreConfig, accountId, {
homeserver: input.homeserver?.trim(),
userId: password && !userId ? null : userId,
accessToken: accessToken || (password ? null : undefined),
password: password || (accessToken ? null : undefined),
deviceName: input.deviceName?.trim(),
avatarUrl: resolveAvatarInput(input),
initialSyncLimit: input.initialSyncLimit,
});
},
},
outbound: matrixOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "matrix-js",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs, cfg }) => {
try {
const auth = await resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
userId: auth.userId,
timeoutMs,
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
elapsedMs: 0,
};
}
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastProbeAt: runtime?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
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/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/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},
};

View File

@@ -0,0 +1,527 @@
import { Command } from "commander";
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const matrixSetupApplyAccountConfigMock = vi.fn();
const matrixSetupValidateInputMock = vi.fn();
const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
vi.mock("./matrix/client/logging.js", () => ({
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
}));
vi.mock("./matrix/actions/profile.js", () => ({
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
}));
vi.mock("./channel.js", () => ({
matrixPlugin: {
setup: {
applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args),
validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args),
},
},
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args),
writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args),
},
}),
}));
let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli;
function buildProgram(): Command {
const program = new Command();
registerMatrixJsCli({ program });
return program;
}
function formatExpectedLocalTimestamp(value: string): string {
return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value;
}
describe("matrix-js CLI verification commands", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
process.exitCode = undefined;
({ registerMatrixJsCli } = await import("./cli.js"));
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
matrixSetupValidateInputMock.mockReturnValue(null);
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
updateMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
});
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: false,
error: "invalid key",
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
bootstrapMatrixVerificationMock.mockResolvedValue({
success: false,
error: "bootstrap failed",
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
success: false,
error: "missing backup key",
backupVersion: null,
imported: 0,
total: 0,
loadedFromSecretStorage: false,
backup: {
serverVersion: "1",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
},
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("adds a matrix-js account and prints a binding hint", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
matrixSetupApplyAccountConfigMock.mockImplementation(
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
...cfg,
channels: {
...(cfg.channels as Record<string, unknown> | undefined),
"matrix-js": {
accounts: {
[accountId]: {
homeserver: "https://matrix.example.org",
},
},
},
},
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"account",
"add",
"--account",
"Ops",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@ops:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
input: expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
password: "secret",
}),
}),
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
"matrix-js": {
accounts: {
ops: expect.objectContaining({
homeserver: "https://matrix.example.org",
}),
},
},
},
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops");
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:ops",
);
});
it("uses --name as fallback account id and prints account-scoped config path", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"account",
"add",
"--name",
"Main Bot",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@main:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main-bot",
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.main-bot");
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main-bot",
displayName: "Main Bot",
}),
);
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:main-bot",
);
});
it("sets profile name and avatar via profile set command", async () => {
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"profile",
"set",
"--account",
"alerts",
"--name",
"Alerts Bot",
"--avatar-url",
"mxc://example/avatar",
],
{ from: "user" },
);
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "alerts",
displayName: "Alerts Bot",
avatarUrl: "mxc://example/avatar",
}),
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith("Account: alerts");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.alerts");
});
it("returns JSON errors for invalid account setup input", async () => {
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
const program = buildProgram();
await program.parseAsync(["matrix-js", "account", "add", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('"error": "Matrix requires --homeserver"'),
);
});
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
process.exitCode = 0;
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(0);
});
it("prints local timezone timestamps for verify status output in verbose mode", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: recoveryCreatedAt,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status", "--verbose"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
});
it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
const verifiedAt = "2026-02-25T20:14:00.000Z";
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyId: "SSSS",
recoveryKeyCreatedAt: recoveryCreatedAt,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
},
crossSigning: {
published: true,
masterKeyPublished: true,
selfSigningKeyPublished: true,
userSigningKeyPublished: true,
},
pendingVerifications: 0,
cryptoBootstrap: {},
});
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: true,
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
recoveryKeyStored: true,
recoveryKeyId: "SSSS",
recoveryKeyCreatedAt: recoveryCreatedAt,
verifiedAt,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--verbose"], {
from: "user",
});
await program.parseAsync(["matrix-js", "verify", "device", "valid-key", "--verbose"], {
from: "user",
});
expect(console.log).toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).toHaveBeenCalledWith(
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
);
});
it("keeps default output concise when verbose is not provided", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: recoveryCreatedAt,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).not.toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0");
expect(console.log).not.toHaveBeenCalledWith("Diagnostics:");
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet");
});
it("shows explicit backup issue in default status output", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
backup: {
serverVersion: "5256",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
);
expect(console.log).toHaveBeenCalledWith(
"- Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
expect(console.log).not.toHaveBeenCalledWith(
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
});
it("includes key load failure details in status output", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
backup: {
serverVersion: "5256",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: "secret storage key is not available",
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
);
});
it("prints backup health lines for verify backup status in verbose mode", async () => {
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
serverVersion: "2",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "backup", "status", "--verbose"], {
from: "user",
});
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
});
});

View File

@@ -0,0 +1,871 @@
import type { Command } from "commander";
import {
formatZonedTimestamp,
normalizeAccountId,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/matrix-js";
import { matrixPlugin } from "./channel.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
bootstrapMatrixVerification,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
let matrixJsCliExitScheduled = false;
function scheduleMatrixJsCliExit(): void {
if (matrixJsCliExitScheduled || process.env.VITEST) {
return;
}
matrixJsCliExitScheduled = true;
// matrix-js-sdk rust crypto can leave background async work alive after command completion.
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, 0);
}
function markCliFailure(): void {
process.exitCode = 1;
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function printJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function formatLocalTimestamp(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) {
return value;
}
return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value;
}
function printTimestamp(label: string, value: string | null | undefined): void {
const formatted = formatLocalTimestamp(value);
if (formatted) {
console.log(`${label}: ${formatted}`);
}
}
function printAccountLabel(accountId?: string): void {
console.log(`Account: ${normalizeAccountId(accountId)}`);
}
function configureCliLogMode(verbose: boolean): void {
setMatrixSdkLogMode(verbose ? "default" : "quiet");
}
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} must be an integer`);
}
return parsed;
}
type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
profile: {
attempted: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
error?: string;
};
};
async function addMatrixJsAccount(params: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
}): Promise<MatrixCliAccountAddResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const setup = matrixPlugin.setup;
if (!setup?.applyAccountConfig) {
throw new Error("Matrix-js account setup is unavailable.");
}
const input: ChannelSetupInput & { avatarUrl?: string } = {
name: params.name,
avatarUrl: params.avatarUrl,
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"),
useEnv: params.useEnv === true,
};
const accountId =
setup.resolveAccountId?.({
cfg,
accountId: params.account,
input,
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
const validationError = setup.validateInput?.({
cfg,
accountId,
input,
});
if (validationError) {
throw new Error(validationError);
}
const updated = setup.applyAccountConfig({
cfg,
accountId,
input,
}) as CoreConfig;
await runtime.config.writeConfigFile(updated as never);
const desiredDisplayName = input.name?.trim();
const desiredAvatarUrl = input.avatarUrl?.trim();
let profile: MatrixCliAccountAddResult["profile"] = {
attempted: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
if (desiredDisplayName || desiredAvatarUrl) {
try {
const synced = await updateMatrixOwnProfile({
accountId,
displayName: desiredDisplayName,
avatarUrl: desiredAvatarUrl,
});
let resolvedAvatarUrl = synced.resolvedAvatarUrl;
if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) {
const latestCfg = runtime.config.loadConfig() as CoreConfig;
const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, {
avatarUrl: synced.resolvedAvatarUrl,
});
await runtime.config.writeConfigFile(withAvatar as never);
resolvedAvatarUrl = synced.resolvedAvatarUrl;
}
profile = {
attempted: true,
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
};
} catch (err) {
profile = {
attempted: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
error: toErrorMessage(err),
};
}
}
return {
accountId,
configPath: `channels.matrix-js.accounts.${accountId}`,
useEnv: input.useEnv === true,
profile,
};
}
type MatrixCliProfileSetResult = {
accountId: string;
displayName: string | null;
avatarUrl: string | null;
profile: {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
};
async function setMatrixJsProfile(params: {
account?: string;
name?: string;
avatarUrl?: string;
}): Promise<MatrixCliProfileSetResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const displayName = params.name?.trim() || null;
const avatarUrl = params.avatarUrl?.trim() || null;
if (!displayName && !avatarUrl) {
throw new Error("Provide --name and/or --avatar-url.");
}
const synced = await updateMatrixOwnProfile({
accountId,
displayName: displayName ?? undefined,
avatarUrl: avatarUrl ?? undefined,
});
const persistedAvatarUrl =
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
? synced.resolvedAvatarUrl
: avatarUrl;
const updated = updateMatrixAccountConfig(cfg, accountId, {
name: displayName ?? undefined,
avatarUrl: persistedAvatarUrl ?? undefined,
});
await runtime.config.writeConfigFile(updated as never);
return {
accountId,
displayName,
avatarUrl: persistedAvatarUrl ?? null,
profile: {
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl: synced.resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
},
configPath: `channels.matrix-js.accounts.${accountId}`,
};
}
type MatrixCliCommandConfig<TResult> = {
verbose: boolean;
json: boolean;
run: () => Promise<TResult>;
onText: (result: TResult, verbose: boolean) => void;
onJson?: (result: TResult) => unknown;
shouldFail?: (result: TResult) => boolean;
errorPrefix: string;
onJsonError?: (message: string) => unknown;
};
async function runMatrixCliCommand<TResult>(
config: MatrixCliCommandConfig<TResult>,
): Promise<void> {
configureCliLogMode(config.verbose);
try {
const result = await config.run();
if (config.json) {
printJson(config.onJson ? config.onJson(result) : result);
} else {
config.onText(result, config.verbose);
}
if (config.shouldFail?.(result)) {
markCliFailure();
}
} catch (err) {
const message = toErrorMessage(err);
if (config.json) {
printJson(config.onJsonError ? config.onJsonError(message) : { error: message });
} else {
console.error(`${config.errorPrefix}: ${message}`);
}
markCliFailure();
} finally {
scheduleMatrixJsCliExit();
}
}
type MatrixCliBackupStatus = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
type MatrixCliVerificationStatus = {
encryptionEnabled: boolean;
verified: boolean;
userId: string | null;
deviceId: string | null;
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
pendingVerifications: number;
};
function resolveBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): MatrixCliBackupStatus {
return {
serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null,
activeVersion: status.backup?.activeVersion ?? null,
trusted: status.backup?.trusted ?? null,
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
keyLoadAttempted: status.backup?.keyLoadAttempted ?? false,
keyLoadError: status.backup?.keyLoadError ?? null,
};
}
type MatrixCliBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
type MatrixCliBackupIssue = {
code: MatrixCliBackupIssueCode;
summary: string;
message: string | null;
};
function yesNoUnknown(value: boolean | null): string {
if (value === true) {
return "yes";
}
if (value === false) {
return "no";
}
return "unknown";
}
function printBackupStatus(backup: MatrixCliBackupStatus): void {
console.log(`Backup server version: ${backup.serverVersion ?? "none"}`);
console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`);
console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`);
console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`);
console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`);
console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`);
if (backup.keyLoadError) {
console.log(`Backup key load error: ${backup.keyLoadError}`);
}
}
function printVerificationIdentity(status: {
userId: string | null;
deviceId: string | null;
}): void {
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
}
function printVerificationBackupSummary(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupSummary(resolveBackupStatus(status));
}
function printVerificationBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupStatus(resolveBackupStatus(status));
}
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
printGuidance(buildVerificationGuidance(status));
}
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
function printBackupSummary(backup: MatrixCliBackupStatus): void {
const issue = resolveBackupIssue(backup);
console.log(`Backup: ${issue.summary}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
}
}
function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const nextSteps = new Set<string>();
if (!status.verified) {
nextSteps.add("Run 'openclaw matrix-js verify device <key>' to verify this device.");
}
if (backupIssue.code === "missing-server-backup") {
nextSteps.add("Run 'openclaw matrix-js verify bootstrap' to create a room key backup.");
} else if (
backupIssue.code === "key-load-failed" ||
backupIssue.code === "key-not-loaded" ||
backupIssue.code === "inactive"
) {
if (status.recoveryKeyStored) {
nextSteps.add(
"Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
} else {
nextSteps.add(
"Store a recovery key with 'openclaw matrix-js verify device <key>', then run 'openclaw matrix-js verify backup restore'.",
);
}
} else if (backupIssue.code === "key-mismatch") {
nextSteps.add(
"Backup key mismatch on this device. Re-run 'openclaw matrix-js verify device <key>' with the matching recovery key.",
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
"Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
"Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.",
);
}
if (status.pendingVerifications > 0) {
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
}
return Array.from(nextSteps);
}
function printGuidance(lines: string[]): void {
if (lines.length === 0) {
return;
}
console.log("Next steps:");
for (const line of lines) {
console.log(`- ${line}`);
}
}
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
printVerificationBackupSummary(status);
if (backupIssue.message) {
console.log(`Backup issue: ${backupIssue.message}`);
}
if (verbose) {
console.log("Diagnostics:");
printVerificationIdentity(status);
printVerificationBackupStatus(status);
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${status.pendingVerifications}`);
} else {
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
}
printVerificationGuidance(status);
}
export function registerMatrixJsCli(params: { program: Command }): void {
const root = params.program
.command("matrix-js")
.description("Matrix-js channel utilities")
.addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix-js\n");
const account = root.command("account").description("Manage matrix-js channel accounts");
account
.command("add")
.description("Add or update a matrix-js account (wrapper around channel setup)")
.option("--account <id>", "Account ID (default: normalized --name, else default)")
.option("--name <name>", "Optional display name for this account")
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device display name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option(
"--use-env",
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
)
.option("--verbose", "Show setup details")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await addMatrixJsAccount({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
homeserver: options.homeserver,
userId: options.userId,
accessToken: options.accessToken,
password: options.password,
deviceName: options.deviceName,
initialSyncLimit: options.initialSyncLimit,
useEnv: options.useEnv === true,
}),
onText: (result) => {
console.log(`Saved matrix-js account: ${result.accountId}`);
console.log(`Config path: ${result.configPath}`);
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
if (result.profile.attempted) {
if (result.profile.error) {
console.error(`Profile sync warning: ${result.profile.error}`);
} else {
console.log(
`Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) {
console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`);
}
}
}
const bindHint = `openclaw agents bind --agent <id> --bind matrix-js:${result.accountId}`;
console.log(`Bind this account to an agent: ${bindHint}`);
},
errorPrefix: "Account setup failed",
});
},
);
const profile = root.command("profile").description("Manage Matrix-js bot profile");
profile
.command("set")
.description("Update Matrix profile display name and/or avatar")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--name <name>", "Profile display name")
.option("--avatar-url <url>", "Profile avatar URL (mxc:// or http(s) URL)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await setMatrixJsProfile({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
}),
onText: (result) => {
printAccountLabel(result.accountId);
console.log(`Config path: ${result.configPath}`);
console.log(
`Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.avatarUrl) {
console.log(`Avatar converted and saved as: ${result.avatarUrl}`);
}
},
errorPrefix: "Profile update failed",
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify
.command("status")
.description("Check Matrix-js device verification status")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--include-recovery-key", "Include stored recovery key in output")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
verbose?: boolean;
includeRecoveryKey?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await getMatrixVerificationStatus({
accountId: options.account,
includeRecoveryKey: options.includeRecoveryKey === true,
}),
onText: (status, verbose) => {
printAccountLabel(options.account);
printVerificationStatus(status, verbose);
},
errorPrefix: "Error",
});
},
);
const backup = verify.command("backup").description("Matrix room-key backup health and restore");
backup
.command("status")
.description("Show Matrix room-key backup status for this device")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }),
onText: (status, verbose) => {
printAccountLabel(options.account);
printBackupSummary(status);
if (verbose) {
printBackupStatus(status);
}
},
errorPrefix: "Backup status failed",
});
});
backup
.command("restore")
.description("Restore encrypted room keys from server backup")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await restoreMatrixRoomKeyBackup({
accountId: options.account,
recoveryKey: options.recoveryKey,
}),
onText: (result, verbose) => {
printAccountLabel(options.account);
console.log(`Restore success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
console.log(`Imported keys: ${result.imported}/${result.total}`);
printBackupSummary(result.backup);
if (verbose) {
console.log(
`Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`,
);
printTimestamp("Restored at", result.restoredAt);
printBackupStatus(result.backup);
}
},
shouldFail: (result) => !result.success,
errorPrefix: "Backup restore failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("bootstrap")
.description("Bootstrap Matrix-js cross-signing and device verification state")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await bootstrapMatrixVerification({
accountId: options.account,
recoveryKey: options.recoveryKey,
forceResetCrossSigning: options.forceResetCrossSigning === true,
}),
onText: (result, verbose) => {
printAccountLabel(options.account);
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
printVerificationIdentity(result.verification);
if (verbose) {
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
);
printVerificationBackupStatus(result.verification);
printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${result.pendingVerifications}`);
} else {
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
);
printVerificationBackupSummary(result.verification);
}
printVerificationGuidance({
...result.verification,
pendingVerifications: result.pendingVerifications,
});
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification bootstrap failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("device <key>")
.description("Verify device using a Matrix recovery key")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }),
onText: (result, verbose) => {
printAccountLabel(options.account);
if (!result.success) {
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
return;
}
console.log("Device verification completed successfully.");
printVerificationIdentity(result);
printVerificationBackupSummary(result);
if (verbose) {
printVerificationBackupStatus(result);
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
printTimestamp("Verified at", result.verifiedAt);
}
printVerificationGuidance({
...result,
pendingVerifications: 0,
});
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
}

View File

@@ -0,0 +1,133 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js";
type LegacyAccountField =
| "name"
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName"
| "initialSyncLimit"
| "encryption"
| "allowlistOnly"
| "groupPolicy"
| "groupAllowFrom"
| "replyToMode"
| "threadReplies"
| "textChunkLimit"
| "chunkMode"
| "responsePrefix"
| "mediaMaxMb"
| "autoJoin"
| "autoJoinAllowlist"
| "dm"
| "groups"
| "rooms"
| "actions";
const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"groupPolicy",
"groupAllowFrom",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeLegacyFieldIntoDefault(
current: MatrixAccountConfig[LegacyAccountField] | undefined,
legacy: MatrixAccountConfig[LegacyAccountField],
): MatrixAccountConfig[LegacyAccountField] {
if (current === undefined) {
return legacy;
}
if (isRecord(current) && isRecord(legacy)) {
return {
...legacy,
...current,
} as MatrixAccountConfig[LegacyAccountField];
}
return current;
}
function clearLegacyOnlyFields(nextMatrix: MatrixConfig): void {
// Legacy matrix-bot-sdk onboarding toggle; not used by matrix-js config.
delete (nextMatrix as Record<string, unknown>).register;
}
export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig {
const matrix = cfg.channels?.["matrix-js"];
if (!matrix) {
return cfg;
}
const defaultAccount = {
...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}),
} as MatrixAccountConfig;
let changed = false;
for (const field of LEGACY_ACCOUNT_FIELDS) {
const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined;
if (legacyValue === undefined) {
continue;
}
(
defaultAccount as Record<
LegacyAccountField,
MatrixAccountConfig[LegacyAccountField] | undefined
>
)[field] = mergeLegacyFieldIntoDefault(defaultAccount[field], legacyValue);
changed = true;
}
const registerPresent = (matrix as Record<string, unknown>).register !== undefined;
if (registerPresent) {
changed = true;
}
if (!changed) {
return cfg;
}
const nextMatrix = { ...matrix } as MatrixConfig;
for (const field of LEGACY_ACCOUNT_FIELDS) {
delete nextMatrix[field];
}
clearLegacyOnlyFields(nextMatrix);
nextMatrix.accounts = {
...matrix.accounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
};
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": nextMatrix,
},
};
}

View File

@@ -0,0 +1,66 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix-js";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),
})
.optional();
const matrixDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: z.array(allowFromEntry).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().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(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
describe("matrix directory live", () => {
const cfg = { channels: { "matrix-js": {} } };
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [] }),
text: async () => "",
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
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: "!room:example.org",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("returns no peer results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryPeersLive({
cfg,
query: " ",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryGroupsLive({
cfg,
query: "",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,208 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAuth } from "./matrix/client.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>>;
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && limit > 0 ? limit : 20;
}
async function resolveMatrixDirectoryContext(
params: MatrixDirectoryLiveParams,
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
const query = normalizeQuery(params.query);
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return { query, auth };
}
function createGroupDirectoryEntry(params: {
id: string;
name: string;
handle?: string;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name,
handle: params.handle,
} satisfies ChannelDirectoryEntry;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
method: "POST",
body: {
search_term: query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = entry.user_id?.trim();
if (!userId) {
return null;
}
return {
kind: "user",
id: userId,
name: entry.display_name?.trim() || undefined,
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
}
if (query.startsWith("!")) {
return [createGroupDirectoryEntry({ id: query, name: query })];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) {
continue;
}
if (!name.toLowerCase().includes(query)) {
continue;
}
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) {
break;
}
}
return results;
}

View File

@@ -0,0 +1,52 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
return value.toLowerCase().startsWith(prefix.toLowerCase())
? value.slice(prefix.length).trim()
: value;
}
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
const rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
return resolveMatrixRoomConfig({
rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).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,37 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
return cfg.channels?.["matrix-js"] ?? {};
}
export function resolveMatrixAccountsMap(
cfg: CoreConfig,
): Readonly<Record<string, MatrixAccountConfig>> {
const accounts = resolveMatrixBaseConfig(cfg).accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
return accounts;
}
export function findMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = resolveMatrixAccountsMap(cfg);
if (accounts[accountId] && typeof accounts[accountId] === "object") {
return accounts[accountId];
}
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
const candidate = accounts[key];
if (candidate && typeof candidate === "object") {
return candidate;
}
return undefined;
}
}
return undefined;
}

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
];
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
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-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
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-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
});

View File

@@ -0,0 +1,127 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountsMap,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
/** Merge account config with top-level defaults, preserving nested objects. */
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
const merged = { ...base, ...account };
// Deep-merge known nested objects so partial overrides inherit base fields
for (const key of ["dm", "actions"] as const) {
const b = base[key];
const o = account[key];
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
(merged as Record<string, unknown>)[key] = { ...b, ...o };
}
}
// Don't propagate the accounts map into the merged per-account config
delete (merged as Record<string, unknown>).accounts;
return merged;
}
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = resolveMatrixAccountsMap(cfg);
if (Object.keys(accounts).length === 0) {
return [];
}
// Normalize and de-duplicate keys so listing and resolution use the same semantics
return [
...new Set(
Object.keys(accounts)
.filter(Boolean)
.map((id) => normalizeAccountId(id)),
),
];
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Fall back to default if no accounts configured (legacy top-level config)
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
const ids = listMatrixAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
return findMatrixAccountConfig(cfg, accountId);
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env, accountId);
const hasStored =
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,
name: base.name?.trim() || undefined,
configured,
homeserver: resolved.homeserver || undefined,
userId: resolved.userId || undefined,
config: base,
};
}
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const accountConfig = resolveAccountConfig(params.cfg, accountId);
if (!accountConfig) {
return matrixBase;
}
// Merge account-specific config with top-level defaults so settings like
// groupPolicy and blockStreaming inherit when not overridden.
return mergeAccountConfig(matrixBase, accountConfig);
}
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,35 @@
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.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,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const loadConfigMock = vi.fn(() => ({}));
const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: loadConfigMock,
},
}),
}));
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: getActiveMatrixClientMock,
}));
vi.mock("../client.js", () => ({
createMatrixClient: createMatrixClientMock,
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: resolveMatrixAuthMock,
}));
let resolveActionClient: typeof import("./client.js").resolveActionClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
describe("resolveActionClient", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveActionClient } = await import("./client.js"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await resolveActionClient({ accountId: "default" });
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
autoBootstrapCrypto: false,
}),
);
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveActionClient({ accountId: "default" });
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId: opts.accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();
return { client, stopOnDone: true };
}
export type MatrixActionClientStopMode = "stop" | "persist";
export async function stopActionClient(
resolved: MatrixActionClient,
mode: MatrixActionClientStopMode = "stop",
): Promise<void> {
if (!resolved.stopOnDone) {
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
const resolved = await resolveActionClient(opts);
try {
return await run(resolved.client);
} finally {
await stopActionClient(resolved, mode);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
});
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
});
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit = resolveMatrixActionLimit(opts.limit, 20);
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
});
}

View File

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

View File

@@ -0,0 +1,79 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixActionClient,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
type ActionClient = MatrixActionClient["client"];
async function withResolvedPinRoom<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}
async function updateMatrixPins(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts,
update: (current: string[]) => string[],
): Promise<{ pinned: string[] }> {
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
const current = await readPinnedEvents(client, resolvedRoom);
const next = update(current);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
});
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.includes(messageId) ? current : [...current, messageId],
);
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.filter((id) => id !== messageId),
);
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
return await withResolvedPinRoom(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,29 @@
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;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.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,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
});
},
"persist",
);
}

View File

@@ -0,0 +1,109 @@
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: any) => ({
chunk: params.chunk.map((item) => ({
event_id: item.event_id ?? "",
sender: item.sender ?? "",
content: item.key
? {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$target",
key: item.key,
},
}
: {},
})),
}));
const getUserId = vi.fn(async () => params.userId ?? null);
const redactEvent = vi.fn(async () => undefined);
return {
client: {
doRequest,
getUserId,
redactEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
redactEvent,
};
}
describe("matrix reaction actions", () => {
it("aggregates reactions by key and unique sender", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
{ event_id: "$4", sender: "@bot:example.org" },
],
userId: "@bot:example.org",
});
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
expect.objectContaining({ limit: 2 }),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "👍",
count: 2,
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
}),
expect.objectContaining({
key: "👎",
count: 1,
users: ["@alice:example.org"],
}),
]),
);
});
it("removes only current-user reactions matching emoji filter", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
],
userId: "@me:example.org",
});
const result = await removeMatrixReactions("!room:example.org", "$msg", {
client,
emoji: "👍",
});
expect(result).toEqual({ removed: 1 });
expect(redactEvent).toHaveBeenCalledTimes(1);
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
});
it("returns removed=0 when current user id is unavailable", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
userId: null,
});
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual({ removed: 0 });
expect(redactEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,86 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import {
EventType,
RelationType,
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
type ReactionEventContent,
} from "./types.js";
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// Relations are queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
)) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) {
continue;
}
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
});
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
)) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) {
return { removed: 0 };
}
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) {
return true;
}
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
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,72 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } 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 withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
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,75 @@
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
if (raw.unsigned?.redacted_because) {
return null;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View File

@@ -0,0 +1,75 @@
import type { MatrixClient, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
};
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
userId: opts.auth.userId,
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off requests.
}
}
await client.start();
return client;
}

View File

@@ -0,0 +1,314 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: saveMatrixCredentialsMock,
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
});
describe("resolveMatrixAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("surfaces password login errors when account credentials are invalid", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
await expect(
resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Invalid username or password");
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("resolves missing whoami identity fields for token auth", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
});

View File

@@ -0,0 +1,17 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixConfig,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {
resolveSharedMatrixClient,
waitForMatrixSync,
stopSharedClient,
stopSharedClientForAccount,
} from "./client/shared.js";

View File

@@ -0,0 +1,362 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined,
password: clean(env.MATRIX_PASSWORD) || undefined,
deviceId: clean(env.MATRIX_DEVICE_ID) || undefined,
deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined,
};
}
function resolveMatrixEnvAccountToken(accountId: string): string {
return normalizeAccountId(accountId)
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
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`,
};
}
export function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]) || undefined,
password: clean(env[keys.password]) || undefined,
deviceId: clean(env[keys.deviceId]) || undefined,
deviceName: clean(env[keys.deviceName]) || undefined,
};
}
export function hasReadyMatrixEnvAuth(config: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
}): boolean {
const homeserver = clean(config.homeserver);
const userId = clean(config.userId);
const accessToken = clean(config.accessToken);
const password = clean(config.password);
return Boolean(homeserver && (accessToken || (userId && password)));
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const homeserver =
clean(matrix.homeserver) || defaultScopedEnv.homeserver || globalEnv.homeserver;
const userId = clean(matrix.userId) || defaultScopedEnv.userId || globalEnv.userId;
const accessToken =
clean(matrix.accessToken) || defaultScopedEnv.accessToken || globalEnv.accessToken || undefined;
const password =
clean(matrix.password) || defaultScopedEnv.password || globalEnv.password || undefined;
const deviceId =
clean(matrix.deviceId) || defaultScopedEnv.deviceId || globalEnv.deviceId || undefined;
const deviceName =
clean(matrix.deviceName) || defaultScopedEnv.deviceName || globalEnv.deviceName || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export function resolveMatrixConfigForAccount(
cfg: CoreConfig,
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const accountHomeserver = clean(
typeof account.homeserver === "string" ? account.homeserver : undefined,
);
const accountUserId = clean(typeof account.userId === "string" ? account.userId : undefined);
const accountAccessToken = clean(
typeof account.accessToken === "string" ? account.accessToken : undefined,
);
const accountPassword = clean(
typeof account.password === "string" ? account.password : undefined,
);
const accountDeviceId = clean(
typeof account.deviceId === "string" ? account.deviceId : undefined,
);
const accountDeviceName = clean(
typeof account.deviceName === "string" ? account.deviceName : undefined,
);
const homeserver =
accountHomeserver || scopedEnv.homeserver || clean(matrix.homeserver) || globalEnv.homeserver;
const userId = accountUserId || scopedEnv.userId || clean(matrix.userId) || globalEnv.userId;
const accessToken =
accountAccessToken ||
scopedEnv.accessToken ||
clean(matrix.accessToken) ||
globalEnv.accessToken ||
undefined;
const password =
accountPassword ||
scopedEnv.password ||
clean(matrix.password) ||
globalEnv.password ||
undefined;
const deviceId =
accountDeviceId ||
scopedEnv.deviceId ||
clean(matrix.deviceId) ||
globalEnv.deviceId ||
undefined;
const deviceName =
accountDeviceName ||
scopedEnv.deviceName ||
clean(matrix.deviceName) ||
globalEnv.deviceName ||
undefined;
const accountInitialSyncLimit =
typeof account.initialSyncLimit === "number"
? Math.max(0, Math.floor(account.initialSyncLimit))
: undefined;
const initialSyncLimit =
accountInitialSyncLimit ??
(typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined);
const encryption =
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const accountId = params?.accountId;
const resolved = accountId
? resolveMatrixConfigForAccount(cfg, accountId, env)
: resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix-js.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
let knownDeviceId = hasMatchingCachedToken
? cachedCredentials?.deviceId || resolved.deviceId
: resolved.deviceId;
if (!userId || !knownDeviceId) {
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
device_id?: string;
};
if (!userId) {
const fetchedUserId = whoami.user_id?.trim();
if (!fetchedUserId) {
throw new Error("Matrix whoami did not return user_id");
}
userId = fetchedUserId;
}
if (!knownDeviceId) {
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
}
}
const shouldRefreshCachedCredentials =
!cachedCredentials ||
!hasMatchingCachedToken ||
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
await saveMatrixCredentials(
{
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
},
env,
accountId,
);
} else if (hasMatchingCachedToken) {
await touchMatrixCredentials(env, accountId);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
password: resolved.password,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
await touchMatrixCredentials(env, accountId);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
password: resolved.password,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix-js.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix-js.password)",
);
}
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
password: resolved.password,
deviceId: login.device_id ?? resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
await saveMatrixCredentials(
{
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
},
env,
accountId,
);
return auth;
}

View File

@@ -0,0 +1,58 @@
import fs from "node:fs";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
export async function createMatrixClient(params: {
homeserver: string;
userId?: string;
accessToken: string;
password?: string;
deviceId?: string;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
autoBootstrapCrypto?: boolean;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const userId = params.userId?.trim() || "unknown";
const matrixClientUserId = params.userId?.trim() || undefined;
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId,
accountId: params.accountId,
});
const cryptoDatabasePrefix = `openclaw-matrix-js-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
password: params.password,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
autoBootstrapCrypto: params.autoBootstrapCrypto,
});
}

View File

@@ -0,0 +1,100 @@
import { ConsoleLogger, LogService } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
debug: (...messageOrObject: unknown[]) => void;
info: (...messageOrObject: unknown[]) => void;
warn: (...messageOrObject: unknown[]) => void;
error: (...messageOrObject: unknown[]) => void;
getChild: (namespace: string) => MatrixJsSdkLogger;
};
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (!matrixSdkLoggingConfigured) {
matrixSdkLoggingConfigured = true;
}
applyMatrixSdkLogger();
}
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
matrixSdkLogMode = mode;
if (!matrixSdkLoggingConfigured) {
return;
}
applyMatrixSdkLogger();
}
export function createMatrixJsSdkClientLogger(prefix = "matrix-js"): MatrixJsSdkLogger {
return createMatrixJsSdkLoggerInstance(prefix);
}
function applyMatrixSdkLogger(): void {
if (matrixSdkLogMode === "quiet") {
LogService.setLogger({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
return;
}
LogService.setLogger({
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
if (matrixSdkLogMode === "quiet") {
return;
}
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
prefix,
...messageOrObject,
);
};
return {
trace: (...messageOrObject) => log("trace", ...messageOrObject),
debug: (...messageOrObject) => log("debug", ...messageOrObject),
info: (...messageOrObject) => log("info", ...messageOrObject),
warn: (...messageOrObject) => log("warn", ...messageOrObject),
error: (...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
return;
}
log("error", ...messageOrObject);
},
getChild: (namespace: string) => {
const nextNamespace = namespace.trim();
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
},
};
}

View File

@@ -0,0 +1,4 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "./types.js";
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
const createMatrixClientMock = vi.hoisted(() => vi.fn());
vi.mock("./config.js", () => ({
resolveMatrixAuth: resolveMatrixAuthMock,
}));
vi.mock("./create-client.js", () => ({
createMatrixClient: createMatrixClientMock,
}));
import {
resolveSharedMatrixClient,
stopSharedClient,
stopSharedClientForAccount,
} from "./shared.js";
function authFor(accountId: string): MatrixAuth {
return {
homeserver: "https://matrix.example.org",
userId: `@${accountId}:example.org`,
accessToken: `token-${accountId}`,
password: "secret",
deviceId: `${accountId.toUpperCase()}-DEVICE`,
deviceName: `${accountId} device`,
initialSyncLimit: undefined,
encryption: false,
};
}
function createMockClient(name: string) {
const client = {
name,
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
getJoinedRooms: vi.fn(async () => [] as string[]),
crypto: undefined,
};
return client;
}
describe("resolveSharedMatrixClient", () => {
beforeEach(() => {
resolveMatrixAuthMock.mockReset();
createMatrixClientMock.mockReset();
});
afterEach(() => {
stopSharedClient();
vi.clearAllMocks();
});
it("keeps account clients isolated when resolves are interleaved", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
expect(firstMain).toBe(mainClient);
expect(firstPoe).toBe(poeClient);
expect(secondMain).toBe(mainClient);
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
expect(mainClient.start).toHaveBeenCalledTimes(1);
expect(poeClient.start).toHaveBeenCalledTimes(0);
});
it("stops only the targeted account client", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
stopSharedClientForAccount(mainAuth, "main");
expect(mainClient.stop).toHaveBeenCalledTimes(1);
expect(poeClient.stop).toHaveBeenCalledTimes(0);
stopSharedClient();
expect(poeClient.stop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,193 @@
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
startPromise: Promise<void> | null;
};
const sharedClientStates = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
password: params.auth.password,
deviceId: params.auth.deviceId,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
startPromise: null,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) {
return;
}
if (params.state.startPromise) {
await params.state.startPromise;
return;
}
params.state.startPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
try {
await params.state.startPromise;
} finally {
params.state.startPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth =
params.auth ??
(await resolveMatrixAuth({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
}));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
const existingState = sharedClientStates.get(key);
if (existingState) {
if (shouldStart) {
await ensureSharedClientStarted({
state: existingState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return existingState.client;
}
const existingPromise = sharedClientPromises.get(key);
if (existingPromise) {
const pending = await existingPromise;
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
const creationPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
sharedClientPromises.set(key, creationPromise);
try {
const created = await creationPromise;
sharedClientStates.set(key, created);
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromises.delete(key);
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-js-sdk handles sync lifecycle in start() for this integration.
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
for (const state of sharedClientStates.values()) {
state.client.stop();
}
sharedClientStates.clear();
sharedClientPromises.clear();
}
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, accountId);
const state = sharedClientStates.get(key);
if (!state) {
return;
}
state.client.stop();
sharedClientStates.delete(key);
sharedClientPromises.delete(key);
}

View File

@@ -0,0 +1,134 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizePathSegment(url.host);
}
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "credentials", "matrix-js", "bot-storage.json"),
cryptoPath: path.join(stateDir, "credentials", "matrix-js", "crypto"),
};
}
export function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"credentials",
"matrix-js",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"),
accountKey,
tokenHash,
};
}
export function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) {
return;
}
if (hasNewStorage) {
return;
}
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
export function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8");
} catch {
// ignore meta write failures
}
}

View File

@@ -0,0 +1,39 @@
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
password?: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
export type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
recoveryKeyPath: string;
idbSnapshotPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "../types.js";
import { updateMatrixAccountConfig } from "./config-update.js";
describe("updateMatrixAccountConfig", () => {
it("supports explicit null clears and boolean false values", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "old-token",
password: "old-password",
encryption: true,
},
},
},
},
} as CoreConfig;
const updated = updateMatrixAccountConfig(cfg, "default", {
accessToken: "new-token",
password: null,
userId: null,
encryption: false,
});
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "new-token",
encryption: false,
});
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default?.userId).toBeUndefined();
});
it("normalizes account id and defaults account enabled=true", () => {
const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", {
name: "Main Bot",
homeserver: "https://matrix.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.["main-bot"]).toMatchObject({
name: "Main Bot",
homeserver: "https://matrix.example.org",
enabled: true,
});
});
});

View File

@@ -0,0 +1,102 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixConfig } from "../types.js";
export type MatrixAccountPatch = {
name?: string | null;
enabled?: boolean;
homeserver?: string | null;
userId?: string | null;
accessToken?: string | null;
password?: string | null;
deviceName?: string | null;
avatarUrl?: string | null;
encryption?: boolean | null;
initialSyncLimit?: number | null;
};
function applyNullableStringField(
target: Record<string, unknown>,
key: keyof MatrixAccountPatch,
value: string | null | undefined,
): void {
if (value === undefined) {
return;
}
if (value === null) {
delete target[key];
return;
}
const trimmed = value.trim();
if (!trimmed) {
delete target[key];
return;
}
target[key] = trimmed;
}
export function updateMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
patch: MatrixAccountPatch,
): CoreConfig {
const matrix = cfg.channels?.["matrix-js"] ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? {}) as MatrixConfig;
const nextAccount: Record<string, unknown> = { ...existingAccount };
if (patch.name !== undefined) {
if (patch.name === null) {
delete nextAccount.name;
} else {
const trimmed = patch.name.trim();
if (trimmed) {
nextAccount.name = trimmed;
} else {
delete nextAccount.name;
}
}
}
if (typeof patch.enabled === "boolean") {
nextAccount.enabled = patch.enabled;
} else if (typeof nextAccount.enabled !== "boolean") {
nextAccount.enabled = true;
}
applyNullableStringField(nextAccount, "homeserver", patch.homeserver);
applyNullableStringField(nextAccount, "userId", patch.userId);
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
applyNullableStringField(nextAccount, "password", patch.password);
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
if (patch.initialSyncLimit !== undefined) {
if (patch.initialSyncLimit === null) {
delete nextAccount.initialSyncLimit;
} else {
nextAccount.initialSyncLimit = Math.max(0, Math.floor(patch.initialSyncLimit));
}
}
if (patch.encryption !== undefined) {
if (patch.encryption === null) {
delete nextAccount.encryption;
} else {
nextAccount.encryption = patch.encryption;
}
}
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...matrix,
enabled: true,
accounts: {
...matrix.accounts,
[normalizedAccountId]: nextAccount as MatrixConfig,
},
},
},
};
}

View File

@@ -0,0 +1,80 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
import {
loadMatrixCredentials,
resolveMatrixCredentialsPath,
saveMatrixCredentials,
touchMatrixCredentials,
} from "./credentials.js";
describe("matrix credentials storage", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function setupStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-"));
tempDirs.push(dir);
setMatrixRuntime({
state: {
resolveStateDir: () => dir,
},
} as never);
return dir;
}
it("writes credentials atomically with secure file permissions", async () => {
setupStateDir();
await saveMatrixCredentials(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
deviceId: "DEVICE123",
},
{},
"ops",
);
const credPath = resolveMatrixCredentialsPath({}, "ops");
expect(fs.existsSync(credPath)).toBe(true);
const mode = fs.statSync(credPath).mode & 0o777;
expect(mode).toBe(0o600);
});
it("touch updates lastUsedAt while preserving createdAt", async () => {
setupStateDir();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
await saveMatrixCredentials(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
},
{},
"default",
);
const initial = loadMatrixCredentials({}, "default");
expect(initial).not.toBeNull();
vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z"));
await touchMatrixCredentials({}, "default");
const touched = loadMatrixCredentials({}, "default");
expect(touched).not.toBeNull();
expect(touched?.createdAt).toBe(initial?.createdAt);
expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z");
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,123 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
function credentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (normalized === DEFAULT_ACCOUNT_ID) {
return "credentials.json";
}
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
// Different raw IDs that normalize to the same value are the same logical account.
return `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix-js");
}
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, credentialsFilename(accountId));
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credPath)) {
return null;
}
const raw = fs.readFileSync(credPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return parsed as MatrixStoredCredentials;
} catch {
return null;
}
}
export async function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
const credPath = resolveMatrixCredentialsPath(env, accountId);
const existing = loadMatrixCredentials(env, accountId);
const now = new Date().toISOString();
const toSave: MatrixStoredCredentials = {
...credentials,
createdAt: existing?.createdAt ?? now,
lastUsedAt: now,
};
await writeJsonFileAtomically(credPath, toSave);
}
export async function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
const existing = loadMatrixCredentials(env, accountId);
if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env, accountId);
await writeJsonFileAtomically(credPath, existing);
}
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
fs.unlinkSync(credPath);
}
} catch {
// ignore
}
}
export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): boolean {
// If userId is empty (token-based auth), only match homeserver
if (!config.userId) {
return stored.homeserver === config.homeserver;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@@ -0,0 +1,157 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
function resolveMissingMatrixPackages(): string[] {
try {
const req = createRequire(import.meta.url);
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
try {
req.resolve(pkg);
return false;
} catch {
return true;
}
});
} catch {
return [...REQUIRED_MATRIX_PACKAGES];
}
}
export function isMatrixSdkAvailable(): boolean {
return resolveMissingMatrixPackages().length === 0;
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
}
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(params: {
argv: string[];
cwd: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
}): Promise<CommandResult> {
return await new Promise((resolve) => {
const [command, ...args] = params.argv;
if (!command) {
resolve({
code: 1,
stdout: "",
stderr: "command is required",
});
return;
}
const proc = spawn(command, args, {
cwd: params.cwd,
env: { ...process.env, ...params.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const finalize = (result: CommandResult) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
timer = setTimeout(() => {
proc.kill("SIGKILL");
finalize({
code: 124,
stdout,
stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
});
}, params.timeoutMs);
proc.on("error", (err) => {
finalize({
code: 1,
stdout,
stderr: err.message,
});
});
proc.on("close", (code) => {
finalize({
code: code ?? 1,
stdout,
stderr,
});
});
});
}
export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;
}): Promise<void> {
if (isMatrixSdkAvailable()) {
return;
}
const confirm = params.confirm;
if (confirm) {
const ok = await confirm(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
);
if (!ok) {
throw new Error(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
);
}
}
const root = resolvePluginRoot();
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await runFixedCommandWithTimeout({
argv: command,
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
if (result.code !== 0) {
throw new Error(
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
);
}
if (!isMatrixSdkAvailable()) {
const missing = resolveMissingMatrixPackages();
throw new Error(
missing.length > 0
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
: "Matrix dependency install completed but Matrix dependencies are still missing.",
);
}
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { markdownToMatrixHtml } from "./format.js";
describe("markdownToMatrixHtml", () => {
it("renders basic inline formatting", () => {
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
expect(html).toContain("<em>there</em>");
expect(html).toContain("<strong>boss</strong>");
expect(html).toContain("<code>code</code>");
});
it("renders links as HTML", () => {
const html = markdownToMatrixHtml("see [docs](https://example.com)");
expect(html).toContain('<a href="https://example.com">docs</a>');
});
it("escapes raw HTML", () => {
const html = markdownToMatrixHtml("<b>nope</b>");
expect(html).toContain("&lt;b&gt;nope&lt;/b&gt;");
expect(html).not.toContain("<b>nope</b>");
});
it("flattens images into alt text", () => {
const html = markdownToMatrixHtml("![alt](https://example.com/img.png)");
expect(html).toContain("alt");
expect(html).not.toContain("<img");
});
it("preserves line breaks", () => {
const html = markdownToMatrixHtml("line1\nline2");
expect(html).toContain("<br");
});
});

View File

@@ -0,0 +1,22 @@
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
typographer: false,
});
md.enable("strikethrough");
const { escapeHtml } = md.utils;
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
export function markdownToMatrixHtml(markdown: string): string {
const rendered = md.render(markdown ?? "");
return rendered.trimEnd();
}

View File

@@ -0,0 +1,11 @@
export { monitorMatrixProvider } from "./monitor/index.js";
export { probeMatrix } from "./probe.js";
export {
reactMatrixMessage,
resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix,
sendPollMatrix,
sendTypingMatrix,
} from "./send.js";
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
describe("resolveMatrixAllowListMatch", () => {
it("matches full user IDs and prefixes", () => {
const userId = "@Alice:Example.org";
const direct = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
userId,
});
expect(direct.allowed).toBe(true);
expect(direct.matchSource).toBe("id");
const prefixedMatrix = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
userId,
});
expect(prefixedMatrix.allowed).toBe(true);
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
const prefixedUser = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
userId,
});
expect(prefixedUser.allowed).toBe(true);
expect(prefixedUser.matchSource).toBe("prefixed-user");
});
it("ignores display names and localparts", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(false);
});
it("matches wildcard", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["*"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(true);
expect(match.matchSource).toBe("wildcard");
});
});

View File

@@ -0,0 +1,103 @@
import type { AllowlistMatch } from "openclaw/plugin-sdk/matrix-js";
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
function normalizeMatrixUser(raw?: string | null): string {
const value = (raw ?? "").trim();
if (!value) {
return "";
}
if (!value.startsWith("@") || !value.includes(":")) {
return value.toLowerCase();
}
const withoutAt = value.slice(1);
const splitIndex = withoutAt.indexOf(":");
if (splitIndex === -1) {
return value.toLowerCase();
}
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
if (!server) {
return value.toLowerCase();
}
return `@${localpart}:${server.toLowerCase()}`;
}
export function normalizeMatrixUserId(raw?: string | null): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return normalizeMatrixUser(trimmed.slice("matrix:".length));
}
if (lowered.startsWith("user:")) {
return normalizeMatrixUser(trimmed.slice("user:".length));
}
return normalizeMatrixUser(trimmed);
}
function normalizeMatrixAllowListEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return trimmed;
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
}
if (lowered.startsWith("user:")) {
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
}
return normalizeMatrixUser(trimmed);
}
export function normalizeMatrixAllowList(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
}
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) {
return { allowed: false };
}
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
];
for (const candidate of candidates) {
if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
return resolveMatrixAllowListMatch(params).allowed;
}

View File

@@ -0,0 +1,127 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
function createClientStub() {
let inviteHandler: InviteHandler | null = null;
const client = {
on: vi.fn((eventName: string, listener: unknown) => {
if (eventName === "room.invite") {
inviteHandler = listener as InviteHandler;
}
return client;
}),
joinRoom: vi.fn(async () => {}),
getRoomStateEvent: vi.fn(async () => ({})),
} as unknown as import("../sdk.js").MatrixClient;
return {
client,
getInviteHandler: () => inviteHandler,
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
.getRoomStateEvent,
};
}
describe("registerMatrixAutoJoin", () => {
beforeEach(() => {
setMatrixRuntime({
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
});
it("joins all invites when autoJoin=always", async () => {
const { client, getInviteHandler, joinRoom } = createClientStub();
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "always",
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#other:example.org",
alt_aliases: ["#else:example.org"],
});
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).not.toHaveBeenCalled();
});
it("joins invite when alias matches allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#allowed:example.org",
alt_aliases: ["#backup:example.org"],
});
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
});

View File

@@ -0,0 +1,75 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
cfg: CoreConfig;
runtime: RuntimeEnv;
}) {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
runtime.log?.(message);
};
const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always";
const autoJoinAllowlist = new Set(
(cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
logVerbose("matrix: auto-join enabled for all invites");
} else {
logVerbose("matrix: auto-join enabled for allowlist invites");
}
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin === "allowlist") {
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
altAliases =
aliasState && Array.isArray(aliasState.alt_aliases)
? aliasState.alt_aliases
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean)
: [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.has("*") ||
autoJoinAllowlist.has(roomId) ||
(alias ? autoJoinAllowlist.has(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.has(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
});
}

View File

@@ -0,0 +1,104 @@
import type { MatrixClient } from "../sdk.js";
type DirectMessageCheck = {
roomId: string;
senderId?: string;
selfUserId?: string;
};
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
};
const DM_CACHE_TTL_MS = 30_000;
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
const log = opts.log ?? (() => {});
let lastDmUpdateMs = 0;
let cachedSelfUserId: string | null = null;
const memberCountCache = new Map<string, { count: number; ts: number }>();
const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) {
return cachedSelfUserId;
}
try {
cachedSelfUserId = await client.getUserId();
} catch {
cachedSelfUserId = null;
}
return cachedSelfUserId;
};
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
return;
}
lastDmUpdateMs = now;
try {
await client.dms.update();
} catch (err) {
log(`matrix: dm cache refresh failed (${String(err)})`);
}
};
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
const cached = memberCountCache.get(roomId);
const now = Date.now();
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
return cached.count;
}
try {
const members = await client.getJoinedRoomMembers(roomId);
const count = members.length;
memberCountCache.set(roomId, { count, ts: now });
return count;
} catch (err) {
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
return null;
}
};
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim();
if (!target) {
return false;
}
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;
} catch {
return false;
}
};
return {
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
await refreshDmCache();
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}
const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
return true;
}
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const directViaState =
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
if (directViaState) {
log(`matrix: dm detected via member state room=${roomId}`);
return true;
}
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
return false;
},
};
}

View File

@@ -0,0 +1,208 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import { registerMatrixMonitorEvents } from "./events.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string {
const calls = sendMessage.mock.calls as unknown[][];
const payload = (calls[index]?.[1] ?? {}) as { body?: string };
return payload.body ?? "";
}
function createHarness(params?: {
verifications?: Array<{
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
}>;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const onRoomMessage = vi.fn(async () => {});
const listVerifications = vi.fn(async () => params?.verifications ?? []);
const sendMessage = vi.fn(async () => "$notice");
const client = {
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
return client;
}),
sendMessage,
crypto: {
listVerifications,
},
} as unknown as MatrixClient;
registerMatrixMonitorEvents({
client,
auth: { encryption: true } as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
formatNativeDependencyHint: vi.fn(() => "install hint"),
onRoomMessage,
});
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
if (!roomEventListener) {
throw new Error("room.event listener was not registered");
}
return {
onRoomMessage,
sendMessage,
roomEventListener,
listVerifications,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
};
}
describe("registerMatrixMonitorEvents verification routing", () => {
it("posts verification request notices directly into the room", async () => {
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
roomMessageListener("!room:example.org", {
event_id: "$req1",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.key.verification.request",
body: "verification request",
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
expect(onRoomMessage).not.toHaveBeenCalled();
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification request received from @alice:example.org.");
expect(body).toContain('Open "Verify by emoji"');
});
it("posts ready-stage guidance for emoji verification", async () => {
const { sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$ready-1",
sender: "@alice:example.org",
type: "m.key.verification.ready",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-ready-1" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
expect(body).toContain('Choose "Verify by emoji"');
});
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-1",
transactionId: "$different-flow-id",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start2",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req2" },
},
});
await vi.waitFor(() => {
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
});
it("does not emit duplicate SAS notices for the same verification payload", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-3",
transactionId: "$req3",
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start3",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(sendMessage.mock.calls.length).toBeGreaterThan(0);
});
roomEventListener("!room:example.org", {
event_id: "$key3",
sender: "@alice:example.org",
type: "m.key.verification.key",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(listVerifications).toHaveBeenCalledTimes(2);
});
const sasBodies = sendMessage.mock.calls
.map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? ""))
.filter((body) => body.includes("SAS emoji:"));
expect(sasBodies).toHaveLength(1);
});
});

View File

@@ -0,0 +1,396 @@
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
import {
isMatrixVerificationEventType,
isMatrixVerificationRequestMsgType,
matrixVerificationConstants,
} from "./verification-utils.js";
const MAX_TRACKED_VERIFICATION_EVENTS = 1024;
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
type MatrixVerificationSummaryLike = {
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
};
function trimMaybeString(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readVerificationSignal(event: MatrixRawEvent): {
stage: MatrixVerificationStage;
flowId: string | null;
} | null {
const type = trimMaybeString(event?.type) ?? "";
const content = event?.content ?? {};
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
const relatedEventId = trimMaybeString(
(content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id,
);
const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id);
if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) {
return {
stage: "request",
flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId,
};
}
if (!isMatrixVerificationEventType(type)) {
return null;
}
const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id);
if (type === `${matrixVerificationConstants.eventPrefix}request`) {
return { stage: "request", flowId };
}
if (type === `${matrixVerificationConstants.eventPrefix}ready`) {
return { stage: "ready", flowId };
}
if (type === "m.key.verification.start") {
return { stage: "start", flowId };
}
if (type === "m.key.verification.cancel") {
return { stage: "cancel", flowId };
}
if (type === "m.key.verification.done") {
return { stage: "done", flowId };
}
return { stage: "other", flowId };
}
function formatVerificationStageNotice(params: {
stage: MatrixVerificationStage;
senderId: string;
event: MatrixRawEvent;
}): string | null {
const { stage, senderId, event } = params;
const content = event.content as { code?: unknown; reason?: unknown };
switch (stage) {
case "request":
return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`;
case "ready":
return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`;
case "start":
return `Matrix verification started with ${senderId}.`;
case "done":
return `Matrix verification completed with ${senderId}.`;
case "cancel": {
const code = trimMaybeString(content.code);
const reason = trimMaybeString(content.reason);
if (code && reason) {
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
}
if (reason) {
return `Matrix verification cancelled by ${senderId} (${reason}).`;
}
return `Matrix verification cancelled by ${senderId}.`;
}
default:
return null;
}
}
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
const sas = summary.sas;
if (!sas) {
return null;
}
const emojiLine =
Array.isArray(sas.emoji) && sas.emoji.length > 0
? `SAS emoji: ${sas.emoji
.map(
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
)
.join(" | ")}`
: null;
const decimalLine =
Array.isArray(sas.decimal) && sas.decimal.length === 3
? `SAS decimal: ${sas.decimal.join(" ")}`
: null;
if (!emojiLine && !decimalLine) {
return null;
}
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
if (emojiLine) {
lines.push(emojiLine);
}
if (decimalLine) {
lines.push(decimalLine);
}
lines.push("If both sides match, choose 'They match' in your Matrix app.");
return lines.join("\n");
}
function resolveVerificationFlowCandidates(params: {
event: MatrixRawEvent;
flowId: string | null;
}): string[] {
const { event, flowId } = params;
const content = event.content as {
transaction_id?: unknown;
"m.relates_to"?: { event_id?: unknown };
};
const candidates = new Set<string>();
const add = (value: unknown) => {
const normalized = trimMaybeString(value);
if (normalized) {
candidates.add(normalized);
}
};
add(flowId);
add(event.event_id);
add(content.transaction_id);
add(content["m.relates_to"]?.event_id);
return Array.from(candidates);
}
function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number {
const ts = Date.parse(summary.updatedAt ?? "");
return Number.isFinite(ts) ? ts : 0;
}
async function resolveVerificationSummaryForSignal(
client: MatrixClient,
params: {
event: MatrixRawEvent;
senderId: string;
flowId: string | null;
},
): Promise<MatrixVerificationSummaryLike | null> {
if (!client.crypto) {
return null;
}
const list = await client.crypto.listVerifications();
if (list.length === 0) {
return null;
}
const candidates = resolveVerificationFlowCandidates({
event: params.event,
flowId: params.flowId,
});
const byTransactionId = list.find((entry) =>
candidates.some((candidate) => entry.transactionId === candidate),
);
if (byTransactionId) {
return byTransactionId;
}
// Fallback for flows where transaction IDs do not match room event IDs consistently.
const byUser = list
.filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true)
.sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0];
return byUser ?? null;
}
function trackBounded(set: Set<string>, value: string): boolean {
if (!value || set.has(value)) {
return false;
}
set.add(value);
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
const oldest = set.values().next().value;
if (typeof oldest === "string") {
set.delete(oldest);
}
}
return true;
}
async function sendVerificationNotice(params: {
client: MatrixClient;
roomId: string;
body: string;
logVerboseMessage: (message: string) => void;
}): Promise<void> {
const roomId = trimMaybeString(params.roomId);
if (!roomId) {
return;
}
try {
await params.client.sendMessage(roomId, {
msgtype: "m.notice",
body: params.body,
});
} catch (err) {
params.logVerboseMessage(
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
);
}
}
export function registerMatrixMonitorEvents(params: {
client: MatrixClient;
auth: MatrixAuth;
logVerboseMessage: (message: string) => void;
warnedEncryptedRooms: Set<string>;
warnedCryptoMissingRooms: Set<string>;
logger: RuntimeLogger;
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
const {
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint,
onRoomMessage,
} = params;
const routedVerificationEvents = new Set<string>();
const routedVerificationSasFingerprints = new Set<string>();
const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => {
const senderId = trimMaybeString(event?.sender);
if (!senderId) {
return false;
}
const signal = readVerificationSignal(event);
if (!signal) {
return false;
}
void (async () => {
const flowId = signal.flowId;
const sourceEventId = trimMaybeString(event?.event_id);
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
return;
}
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
const summary = await resolveVerificationSummaryForSignal(client, {
event,
senderId,
flowId,
}).catch(() => null);
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
const notices: string[] = [];
if (stageNotice) {
notices.push(stageNotice);
}
if (summary && sasNotice) {
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
notices.push(sasNotice);
}
}
if (notices.length === 0) {
return;
}
for (const body of notices) {
await sendVerificationNotice({
client,
roomId,
body,
logVerboseMessage,
});
}
})().catch((err) => {
logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
});
return true;
};
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
if (routeVerificationEvent(roomId, event)) {
return;
}
void onRoomMessage(roomId, event);
});
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on(
"room.failed_decryption",
async (roomId: string, event: MatrixRawEvent, error: Error) => {
logger.warn("Failed to decrypt message", {
roomId,
eventId: event.event_id,
error: error.message,
});
logVerboseMessage(
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
);
},
);
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const sender = event?.sender ?? "unknown";
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
logVerboseMessage(
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
);
});
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
});
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
const eventType = event?.type ?? "unknown";
if (eventType === EventType.RoomMessageEncrypted) {
logVerboseMessage(
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
);
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
warnedEncryptedRooms.add(roomId);
const warning =
"matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt";
logger.warn(warning, { roomId });
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
warnedCryptoMissingRooms.add(roomId);
const hint = formatNativeDependencyHint({
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
manager: "pnpm",
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
});
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
logger.warn(warning, { roomId });
}
return;
}
if (eventType === EventType.RoomMember) {
const membership = (event?.content as { membership?: string } | undefined)?.membership;
const stateKey = (event as { state_key?: string }).state_key ?? "";
logVerboseMessage(
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
);
}
routeVerificationEvent(roomId, event);
});
}

View File

@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from "vitest";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { EventType, type MatrixRawEvent } from "./types.js";
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })),
);
vi.mock("../send.js", () => ({
reactMatrixMessage: vi.fn(async () => {}),
sendMessageMatrix: sendMessageMatrixMock,
sendReadReceiptMatrix: vi.fn(async () => {}),
sendTypingMatrix: vi.fn(async () => {}),
}));
describe("matrix monitor handler pairing account scope", () => {
it("caches account-scoped allowFrom store reads on hot path", async () => {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
sendMessageMatrixMock.mockClear();
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: () => "pairing",
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "pairing",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event1",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event2",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello again",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
});
it("sends pairing reminders for pending requests with cooldown", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
try {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
sendMessageMatrixMock.mockClear();
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: () => "Pairing code: ABCDEFGH",
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "pairing",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
const makeEvent = (id: string): MatrixRawEvent =>
({
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: id,
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
}) as MatrixRawEvent;
await handler("!room:example.org", makeEvent("$event1"));
await handler("!room:example.org", makeEvent("$event2"));
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing request is still pending approval.",
);
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
await handler("!room:example.org", makeEvent("$event3"));
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("uses account-scoped pairing store reads and upserts for dm pairing", async () => {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "pairing",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event1",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "matrix-js",
env: process.env,
accountId: "ops",
});
expect(upsertPairingRequest).toHaveBeenCalledWith({
channel: "matrix-js",
id: "@user:example.org",
accountId: "ops",
meta: { name: "sender" },
});
});
it("passes accountId into route resolution for inbound dm messages", async () => {
const resolveAgentRoute = vi.fn(() => ({
agentId: "ops",
channel: "matrix-js",
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
}));
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore: async () => [] as string[],
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
},
routing: {
resolveAgentRoute,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {
error: () => {},
} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "open",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event2",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
expect(resolveAgentRoute).toHaveBeenCalledWith(
expect.objectContaining({
channel: "matrix-js",
accountId: "ops",
}),
);
});
});

View File

@@ -0,0 +1,713 @@
import {
createReplyPrefixOptions,
createTypingCallbacks,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type PluginRuntime,
type ReplyPayload,
type RuntimeEnv,
type RuntimeLogger,
} from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import {
formatPollAsText,
isPollStartType,
parsePollStartContent,
type PollStartContent,
} from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
} from "./allowlist.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { EventType, RelationType } from "./types.js";
import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
export type MatrixMonitorHandlerParams = {
client: MatrixClient;
core: PluginRuntime;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
logger: RuntimeLogger;
logVerboseMessage: (message: string) => void;
allowFrom: string[];
roomsConfig?: Record<string, MatrixRoomConfig>;
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
groupPolicy: "open" | "allowlist" | "disabled";
replyToMode: ReplyToMode;
threadReplies: "off" | "inbound" | "always";
dmEnabled: boolean;
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
textLimit: number;
mediaMaxBytes: number;
startupMs: number;
startupGraceMs: number;
directTracker: {
isDirectMessage: (params: {
roomId: string;
senderId: string;
selfUserId: string;
}) => Promise<boolean>;
};
getRoomInfo: (
roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
};
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
const {
client,
core,
cfg,
accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
} = params;
let cachedStoreAllowFrom: {
value: string[];
expiresAtMs: number;
} | null = null;
const pairingReplySentAtMsBySender = new Map<string, number>();
const readStoreAllowFrom = async (): Promise<string[]> => {
const now = Date.now();
if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
return cachedStoreAllowFrom.value;
}
const value = await core.channel.pairing
.readAllowFromStore({
channel: "matrix-js",
env: process.env,
accountId,
})
.catch(() => []);
cachedStoreAllowFrom = {
value,
expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
};
return value;
};
const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
const now = Date.now();
if (created) {
pairingReplySentAtMsBySender.set(senderId, now);
return true;
}
const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
return false;
}
pairingReplySentAtMsBySender.set(senderId, now);
if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
if (typeof oldestSender === "string") {
pairingReplySentAtMsBySender.delete(oldestSender);
}
}
return true;
};
return async (roomId: string, event: MatrixRawEvent) => {
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted payloads are emitted separately after decryption.
return;
}
const isPollEvent = isPollStartType(eventType);
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
return;
}
logVerboseMessage(
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
);
if (event.unsigned?.redacted_because) {
return;
}
const senderId = event.sender;
if (!senderId) {
return;
}
const selfUserId = await client.getUserId();
if (senderId === selfUserId) {
return;
}
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
}
if (
typeof eventTs !== "number" &&
typeof eventAge === "number" &&
eventAge > startupGraceMs
) {
return;
}
const roomInfo = await getRoomInfo(roomId);
const roomName = roomInfo.name;
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
body: pollText,
} as unknown as RoomMessageEventContent;
} else {
return;
}
}
if (
eventType === EventType.RoomMessage &&
isMatrixVerificationRoomMessage({
msgtype: (content as { msgtype?: unknown }).msgtype,
body: content.body,
})
) {
logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
return;
}
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
eventType,
content: content as LocationMessageEventContent,
});
const relates = content["m.relates_to"];
if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) {
return;
}
}
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
senderId,
selfUserId,
});
const isRoom = !isDirectMessage;
if (isRoom && groupPolicy === "disabled") {
return;
}
const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases: roomAliases,
name: roomName,
})
: undefined;
const roomConfig = roomConfigInfo?.config;
const roomMatchMeta = roomConfigInfo
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`
: "matchKey=none matchSource=none";
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await readStoreAllowFrom();
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
return;
}
if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix-js",
id: senderId,
accountId,
meta: { name: senderName },
});
if (shouldSendPairingReply(senderId, created)) {
const pairingReply = core.channel.pairing.buildPairingReply({
channel: "matrix-js",
idLine: `Your Matrix user id: ${senderId}`,
code,
});
logVerboseMessage(
created
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
created
? pairingReply
: `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
} else {
logVerboseMessage(
`matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
);
}
}
if (dmPolicy !== "pairing") {
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
}
}
const roomUsers = roomConfig?.users ?? [];
if (isRoom && roomUsers.length > 0) {
const userMatch = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
});
if (!userMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch,
)})`,
);
return;
}
}
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
});
if (!groupAllowMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
groupAllowMatch,
)})`,
);
return;
}
}
if (isRoom) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody =
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
let media: {
path: string;
contentType?: string;
placeholder: string;
} | null = null;
const contentUrl =
"url" in content && typeof content.url === "string" ? content.url : undefined;
const contentFile =
"file" in content && content.file && typeof content.file === "object"
? content.file
: undefined;
const mediaUrl = contentUrl ?? contentFile?.url;
if (!rawBody && !mediaUrl) {
return;
}
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});
} catch (err) {
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
}
}
const bodyText = rawBody || media?.placeholder || "";
if (!bodyText) {
return;
}
const { wasMentioned, hasExplicitMention } = resolveMentions({
content,
userId: selfUserId,
text: bodyText,
mentionRegexes,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "matrix-js",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix-js",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
const shouldRequireMention = isRoom
? roomConfig?.autoReply === true
? false
: roomConfig?.autoReply === false
? true
: typeof roomConfig?.requireMention === "boolean"
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info("skipping room message", { roomId, reason: "no-mention" });
return;
}
const messageId = event.event_id ?? "";
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
const threadRootId = resolveMatrixThreadRootId({ event, content });
const threadTarget = resolveMatrixThreadTarget({
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
});
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix-js",
accountId,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName,
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix-js" as const,
Surface: "matrix-js" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
MessageThreadId: threadTarget,
Timestamp: eventTs ?? undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...locationPayload?.context,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix-js" as const,
OriginatingTo: `room:${roomId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix-js",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
});
},
});
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
const replyTarget = ctxPayload.To;
if (!replyTarget) {
runtime.error?.("matrix: missing reply target");
return;
}
if (messageId) {
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: route.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "matrix-js",
accountId: route.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
await deliverMatrixReplies({
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
});
markDispatchIdle();
if (!queuedFinal) {
return;
}
didSendReply = true;
const finalCount = counts.final;
logVerboseMessage(
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
if (didSendReply) {
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
});
}
} catch (err) {
runtime.error?.(`matrix handler failed: ${String(err)}`);
}
};
}

View File

@@ -0,0 +1,400 @@
import { format } from "node:util";
import {
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
type RuntimeEnv,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import { resolveMatrixAccount } from "../accounts.js";
import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
initialSyncLimit?: number;
replyToMode?: ReplyToMode;
accountId?: string | null;
};
const DEFAULT_MEDIA_MAX_MB = 20;
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.["matrix-js"]?.enabled === false) {
return;
}
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
const runtime: RuntimeEnv = opts.runtime ?? {
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug?.(message);
};
const normalizeUserEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^user:/i, "")
.trim();
const normalizeRoomEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^(room|channel):/i, "")
.trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const resolveUserAllowlist = async (
label: string,
list?: Array<string | number>,
): Promise<string[]> => {
let allowList = list ?? [];
if (allowList.length === 0) {
return allowList.map(String);
}
const entries = allowList
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length === 0) {
return allowList.map(String);
}
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(normalizeMatrixUserId(entry));
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
const normalizedId = normalizeMatrixUserId(entry.id);
additions.push(normalizedId);
mapping.push(`${entry.input}${normalizedId}`);
} else {
unresolved.push(entry.input);
}
}
}
allowList = mergeAllowlist({ existing: allowList, additions });
summarizeMapping(label, mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
);
}
return allowList.map(String);
};
// Resolve account-specific config for multi-account support
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
const accountConfig = account.config;
const allowlistOnly = accountConfig.allowlistOnly === true;
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
if (roomsConfig["*"]) {
nextRooms["*"] = roomsConfig["*"];
}
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
[];
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
if (entry === "*") {
continue;
}
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomConfig;
}
if (cleaned !== entry) {
mapping.push(`${entry}${cleaned}`);
}
continue;
}
pending.push({ input: entry, query: trimmed, config: roomConfig });
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending.map((entry) => entry.query),
kind: "group",
runtime,
});
resolved.forEach((entry, index) => {
const source = pending[index];
if (!source) {
return;
}
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = source.config;
}
mapping.push(`${source.input}${entry.id}`);
} else {
unresolved.push(source.input);
}
});
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const nextRooms = { ...roomsConfig };
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
const users = roomConfig?.users ?? [];
if (users.length === 0) {
continue;
}
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
if (resolvedUsers !== users) {
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
}
}
roomsConfig = nextRooms;
}
cfg = {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
dm: {
...cfg.channels?.["matrix-js"]?.dm,
allowFrom,
},
groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))
: auth.initialSyncLimit;
const authWithLimit =
resolvedInitialSyncLimit === auth.initialSyncLimit
? auth
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
const client = await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
startClient: false,
accountId: opts.accountId ?? undefined,
});
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix-js",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => logVerboseMessage(message),
});
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = accountConfig.threadReplies ?? "inbound";
const dmConfig = accountConfig.dm;
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
const warnedCryptoMissingRooms = new Set<string>();
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
const handleRoomMessage = createMatrixRoomMessageHandler({
client,
core,
cfg,
accountId: account.accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
});
registerMatrixMonitorEvents({
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
onRoomMessage: handleRoomMessage,
});
logVerboseMessage("matrix: starting client");
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
accountId: opts.accountId,
});
logVerboseMessage("matrix: client started");
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
try {
const profileSync = await syncMatrixOwnProfile({
client,
userId: auth.userId,
displayName: accountConfig.name,
avatarUrl: accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
logger.info(`matrix: profile display name updated for ${auth.userId}`);
}
if (profileSync.avatarUpdated) {
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = core.config.loadConfig() as CoreConfig;
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await core.config.writeConfigFile(updatedCfg as never);
logVerboseMessage(
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and guidance.
if (auth.encryption && client.crypto) {
try {
const status = await client.getOwnDeviceVerificationStatus();
if (status.verified) {
logger.info("matrix: device is verified and ready for encrypted rooms");
} else {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
);
}
} catch (err) {
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
error: String(err),
});
}
}
await new Promise<void>((resolve) => {
const onAbort = () => {
try {
logVerboseMessage("matrix: stopping client");
stopSharedClientForAccount(auth, opts.accountId);
} finally {
setActiveMatrixClient(null, opts.accountId);
resolve();
}
};
if (opts.abortSignal?.aborted) {
onAbort();
return;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,100 @@
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk/matrix-js";
import type { LocationMessageEventContent } from "../sdk.js";
import { EventType } from "./types.js";
export type MatrixLocationPayload = {
text: string;
context: ReturnType<typeof toLocationContext>;
};
type GeoUriParams = {
latitude: number;
longitude: number;
accuracy?: number;
};
function parseGeoUri(value: string): GeoUriParams | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!trimmed.toLowerCase().startsWith("geo:")) {
return null;
}
const payload = trimmed.slice(4);
const [coordsPart, ...paramParts] = payload.split(";");
const coords = coordsPart.split(",");
if (coords.length < 2) {
return null;
}
const latitude = Number.parseFloat(coords[0] ?? "");
const longitude = Number.parseFloat(coords[1] ?? "");
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const params = new Map<string, string>();
for (const part of paramParts) {
const segment = part.trim();
if (!segment) {
continue;
}
const eqIndex = segment.indexOf("=");
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
const key = rawKey.trim().toLowerCase();
if (!key) {
continue;
}
const valuePart = rawValue.trim();
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
}
const accuracyRaw = params.get("u");
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
return {
latitude,
longitude,
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
};
}
export function resolveMatrixLocation(params: {
eventType: string;
content: LocationMessageEventContent;
}): MatrixLocationPayload | null {
const { eventType, content } = params;
const isLocation =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
if (!isLocation) {
return null;
}
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
if (!geoUri) {
return null;
}
const parsed = parseGeoUri(geoUri);
if (!parsed) {
return null;
}
const caption = typeof content.body === "string" ? content.body.trim() : "";
const location: NormalizedLocation = {
latitude: parsed.latitude,
longitude: parsed.longitude,
accuracy: parsed.accuracy,
caption: caption || undefined,
source: "pin",
isLive: false,
};
return {
text: formatLocationText(location),
context: toLocationContext(location),
};
}

View File

@@ -0,0 +1,102 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import { downloadMatrixMedia } from "./media.js";
describe("downloadMatrixMedia", () => {
const saveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/media",
contentType: "image/png",
});
const runtimeStub = {
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
},
},
} as unknown as PluginRuntime;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const result = await downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
maxBytes: 1024,
file,
});
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
"inbound",
1024,
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,117 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
// Type for encrypted file info
type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
// The client wrapper exposes mxcToHttp for Matrix media URIs.
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) {
return null;
}
// Use the client's download method which handles auth
try {
const raw = await params.client.downloadContent(params.mxcUrl);
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
}
}
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses the Matrix crypto adapter's decryptMedia helper.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
file: EncryptedFile;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
if (!params.client.crypto) {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(
params.file as Parameters<typeof params.client.crypto.decryptMedia>[0],
);
if (decrypted.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: decrypted };
}
export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
client: params.client,
file: params.file,
maxBytes: params.maxBytes,
});
} else {
// Unencrypted media
fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
}
if (!fetched) {
return null;
}
const headerType = params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "[matrix media]",
};
}

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from "vitest";
// Mock the runtime before importing resolveMentions
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
mentions: {
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
patterns.some((p) => p.test(text)),
},
},
}),
}));
import { resolveMentions } from "./mentions.js";
describe("resolveMentions", () => {
const userId = "@bot:matrix.org";
const mentionRegexes = [/@bot/i];
describe("m.mentions field", () => {
it("detects mention via m.mentions.user_ids", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { user_ids: ["@bot:matrix.org"] },
},
userId,
text: "hello",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
expect(result.hasExplicitMention).toBe(true);
});
it("detects room mention via m.mentions.room", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello everyone",
"m.mentions": { room: true },
},
userId,
text: "hello everyone",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("formatted_body matrix.to links", () => {
it("detects mention in formatted_body with plain user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/@bot:matrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention in formatted_body with URL-encoded user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/%40bot%3Amatrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention with single quotes in href", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: "<a href='https://matrix.to/#/@bot:matrix.org'>Bot</a>: hello",
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("does not detect mention for different user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Other: hello",
formatted_body: '<a href="https://matrix.to/#/@other:matrix.org">Other</a>: hello',
},
userId,
text: "Other: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
it("does not false-positive on partial user ID match", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot2: hello",
formatted_body: '<a href="https://matrix.to/#/@bot2:matrix.org">Bot2</a>: hello',
},
userId: "@bot:matrix.org",
text: "Bot2: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
});
describe("regex patterns", () => {
it("detects mention via regex pattern in body text", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hey @bot can you help?",
},
userId,
text: "hey @bot can you help?",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("no mention", () => {
it("returns false when no mention is present", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello world",
},
userId,
text: "hello world",
mentionRegexes,
});
expect(result.wasMentioned).toBe(false);
expect(result.hasExplicitMention).toBe(false);
});
});
});

View File

@@ -0,0 +1,52 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
/**
* Check if the formatted_body contains a matrix.to mention link for the given user ID.
* Many Matrix clients (including Element) use HTML links in formatted_body instead of
* or in addition to the m.mentions field.
*/
function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
if (!formattedBody || !userId) {
return false;
}
// Escape special regex characters in the user ID (e.g., @user:matrix.org)
const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match matrix.to links with the user ID, handling both URL-encoded and plain formats
// Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
if (plainPattern.test(formattedBody)) {
return true;
}
// Also check URL-encoded version (@ -> %40, : -> %3A)
const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
return encodedPattern.test(formattedBody);
}
export function resolveMentions(params: {
content: RoomMessageEventContent;
userId?: string | null;
text?: string;
mentionRegexes: RegExp[];
}) {
const mentions = params.content["m.mentions"];
const mentionedUsers = Array.isArray(mentions?.user_ids)
? new Set(mentions.user_ids)
: new Set<string>();
// Check formatted_body for matrix.to mention links (legacy/alternative mention format)
const mentionedInFormattedBody = params.userId
? checkFormattedBodyMention(params.content.formatted_body, params.userId)
: false;
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
mentionedInFormattedBody ||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View File

@@ -0,0 +1,132 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
vi.mock("../send.js", () => ({
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
sendMessageMatrixMock(to, message, opts),
}));
import { setMatrixRuntime } from "../../runtime.js";
import { deliverMatrixReplies } from "./replies.js";
describe("deliverMatrixReplies", () => {
const loadConfigMock = vi.fn(() => ({}));
const resolveMarkdownTableModeMock = vi.fn(() => "code");
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const resolveChunkModeMock = vi.fn(() => "length");
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
const runtimeStub = {
config: {
loadConfig: () => loadConfigMock(),
},
channel: {
text: {
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
});
it("keeps replyToId on first reply only when replyToMode=first", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [
{ text: "first-a|first-b", replyToId: "reply-1" },
{ text: "second", replyToId: "reply-2" },
],
roomId: "room:1",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "first",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: undefined }),
);
});
it("keeps replyToId on every reply when replyToMode=all", async () => {
await deliverMatrixReplies({
replies: [
{
text: "caption",
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
replyToId: "reply-media",
audioAsVoice: true,
},
{ text: "plain", replyToId: "reply-text" },
],
roomId: "room:2",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
"room:2",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
"room:2",
"",
expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-text" }),
);
});
it("suppresses replyToId when threadId is set", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
roomId: "room:3",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
threadId: "thread-77",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
});
});

View File

@@ -0,0 +1,100 @@
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import { sendMessageMatrix } from "../send.js";
export async function deliverMatrixReplies(params: {
replies: ReplyPayload[];
roomId: string;
client: MatrixClient;
runtime: RuntimeEnv;
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.("matrix reply missing text/media");
continue;
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const shouldIncludeReply = (id?: string) =>
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
if (mediaList.length === 0) {
let sentTextChunk = false;
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) {
continue;
}
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: replyToIdForReply,
threadId: params.threadId,
accountId: params.accountId,
});
sentTextChunk = true;
}
if (replyToIdForReply && !hasReplied && sentTextChunk) {
hasReplied = true;
}
continue;
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
first = false;
}
if (replyToIdForReply && !hasReplied) {
hasReplied = true;
}
}
}

View File

@@ -0,0 +1,65 @@
import type { MatrixClient } from "../sdk.js";
export type MatrixRoomInfo = {
name?: string;
canonicalAlias?: string;
altAliases: string[];
};
export function createMatrixRoomInfoResolver(client: MatrixClient) {
const roomInfoCache = new Map<string, MatrixRoomInfo>();
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
const cached = roomInfoCache.get(roomId);
if (cached) {
return cached;
}
let name: string | undefined;
let canonicalAlias: string | undefined;
let altAliases: string[] = [];
try {
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
if (nameState && typeof nameState.name === "string") {
name = nameState.name;
}
} catch {
// ignore
}
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
if (aliasState && typeof aliasState.alias === "string") {
canonicalAlias = aliasState.alias;
}
const rawAliases = aliasState?.alt_aliases;
if (Array.isArray(rawAliases)) {
altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string");
}
} catch {
// ignore
}
const info = { name, canonicalAlias, altAliases };
roomInfoCache.set(roomId, info);
return info;
};
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
try {
const memberState = await client
.getRoomStateEvent(roomId, "m.room.member", userId)
.catch(() => null);
if (memberState && typeof memberState.displayname === "string") {
return memberState.displayname;
}
return userId;
} catch {
return userId;
}
};
return {
getRoomInfo,
getMemberDisplayName,
};
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixRoomConfig } from "./rooms.js";
describe("resolveMatrixRoomConfig", () => {
it("matches room IDs and aliases, not names", () => {
const rooms = {
"!room:example.org": { allow: true },
"#alias:example.org": { allow: true },
"Project Room": { allow: true },
};
const byId = resolveMatrixRoomConfig({
rooms,
roomId: "!room:example.org",
aliases: [],
name: "Project Room",
});
expect(byId.allowed).toBe(true);
expect(byId.matchKey).toBe("!room:example.org");
const byAlias = resolveMatrixRoomConfig({
rooms,
roomId: "!other:example.org",
aliases: ["#alias:example.org"],
name: "Other Room",
});
expect(byAlias.allowed).toBe(true);
expect(byAlias.matchKey).toBe("#alias:example.org");
const byName = resolveMatrixRoomConfig({
rooms: { "Project Room": { allow: true } },
roomId: "!different:example.org",
aliases: [],
name: "Project Room",
});
expect(byName.allowed).toBe(false);
expect(byName.config).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixRoomConfig } from "../../types.js";
export type MatrixRoomConfigResolved = {
allowed: boolean;
allowlistConfigured: boolean;
config?: MatrixRoomConfig;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
export function resolveMatrixRoomConfig(params: {
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
aliases: string[];
name?: string | null;
}): MatrixRoomConfigResolved {
const rooms = params.rooms ?? {};
const keys = Object.keys(rooms);
const allowlistConfigured = keys.length > 0;
const candidates = buildChannelKeyCandidates(
params.roomId,
`room:${params.roomId}`,
...params.aliases,
);
const {
entry: matched,
key: matchedKey,
wildcardEntry,
wildcardKey,
} = resolveChannelEntryMatch({
entries: rooms,
keys: candidates,
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {
allowed,
allowlistConfigured,
config: resolved,
matchKey,
matchSource,
};
}

View File

@@ -0,0 +1,48 @@
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { RelationType } from "./types.js";
export function resolveMatrixThreadTarget(params: {
threadReplies: "off" | "inbound" | "always";
messageId: string;
threadRootId?: string;
isThreadRoot?: boolean;
}): string | undefined {
const { threadReplies, messageId, threadRootId } = params;
if (threadReplies === "off") {
return undefined;
}
const isThreadRoot = params.isThreadRoot === true;
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
if (threadReplies === "inbound") {
return hasInboundThread ? threadRootId : undefined;
}
if (threadReplies === "always") {
return threadRootId ?? messageId;
}
return undefined;
}
export function resolveMatrixThreadRootId(params: {
event: MatrixRawEvent;
content: RoomMessageEventContent;
}): string | undefined {
const relates = params.content["m.relates_to"];
if (!relates || typeof relates !== "object") {
return undefined;
}
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
if ("event_id" in relates && typeof relates.event_id === "string") {
return relates.event_id;
}
if (
"m.in_reply_to" in relates &&
typeof relates["m.in_reply_to"] === "object" &&
relates["m.in_reply_to"] &&
"event_id" in relates["m.in_reply_to"] &&
typeof relates["m.in_reply_to"].event_id === "string"
) {
return relates["m.in_reply_to"].event_id;
}
}
return undefined;
}

View File

@@ -0,0 +1,28 @@
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const EventType = {
RoomMessage: "m.room.message",
RoomMessageEncrypted: "m.room.encrypted",
RoomMember: "m.room.member",
Location: "m.location",
} as const;
export const RelationType = {
Replace: "m.replace",
Thread: "m.thread",
} as const;
export type RoomMessageEventContent = MessageEventContent & {
url?: string;
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
isMatrixVerificationEventType,
isMatrixVerificationNoticeBody,
isMatrixVerificationRequestMsgType,
isMatrixVerificationRoomMessage,
} from "./verification-utils.js";
describe("matrix verification message classifiers", () => {
it("recognizes verification event types", () => {
expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true);
expect(isMatrixVerificationEventType("m.room.message")).toBe(false);
});
it("recognizes verification request message type", () => {
expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true);
expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false);
});
it("recognizes verification notice bodies", () => {
expect(
isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."),
).toBe(true);
expect(isMatrixVerificationNoticeBody("hello world")).toBe(false);
});
it("classifies verification room messages", () => {
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.key.verification.request",
body: "verify request",
}),
).toBe(true);
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.notice",
body: "Matrix verification cancelled by @alice:example.org.",
}),
).toBe(true);
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.text",
body: "normal chat message",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
const VERIFICATION_EVENT_PREFIX = "m.key.verification.";
const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request";
const VERIFICATION_NOTICE_PREFIXES = [
"Matrix verification request received from ",
"Matrix verification is ready with ",
"Matrix verification started with ",
"Matrix verification completed with ",
"Matrix verification cancelled by ",
"Matrix verification SAS with ",
];
function trimMaybeString(input: unknown): string {
return typeof input === "string" ? input.trim() : "";
}
export function isMatrixVerificationEventType(type: unknown): boolean {
return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX);
}
export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean {
return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE;
}
export function isMatrixVerificationNoticeBody(body: unknown): boolean {
const text = trimMaybeString(body);
return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix));
}
export function isMatrixVerificationRoomMessage(content: {
msgtype?: unknown;
body?: unknown;
}): boolean {
return (
isMatrixVerificationRequestMsgType(content.msgtype) ||
(trimMaybeString(content.msgtype) === "m.notice" &&
isMatrixVerificationNoticeBody(content.body))
);
}
export const matrixVerificationConstants = {
eventPrefix: VERIFICATION_EVENT_PREFIX,
requestMsgtype: VERIFICATION_REQUEST_MSGTYPE,
} as const;

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});

View File

@@ -0,0 +1,167 @@
/**
* Matrix Poll Types (MSC3381)
*
* Defines types for Matrix poll events:
* - m.poll.start - Creates a new poll
* - m.poll.response - Records a vote
* - m.poll.end - Closes a poll
*/
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [
M_POLL_START,
M_POLL_RESPONSE,
M_POLL_END,
ORG_POLL_START,
ORG_POLL_RESPONSE,
ORG_POLL_END,
];
export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string;
};
export type PollAnswer = {
id: string;
} & TextContent;
export type PollStartSubtype = {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = {
[M_POLL_START]?: PollStartSubtype;
[ORG_POLL_START]?: PollStartSubtype;
"m.poll"?: PollStartSubtype;
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
};
export type PollSummary = {
eventId: string;
roomId: string;
sender: string;
senderName: string;
question: string;
answers: string[];
kind: PollKind;
maxSelections: number;
};
export function isPollStartType(eventType: string): boolean {
return (POLL_START_TYPES as readonly string[]).includes(eventType);
}
export function getTextContent(text?: TextContent): string {
if (!text) {
return "";
}
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
}
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll =
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) {
return null;
}
const question = getTextContent(poll.question);
if (!question) {
return null;
}
const answers = poll.answers
.map((answer) => getTextContent(answer))
.filter((a) => a.trim().length > 0);
return {
eventId: "",
roomId: "",
sender: "",
senderName: "",
question,
answers,
kind: poll.kind ?? "m.poll.disclosed",
maxSelections: poll.max_selections ?? 1,
};
}
export function formatPollAsText(summary: PollSummary): string {
const lines = [
"[Poll]",
summary.question,
"",
...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
];
return lines.join("\n");
}
function buildTextContent(body: string): TextContent {
return {
"m.text": body,
"org.matrix.msc1767.text": body,
};
}
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) {
return question;
}
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim();
const answers = poll.options
.map((option) => option.trim())
.filter((option) => option.length > 0)
.map((option, idx) => ({
id: `answer${idx + 1}`,
...buildTextContent(option),
}));
const isMultiple = (poll.maxSelections ?? 1) > 1;
const maxSelections = isMultiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return {
[M_POLL_START]: {
question: buildTextContent(question),
kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
vi.mock("./client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
}));
import { probeMatrix } from "./probe.js";
describe("probeMatrix", () => {
beforeEach(() => {
vi.clearAllMocks();
isBunRuntimeMock.mockReturnValue(false);
createMatrixClientMock.mockResolvedValue({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
it("passes undefined userId when not provided", async () => {
const result = await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
timeoutMs: 1234,
});
expect(result.ok).toBe(true);
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: undefined,
accessToken: "tok",
localTimeoutMs: 1234,
});
});
it("trims provided userId before client creation", async () => {
await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
userId: " @bot:example.org ",
timeoutMs: 500,
});
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
localTimeoutMs: 500,
});
});
});

View File

@@ -0,0 +1,70 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix-js";
import { createMatrixClient, isBunRuntime } from "./client.js";
export type MatrixProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
userId?: string | null;
};
export async function probeMatrix(params: {
homeserver: string;
accessToken: string;
userId?: string;
timeoutMs: number;
}): Promise<MatrixProbe> {
const started = Date.now();
const result: MatrixProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
if (isBunRuntime()) {
return {
...result,
error: "Matrix probe requires Node (bun runtime not supported)",
elapsedMs: Date.now() - started,
};
}
if (!params.homeserver?.trim()) {
return {
...result,
error: "missing homeserver",
elapsedMs: Date.now() - started,
};
}
if (!params.accessToken?.trim()) {
return {
...result,
error: "missing access token",
elapsedMs: Date.now() - started,
};
}
try {
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
result.elapsedMs = Date.now() - started;
return result;
} catch (err) {
return {
...result,
status:
typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import {
isSupportedMatrixAvatarSource,
syncMatrixOwnProfile,
type MatrixProfileSyncResult,
} from "./profile.js";
function createClientStub() {
return {
getUserProfile: vi.fn(async () => ({})),
setDisplayName: vi.fn(async () => {}),
setAvatarUrl: vi.fn(async () => {}),
uploadContent: vi.fn(async () => "mxc://example/avatar"),
};
}
function expectNoUpdates(result: MatrixProfileSyncResult) {
expect(result.displayNameUpdated).toBe(false);
expect(result.avatarUpdated).toBe(false);
}
describe("matrix profile sync", () => {
it("skips when no desired profile values are provided", async () => {
const client = createClientStub();
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
});
expect(result.skipped).toBe(true);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("updates display name when desired name differs", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Old Name",
avatar_url: "mxc://example/existing",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "New Name",
});
expect(result.skipped).toBe(false);
expect(result.displayNameUpdated).toBe(true);
expect(result.avatarUpdated).toBe(false);
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
});
it("does not update when name and avatar already match", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/avatar",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "Bot",
avatarUrl: "mxc://example/avatar",
});
expect(result.skipped).toBe(false);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("converts http avatar URL by uploading and then updates profile avatar", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/old",
});
client.uploadContent.mockResolvedValue("mxc://example/new-avatar");
const loadAvatarFromUrl = vi.fn(async () => ({
buffer: Buffer.from("avatar-bytes"),
contentType: "image/png",
fileName: "avatar.png",
}));
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "https://cdn.example.org/avatar.png",
loadAvatarFromUrl,
});
expect(result.convertedAvatarFromHttp).toBe(true);
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
expect(result.avatarUpdated).toBe(true);
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
"https://cdn.example.org/avatar.png",
10 * 1024 * 1024,
);
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
});
it("rejects unsupported avatar URL schemes", async () => {
const client = createClientStub();
await expect(
syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "file:///tmp/avatar.png",
}),
).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
});
it("recognizes supported avatar sources", () => {
expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true);
expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false);
});
});

View File

@@ -0,0 +1,143 @@
import type { MatrixClient } from "./sdk.js";
export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024;
type MatrixProfileClient = Pick<
MatrixClient,
"getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent"
>;
type MatrixProfileLoadResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type MatrixProfileSyncResult = {
skipped: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
function normalizeOptionalText(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function isMatrixMxcUri(value: string): boolean {
return value.trim().toLowerCase().startsWith("mxc://");
}
export function isMatrixHttpAvatarUri(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("https://") || normalized.startsWith("http://");
}
export function isSupportedMatrixAvatarSource(value: string): boolean {
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
}
async function resolveAvatarUrl(params: {
client: MatrixProfileClient;
avatarUrl: string | null;
avatarMaxBytes: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> {
const avatarUrl = normalizeOptionalText(params.avatarUrl);
if (!avatarUrl) {
return {
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
}
if (isMatrixMxcUri(avatarUrl)) {
return {
resolvedAvatarUrl: avatarUrl,
convertedAvatarFromHttp: false,
};
}
if (!isMatrixHttpAvatarUri(avatarUrl)) {
throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
}
if (!params.loadAvatarFromUrl) {
throw new Error("Matrix avatar URL conversion requires a media loader.");
}
const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes);
const uploadedMxc = await params.client.uploadContent(
media.buffer,
media.contentType,
media.fileName || "avatar",
);
return {
resolvedAvatarUrl: uploadedMxc,
convertedAvatarFromHttp: true,
};
}
export async function syncMatrixOwnProfile(params: {
client: MatrixProfileClient;
userId: string;
displayName?: string | null;
avatarUrl?: string | null;
avatarMaxBytes?: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<MatrixProfileSyncResult> {
const desiredDisplayName = normalizeOptionalText(params.displayName);
const avatar = await resolveAvatarUrl({
client: params.client,
avatarUrl: params.avatarUrl ?? null,
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
loadAvatarFromUrl: params.loadAvatarFromUrl,
});
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
if (!desiredDisplayName && !desiredAvatarUrl) {
return {
skipped: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}
let currentDisplayName: string | undefined;
let currentAvatarUrl: string | undefined;
try {
const currentProfile = await params.client.getUserProfile(params.userId);
currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined;
currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined;
} catch {
// If profile fetch fails, attempt writes directly.
}
let displayNameUpdated = false;
let avatarUpdated = false;
if (desiredDisplayName && currentDisplayName !== desiredDisplayName) {
await params.client.setDisplayName(desiredDisplayName);
displayNameUpdated = true;
}
if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) {
await params.client.setAvatarUrl(desiredAvatarUrl);
avatarUpdated = true;
}
return {
skipped: false,
displayNameUpdated,
avatarUpdated,
resolvedAvatarUrl: desiredAvatarUrl,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
function createBootstrapperDeps() {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getPassword: vi.fn(() => "super-secret-password"),
getDeviceId: vi.fn(() => "DEVICE123"),
verificationManager: {
trackVerificationRequest: vi.fn(),
},
recoveryKeyStore: {
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
},
decryptBridge: {
bindCryptoRetrySignals: vi.fn(),
},
};
}
function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixCryptoBootstrapApi {
return {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
...overrides,
};
}
describe("MatrixCryptoBootstrapper", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
});
it("forces new cross-signing keys only when readiness check still fails", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
bootstrapCrossSigning: vi.fn(async () => {}),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow(
"Cross-signing bootstrap finished but server keys are still not published",
);
});
it("uses password UIA fallback when null and dummy auth fail", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const bootstrapCrossSigningCalls = bootstrapCrossSigning.mock.calls as Array<
[
{
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
}?,
]
>;
const authUploadDeviceSigningKeys =
bootstrapCrossSigningCalls[0]?.[0]?.authUploadDeviceSigningKeys;
expect(authUploadDeviceSigningKeys).toBeTypeOf("function");
const seenAuthStages: Array<Record<string, unknown> | null> = [];
const result = await authUploadDeviceSigningKeys?.(async (authData) => {
seenAuthStages.push(authData);
if (authData === null) {
throw new Error("need auth");
}
if (authData.type === "m.login.dummy") {
throw new Error("dummy rejected");
}
if (authData.type === "m.login.password") {
return "ok";
}
throw new Error("unexpected auth stage");
});
expect(result).toBe("ok");
expect(seenAuthStages).toEqual([
null,
{ type: "m.login.dummy" },
{
type: "m.login.password",
identifier: { type: "m.id.user", user: "@bot:example.org" },
password: "super-secret-password",
},
]);
});
it("resets cross-signing when first bootstrap attempt throws", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("first attempt failed"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("marks own device verified and cross-signs it when needed", async () => {
const deps = createBootstrapperDeps();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
})),
setDeviceVerified,
crossSignDevice,
isCrossSigningReady: vi.fn(async () => true),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("auto-accepts incoming verification requests from other users", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
verificationRequest,
);
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
});
it("still auto-accepts verification when tracking summary throws", async () => {
const deps = createBootstrapperDeps();
deps.verificationManager.trackVerificationRequest = vi.fn(() => {
throw new Error("summary failure");
});
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
});
it("skips auto-accept for requests that are no longer requested", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
phase: VerificationPhase.Cancelled,
accepting: false,
declining: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(verificationRequest.accept).not.toHaveBeenCalled();
});
it("registers verification listeners only once across repeated bootstrap calls", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
await bootstrapper.bootstrap(crypto);
expect(crypto.on).toHaveBeenCalledTimes(1);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,334 @@
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import { LogService } from "./logger.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type {
MatrixAuthDict,
MatrixCryptoBootstrapApi,
MatrixRawEvent,
MatrixUiAuthCallback,
} from "./types.js";
import type {
MatrixVerificationManager,
MatrixVerificationRequestLike,
} from "./verification-manager.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
};
export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean;
strict?: boolean;
};
export type MatrixCryptoBootstrapResult = {
crossSigningReady: boolean;
crossSigningPublished: boolean;
ownDeviceVerified: boolean | null;
};
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private verificationHandlerRegistered = false;
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(
crypto: MatrixCryptoBootstrapApi,
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
await this.bootstrapSecretStorage(crypto, strict);
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
strict,
});
await this.bootstrapSecretStorage(crypto, strict);
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
ownDeviceVerified,
};
}
private createSigningKeysUiAuthCallback(params: {
userId: string;
password?: string;
}): MatrixUiAuthCallback {
return async <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
try {
return await makeRequest(null);
} catch {
// Some homeservers require an explicit dummy UIA stage even when no user interaction is needed.
try {
return await makeRequest({ type: "m.login.dummy" });
} catch {
if (!params.password?.trim()) {
throw new Error(
"Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback",
);
}
return await makeRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: params.userId },
password: params.password,
});
}
}
};
}
private async bootstrapCrossSigning(
crypto: MatrixCryptoBootstrapApi,
options: { forceResetCrossSigning: boolean; strict: boolean },
): Promise<{ ready: boolean; published: boolean }> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
userId,
password: this.deps.getPassword?.(),
});
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
if (typeof crypto.userHasCrossSigningKeys !== "function") {
return true;
}
try {
return await crypto.userHasCrossSigningKeys(userId, true);
} catch {
return false;
}
};
const isCrossSigningReady = async (): Promise<boolean> => {
if (typeof crypto.isCrossSigningReady !== "function") {
return true;
}
try {
return await crypto.isCrossSigningReady();
} catch {
return false;
}
};
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
}
const message = "Cross-signing bootstrap finished but server keys are still not published";
LogService.warn("MatrixClientLite", message);
if (options.strict) {
throw new Error(message);
}
return { ready, published };
};
if (options.forceResetCrossSigning) {
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
return await finalize();
}
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
try {
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Initial cross-signing bootstrap failed, trying reset:",
err,
);
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (resetErr) {
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
if (options.strict) {
throw resetErr instanceof Error ? resetErr : new Error(String(resetErr));
}
return { ready: false, published: false };
}
}
const firstPassReady = await isCrossSigningReady();
const firstPassPublished = await hasPublishedCrossSigningKeys();
if (firstPassReady && firstPassPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready: true, published: true };
}
// Fallback: recover from broken local/server state by creating a fresh identity.
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
return await finalize();
}
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): Promise<void> {
try {
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
if (strict) {
throw err instanceof Error ? err : new Error(String(err));
}
}
}
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
if (this.verificationHandlerRegistered) {
return;
}
this.verificationHandlerRegistered = true;
// Auto-accept incoming verification requests from other users/devices.
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
const verificationRequest = request as MatrixVerificationRequestLike;
try {
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
} catch (err) {
LogService.warn(
"MatrixClientLite",
`Failed to track verification request from ${verificationRequest.otherUserId}:`,
err,
);
}
const otherUserId = verificationRequest.otherUserId;
const isSelfVerification = verificationRequest.isSelfVerification;
const initiatedByMe = verificationRequest.initiatedByMe;
const phase =
typeof verificationRequest.phase === "number"
? verificationRequest.phase
: VerificationPhase.Requested;
const accepting = verificationRequest.accepting === true;
const declining = verificationRequest.declining === true;
if (isSelfVerification || initiatedByMe) {
LogService.debug(
"MatrixClientLite",
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
);
return;
}
if (phase !== VerificationPhase.Requested || accepting || declining) {
LogService.debug(
"MatrixClientLite",
`Skipping auto-accept for ${otherUserId} in phase=${phase} accepting=${accepting} declining=${declining}`,
);
return;
}
try {
LogService.info(
"MatrixClientLite",
`Auto-accepting verification request from ${otherUserId}`,
);
await verificationRequest.accept();
LogService.info(
"MatrixClientLite",
`Verification request from ${otherUserId} accepted, waiting for SAS...`,
);
} catch (err) {
LogService.warn(
"MatrixClientLite",
`Failed to auto-accept verification from ${otherUserId}:`,
err,
);
}
});
this.deps.decryptBridge.bindCryptoRetrySignals(crypto);
LogService.info("MatrixClientLite", "Verification request handler registered");
}
private async ensureOwnDeviceTrust(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): Promise<boolean | null> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
return null;
}
const userId = await this.deps.getUserId();
const deviceStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const alreadyVerified =
deviceStatus?.isVerified?.() === true ||
deviceStatus?.localVerified === true ||
deviceStatus?.crossSigningVerified === true ||
deviceStatus?.signedByOwner === true;
if (alreadyVerified) {
return true;
}
if (typeof crypto.setDeviceVerified === "function") {
await crypto.setDeviceVerified(userId, deviceId, true);
}
if (typeof crypto.crossSignDevice === "function") {
const crossSigningReady =
typeof crypto.isCrossSigningReady === "function"
? await crypto.isCrossSigningReady()
: true;
if (crossSigningReady) {
await crypto.crossSignDevice(deviceId);
}
}
const refreshedStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified =
refreshedStatus?.isVerified?.() === true ||
refreshedStatus?.localVerified === true ||
refreshedStatus?.crossSigningVerified === true ||
refreshedStatus?.signedByOwner === true;
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
}
return verified;
}
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import { createMatrixCryptoFacade } from "./crypto-facade.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixVerificationManager } from "./verification-manager.js";
describe("createMatrixCryptoFacade", () => {
it("detects encrypted rooms from cached room state", async () => {
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => true,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
});
it("falls back to server room state when room cache has no encryption event", async () => {
const getRoomStateEvent = vi.fn(async () => ({
algorithm: "m.megolm.v1.aes-sha2",
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => false,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent,
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
});
it("forwards verification requests and uses client crypto API", async () => {
const crypto = { requestOwnUserVerification: vi.fn(async () => null) };
const requestVerification = vi.fn(async () => ({
id: "verification-1",
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: true,
phase: 2,
phaseName: "ready",
pending: true,
methods: ["m.sas.v1"],
canAccept: false,
hasSas: false,
hasReciprocateQr: false,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
requestVerification,
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({})),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
const result = await facade.requestVerification({
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(requestVerification).toHaveBeenCalledWith(crypto, {
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(result.id).toBe("verification-1");
await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" });
});
});

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