Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/mock-bridge/appservice.yaml
Original file line number Diff line number Diff line change
@@ -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: []


127 changes: 127 additions & 0 deletions packages/mock-bridge/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
DEFAULT_HS_URL,
logOutbound,
matrixCreateRoom,
matrixResolveAlias,
matrixSendMessage,
} from './shared';

function parseCliArgs(argv: string[]) {
const args: Record<string, string | true> = {};
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<string, string | true>,
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;
});
2 changes: 2 additions & 0 deletions packages/mock-bridge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Backwards-compatible entrypoint: start the server.
import './server';
14 changes: 14 additions & 0 deletions packages/mock-bridge/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
229 changes: 229 additions & 0 deletions packages/mock-bridge/server.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading