1431 lines
46 KiB
Markdown
1431 lines
46 KiB
Markdown
# Matrix Channel Adapter Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Add a Matrix channel adapter using raw fetch against the Matrix Client-Server API v3, with zero external dependencies (no matrix-js-sdk).
|
|
|
|
**Architecture:** Polling-based adapter using `/_matrix/client/v3/sync` with incremental `since` tokens. Auth via pre-provisioned access token in config. Room allowlist + mention gating for multi-room safety. DM detection via `m.direct` account data. Outbound via `/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}`.
|
|
|
|
**Tech Stack:** Node.js fetch (built-in), Vitest for tests, Zod for config schema validation.
|
|
|
|
---
|
|
|
|
## Context
|
|
|
|
### Existing Patterns
|
|
|
|
All Flynn channel adapters follow a consistent pattern:
|
|
|
|
1. **Config interface** — e.g. `TelegramAdapterConfig`, `DiscordAdapterConfig`
|
|
2. **Adapter class** implementing `ChannelAdapter` from `src/channels/types.ts`
|
|
3. **Barrel export** from `src/channels/<name>/index.ts`
|
|
4. **Re-export** in `src/channels/index.ts`
|
|
5. **Zod schema** in `src/config/schema.ts` (optional field on root config)
|
|
6. **Registration** in `src/daemon/channels.ts` (conditional on config presence)
|
|
7. **Tests** mocking the platform SDK; here we mock `global.fetch`
|
|
|
|
### Key ChannelAdapter interface
|
|
|
|
```typescript
|
|
interface ChannelAdapter {
|
|
readonly name: string;
|
|
readonly status: ChannelStatus;
|
|
connect(): Promise<void>;
|
|
disconnect(): Promise<void>;
|
|
send(peerId: string, message: OutboundMessage): Promise<void>;
|
|
onMessage(handler: (msg: InboundMessage) => void): void;
|
|
}
|
|
```
|
|
|
|
### senderId convention
|
|
|
|
- Telegram: `String(chatId)` (numeric chat ID)
|
|
- Discord: `channelId` (snowflake string)
|
|
- Slack: `channelId:threadTs`
|
|
- WhatsApp: `number@c.us`
|
|
|
|
For Matrix: `roomId` (e.g. `!abc123:matrix.org`). This gives one session per room, matching the Discord pattern. The Matrix userId (e.g. `@flynn:matrix.org`) goes into `senderName`.
|
|
|
|
### peerId convention
|
|
|
|
`peerId` passed to `send()` is the `roomId`. This is the same as `senderId` since replies go back to the same room.
|
|
|
|
---
|
|
|
|
## Config Shape
|
|
|
|
```yaml
|
|
matrix:
|
|
homeserver_url: "https://matrix.example.org"
|
|
access_token: "${MATRIX_ACCESS_TOKEN}"
|
|
allowed_room_ids:
|
|
- "!abc123:matrix.org"
|
|
require_mention: true # default: true
|
|
sync_timeout_ms: 30000 # default: 30000 (long-poll timeout)
|
|
display_name: "Flynn" # optional: set bot display name on connect
|
|
```
|
|
|
|
Zod schema:
|
|
|
|
```typescript
|
|
const matrixSchema = z.object({
|
|
homeserver_url: z.string().url('Homeserver URL is required'),
|
|
access_token: z.string().min(1, 'Access token is required'),
|
|
allowed_room_ids: z.array(z.string()).default([]),
|
|
require_mention: z.boolean().default(true),
|
|
sync_timeout_ms: z.number().min(1000).max(120000).default(30000),
|
|
display_name: z.string().optional(),
|
|
}).optional();
|
|
```
|
|
|
|
---
|
|
|
|
## Matrix Client-Server API Endpoints Used
|
|
|
|
| Endpoint | Method | Purpose |
|
|
|----------|--------|---------|
|
|
| `/_matrix/client/v3/sync` | GET | Long-poll for events (with `?since=&timeout=&filter=`) |
|
|
| `/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` | PUT | Send a message |
|
|
| `/_matrix/client/v3/user/{userId}/account_data/m.direct` | GET | Detect DM rooms |
|
|
| `/_matrix/client/v3/account/whoami` | GET | Resolve own userId on connect |
|
|
|
|
### Sync filter (minimize bandwidth)
|
|
|
|
```json
|
|
{
|
|
"room": {
|
|
"timeline": { "limit": 50 },
|
|
"state": { "lazy_load_members": true }
|
|
},
|
|
"presence": { "not_types": ["*"] },
|
|
"account_data": { "types": ["m.direct"] }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Design Decisions
|
|
|
|
### 1. No matrix-js-sdk dependency
|
|
- **Choice**: Raw fetch calls against `/_matrix/client/v3` endpoints
|
|
- **Rationale**: User explicitly requested minimal dependencies. The Matrix CS API is well-documented REST/JSON. We only need sync, send, whoami, and account_data — 4 endpoints total.
|
|
- **Alternatives**: matrix-js-sdk (heavy, ~2MB, pulls in many transitive deps), matrix-bot-sdk (lighter but still a dep).
|
|
|
|
### 2. Polling via /sync long-poll (not WebSocket or SSE)
|
|
- **Choice**: `/sync` with `timeout` param for long-polling, looped in an async function with `AbortController` for clean shutdown.
|
|
- **Rationale**: This is the standard Matrix sync mechanism. Simple, robust, no extra deps. The `timeout` param makes it behave like long-polling (server holds connection open until events arrive or timeout elapses).
|
|
- **Alternatives**: Sliding Sync (MSC3575, not universally supported), WebSocket transport (experimental extension).
|
|
|
|
### 3. senderId = roomId
|
|
- **Choice**: Use the Matrix room ID (`!...`) as `senderId` in `InboundMessage`, matching the Discord adapter pattern where `senderId = channelId`.
|
|
- **Rationale**: Flynn creates one session per `channel:senderId` pair. Using `roomId` means one session per room, which is the natural boundary. The human user's Matrix ID goes into `senderName`.
|
|
|
|
### 4. DM detection via m.direct account data
|
|
- **Choice**: On connect, fetch the bot's `m.direct` account data to build a `Set<string>` of known DM room IDs. DM rooms bypass `require_mention`.
|
|
- **Rationale**: Matrix DMs are just rooms, but the `m.direct` account data flags which rooms are direct messages. This is the standard approach and doesn't require room member counting heuristics.
|
|
|
|
### 5. Mention detection via body text
|
|
- **Choice**: Check for `@displayName` or bot's Matrix user ID in the message body text.
|
|
- **Rationale**: Matrix `m.room.message` events include both `body` (plain text) and optional `formatted_body` (HTML with `<a href="https://matrix.to/#/@user:server">` pill links). Checking the plain text `body` for the display name or MXID is simpler and sufficient. The HTML formatted_body could also be checked for matrix.to links, but body text matching covers all clients.
|
|
|
|
### 6. Transaction ID for idempotent sends
|
|
- **Choice**: Generate txnId as `m${Date.now()}.${counter++}` per adapter instance.
|
|
- **Rationale**: Matrix PUT send requires a txnId for idempotency. A timestamp + counter ensures uniqueness within a process lifetime.
|
|
|
|
### 7. Message size limit
|
|
- **Choice**: Use `splitMessage()` utility with a 65536-char limit (Matrix spec allows up to 65536 bytes for event content).
|
|
- **Rationale**: Matrix's limit is much higher than Telegram (4096) or Discord (2000), but chunking at 65536 chars prevents oversized events.
|
|
|
|
---
|
|
|
|
## Implementation Steps
|
|
|
|
### Task 1: Add Matrix Zod Schema to Config
|
|
|
|
**Files:**
|
|
- Modify: `src/config/schema.ts`
|
|
|
|
**Step 1: Add the matrixSchema definition**
|
|
|
|
Add after the `whatsappSchema` definition (around line 350):
|
|
|
|
```typescript
|
|
const matrixSchema = z.object({
|
|
homeserver_url: z.string().url('Homeserver URL is required'),
|
|
access_token: z.string().min(1, 'Access token is required'),
|
|
allowed_room_ids: z.array(z.string()).default([]),
|
|
require_mention: z.boolean().default(true),
|
|
sync_timeout_ms: z.number().min(1000).max(120000).default(30000),
|
|
display_name: z.string().optional(),
|
|
}).optional();
|
|
```
|
|
|
|
**Step 2: Add `matrix` to configSchema**
|
|
|
|
In `configSchema` (around line 510), add after `whatsapp`:
|
|
|
|
```typescript
|
|
matrix: matrixSchema,
|
|
```
|
|
|
|
**Step 3: Add exported type**
|
|
|
|
After the existing type exports (around line 559), add:
|
|
|
|
```typescript
|
|
export type MatrixConfig = z.infer<typeof matrixSchema>;
|
|
```
|
|
|
|
**Step 4: Run typecheck**
|
|
|
|
Run: `pnpm typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/config/schema.ts
|
|
git commit -m "feat(matrix): add Matrix channel config schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create Matrix Adapter — Core Structure and Connect
|
|
|
|
**Files:**
|
|
- Create: `src/channels/matrix/adapter.ts`
|
|
- Create: `src/channels/matrix/adapter.test.ts`
|
|
- Create: `src/channels/matrix/index.ts`
|
|
|
|
**Step 1: Write the adapter.test.ts skeleton with connect/disconnect tests**
|
|
|
|
Create `src/channels/matrix/adapter.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { MatrixAdapter, type MatrixAdapterConfig } from './adapter.js';
|
|
import type { InboundMessage } from '../types.js';
|
|
|
|
// ── Mock global fetch ─────────────────────────────────────────────
|
|
|
|
const mockFetch = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const baseConfig: MatrixAdapterConfig = {
|
|
homeserverUrl: 'https://matrix.example.org',
|
|
accessToken: 'syt_test_token',
|
|
allowedRoomIds: ['!room1:example.org'],
|
|
requireMention: true,
|
|
syncTimeoutMs: 30000,
|
|
};
|
|
|
|
/** Helper: create a mock fetch Response. */
|
|
function mockResponse(body: unknown, status = 200): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => body,
|
|
text: async () => JSON.stringify(body),
|
|
} as Response;
|
|
}
|
|
|
|
/** Helper: make fetch return specific responses in sequence. */
|
|
function mockFetchSequence(...responses: Response[]) {
|
|
for (const response of responses) {
|
|
mockFetch.mockResolvedValueOnce(response);
|
|
}
|
|
}
|
|
|
|
describe('MatrixAdapter', () => {
|
|
let adapter: MatrixAdapter;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
adapter = new MatrixAdapter(baseConfig);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Ensure sync loop is stopped
|
|
if (adapter.status === 'connected' || adapter.status === 'connecting') {
|
|
await adapter.disconnect();
|
|
}
|
|
});
|
|
|
|
// ── Basic properties ────────────────────────────────────────────
|
|
|
|
it('has name "matrix"', () => {
|
|
expect(adapter.name).toBe('matrix');
|
|
});
|
|
|
|
it('starts as disconnected', () => {
|
|
expect(adapter.status).toBe('disconnected');
|
|
});
|
|
|
|
// ── connect / disconnect ────────────────────────────────────────
|
|
|
|
it('connect resolves userId via whoami and sets connected', async () => {
|
|
// whoami
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ user_id: '@flynn:example.org' }));
|
|
// m.direct account data
|
|
mockFetch.mockResolvedValueOnce(mockResponse({}));
|
|
// First sync (will be aborted on disconnect)
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Promise(() => {}), // never resolves — simulates long-poll
|
|
);
|
|
|
|
await adapter.connect();
|
|
|
|
expect(adapter.status).toBe('connected');
|
|
// Verify whoami was called
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://matrix.example.org/_matrix/client/v3/account/whoami',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer syt_test_token',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('connect throws on whoami failure', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ errcode: 'M_UNKNOWN_TOKEN' }, 401));
|
|
|
|
await expect(adapter.connect()).rejects.toThrow();
|
|
});
|
|
|
|
it('disconnect sets status to disconnected', async () => {
|
|
// whoami
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ user_id: '@flynn:example.org' }));
|
|
// m.direct
|
|
mockFetch.mockResolvedValueOnce(mockResponse({}));
|
|
// sync long-poll (never resolves)
|
|
mockFetch.mockResolvedValueOnce(new Promise(() => {}));
|
|
|
|
await adapter.connect();
|
|
expect(adapter.status).toBe('connected');
|
|
|
|
await adapter.disconnect();
|
|
expect(adapter.status).toBe('disconnected');
|
|
});
|
|
|
|
it('disconnect is safe when not connected', async () => {
|
|
await adapter.disconnect();
|
|
expect(adapter.status).toBe('disconnected');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails (adapter doesn't exist yet)**
|
|
|
|
Run: `pnpm test:run src/channels/matrix/adapter.test.ts`
|
|
Expected: FAIL — cannot resolve `./adapter.js`
|
|
|
|
**Step 3: Write adapter.ts with connect/disconnect**
|
|
|
|
Create `src/channels/matrix/adapter.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* Matrix channel adapter.
|
|
*
|
|
* Implements the ChannelAdapter interface using raw fetch against the
|
|
* Matrix Client-Server API v3. No external Matrix SDK dependency.
|
|
* Uses /sync long-polling for inbound messages and PUT /send for outbound.
|
|
*/
|
|
|
|
import type {
|
|
InboundMessage,
|
|
OutboundMessage,
|
|
ChannelAdapter,
|
|
ChannelStatus,
|
|
} from '../types.js';
|
|
import { splitMessage } from '../utils.js';
|
|
import type { PairingManager } from '../pairing.js';
|
|
|
|
/** Configuration for the Matrix channel adapter. */
|
|
export interface MatrixAdapterConfig {
|
|
homeserverUrl: string;
|
|
accessToken: string;
|
|
/** Room IDs to respond in. Empty = all joined rooms. */
|
|
allowedRoomIds?: string[];
|
|
/** Require @mention to respond in rooms (default: true). DMs always respond. */
|
|
requireMention?: boolean;
|
|
/** Long-poll timeout for /sync in milliseconds (default: 30000). */
|
|
syncTimeoutMs?: number;
|
|
/** Optional display name to set on connect. */
|
|
displayName?: string;
|
|
/** Optional pairing manager for DM pairing codes. */
|
|
pairingManager?: PairingManager;
|
|
}
|
|
|
|
/** Maximum message length before chunking (Matrix allows up to 65536 bytes). */
|
|
const MAX_MESSAGE_LENGTH = 65536;
|
|
|
|
/**
|
|
* Matrix channel adapter backed by raw Client-Server API v3 fetch calls.
|
|
*
|
|
* Handles room allowlist filtering, optional mention requirement,
|
|
* DM detection via m.direct, and message chunking.
|
|
*/
|
|
export class MatrixAdapter implements ChannelAdapter {
|
|
readonly name = 'matrix';
|
|
|
|
private _status: ChannelStatus = 'disconnected';
|
|
private messageHandler?: (msg: InboundMessage) => void;
|
|
private config: MatrixAdapterConfig;
|
|
|
|
/** Resolved bot user ID (e.g. @flynn:example.org). */
|
|
private userId: string | null = null;
|
|
/** Resolved bot display name (for mention detection). */
|
|
private botDisplayName: string | null = null;
|
|
/** Set of room IDs that are direct messages. */
|
|
private dmRoomIds: Set<string> = new Set();
|
|
/** Incremental sync token. */
|
|
private syncToken: string | null = null;
|
|
/** AbortController for the sync loop. */
|
|
private syncAbort: AbortController | null = null;
|
|
/** Transaction ID counter for idempotent sends. */
|
|
private txnCounter = 0;
|
|
|
|
get status(): ChannelStatus {
|
|
return this._status;
|
|
}
|
|
|
|
constructor(config: MatrixAdapterConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/** Register the inbound message handler. Called by the registry before connect(). */
|
|
onMessage(handler: (msg: InboundMessage) => void): void {
|
|
this.messageHandler = handler;
|
|
}
|
|
|
|
/**
|
|
* Connect to the Matrix homeserver:
|
|
* 1. Resolve bot userId via /account/whoami
|
|
* 2. Fetch m.direct account data for DM detection
|
|
* 3. Start the /sync long-poll loop
|
|
*/
|
|
async connect(): Promise<void> {
|
|
this._status = 'connecting';
|
|
|
|
try {
|
|
// Resolve our own user ID
|
|
const whoami = await this.matrixGet<{ user_id: string }>(
|
|
'/_matrix/client/v3/account/whoami',
|
|
);
|
|
this.userId = whoami.user_id;
|
|
this.botDisplayName = this.config.displayName ?? this.userId.split(':')[0].slice(1);
|
|
|
|
// Fetch m.direct account data for DM detection
|
|
await this.loadDirectRooms();
|
|
|
|
// Start sync loop (fire and forget — runs until disconnect)
|
|
this.syncAbort = new AbortController();
|
|
this.runSyncLoop(this.syncAbort.signal);
|
|
|
|
this._status = 'connected';
|
|
console.log(`Matrix adapter connected as ${this.userId}`);
|
|
} catch (error) {
|
|
this._status = 'error';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Stop the sync loop and clean up. */
|
|
async disconnect(): Promise<void> {
|
|
if (this.syncAbort) {
|
|
this.syncAbort.abort();
|
|
this.syncAbort = null;
|
|
}
|
|
this.userId = null;
|
|
this.syncToken = null;
|
|
this.dmRoomIds.clear();
|
|
this._status = 'disconnected';
|
|
}
|
|
|
|
/** Send an outbound message to a Matrix room. */
|
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
|
if (this._status !== 'connected') {
|
|
throw new Error('Matrix adapter not connected');
|
|
}
|
|
|
|
const text = message.text ?? '';
|
|
if (!text.trim()) { return; }
|
|
|
|
if (text.length <= MAX_MESSAGE_LENGTH) {
|
|
await this.sendRoomMessage(peerId, text);
|
|
} else {
|
|
const chunks = splitMessage(text, MAX_MESSAGE_LENGTH);
|
|
for (const chunk of chunks) {
|
|
await this.sendRoomMessage(peerId, chunk);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Private: Matrix API helpers ─────────────────────────────────
|
|
|
|
/** Make an authenticated GET request to the homeserver. */
|
|
private async matrixGet<T>(path: string, query?: Record<string, string>): Promise<T> {
|
|
const url = new URL(path, this.config.homeserverUrl);
|
|
if (query) {
|
|
for (const [k, v] of Object.entries(query)) {
|
|
url.searchParams.set(k, v);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Matrix API error (${response.status}): ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/** Make an authenticated PUT request to the homeserver. */
|
|
private async matrixPut<T>(path: string, body: unknown): Promise<T> {
|
|
const url = new URL(path, this.config.homeserverUrl);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Matrix API error (${response.status}): ${text}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/** Send a text message to a room with idempotent txnId. */
|
|
private async sendRoomMessage(roomId: string, text: string): Promise<void> {
|
|
const txnId = `m${Date.now()}.${this.txnCounter++}`;
|
|
await this.matrixPut(
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
|
|
{
|
|
msgtype: 'm.text',
|
|
body: text,
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Load the m.direct account data to populate dmRoomIds. */
|
|
private async loadDirectRooms(): Promise<void> {
|
|
try {
|
|
const data = await this.matrixGet<Record<string, string[]>>(
|
|
`/_matrix/client/v3/user/${encodeURIComponent(this.userId!)}/account_data/m.direct`,
|
|
);
|
|
this.dmRoomIds.clear();
|
|
// m.direct maps userId → [roomId, ...]. Flatten all room IDs.
|
|
for (const roomIds of Object.values(data)) {
|
|
if (Array.isArray(roomIds)) {
|
|
for (const roomId of roomIds) {
|
|
this.dmRoomIds.add(roomId);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// m.direct may not exist if the bot has no DMs yet — that's fine.
|
|
this.dmRoomIds.clear();
|
|
}
|
|
}
|
|
|
|
/** Check if a room is a DM. */
|
|
private isDM(roomId: string): boolean {
|
|
return this.dmRoomIds.has(roomId);
|
|
}
|
|
|
|
// ── Private: Sync loop ──────────────────────────────────────────
|
|
|
|
/** Run the /sync long-poll loop until aborted. */
|
|
private async runSyncLoop(signal: AbortSignal): Promise<void> {
|
|
// Build the sync filter to minimize bandwidth
|
|
const filter = JSON.stringify({
|
|
room: {
|
|
timeline: { limit: 50 },
|
|
state: { lazy_load_members: true },
|
|
},
|
|
presence: { not_types: ['*'] },
|
|
account_data: { types: ['m.direct'] },
|
|
});
|
|
|
|
while (!signal.aborted) {
|
|
try {
|
|
const query: Record<string, string> = {
|
|
timeout: String(this.config.syncTimeoutMs ?? 30000),
|
|
filter,
|
|
};
|
|
if (this.syncToken) {
|
|
query.since = this.syncToken;
|
|
}
|
|
|
|
const url = new URL('/_matrix/client/v3/sync', this.config.homeserverUrl);
|
|
for (const [k, v] of Object.entries(query)) {
|
|
url.searchParams.set(k, v);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.accessToken}`,
|
|
},
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
console.error(`Matrix sync error (${response.status}): ${body}`);
|
|
// Back off on error
|
|
await this.sleep(5000, signal);
|
|
continue;
|
|
}
|
|
|
|
const syncResponse = await response.json() as MatrixSyncResponse;
|
|
this.syncToken = syncResponse.next_batch;
|
|
|
|
// Process sync response
|
|
this.processSyncResponse(syncResponse);
|
|
} catch (error) {
|
|
if (signal.aborted) { return; }
|
|
console.error('Matrix sync loop error:', error instanceof Error ? error.message : 'Unknown error');
|
|
// Back off on error
|
|
await this.sleep(5000, signal);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Process a /sync response — extract timeline events from joined rooms. */
|
|
private processSyncResponse(sync: MatrixSyncResponse): void {
|
|
if (!this.messageHandler) { return; }
|
|
|
|
const joinedRooms = sync.rooms?.join;
|
|
if (!joinedRooms) { return; }
|
|
|
|
// Update m.direct from account_data if present
|
|
const accountData = sync.account_data?.events;
|
|
if (accountData) {
|
|
for (const event of accountData) {
|
|
if (event.type === 'm.direct' && event.content) {
|
|
this.dmRoomIds.clear();
|
|
for (const roomIds of Object.values(event.content as Record<string, string[]>)) {
|
|
if (Array.isArray(roomIds)) {
|
|
for (const roomId of roomIds) {
|
|
this.dmRoomIds.add(roomId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [roomId, roomData] of Object.entries(joinedRooms)) {
|
|
const events = roomData.timeline?.events;
|
|
if (!events) { continue; }
|
|
|
|
for (const event of events) {
|
|
this.handleTimelineEvent(roomId, event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Handle a single timeline event from a joined room. */
|
|
private handleTimelineEvent(roomId: string, event: MatrixEvent): void {
|
|
if (!this.messageHandler) { return; }
|
|
|
|
// Only process m.room.message events
|
|
if (event.type !== 'm.room.message') { return; }
|
|
|
|
// Ignore our own messages
|
|
if (event.sender === this.userId) { return; }
|
|
|
|
// Room allowlist check
|
|
const allowedRoomIds = this.config.allowedRoomIds ?? [];
|
|
if (allowedRoomIds.length > 0 && !allowedRoomIds.includes(roomId)) {
|
|
// Pairing fallback
|
|
const pm = this.config.pairingManager;
|
|
if (pm?.enabled) {
|
|
if (pm.isApproved('matrix', roomId)) {
|
|
// Approved — fall through
|
|
} else {
|
|
const text = (event.content?.body ?? '').trim();
|
|
if (text && pm.validateCode('matrix', roomId, text)) {
|
|
this.sendRoomMessage(roomId, 'Pairing successful! You can now chat with Flynn.').catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const body = event.content?.body ?? '';
|
|
const isDm = this.isDM(roomId);
|
|
|
|
// Mention gating: require mention in non-DM rooms
|
|
const requireMention = this.config.requireMention ?? true;
|
|
if (!isDm && requireMention) {
|
|
const isMentioned = this.isBotMentioned(body);
|
|
if (!isMentioned) { return; }
|
|
}
|
|
|
|
// Strip bot mention from text
|
|
let text = body;
|
|
if (this.botDisplayName) {
|
|
text = text.replace(new RegExp(`@?${this.escapeRegex(this.botDisplayName)}\\b`, 'gi'), '').trim();
|
|
}
|
|
if (this.userId) {
|
|
text = text.replace(new RegExp(this.escapeRegex(this.userId), 'g'), '').trim();
|
|
}
|
|
|
|
// Detect reset command
|
|
if (text === '!reset' || text === 'reset') {
|
|
this.messageHandler({
|
|
id: event.event_id,
|
|
channel: 'matrix',
|
|
senderId: roomId,
|
|
senderName: this.extractDisplayName(event.sender),
|
|
text: '!reset',
|
|
timestamp: event.origin_server_ts,
|
|
metadata: { isCommand: true, command: 'reset' },
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Regular message
|
|
this.messageHandler({
|
|
id: event.event_id,
|
|
channel: 'matrix',
|
|
senderId: roomId,
|
|
senderName: this.extractDisplayName(event.sender),
|
|
text,
|
|
timestamp: event.origin_server_ts,
|
|
});
|
|
}
|
|
|
|
/** Check if the bot is mentioned in the message text. */
|
|
private isBotMentioned(text: string): boolean {
|
|
if (this.botDisplayName && text.toLowerCase().includes(this.botDisplayName.toLowerCase())) {
|
|
return true;
|
|
}
|
|
if (this.userId && text.includes(this.userId)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Extract a display name from a Matrix user ID. @user:server → user */
|
|
private extractDisplayName(userId: string): string {
|
|
const match = userId.match(/^@([^:]+)/);
|
|
return match ? match[1] : userId;
|
|
}
|
|
|
|
/** Escape a string for use in a RegExp. */
|
|
private escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/** Sleep with abort support. */
|
|
private sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, ms);
|
|
signal.addEventListener('abort', () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}, { once: true });
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Matrix API response types ──────────────────────────────────────
|
|
|
|
interface MatrixSyncResponse {
|
|
next_batch: string;
|
|
rooms?: {
|
|
join?: Record<string, MatrixJoinedRoom>;
|
|
};
|
|
account_data?: {
|
|
events?: Array<{ type: string; content?: unknown }>;
|
|
};
|
|
}
|
|
|
|
interface MatrixJoinedRoom {
|
|
timeline?: {
|
|
events?: MatrixEvent[];
|
|
};
|
|
}
|
|
|
|
interface MatrixEvent {
|
|
type: string;
|
|
event_id: string;
|
|
sender: string;
|
|
origin_server_ts: number;
|
|
content?: {
|
|
body?: string;
|
|
msgtype?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify connect/disconnect pass**
|
|
|
|
Run: `pnpm test:run src/channels/matrix/adapter.test.ts`
|
|
Expected: PASS (4 tests)
|
|
|
|
**Step 5: Create barrel export**
|
|
|
|
Create `src/channels/matrix/index.ts`:
|
|
|
|
```typescript
|
|
export { MatrixAdapter, type MatrixAdapterConfig } from './adapter.js';
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/channels/matrix/
|
|
git commit -m "feat(matrix): add Matrix adapter core with connect/disconnect"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add Send Tests and Verify Send Works
|
|
|
|
**Files:**
|
|
- Modify: `src/channels/matrix/adapter.test.ts`
|
|
|
|
**Step 1: Add send tests to the test file**
|
|
|
|
Append these tests inside the `describe('MatrixAdapter')` block:
|
|
|
|
```typescript
|
|
// ── send ──────────────────────────────────────────────────────
|
|
|
|
it('send throws when not connected', async () => {
|
|
await expect(adapter.send('!room1:example.org', { text: 'hello' })).rejects.toThrow(
|
|
'Matrix adapter not connected',
|
|
);
|
|
});
|
|
|
|
it('send delivers a message via PUT with txnId', async () => {
|
|
// Connect first
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ user_id: '@flynn:example.org' }));
|
|
mockFetch.mockResolvedValueOnce(mockResponse({}));
|
|
mockFetch.mockResolvedValueOnce(new Promise(() => {})); // sync hangs
|
|
await adapter.connect();
|
|
|
|
// Send message
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ event_id: '$sent1' }));
|
|
await adapter.send('!room1:example.org', { text: 'Hello there' });
|
|
|
|
// Find the PUT call (skip whoami, m.direct, sync)
|
|
const putCall = mockFetch.mock.calls.find(
|
|
(call) => call[1]?.method === 'PUT',
|
|
);
|
|
expect(putCall).toBeDefined();
|
|
expect(putCall![0]).toContain('/_matrix/client/v3/rooms/');
|
|
expect(putCall![0]).toContain('/send/m.room.message/');
|
|
|
|
const body = JSON.parse(putCall![1].body);
|
|
expect(body.msgtype).toBe('m.text');
|
|
expect(body.body).toBe('Hello there');
|
|
});
|
|
|
|
it('send skips empty text', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ user_id: '@flynn:example.org' }));
|
|
mockFetch.mockResolvedValueOnce(mockResponse({}));
|
|
mockFetch.mockResolvedValueOnce(new Promise(() => {}));
|
|
await adapter.connect();
|
|
|
|
await adapter.send('!room1:example.org', { text: '' });
|
|
|
|
// No PUT call should have been made
|
|
const putCall = mockFetch.mock.calls.find(
|
|
(call) => call[1]?.method === 'PUT',
|
|
);
|
|
expect(putCall).toBeUndefined();
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `pnpm test:run src/channels/matrix/adapter.test.ts`
|
|
Expected: PASS (7 tests)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/channels/matrix/adapter.test.ts
|
|
git commit -m "test(matrix): add send tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add Inbound Message Handling Tests
|
|
|
|
**Files:**
|
|
- Modify: `src/channels/matrix/adapter.test.ts`
|
|
|
|
**Step 1: Add a helper to connect the adapter and simulate sync**
|
|
|
|
Add these helpers near the top of the test file (after `mockFetchSequence`):
|
|
|
|
```typescript
|
|
/** Helper: connect the adapter with standard mocks. Returns a function to simulate sync. */
|
|
async function connectAdapter(adapter: MatrixAdapter, directRooms: Record<string, string[]> = {}) {
|
|
// whoami
|
|
mockFetch.mockResolvedValueOnce(mockResponse({ user_id: '@flynn:example.org' }));
|
|
// m.direct
|
|
mockFetch.mockResolvedValueOnce(mockResponse(directRooms));
|
|
// sync long-poll — we'll control this manually
|
|
let syncResolve: ((value: Response) => void) | null = null;
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Promise<Response>((resolve) => { syncResolve = resolve; }),
|
|
);
|
|
await adapter.connect();
|
|
|
|
return {
|
|
/** Simulate a sync response arriving. */
|
|
async emitSync(syncData: unknown) {
|
|
if (syncResolve) {
|
|
syncResolve(mockResponse(syncData));
|
|
syncResolve = null;
|
|
}
|
|
// Allow the sync loop to process
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
// Set up next sync to hang
|
|
mockFetch.mockResolvedValueOnce(new Promise(() => {}));
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Helper: create a Matrix timeline event. */
|
|
function createMatrixEvent(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
type: 'm.room.message',
|
|
event_id: '$evt1',
|
|
sender: '@alice:example.org',
|
|
origin_server_ts: 1700000000000,
|
|
content: {
|
|
msgtype: 'm.text',
|
|
body: 'Hello Flynn',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Helper: wrap events into a sync response. */
|
|
function createSyncResponse(roomId: string, events: unknown[], extras: Record<string, unknown> = {}) {
|
|
return {
|
|
next_batch: 's_next',
|
|
rooms: {
|
|
join: {
|
|
[roomId]: {
|
|
timeline: { events },
|
|
},
|
|
},
|
|
},
|
|
...extras,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 2: Add inbound message tests**
|
|
|
|
Append inside the `describe('MatrixAdapter')` block:
|
|
|
|
```typescript
|
|
// ── Inbound message handling ────────────────────────────────────
|
|
|
|
it('inbound message from allowed room with mention triggers handler', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '@Flynn Hello there' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.channel).toBe('matrix');
|
|
expect(msg.senderId).toBe('!room1:example.org');
|
|
expect(msg.senderName).toBe('alice');
|
|
expect(msg.text).toBe('Hello there');
|
|
expect(msg.id).toBe('$evt1');
|
|
});
|
|
|
|
it('inbound message without mention is ignored when requireMention is true', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: 'Hello everyone' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('inbound message without mention is accepted when requireMention is false', async () => {
|
|
const noMentionAdapter = new MatrixAdapter({
|
|
...baseConfig,
|
|
requireMention: false,
|
|
});
|
|
const handler = vi.fn();
|
|
noMentionAdapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(noMentionAdapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: 'Hello everyone' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
await noMentionAdapter.disconnect();
|
|
});
|
|
|
|
it('ignores own messages (sender === userId)', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
sender: '@flynn:example.org',
|
|
content: { msgtype: 'm.text', body: 'My own message' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores messages in disallowed rooms', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '@Flynn hello' },
|
|
});
|
|
await emitSync(createSyncResponse('!notallowed:example.org', [event]));
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('DM room bypasses mention requirement', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
// Mark !dm1 as a DM room via m.direct
|
|
const directRooms = { '@alice:example.org': ['!dm1:example.org'] };
|
|
const dmAdapter = new MatrixAdapter({
|
|
...baseConfig,
|
|
allowedRoomIds: ['!dm1:example.org'],
|
|
});
|
|
dmAdapter.onMessage(handler);
|
|
const { emitSync } = await connectAdapter(dmAdapter, directRooms);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: 'Hello without mention' },
|
|
});
|
|
await emitSync(createSyncResponse('!dm1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.text).toBe('Hello without mention');
|
|
|
|
await dmAdapter.disconnect();
|
|
});
|
|
|
|
it('!reset command delivers reset metadata', async () => {
|
|
const noMentionAdapter = new MatrixAdapter({
|
|
...baseConfig,
|
|
requireMention: false,
|
|
});
|
|
const handler = vi.fn();
|
|
noMentionAdapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(noMentionAdapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '!reset' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.text).toBe('!reset');
|
|
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
|
|
|
|
await noMentionAdapter.disconnect();
|
|
});
|
|
|
|
it('strips bot mention from message text', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '@Flynn What is the weather?' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.text).toBe('What is the weather?');
|
|
});
|
|
|
|
it('ignores non-message events (e.g. m.room.member)', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
type: 'm.room.member',
|
|
content: { membership: 'join' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts all rooms when allowedRoomIds is empty', async () => {
|
|
const openAdapter = new MatrixAdapter({
|
|
...baseConfig,
|
|
allowedRoomIds: [],
|
|
requireMention: false,
|
|
});
|
|
const handler = vi.fn();
|
|
openAdapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(openAdapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: 'Hello from any room' },
|
|
});
|
|
await emitSync(createSyncResponse('!anyrandom:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.text).toBe('Hello from any room');
|
|
|
|
await openAdapter.disconnect();
|
|
});
|
|
|
|
it('does nothing when no message handler is registered', async () => {
|
|
// Don't call onMessage
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '@Flynn hello' },
|
|
});
|
|
|
|
// Should not throw
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
});
|
|
|
|
it('mention detection works with Matrix user ID', async () => {
|
|
const handler = vi.fn();
|
|
adapter.onMessage(handler);
|
|
|
|
const { emitSync } = await connectAdapter(adapter);
|
|
|
|
const event = createMatrixEvent({
|
|
content: { msgtype: 'm.text', body: '@flynn:example.org can you help?' },
|
|
});
|
|
await emitSync(createSyncResponse('!room1:example.org', [event]));
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
|
expect(msg.text).toBe('can you help?');
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `pnpm test:run src/channels/matrix/adapter.test.ts`
|
|
Expected: PASS (all ~19 tests)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/channels/matrix/adapter.test.ts
|
|
git commit -m "test(matrix): add inbound message handling and DM detection tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Wire Matrix Adapter into the System
|
|
|
|
**Files:**
|
|
- Modify: `src/channels/index.ts`
|
|
- Modify: `src/daemon/channels.ts`
|
|
|
|
**Step 1: Add Matrix exports to src/channels/index.ts**
|
|
|
|
Add after the WhatsApp export line:
|
|
|
|
```typescript
|
|
export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
|
|
```
|
|
|
|
**Step 2: Register Matrix adapter in src/daemon/channels.ts**
|
|
|
|
Add import at top:
|
|
|
|
```typescript
|
|
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, PairingManager } from '../channels/index.js';
|
|
```
|
|
|
|
Add registration block after the WhatsApp block (around line 71):
|
|
|
|
```typescript
|
|
// Register Matrix adapter (if configured)
|
|
if (config.matrix) {
|
|
const matrixAdapter = new MatrixAdapter({
|
|
homeserverUrl: config.matrix.homeserver_url,
|
|
accessToken: config.matrix.access_token,
|
|
allowedRoomIds: config.matrix.allowed_room_ids.length > 0 ? config.matrix.allowed_room_ids : undefined,
|
|
requireMention: config.matrix.require_mention,
|
|
syncTimeoutMs: config.matrix.sync_timeout_ms,
|
|
displayName: config.matrix.display_name,
|
|
pairingManager,
|
|
});
|
|
channelRegistry.register(matrixAdapter);
|
|
}
|
|
```
|
|
|
|
**Step 3: Run typecheck**
|
|
|
|
Run: `pnpm typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 4: Run all channel tests to ensure no regressions**
|
|
|
|
Run: `pnpm test:run src/channels/`
|
|
Expected: All existing tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/channels/index.ts src/daemon/channels.ts
|
|
git commit -m "feat(matrix): wire Matrix adapter into channel registry and daemon"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add Config Schema Test
|
|
|
|
**Files:**
|
|
- Modify: `src/config/schema.test.ts` (if exists, otherwise verify with existing tests)
|
|
|
|
**Step 1: Check for existing config schema tests**
|
|
|
|
Read `src/config/schema.test.ts` and add a Matrix config validation test.
|
|
|
|
```typescript
|
|
it('validates matrix config', () => {
|
|
const config = configSchema.parse({
|
|
models: { default: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' } },
|
|
matrix: {
|
|
homeserver_url: 'https://matrix.example.org',
|
|
access_token: 'syt_test_token',
|
|
allowed_room_ids: ['!room1:example.org'],
|
|
require_mention: true,
|
|
},
|
|
});
|
|
expect(config.matrix).toBeDefined();
|
|
expect(config.matrix!.homeserver_url).toBe('https://matrix.example.org');
|
|
expect(config.matrix!.require_mention).toBe(true);
|
|
expect(config.matrix!.sync_timeout_ms).toBe(30000); // default
|
|
});
|
|
|
|
it('matrix config is optional', () => {
|
|
const config = configSchema.parse({
|
|
models: { default: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' } },
|
|
});
|
|
expect(config.matrix).toBeUndefined();
|
|
});
|
|
|
|
it('matrix config rejects invalid homeserver URL', () => {
|
|
expect(() => configSchema.parse({
|
|
models: { default: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' } },
|
|
matrix: {
|
|
homeserver_url: 'not-a-url',
|
|
access_token: 'token',
|
|
},
|
|
})).toThrow();
|
|
});
|
|
```
|
|
|
|
**Step 2: Run config schema tests**
|
|
|
|
Run: `pnpm test:run src/config/schema.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/config/schema.test.ts
|
|
git commit -m "test(matrix): add config schema validation tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Add Credential Redaction for Matrix Token
|
|
|
|
**Files:**
|
|
- Modify: `src/gateway/handlers/config.ts`
|
|
|
|
**Step 1: Add matrix access_token to redaction**
|
|
|
|
In the `redactConfig()` function, add redaction for the Matrix access token alongside the existing channel token redactions:
|
|
|
|
```typescript
|
|
// After the existing channel token redactions (e.g., discord.bot_token)
|
|
if (redacted.matrix) {
|
|
redacted.matrix = { ...redacted.matrix, access_token: REDACTED };
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `pnpm test:run src/gateway/handlers/handlers.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/gateway/handlers/config.ts
|
|
git commit -m "feat(matrix): add access_token to config redaction"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Final Verification
|
|
|
|
**Step 1: Run full typecheck**
|
|
|
|
Run: `pnpm typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `pnpm test:run`
|
|
Expected: All tests PASS
|
|
|
|
**Step 3: Commit any remaining changes**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(matrix): complete Matrix channel adapter implementation"
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Summary
|
|
|
|
| Test Category | Count | Description |
|
|
|---------------|-------|-------------|
|
|
| Basic properties | 2 | name = "matrix", starts disconnected |
|
|
| connect/disconnect | 4 | whoami resolution, error handling, clean disconnect, safe disconnect when not connected |
|
|
| send | 3 | throws when disconnected, PUT with txnId, skips empty text |
|
|
| Inbound messages | 12 | mention gating, DM bypass, own message filtering, room allowlist, reset command, mention stripping, MXID mention, non-message events, open rooms, no handler |
|
|
| Config schema | 3 | valid config, optional, invalid URL rejection |
|
|
| **Total** | **~24** | |
|
|
|
|
## Error Handling Strategy
|
|
|
|
| Error | Recovery |
|
|
|-------|----------|
|
|
| `/account/whoami` 401 | Throw on connect — adapter stays in error state |
|
|
| `/sync` HTTP error | Log, back off 5s, retry |
|
|
| `/sync` network error | Log, back off 5s, retry |
|
|
| `/send` failure | Throw — caller (registry) logs the error |
|
|
| `m.direct` 404 | Silently ignore — no DMs known yet |
|
|
| AbortError during sync | Clean exit from loop |
|
|
|
|
## Sync Loop Lifecycle
|
|
|
|
```
|
|
connect()
|
|
├── GET /account/whoami → resolve userId
|
|
├── GET /user/{userId}/account_data/m.direct → populate dmRoomIds
|
|
└── runSyncLoop() (fire-and-forget async)
|
|
├── GET /sync?timeout=30000 (first call, no since token)
|
|
│ └── next_batch → save as syncToken
|
|
│ └── rooms.join → process timeline events
|
|
│ └── account_data → update dmRoomIds
|
|
├── GET /sync?timeout=30000&since=... (subsequent calls)
|
|
│ └── (repeat)
|
|
└── (on error) → sleep 5s → retry
|
|
|
|
disconnect()
|
|
└── AbortController.abort() → sync loop exits
|
|
```
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
|------|------------|
|
|
| Initial sync floods events from history | First sync may replay old events. The adapter could skip the first sync's events (only use it to get `next_batch`). However, this is how other adapters work (Telegram's grammy does NOT replay), so we process all events from the first sync. If this is a problem, a `skip_initial_sync` config flag can be added later. |
|
|
| Sync loop leak on unhandled error | `try/catch` wraps the entire loop body with exponential backoff. AbortController ensures clean shutdown. |
|
|
| Bot invited to many rooms, only some allowed | Room allowlist (`allowed_room_ids`) prevents processing. If empty, all joined rooms are processed. |
|
|
| Matrix homeserver rate limiting | The sync loop naturally rate-limits (one request at a time with 30s timeout). Send calls are user-triggered and unlikely to hit limits. |
|
|
| Access token expiry | Matrix access tokens are typically long-lived. If expired, whoami will fail on connect. Short-lived tokens (refresh tokens) are a future enhancement. |
|
|
|
|
## Open Questions
|
|
|
|
1. **Skip initial sync?** — Should the adapter skip processing events from the very first `/sync` response (which may contain old history)? Current design processes them all. Can add a `skip_initial_sync: true` config flag if needed.
|
|
|
|
2. **Typing indicators?** — Matrix supports `m.typing` events. Should the adapter send a typing indicator while processing? Deferred for initial implementation (other adapters do this inconsistently — Telegram yes, Discord yes, Slack no).
|
|
|
|
3. **Image/media attachments?** — Matrix supports `m.image`, `m.file`, `m.audio` msgtypes. The initial implementation only handles `m.text`. Media support can be added in a follow-up (download via `/_matrix/media/v3/download/{serverName}/{mediaId}`).
|
|
|
|
4. **Formatted messages?** — Matrix supports `format: "org.matrix.custom.html"` with `formatted_body`. Should outbound messages include HTML? Deferred — plain text is sufficient for initial implementation.
|
|
|
|
---
|
|
|
|
## Files Summary
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/config/schema.ts` — add `matrixSchema` + type export |
|
|
| Create | `src/channels/matrix/adapter.ts` — full adapter implementation |
|
|
| Create | `src/channels/matrix/adapter.test.ts` — 24 tests |
|
|
| Create | `src/channels/matrix/index.ts` — barrel export |
|
|
| Modify | `src/channels/index.ts` — re-export Matrix adapter |
|
|
| Modify | `src/daemon/channels.ts` — register Matrix adapter |
|
|
| Modify | `src/gateway/handlers/config.ts` — redact access_token |
|
|
| Modify | `src/config/schema.test.ts` — config validation tests |
|