From d3eea3d50d46e31a6c220a7025165ce2b9aa3ceb Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 30 Jan 2026 10:15:42 -0300 Subject: [PATCH] feat: add mock bridge application service with CLI and server implementation --- packages/mock-bridge/appservice.yaml | 20 ++ packages/mock-bridge/cli.ts | 127 ++++++++++ packages/mock-bridge/index.ts | 2 + packages/mock-bridge/package.json | 14 ++ packages/mock-bridge/server.ts | 229 +++++++++++++++++ packages/mock-bridge/shared.ts | 352 +++++++++++++++++++++++++++ packages/mock-bridge/tsconfig.json | 10 + 7 files changed, 754 insertions(+) create mode 100644 packages/mock-bridge/appservice.yaml create mode 100644 packages/mock-bridge/cli.ts create mode 100644 packages/mock-bridge/index.ts create mode 100644 packages/mock-bridge/package.json create mode 100644 packages/mock-bridge/server.ts create mode 100644 packages/mock-bridge/shared.ts create mode 100644 packages/mock-bridge/tsconfig.json diff --git a/packages/mock-bridge/appservice.yaml b/packages/mock-bridge/appservice.yaml new file mode 100644 index 00000000..de7793c1 --- /dev/null +++ b/packages/mock-bridge/appservice.yaml @@ -0,0 +1,20 @@ +id: mockbridge +url: http://localhost:9000 + +as_token: mock-as-token-123 +hs_token: mock-hs-token-456 + +sender_localpart: mockbot + +namespaces: + users: + - regex: "@mock_.*" + exclusive: true + + aliases: + - regex: "#mock_.*" + exclusive: true + + rooms: [] + + diff --git a/packages/mock-bridge/cli.ts b/packages/mock-bridge/cli.ts new file mode 100644 index 00000000..35c19848 --- /dev/null +++ b/packages/mock-bridge/cli.ts @@ -0,0 +1,127 @@ +import { + DEFAULT_HS_URL, + logOutbound, + matrixCreateRoom, + matrixResolveAlias, + matrixSendMessage, +} from './shared'; + +function parseCliArgs(argv: string[]) { + const args: Record = {}; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i]; + if (!item.startsWith('--')) continue; + const key = item.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + args[key] = true; + continue; + } + args[key] = next; + i += 1; + } + return args; +} + +function requireStringArg( + args: Record, + name: string, +): string { + const val = args[name]; + if (typeof val === 'string' && val.trim()) return val; + throw new Error(`missing required --${name}`); +} + +function printCliHelp() { + const help = [ + 'Mock Bridge CLI', + '', + 'Commands:', + ' bun run cli create-room --user-id @mock_alice:example.org [--hs-url http://localhost:8008] [--name "Room"] [--alias "#mock_test:example.org"]', + ' bun run cli send --user-id @mock_alice:example.org (--room-id !id:server | --alias "#mock_test:example.org") --text "hello" [--hs-url http://localhost:8008]', + '', + 'Env:', + ` MOCK_BRIDGE_HS_URL (default: ${DEFAULT_HS_URL})`, + ].join('\n'); + console.log(help); +} + +async function main() { + const argv = process.argv.slice(2); + const sub = argv[0]; + if (!sub || sub === '--help' || sub === '-h') { + printCliHelp(); + return; + } + + const args = parseCliArgs(argv.slice(1)); + const hsUrl = + typeof args['hs-url'] === 'string' + ? (args['hs-url'] as string) + : DEFAULT_HS_URL; + + if (sub === 'create-room') { + const userId = requireStringArg(args, 'user-id'); + const name = + typeof args.name === 'string' ? (args.name as string) : undefined; + const topic = + typeof args.topic === 'string' ? (args.topic as string) : undefined; + const alias = + typeof args.alias === 'string' ? (args.alias as string) : undefined; + + const result = await matrixCreateRoom({ + hsUrl, + userId, + name, + topic, + alias, + }); + logOutbound( + `createRoom userId=${userId} room_id=${result.room_id} alias=${result.alias ?? '-'}`, + ); + console.log(JSON.stringify(result)); + return; + } + + if (sub === 'send') { + const userId = requireStringArg(args, 'user-id'); + const text = requireStringArg(args, 'text'); + const msgtype = + typeof args.msgtype === 'string' ? (args.msgtype as string) : undefined; + + const roomIdArg = + typeof args['room-id'] === 'string' ? (args['room-id'] as string) : null; + const aliasArg = + typeof args.alias === 'string' ? (args.alias as string) : null; + + let roomId: string | null = roomIdArg; + if (!roomId && aliasArg) { + roomId = await matrixResolveAlias({ hsUrl, userId, alias: aliasArg }); + if (!roomId) + throw new Error( + `unknown alias: ${aliasArg}. Create it first or pass --room-id.`, + ); + } + if (!roomId) throw new Error('missing --room-id or --alias'); + + const result = await matrixSendMessage({ + hsUrl, + userId, + roomId, + text, + msgtype, + }); + logOutbound( + `send userId=${userId} room_id=${roomId} event_id=${result.event_id ?? '-'} txn_id=${result.txn_id}`, + ); + console.log(JSON.stringify({ room_id: roomId, ...result })); + return; + } + + throw new Error(`unknown cli subcommand: ${sub}`); +} + +main().catch((err) => { + console.error('❌ fatal', err); + process.exitCode = 1; +}); diff --git a/packages/mock-bridge/index.ts b/packages/mock-bridge/index.ts new file mode 100644 index 00000000..379d1cd3 --- /dev/null +++ b/packages/mock-bridge/index.ts @@ -0,0 +1,2 @@ +// Backwards-compatible entrypoint: start the server. +import './server'; diff --git a/packages/mock-bridge/package.json b/packages/mock-bridge/package.json new file mode 100644 index 00000000..21f6d575 --- /dev/null +++ b/packages/mock-bridge/package.json @@ -0,0 +1,14 @@ +{ + "name": "@rocket.chat/mock-bridge", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "bun run server.ts", + "dev": "bun run --watch server.ts", + "cli": "bun run cli.ts" + }, + "devDependencies": { + "bun-types": "latest" + } +} diff --git a/packages/mock-bridge/server.ts b/packages/mock-bridge/server.ts new file mode 100644 index 00000000..c9a95fa6 --- /dev/null +++ b/packages/mock-bridge/server.ts @@ -0,0 +1,229 @@ +/** + * Mock Matrix Application Service (bridge) server. + * + * Implements the Application Service API endpoints + a couple of non-spec + * control endpoints to trigger outbound actions against a homeserver. + */ + +import http from 'node:http'; +import { + APPSERVICE, + AS_TOKEN, + DEFAULT_HS_URL, + HOST, + HS_TOKEN, + PORT, + aliasToRoomId, + getAccessToken, + isRoomsRoute, + isTxnRoute, + isUsersRoute, + logAuthInvalid, + logEvents, + logOutbound, + logQuery, + matrixCreateRoom, + matrixResolveAlias, + matrixSendMessage, + readJson, + seenTxnIds, + writeEmpty, + writeJson, +} from './shared'; + +export function startServer() { + const server = http.createServer(async (req, res) => { + const method = req.method ?? 'GET'; + const reqUrl = new URL( + req.url ?? '/', + `http://${req.headers.host ?? `${HOST}:${PORT}`}`, + ); + const pathname = reqUrl.pathname; + + try { + /** + * Endpoint: PUT /_matrix/app/v1/transactions/{txnId} + * + * HS -> AS: delivers events. Must authenticate using `as_token`. + * - Always respond 200 when authenticated (including txnId replay). + * - `txnId` is treated as idempotent: replays should not error. + */ + const txnId = isTxnRoute(pathname); + if (txnId && method === 'PUT') { + const token = getAccessToken(reqUrl); + if (!token || token !== AS_TOKEN) { + logAuthInvalid(`transactions txnId=${txnId}`); + return writeEmpty(res, 401); + } + + const isReplay = seenTxnIds.has(txnId); + seenTxnIds.add(txnId); + + try { + const body = (await readJson(req)) as { events?: unknown[] }; + const eventsCount = Array.isArray(body?.events) + ? body.events.length + : null; + logEvents( + `txnId=${txnId} ${isReplay ? '(replay) ' : ''}events=${eventsCount ?? '?'}`, + ); + if (Array.isArray(body?.events)) { + console.log(body.events); + } else { + console.log({ body }); + } + } catch (err) { + logEvents( + `txnId=${txnId} ${isReplay ? '(replay) ' : ''}json_parse_failed=${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + return writeEmpty(res, 200); + } + + /** + * Endpoint: GET /_matrix/app/v1/users/{userId} + * + * HS -> AS: user query. Must authenticate using `hs_token`. + * - Return 200 if userId matches @mock_* + * - Return 404 otherwise + */ + const userIdRaw = isUsersRoute(pathname); + if (userIdRaw && method === 'GET') { + const token = getAccessToken(reqUrl); + if (!token || token !== HS_TOKEN) { + logAuthInvalid(`users userId=${userIdRaw}`); + return writeEmpty(res, 401); + } + + const userId = decodeURIComponent(userIdRaw); + const ok = userId.startsWith('@mock_'); + logQuery(`users userId=${userId} -> ${ok ? 200 : 404}`); + return writeEmpty(res, ok ? 200 : 404); + } + + /** + * Endpoint: GET /_matrix/app/v1/rooms/{alias} + * + * HS -> AS: alias query. Must authenticate using `hs_token`. + * - Return 200 with { room_id } if alias matches #mock_* + * - Return 404 otherwise + */ + const aliasRaw = isRoomsRoute(pathname); + if (aliasRaw && method === 'GET') { + const token = getAccessToken(reqUrl); + if (!token || token !== HS_TOKEN) { + logAuthInvalid(`rooms alias=${aliasRaw}`); + return writeEmpty(res, 401); + } + + const alias = decodeURIComponent(aliasRaw); + const ok = alias.startsWith('#mock_'); + logQuery(`rooms alias=${alias} -> ${ok ? 200 : 404}`); + if (ok) { + const mapped = aliasToRoomId.get(alias); + return writeJson(res, 200, { + room_id: mapped ?? '!mockroom:example.org', + }); + } + return writeEmpty(res, 404); + } + + /** + * Control endpoints (non-spec) + * Auth: uses `hs_token` via access_token query param. + */ + if (pathname === '/_mock/createRoom' && method === 'POST') { + const token = getAccessToken(reqUrl); + if (!token || token !== HS_TOKEN) { + logAuthInvalid('mock createRoom'); + return writeEmpty(res, 401); + } + + const body = (await readJson(req)) as { + hs_url?: string; + user_id?: string; + name?: string; + topic?: string; + alias?: string; + }; + + const hsUrl = body.hs_url?.trim() || DEFAULT_HS_URL; + const userId = body.user_id?.trim(); + if (!userId) return writeJson(res, 400, { error: 'missing user_id' }); + + const result = await matrixCreateRoom({ + hsUrl, + userId, + name: body.name, + topic: body.topic, + alias: body.alias, + }); + + logOutbound( + `createRoom(hook) userId=${userId} room_id=${result.room_id} alias=${result.alias ?? '-'}`, + ); + return writeJson(res, 200, result); + } + + if (pathname === '/_mock/sendMessage' && method === 'POST') { + const token = getAccessToken(reqUrl); + if (!token || token !== HS_TOKEN) { + logAuthInvalid('mock sendMessage'); + return writeEmpty(res, 401); + } + + const body = (await readJson(req)) as { + hs_url?: string; + user_id?: string; + room_id?: string; + alias?: string; + text?: string; + msgtype?: string; + }; + + const hsUrl = body.hs_url?.trim() || DEFAULT_HS_URL; + const userId = body.user_id?.trim(); + const text = body.text?.toString() ?? ''; + if (!userId) return writeJson(res, 400, { error: 'missing user_id' }); + if (!text.trim()) return writeJson(res, 400, { error: 'missing text' }); + + let roomId = body.room_id?.trim() ?? null; + const alias = body.alias?.trim() ?? null; + if (!roomId && alias) roomId = aliasToRoomId.get(alias) ?? null; + if (!roomId && alias) + roomId = await matrixResolveAlias({ hsUrl, userId, alias }); + if (!roomId) + return writeJson(res, 404, { error: 'unknown room_id/alias' }); + + const result = await matrixSendMessage({ + hsUrl, + userId, + roomId, + text, + msgtype: body.msgtype, + }); + + logOutbound( + `send(hook) userId=${userId} room_id=${roomId} event_id=${result.event_id ?? '-'} txn_id=${result.txn_id}`, + ); + return writeJson(res, 200, { room_id: roomId, ...result }); + } + + return writeEmpty(res, 404); + } catch (err) { + console.error('❌ internal error', err); + return writeEmpty(res, 500); + } + }); + + server.listen(PORT, HOST, () => { + console.log(`Mock Matrix AppService listening on http://${HOST}:${PORT}`); + console.log(`appservice.yaml: ${APPSERVICE.appserviceYamlPath}`); + console.log(`default hs url: ${DEFAULT_HS_URL}`); + }); +} + +startServer(); diff --git a/packages/mock-bridge/shared.ts b/packages/mock-bridge/shared.ts new file mode 100644 index 00000000..d3df5dc5 --- /dev/null +++ b/packages/mock-bridge/shared.ts @@ -0,0 +1,352 @@ +import fs from 'node:fs'; +import http from 'node:http'; + +export const PORT = 9000; +export const HOST = 'localhost'; + +type AppserviceConfig = { + appserviceYamlPath: string; + id: string; + url: string; + as_token: string; + hs_token: string; + sender_localpart: string; +}; + +function stripQuotes(value: string) { + const v = value.trim(); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + return v.slice(1, -1); + } + return v; +} + +function parseTopLevelYaml(text: string) { + // Minimal parser: only reads top-level `key: value` pairs. + // This is enough for our `appservice.yaml` (id/url/as_token/hs_token/sender_localpart). + const out: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + if (rawLine.startsWith(' ') || rawLine.startsWith('\t')) continue; // ignore nested blocks + + const idx = line.indexOf(':'); + if (idx <= 0) continue; + + const key = line.slice(0, idx).trim(); + const value = stripQuotes(line.slice(idx + 1)); + if (!key || !value) continue; + + out[key] = value; + } + return out; +} + +function loadAppserviceConfig(): AppserviceConfig { + const defaultConfig: AppserviceConfig = { + appserviceYamlPath: new URL('./appservice.yaml', import.meta.url).pathname, + id: 'mockbridge', + url: 'http://localhost:9000', + as_token: 'mock-as-token-123', + hs_token: 'mock-hs-token-456', + sender_localpart: 'mockbot', + }; + + const appserviceYamlPath = + process.env.MOCK_BRIDGE_APPSERVICE_YAML?.trim() || + defaultConfig.appserviceYamlPath; + + try { + const text = fs.readFileSync(appserviceYamlPath, 'utf8'); + const parsed = parseTopLevelYaml(text); + + return { + appserviceYamlPath, + id: parsed.id || defaultConfig.id, + url: parsed.url || defaultConfig.url, + as_token: parsed.as_token || defaultConfig.as_token, + hs_token: parsed.hs_token || defaultConfig.hs_token, + sender_localpart: + parsed.sender_localpart || defaultConfig.sender_localpart, + }; + } catch { + // Keep working with defaults if the file isn't available. + return { ...defaultConfig, appserviceYamlPath }; + } +} + +export const APPSERVICE = loadAppserviceConfig(); + +// Tokens (mock). Sourced from appservice.yaml when available. +export const AS_TOKEN = APPSERVICE.as_token; +export const HS_TOKEN = APPSERVICE.hs_token; + +// Default homeserver base URL for outbound calls (CLI + control endpoints). +export const DEFAULT_HS_URL = + process.env.MOCK_BRIDGE_HS_URL ?? 'http://localhost:8008'; + +// In-memory idempotency store for transaction replays. +export const seenTxnIds = new Set(); + +// Optional dynamic alias -> room_id mapping (populated by control endpoints). +export const aliasToRoomId = new Map(); + +let outboundTxnCounter = 0; + +export function logAuthInvalid(message: string) { + console.log(`❌ auth inválida: ${message}`); +} + +export function logEvents(message: string) { + console.log(`📥 eventos: ${message}`); +} + +export function logQuery(message: string) { + console.log(`🔍 queries: ${message}`); +} + +export function logOutbound(message: string) { + console.log(`📤 outbound: ${message}`); +} + +export function getAccessToken(reqUrl: URL): string | null { + return reqUrl.searchParams.get('access_token'); +} + +export function writeJson( + res: http.ServerResponse, + statusCode: number, + body: unknown, +) { + const payload = JSON.stringify(body); + res.statusCode = statusCode; + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.setHeader('content-length', Buffer.byteLength(payload)); + res.end(payload); +} + +export function writeEmpty(res: http.ServerResponse, statusCode: number) { + res.statusCode = statusCode; + res.end(); +} + +export async function readBody( + req: http.IncomingMessage, + maxBytes = 5 * 1024 * 1024, +): Promise { + let size = 0; + const chunks: Buffer[] = []; + + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + if (size > maxBytes) { + throw new Error(`body_too_large: ${size} bytes`); + } + chunks.push(buf); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +export async function readJson(req: http.IncomingMessage): Promise { + const raw = await readBody(req); + if (!raw.trim()) return {}; + return JSON.parse(raw); +} + +export function isTxnRoute(pathname: string) { + // /_matrix/app/v1/transactions/{txnId} + const prefix = '/_matrix/app/v1/transactions/'; + if (!pathname.startsWith(prefix)) return null; + const txnId = pathname.slice(prefix.length); + if (!txnId || txnId.includes('/')) return null; + return txnId; +} + +export function isUsersRoute(pathname: string) { + // /_matrix/app/v1/users/{userId} + const prefix = '/_matrix/app/v1/users/'; + if (!pathname.startsWith(prefix)) return null; + const rest = pathname.slice(prefix.length); + if (!rest) return null; + return rest; +} + +export function isRoomsRoute(pathname: string) { + // /_matrix/app/v1/rooms/{alias} + const prefix = '/_matrix/app/v1/rooms/'; + if (!pathname.startsWith(prefix)) return null; + const rest = pathname.slice(prefix.length); + if (!rest) return null; + return rest; +} + +function nextOutboundTxnId() { + outboundTxnCounter += 1; + return `mock-${Date.now()}-${outboundTxnCounter}`; +} + +export async function matrixRequest(opts: { + hsUrl: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + asToken: string; + userId?: string; + body?: unknown; +}) { + const url = new URL(opts.path, opts.hsUrl); + url.searchParams.set('access_token', opts.asToken); + if (opts.userId) url.searchParams.set('user_id', opts.userId); + + const res = await fetch(url, { + method: opts.method, + headers: opts.body ? { 'content-type': 'application/json' } : undefined, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + + const contentType = res.headers.get('content-type') ?? ''; + const isJson = contentType.includes('application/json'); + const text = await res.text(); + const json = isJson && text ? (JSON.parse(text) as unknown) : null; + + return { status: res.status, text, json }; +} + +export async function matrixCreateRoom(params: { + hsUrl: string; + userId: string; + name?: string; + topic?: string; + alias?: string; +}) { + const alias = params.alias?.trim(); + const aliasLocalpart = + alias?.startsWith('#') && alias.includes(':') + ? alias.slice(1, alias.indexOf(':')) + : alias?.startsWith('#') + ? alias.slice(1) + : undefined; + + const createRoomBody: Record = {}; + if (params.name) createRoomBody.name = params.name; + if (params.topic) createRoomBody.topic = params.topic; + if (aliasLocalpart) createRoomBody.room_alias_name = aliasLocalpart; + + const createRes = await matrixRequest({ + hsUrl: params.hsUrl, + method: 'POST', + path: '/_matrix/client/v3/createRoom', + asToken: AS_TOKEN, + userId: params.userId, + body: createRoomBody, + }); + + if (createRes.status < 200 || createRes.status >= 300 || !createRes.json) { + throw new Error( + `createRoom failed: status=${createRes.status} body=${createRes.text}`, + ); + } + + const roomId = (createRes.json as { room_id?: string }).room_id; + if (!roomId) throw new Error(`createRoom missing room_id: ${createRes.text}`); + + if (alias) { + aliasToRoomId.set(alias, roomId); + // Best-effort: try to bind the alias in the HS directory too. + try { + const dirRes = await matrixRequest({ + hsUrl: params.hsUrl, + method: 'PUT', + path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + asToken: AS_TOKEN, + userId: params.userId, + body: { room_id: roomId }, + }); + logOutbound( + `setAlias alias=${alias} room_id=${roomId} status=${dirRes.status}`, + ); + } catch (err) { + logOutbound( + `setAlias alias=${alias} room_id=${roomId} error=${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + return { room_id: roomId, alias: alias ?? null }; +} + +export async function matrixEnsureJoined(params: { + hsUrl: string; + userId: string; + roomId: string; +}) { + const joinRes = await matrixRequest({ + hsUrl: params.hsUrl, + method: 'POST', + path: `/_matrix/client/v3/rooms/${encodeURIComponent(params.roomId)}/join`, + asToken: AS_TOKEN, + userId: params.userId, + body: {}, + }); + + // 2xx: joined/ok. Non-2xx: still return to caller for logging. + return joinRes; +} + +export async function matrixResolveAlias(params: { + hsUrl: string; + userId: string; + alias: string; +}) { + const res = await matrixRequest({ + hsUrl: params.hsUrl, + method: 'GET', + path: `/_matrix/client/v3/directory/room/${encodeURIComponent(params.alias)}`, + asToken: AS_TOKEN, + userId: params.userId, + }); + + if (res.status < 200 || res.status >= 300 || !res.json) return null; + return (res.json as { room_id?: string }).room_id ?? null; +} + +export async function matrixSendMessage(params: { + hsUrl: string; + userId: string; + roomId: string; + text: string; + msgtype?: string; +}) { + await matrixEnsureJoined({ + hsUrl: params.hsUrl, + userId: params.userId, + roomId: params.roomId, + }); + + const txnId = nextOutboundTxnId(); + const sendRes = await matrixRequest({ + hsUrl: params.hsUrl, + method: 'PUT', + path: `/_matrix/client/v3/rooms/${encodeURIComponent(params.roomId)}/send/m.room.message/${encodeURIComponent( + txnId, + )}`, + asToken: AS_TOKEN, + userId: params.userId, + body: { msgtype: params.msgtype ?? 'm.text', body: params.text }, + }); + + if (sendRes.status < 200 || sendRes.status >= 300 || !sendRes.json) { + throw new Error( + `send failed: status=${sendRes.status} body=${sendRes.text}`, + ); + } + + const eventId = (sendRes.json as { event_id?: string }).event_id ?? null; + return { event_id: eventId, txn_id: txnId }; +} diff --git a/packages/mock-bridge/tsconfig.json b/packages/mock-bridge/tsconfig.json new file mode 100644 index 00000000..7c066471 --- /dev/null +++ b/packages/mock-bridge/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "tsBuildInfoFile": "./.tsbuildinfo" + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +}