mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 05:20:48 +00:00
Matrix-js: add parity docs and live harness scripts
This commit is contained in:
120
extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md
Normal file
120
extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Legacy Matrix Parity Gap Audit
|
||||
|
||||
Audit date: February 23, 2026
|
||||
|
||||
Scope:
|
||||
|
||||
- Baseline spec: `/Users/gumadeiras/openclaw/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md`
|
||||
- Compared implementations:
|
||||
- Legacy: `/Users/gumadeiras/openclaw/extensions/matrix`
|
||||
- New: `/Users/gumadeiras/openclaw/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:
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/index.ts:7`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/openclaw.plugin.json:2`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:41`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:99`
|
||||
|
||||
2. `PASS (static)`: Config namespace is consistently `channels.matrix-js`.
|
||||
|
||||
- Evidence:
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:116`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:125`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:319`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/onboarding.ts:17`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/onboarding.ts:174`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/client.ts:22`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/config.ts:125`
|
||||
|
||||
3. `PASS (static)`: Outbound/inbound channel tags and routing context emit `matrix-js`.
|
||||
|
||||
- Evidence:
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:20`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:36`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:49`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts:55`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts:496`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts:509`
|
||||
|
||||
4. `PASS (static)`: Matrix-js now uses isolated storage namespace/prefixes.
|
||||
|
||||
- Evidence:
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/credentials.ts:31`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/storage.ts:42`
|
||||
- `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts:127`
|
||||
- `/Users/gumadeiras/openclaw/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) | `/Users/gumadeiras/openclaw/extensions/matrix/src/config-schema.ts` vs `/Users/gumadeiras/openclaw/extensions/matrix-js/src/config-schema.ts` (no semantic diffs) |
|
||||
| Auth precedence (config/env/token/cache/password/register) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/config.ts` |
|
||||
| Bun runtime rejection behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/runtime.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/index.ts` |
|
||||
| Startup/shutdown lifecycle and status updates match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/index.ts` |
|
||||
| DM detection heuristics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/direct.ts` |
|
||||
| DM/group allowlist + pairing flow matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/allowlist.ts` |
|
||||
| Mention detection (`m.mentions`, formatted_body links, regex) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/mentions.ts` |
|
||||
| Control-command authorization gate behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts` |
|
||||
| Inbound poll normalization matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/poll-types.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts` |
|
||||
| Inbound location normalization matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/location.ts` |
|
||||
| Inbound media download/decrypt/size-limit behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/media.ts` |
|
||||
| Reply dispatch + typing + ack reaction + read receipts match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/replies.ts` |
|
||||
| Thread handling (`threadReplies`) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/threads.ts` |
|
||||
| `replyToMode` handling for single/multi reply flows matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/replies.ts` |
|
||||
| Outbound text chunking, markdown, and formatting behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/formatting.ts` |
|
||||
| Outbound media encryption/voice/thumbnail/duration behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/media.ts` |
|
||||
| Outbound poll payload behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/poll-types.ts` |
|
||||
| Action gating and action semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/actions.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/tool-actions.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/actions/*` |
|
||||
| Verification action flow and summary semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/actions/verification.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/verification-manager.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts` |
|
||||
| Directory live lookup + target resolution ambiguity handling matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/directory-live.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/resolve-targets.ts` |
|
||||
| Probe/status reporting fields match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/probe.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts` |
|
||||
| Storage layout and credential persistence semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/storage.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/credentials.ts` |
|
||||
| HTTP hardening and decrypt retry behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/http-client.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts`, `/Users/gumadeiras/openclaw/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.
|
||||
397
extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md
Normal file
397
extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md
Normal 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.
|
||||
104
extensions/matrix-js/scripts/live-basic-send.ts
Normal file
104
extensions/matrix-js/scripts/live-basic-send.ts
Normal 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() || "@gumadeiras:matrix.gumadeiras.com";
|
||||
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);
|
||||
});
|
||||
145
extensions/matrix-js/scripts/live-common.ts
Normal file
145
extensions/matrix-js/scripts/live-common.ts
Normal 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;
|
||||
}
|
||||
126
extensions/matrix-js/scripts/live-cross-signing-probe.ts
Normal file
126
extensions/matrix-js/scripts/live-cross-signing-probe.ts
Normal 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);
|
||||
});
|
||||
28
extensions/matrix-js/scripts/live-e2ee-bootstrap.ts
Normal file
28
extensions/matrix-js/scripts/live-e2ee-bootstrap.ts
Normal 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);
|
||||
});
|
||||
65
extensions/matrix-js/scripts/live-e2ee-room-state.ts
Normal file
65
extensions/matrix-js/scripts/live-e2ee-room-state.ts
Normal 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);
|
||||
});
|
||||
99
extensions/matrix-js/scripts/live-e2ee-send-room.ts
Normal file
99
extensions/matrix-js/scripts/live-e2ee-send-room.ts
Normal 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);
|
||||
});
|
||||
169
extensions/matrix-js/scripts/live-e2ee-send.ts
Normal file
169
extensions/matrix-js/scripts/live-e2ee-send.ts
Normal 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() || "@gumadeiras:matrix.gumadeiras.com";
|
||||
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);
|
||||
});
|
||||
57
extensions/matrix-js/scripts/live-e2ee-status.ts
Normal file
57
extensions/matrix-js/scripts/live-e2ee-status.ts
Normal 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);
|
||||
});
|
||||
122
extensions/matrix-js/scripts/live-e2ee-wait-reply.ts
Normal file
122
extensions/matrix-js/scripts/live-e2ee-wait-reply.ts
Normal 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() || "@gumadeiras:matrix.gumadeiras.com";
|
||||
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);
|
||||
});
|
||||
64
extensions/matrix-js/scripts/live-read-room.ts
Normal file
64
extensions/matrix-js/scripts/live-read-room.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { readMatrixMessages } from "../src/matrix/actions.js";
|
||||
import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js";
|
||||
import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js";
|
||||
|
||||
function toMessageText(msg: {
|
||||
text: string | null;
|
||||
body?: string | null;
|
||||
fallbackBody?: string | null;
|
||||
}): string {
|
||||
return msg.text ?? msg.body ?? msg.fallbackBody ?? "";
|
||||
}
|
||||
|
||||
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.id,
|
||||
sender: msg.sender,
|
||||
ts: msg.timestamp,
|
||||
text: toMessageText(msg),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user