feat(matrix): add Matrix channel adapter
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { MatrixAdapter, type MatrixAdapterConfig } from './adapter.js';
|
||||
import type { InboundMessage } from '../types.js';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('MatrixAdapter', () => {
|
||||
const baseConfig: MatrixAdapterConfig = {
|
||||
homeserverUrl: 'https://matrix.example.org',
|
||||
accessToken: 'syt_test_token',
|
||||
allowedRoomIds: ['!room1:example.org'],
|
||||
requireMention: true,
|
||||
syncTimeoutMs: 30_000,
|
||||
displayName: 'Flynn',
|
||||
};
|
||||
|
||||
let adapter: MatrixAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
vi.clearAllMocks();
|
||||
adapter = new MatrixAdapter(baseConfig);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter.disconnect();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('has name "matrix"', () => {
|
||||
expect(adapter.name).toBe('matrix');
|
||||
});
|
||||
|
||||
it('starts as disconnected', () => {
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('connect resolves whoami and sets connected', async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/_matrix/client/v3/account/whoami')) {
|
||||
return jsonResponse({ user_id: '@flynn:example.org' });
|
||||
}
|
||||
if (url.includes('/account_data/m.direct')) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
// /sync long-poll hangs
|
||||
if (url.includes('/_matrix/client/v3/sync')) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
expect(adapter.status).toBe('connected');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://matrix.example.org/_matrix/client/v3/account/whoami',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer syt_test_token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnect is safe when not connected', async () => {
|
||||
await adapter.disconnect();
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
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', async () => {
|
||||
let syncStarted = false;
|
||||
mockFetch.mockImplementation(async (url: string, init?: any) => {
|
||||
if (url.endsWith('/_matrix/client/v3/account/whoami')) {
|
||||
return jsonResponse({ user_id: '@flynn:example.org' });
|
||||
}
|
||||
if (url.includes('/account_data/m.direct')) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url.includes('/_matrix/client/v3/sync')) {
|
||||
syncStarted = true;
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (init?.method === 'PUT' && url.includes('/send/m.room.message/')) {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.msgtype).toBe('m.text');
|
||||
expect(body.body).toBe('Hello there');
|
||||
return jsonResponse({ event_id: '$sent1' });
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
expect(syncStarted).toBe(true);
|
||||
|
||||
await adapter.send('!room1:example.org', { text: 'Hello there' });
|
||||
});
|
||||
|
||||
it('inbound message requires mention in non-DM rooms', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
let didSync = false;
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/_matrix/client/v3/account/whoami')) {
|
||||
return jsonResponse({ user_id: '@flynn:example.org' });
|
||||
}
|
||||
if (url.includes('/account_data/m.direct')) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url.includes('/_matrix/client/v3/sync')) {
|
||||
if (didSync) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
didSync = true;
|
||||
return jsonResponse({
|
||||
next_batch: 's1',
|
||||
rooms: {
|
||||
join: {
|
||||
'!room1:example.org': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$e1',
|
||||
sender: '@alice:example.org',
|
||||
origin_server_ts: 1700000000000,
|
||||
content: { msgtype: 'm.text', body: 'hello without mention' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DM rooms bypass mention requirement (m.direct from sync)', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
let didSync = false;
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/_matrix/client/v3/account/whoami')) {
|
||||
return jsonResponse({ user_id: '@flynn:example.org' });
|
||||
}
|
||||
if (url.includes('/account_data/m.direct')) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url.includes('/_matrix/client/v3/sync')) {
|
||||
if (didSync) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
didSync = true;
|
||||
return jsonResponse({
|
||||
next_batch: 's1',
|
||||
account_data: {
|
||||
events: [
|
||||
{ type: 'm.direct', content: { '@alice:example.org': ['!room1:example.org'] } },
|
||||
],
|
||||
},
|
||||
rooms: {
|
||||
join: {
|
||||
'!room1:example.org': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$e1',
|
||||
sender: '@alice:example.org',
|
||||
origin_server_ts: 1700000000000,
|
||||
content: { msgtype: 'm.text', body: 'hello dm no mention' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
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 dm no mention');
|
||||
});
|
||||
|
||||
it('inbound message with mention is accepted and mention is stripped', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
let didSync = false;
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/_matrix/client/v3/account/whoami')) {
|
||||
return jsonResponse({ user_id: '@flynn:example.org' });
|
||||
}
|
||||
if (url.includes('/account_data/m.direct')) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url.includes('/_matrix/client/v3/sync')) {
|
||||
if (didSync) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
didSync = true;
|
||||
return jsonResponse({
|
||||
next_batch: 's1',
|
||||
rooms: {
|
||||
join: {
|
||||
'!room1:example.org': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$e1',
|
||||
sender: '@alice:example.org',
|
||||
origin_server_ts: 1700000000000,
|
||||
content: { msgtype: 'm.text', body: '@Flynn Hello there' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||
expect(msg.text).toBe('Hello there');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user