Matrix-js: add parity docs and live harness scripts

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 00:45:15 -05:00
parent 999fa0f50f
commit 1a7ea655bf
12 changed files with 1496 additions and 0 deletions

View 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.

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,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);
});

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() || "@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);
});

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() || "@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);
});

View 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);
});