diff --git a/examples/dummybridge/.env.example b/examples/dummybridge/.env.example new file mode 100644 index 0000000..cc0f338 --- /dev/null +++ b/examples/dummybridge/.env.example @@ -0,0 +1,13 @@ +BEEPER_USERNAME=your_username_or_mxid +BEEPER_PASSWORD=your_password + +# Optional bridge-manager-compatible settings. +# BEEPER_BASE_DOMAIN=beeper.com +# DUMMYBRIDGE_BRIDGE_NAME=dummybridge +# DUMMYBRIDGE_URL=https://bridge.example +DUMMYBRIDGE_SENDER_LOCALPART=dummybridgebot + +# Optional live actions after startup. +# DUMMYBRIDGE_CREATE_ROOM=1 +# DUMMYBRIDGE_PORTAL_ROOM_ID=!existing:example +# DUMMYBRIDGE_BACKFILL_ON_START=1 diff --git a/examples/dummybridge/.gitignore b/examples/dummybridge/.gitignore new file mode 100644 index 0000000..e34488b --- /dev/null +++ b/examples/dummybridge/.gitignore @@ -0,0 +1 @@ +.pickle-bridge \ No newline at end of file diff --git a/examples/dummybridge/README.md b/examples/dummybridge/README.md new file mode 100644 index 0000000..db3ab84 --- /dev/null +++ b/examples/dummybridge/README.md @@ -0,0 +1,62 @@ +# Pickle DummyBridge + +This is a minimal TypeScript bridge built on `@beeper/pickle-bridge`. + +It demonstrates the bridge shape needed to: + +- register a bridge connector +- start Pickle with the same WASM Matrix client +- initialize an appservice registration +- load a user login +- create or register a portal room +- create or register a management room and handle `dummy ...` commands +- backfill historical events +- receive Matrix messages and echo them back through appservice ghost users +- fetch/register Beeper appservice credentials from the Matrix login + +Source lives in `src/*.ts`; the runnable files are built into `dist`. + +## Smoke test + +```sh +pnpm --filter @beeper/pickle-example-dummybridge smoke +``` + +The smoke test uses a fake Matrix client, so it does not need a homeserver. + +## Live run + +Copy `.env.example` to `.env`, then fill in `BEEPER_USERNAME` and `BEEPER_PASSWORD`: + +```sh +pnpm --filter @beeper/pickle-example-dummybridge start +``` + +`loginWithPassword()` returns a standard Pickle Matrix account and defaults to Beeper unless a homeserver is provided. `createBeeperBridge()` takes that account, fetches/registers the Beeper appservice through the bridge-manager-compatible Hungryserv endpoints, uses the default file-backed state package, and starts the bridge runtime with the computed appservice registration. + +To create a portal at startup: + +```sh +DUMMYBRIDGE_CREATE_ROOM=1 pnpm --filter @beeper/pickle-example-dummybridge start +``` + +To attach to an existing portal room instead: + +```sh +DUMMYBRIDGE_PORTAL_ROOM_ID='!room:example' pnpm --filter @beeper/pickle-example-dummybridge start +``` + +To create a management room for commands: + +```sh +DUMMYBRIDGE_CREATE_MANAGEMENT_ROOM=1 DUMMYBRIDGE_INVITE_USER='@you:example' pnpm --filter @beeper/pickle-example-dummybridge start +``` + +Or attach to an existing management room: + +```sh +DUMMYBRIDGE_MANAGEMENT_ROOM_ID='!room:example' pnpm --filter @beeper/pickle-example-dummybridge start +``` + +Send `dummy help` in that room to list commands such as `create-room`, `message`, +`messages`, `ghost`, `kick-me`, `file`, `media`, `cat`, and `avatar`. diff --git a/examples/dummybridge/package.json b/examples/dummybridge/package.json new file mode 100644 index 0000000..30615ad --- /dev/null +++ b/examples/dummybridge/package.json @@ -0,0 +1,24 @@ +{ + "name": "@beeper/pickle-example-dummybridge", + "private": true, + "type": "module", + "scripts": { + "build": "tsdown", + "smoke": "pnpm build && node dist/test/smoke.mjs", + "start": "pnpm build && node dist/src/index.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@beeper/pickle": "workspace:*", + "@beeper/pickle-bridge": "workspace:*", + "@beeper/pickle-state-file": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsdown": "^0.21.10", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts new file mode 100644 index 0000000..cc41997 --- /dev/null +++ b/examples/dummybridge/src/connector.ts @@ -0,0 +1,400 @@ +import { createRemoteMessage } from "@beeper/pickle-bridge"; +import type { + BridgeConfigPart, + BridgeContext, + BridgeRequestContext, + BridgeUser, + CommandHandlingBridgeConnector, + DBMetaTypes, + FetchMessagesResponse, + LoginFlow, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, + LoginProcess, + LoginStep, + MatrixMessage, + MatrixMessageResponse, + MatrixCommand, + MatrixCommandResponse, + NetworkAPI, + NetworkGeneralCapabilities, + UserLogin, +} from "@beeper/pickle-bridge/types"; + +export const LOGIN_ID = "dummy-login"; +export const PORTAL_ID = "dummy-room"; +export const DUMMY_CHAT_IDS = ["dummy-chat-alice", "dummy-chat-bob", "dummy-chat-carol"] as const; + +export class DummyConnector implements CommandHandlingBridgeConnector { + #roomCounter = 0; + + createLogin(_ctx: BridgeRequestContext, _user: BridgeUser, flowId: string): LoginProcess { + return new DummyLoginProcess(flowId); + } + + getBridgeInfoVersion() { + return { capabilities: 1, info: 1 }; + } + + getCapabilities(): NetworkGeneralCapabilities { + return { + native: true, + }; + } + + getConfig(): BridgeConfigPart { + return { + data: { + description: "A minimal TypeScript Pickle bridge.", + }, + }; + } + + getDBMetaTypes(): DBMetaTypes { + return {}; + } + + getLoginFlows(): LoginFlow[] { + return [ + { + description: "Create the built-in dummy login.", + id: "dummy", + name: "Dummy", + }, + { + description: "Create a dummy login from username and password fields.", + id: "password", + name: "Password", + }, + { + description: "Create a dummy login from browser cookies.", + id: "cookies", + name: "Cookies", + }, + { + description: "Create a dummy login from browser local storage.", + id: "local_storage", + name: "Local storage", + }, + { + description: "Create a dummy login after displaying a code and waiting.", + id: "display_and_wait", + name: "Display and wait", + }, + ]; + } + + getName() { + return { + beeperBridgeType: "dummybridge", + defaultCommandPrefix: "dummy", + displayName: "Pickle DummyBridge", + networkId: "dummybridge", + }; + } + + init(ctx: BridgeContext): void { + ctx.log("info", "dummybridge_init", {}); + } + + async handleCommand(ctx: BridgeRequestContext, command: MatrixCommand): Promise { + switch (command.command) { + case "help": + return reply([ + "DummyBridge commands:", + "dummy help", + "dummy create-room [name]", + "dummy message [text]", + "dummy messages [count]", + "dummy ghost [id]", + "dummy kick-me", + "dummy file", + "dummy media", + "dummy cat", + "dummy avatar [id]", + ].join("\n")); + case "create-room": { + const name = command.args.join(" ") || "Pickle DummyBridge"; + const portalId = `dummy-room-${++this.#roomCounter}`; + const login = { id: LOGIN_ID }; + const portal = await ctx.bridge.createPortal(login, { + id: portalId, + invite: [command.sender.userId], + name, + sender: "alice", + topic: "Created from the DummyBridge management room.", + }); + return reply(`created ${portal.mxid} for ${portalId}`); + } + case "message": { + const text = command.args.join(" ") || "hello from DummyBridge"; + ctx.queue({ id: LOGIN_ID }).message({ + id: `dummy-command-${Date.now()}`, + portal: PORTAL_ID, + sender: "alice", + text, + }); + return reply(`queued message: ${text}`); + } + case "messages": { + const count = Math.max(1, Math.min(Number(command.args[0] ?? 3) || 3, 10)); + for (let index = 0; index < count; index += 1) { + ctx.queue({ id: LOGIN_ID }).message({ + id: `dummy-command-${Date.now()}-${index}`, + portal: PORTAL_ID, + sender: "alice", + text: `dummy message ${index + 1}/${count}`, + }); + } + return reply(`queued ${count} messages`); + } + case "ghost": { + const localId = command.args[0] ?? "alice"; + return reply(ctx.bridge.ghostUserId(localId)); + } + case "kick-me": + await ctx.client.raw.request({ + body: { reason: "DummyBridge kick-me command", user_id: command.sender.userId }, + method: "POST", + path: `/_matrix/client/v3/rooms/${encodeURIComponent(command.room.mxid)}/kick`, + }); + return { handled: true }; + case "file": + case "media": + return reply("media upload/download is stubbed in the TypeScript dummybridge"); + case "cat": + return reply("=^._.^="); + case "avatar": + return reply(ctx.bridge.ghostUserId(command.args[0] ?? "alice")); + default: + return reply(`unknown command: ${command.command}`); + } + } + + loadUserLogin(ctx: BridgeRequestContext, login: UserLogin): NetworkAPI { + return new DummyNetworkAPI({ ghostUserId: (localId) => ctx.bridge.ghostUserId(localId), login }); + } + + start(ctx: BridgeContext): void { + ctx.log("info", "dummybridge_start", {}); + } + + stop(): void {} + + #remoteMessage(ctx: BridgeRequestContext, options: { body: string; id: string; portalId?: string; timestamp?: number }) { + return remoteMessage({ + ...options, + ghostUserId: (localId) => ctx.bridge.ghostUserId(localId), + }); + } +} + +function reply(text: string): MatrixCommandResponse { + return { handled: true, text }; +} + +class DummyLoginProcess implements LoginProcess, LoginProcessUserInput, LoginProcessCookies, LoginProcessDisplayAndWait { + #cancelled = false; + #flowId: string; + + constructor(flowId: string) { + this.#flowId = flowId; + } + + cancel(): void { + this.#cancelled = true; + } + + async start(): Promise { + this.#throwIfCancelled(); + if (this.#flowId === "password") { + return { + instructions: "Enter any username and password to create a dummy login.", + stepId: "password", + type: "user_input", + userInput: { + fields: [ + { + description: "Dummy username", + id: "username", + name: "Username", + type: "username", + }, + { + description: "Dummy password", + id: "password", + name: "Password", + type: "password", + }, + ], + }, + }; + } + if (this.#flowId === "cookies" || this.#flowId === "local_storage") { + const sourceType = this.#flowId === "cookies" ? "cookie" : "local_storage"; + return { + cookies: { + fields: [{ + id: "dummy_session", + required: true, + sources: [{ + cookieDomain: ".example.invalid", + name: "dummy_session", + type: sourceType, + }], + }], + url: "https://example.invalid/login", + }, + instructions: `Open the dummy login page and provide the dummy_session ${sourceType}.`, + stepId: this.#flowId, + type: "cookies", + }; + } + if (this.#flowId === "display_and_wait") { + return { + displayAndWait: { + data: "DUMMY-CODE", + type: "code", + }, + instructions: "Use the displayed dummy code, then wait for completion.", + stepId: "display_and_wait", + type: "display_and_wait", + }; + } + return { + complete: { + userLogin: { id: LOGIN_ID }, + userLoginId: LOGIN_ID, + }, + instructions: "DummyBridge creates a local dummy login without external auth.", + stepId: "complete", + type: "complete", + }; + } + + submitUserInput(input: Record): Promise; + submitUserInput(ctx: BridgeRequestContext | undefined, input: Record): Promise; + async submitUserInput(ctxOrInput: BridgeRequestContext | Record | undefined, input?: Record): Promise { + this.#throwIfCancelled(); + const values = input ?? ctxOrInput as Record; + return this.#complete(values.username ? `${LOGIN_ID}:${values.username}` : LOGIN_ID); + } + + submitCookies(cookies: Record): Promise; + submitCookies(ctx: BridgeRequestContext | undefined, cookies: Record): Promise; + async submitCookies(ctxOrCookies: BridgeRequestContext | Record | undefined, cookies?: Record): Promise { + this.#throwIfCancelled(); + const values = cookies ?? ctxOrCookies as Record; + return this.#complete(values.dummy_session ? `${LOGIN_ID}:${values.dummy_session}` : LOGIN_ID); + } + + async wait(): Promise { + this.#throwIfCancelled(); + return this.#complete(`${LOGIN_ID}:display`); + } + + #complete(userLoginId: string): LoginStep { + return { + complete: { + userLogin: { id: userLoginId }, + userLoginId, + }, + instructions: "DummyBridge login complete.", + stepId: "complete", + type: "complete", + }; + } + + #throwIfCancelled(): void { + if (this.#cancelled) { + throw new Error("Login process cancelled"); + } + } +} + +interface DummyNetworkOptions { + ghostUserId(localId: string): string; + login?: UserLogin; +} + +export class DummyNetworkAPI implements NetworkAPI { + #ghostUserId: (localId: string) => string; + #login: UserLogin; + + constructor(options: DummyNetworkOptions) { + this.#ghostUserId = options.ghostUserId; + this.#login = options.login ?? { id: LOGIN_ID }; + } + + connect(ctx: BridgeRequestContext): void { + ctx.log("info", "dummy_network_connected", { login: this.#login.id }); + } + + disconnect(): void {} + + async fetchMessages(): Promise { + return { + hasMore: false, + messages: Array.from({ length: 5 }, (_, index) => ({ + event: this.#remoteMessage({ + body: `DummyBridge historical message ${index + 1}`, + id: `dummy-history-${index + 1}`, + timestamp: Date.now() - (5 - index) * 60_000, + }), + })), + }; + } + + async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + const body = msg.text || stringBody(msg.content?.body) || "non-text Matrix message"; + ctx.queueRemoteEvent(this.#login, this.#remoteMessage({ + body: `dummy echo: ${body}`, + id: `dummy-echo-${Date.now()}`, + portalId: msg.portal?.portalKey?.id ?? PORTAL_ID, + })); + return { + pending: false, + streamOrder: Date.now(), + }; + } + + #remoteMessage(options: { body: string; id: string; portalId?: string; timestamp?: number }) { + return remoteMessage({ + ...options, + ghostUserId: this.#ghostUserId, + loginId: this.#login.id, + }); + } +} + +function remoteMessage(options: { body: string; ghostUserId(localId: string): string; id: string; loginId?: string; portalId?: string; timestamp?: number }) { + const portalKey = { id: options.portalId ?? PORTAL_ID, receiver: options.loginId ?? LOGIN_ID }; + return createRemoteMessage({ + convert: () => textMessage(options.body), + data: {}, + id: options.id, + portalKey, + sender: { + isFromMe: false, + sender: options.ghostUserId("alice"), + }, + timestamp: new Date(options.timestamp ?? Date.now()), + }); +} + +function textMessage(body: string) { + return { + parts: [{ + content: { + body, + msgtype: "m.text", + }, + type: "m.room.message", + }], + }; +} + +function stringBody(value: unknown): string | null { + return typeof value === "string" ? value : null; +} diff --git a/examples/dummybridge/src/env.ts b/examples/dummybridge/src/env.ts new file mode 100644 index 0000000..a83f168 --- /dev/null +++ b/examples/dummybridge/src/env.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export async function loadEnv(path = ".env"): Promise { + try { + const text = await readFile(resolve(path), "utf8"); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + if (!key) continue; + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + process.env[key] ??= value; + } + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } +} + +export function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable ${name}`); + return value; +} + +export function optionalEnv(name: string, fallback?: string): string | undefined { + return process.env[name] || fallback; +} diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts new file mode 100644 index 0000000..ed77f13 --- /dev/null +++ b/examples/dummybridge/src/index.ts @@ -0,0 +1,113 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loginWithPassword } from "@beeper/pickle/auth"; +import { createBeeperBridge } from "@beeper/pickle-bridge"; +import type { CreateNodeBeeperBridgeOptions, Portal } from "@beeper/pickle-bridge/types"; +import { DUMMY_CHAT_IDS, DummyConnector, LOGIN_ID, PORTAL_ID } from "./connector"; +import { loadEnv, optionalEnv, requiredEnv } from "./env"; + +const root = dirname(fileURLToPath(import.meta.url)); +const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve(root, ".."); + +await loadEnv(resolve(sourceRoot, ".env")); + +const account = await loginWithPassword({ + password: requiredEnv("BEEPER_PASSWORD"), + username: requiredEnv("BEEPER_USERNAME"), +}); +const bridgeName = optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge2") ?? "sh-dummybridge"; + +const bridgeOptions: CreateNodeBeeperBridgeOptions = { + account, + bridge: bridgeName, + bridgeType: "dummybridge-js", + connector: new DummyConnector(), +}; +const baseDomain = optionalEnv("BEEPER_BASE_DOMAIN"); +if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; +const bridgeAddress = optionalEnv("DUMMYBRIDGE_URL"); +if (bridgeAddress !== undefined) bridgeOptions.address = bridgeAddress; +const bridge = await createBeeperBridge(bridgeOptions); + +await bridge.start(); +const login = { + id: LOGIN_ID, + remoteName: "Dummy Account", + userId: account.userId, +}; +await bridge.loadUserLogin(login); + +const existingManagementRoomId = optionalEnv("DUMMYBRIDGE_MANAGEMENT_ROOM_ID"); +if (existingManagementRoomId) { + bridge.registerManagementRoom({ mxid: existingManagementRoomId }); + console.log(`registered existing management room ${existingManagementRoomId}`); +} else if (optionalEnv("DUMMYBRIDGE_CREATE_MANAGEMENT_ROOM") === "1") { + const inviteUser = optionalEnv("DUMMYBRIDGE_INVITE_USER"); + const room = await bridge.createManagementRoom({ + invite: inviteUser ? [inviteUser] : [], + name: "Pickle DummyBridge Commands", + topic: "Send dummy help for commands.", + }); + console.log(`created management room ${room.mxid}`); +} + +const existingRoomId = optionalEnv("DUMMYBRIDGE_PORTAL_ROOM_ID"); +let portal: Portal | null = null; + +if (existingRoomId) { + portal = { + id: PORTAL_ID, + mxid: existingRoomId, + portalKey: { id: PORTAL_ID, receiver: login.id }, + receiver: login.id, + }; + bridge.registerPortal(portal); + console.log(`registered existing portal ${existingRoomId}`); +} else if (optionalEnv("DUMMYBRIDGE_CREATE_ROOM") === "1") { + const inviteUser = optionalEnv("DUMMYBRIDGE_INVITE_USER"); + portal = await bridge.createPortal(login, { + id: PORTAL_ID, + invite: inviteUser ? [inviteUser] : [], + name: "Pickle DummyBridge", + sender: "alice", + topic: "A TypeScript bridge built with Pickle.", + }); + console.log(`created portal ${portal.mxid}`); +} + +if (portal?.mxid && optionalEnv("DUMMYBRIDGE_BACKFILL_ON_START") === "1") { + await bridge.backfillPortal(login, portal); + console.log(`backfilled ${portal.mxid}`); +} + +if (optionalEnv("DUMMYBRIDGE_CREATE_DUMMY_CHATS", "1") === "1") { + const count = Math.max(1, Math.min(Number(optionalEnv("DUMMYBRIDGE_DUMMY_CHAT_COUNT", "2")) || 2, DUMMY_CHAT_IDS.length)); + for (const portalId of DUMMY_CHAT_IDS.slice(0, count)) { + try { + const room = await bridge.createPortal(login, { + id: portalId, + invite: [account.userId], + name: `Pickle ${titleCase(portalId)}`, + sender: "alice", + topic: "A dummy chat created by the TypeScript Pickle bridge.", + }); + await bridge.backfillPortal(login, room); + console.log(`created and backfilled dummy chat ${room.mxid}`); + } catch (error) { + console.error(`failed to create/backfill dummy chat ${portalId}`, error); + } + } +} + +console.log("dummybridge running"); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, async () => { + await bridge.stop(); + process.exit(0); + }); +} + +function titleCase(value: string): string { + return value.replace(/^dummy-chat-/, "").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts new file mode 100644 index 0000000..b207bf2 --- /dev/null +++ b/examples/dummybridge/test/smoke.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { RuntimeBridge } from "@beeper/pickle-bridge"; +import type { MatrixClient, MatrixClientEvent, MatrixStore } from "@beeper/pickle"; +import type { BridgeConnector, BridgeMatrixConfig, MatrixAppserviceInitOptions } from "@beeper/pickle-bridge/types"; +import { DummyConnector, LOGIN_ID, PORTAL_ID } from "../src/connector"; + +interface SmokeCalls { + appserviceInit: MatrixAppserviceInitOptions[]; + backfill: Array<{ events: unknown[]; roomId: string }>; + createRoom: Array<{ name?: string; userId?: string }>; + sendMessage: Array<{ content: { body?: string }; roomId: string; userId?: string }>; + subscriptions: Array<{ filter: unknown; options: unknown }>; +} + +const calls: SmokeCalls = { + appserviceInit: [], + backfill: [], + createRoom: [], + sendMessage: [], + subscriptions: [], +}; + +const matrixClient = { + appservice: { + async batchSend(options: { events: unknown[]; roomId: string }) { + calls.backfill.push(options); + return { eventIds: options.events.map((_, index) => `$backfill-${index}`), raw: {} }; + }, + async createRoom(options: { name?: string; userId?: string }) { + calls.createRoom.push(options); + return { raw: {}, roomId: "!dummy:example" }; + }, + async createPortalRoom(options: { name?: string; userId?: string }) { + calls.createRoom.push(options); + return { raw: {}, roomId: "!dummy:example" }; + }, + async ensureJoined() { + return { raw: {} }; + }, + async ensureRegistered() { + return { raw: {} }; + }, + async init(options: MatrixAppserviceInitOptions) { + calls.appserviceInit.push(options); + }, + async sendMessage(options: { content: { body?: string }; roomId: string; userId?: string }) { + calls.sendMessage.push(options); + return { eventId: `$send-${calls.sendMessage.length}`, raw: {}, roomId: options.roomId }; + }, + }, + async boot() { + return { deviceId: "DEVICE", userId: "@dummybridgebot:example" }; + }, + async close() {}, + raw: { + async request() { + throw new Error("smoke test should send remote messages through appservice ghosts"); + }, + }, + async subscribe(filter: unknown, _callback: unknown, options: unknown) { + calls.subscriptions.push({ filter, options }); + return { stop() {} }; + }, +} as unknown as MatrixClient; + +const appservice: MatrixAppserviceInitOptions = { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "dummybridge", + namespaces: { + users: [{ exclusive: true, regex: "@dummybridgebot_.*:example" }], + }, + senderLocalpart: "dummybridgebot", + url: "http://localhost:29300", + }, +}; + +const bridge = new RuntimeBridge({ + appservice, + connector: new DummyConnector() as BridgeConnector, + matrix: { + homeserver: "https://matrix.example", + store: memoryStore(), + token: "token", + wasmModule: {} as WebAssembly.Module, + } satisfies BridgeMatrixConfig, +}, matrixClient); + +await bridge.start(); +assert.equal(calls.appserviceInit.length, 1); + +const login = { id: LOGIN_ID }; +await bridge.loadUserLogin(login); + +const portal = await bridge.createPortal(login, { + id: PORTAL_ID, + name: "Pickle DummyBridge", + sender: "alice", +}); + +assert.equal(portal.mxid, "!dummy:example"); +assert.equal(calls.createRoom[0]?.userId, bridge.ghostUserId("alice")); + +const backfill = await bridge.backfillPortal(login, portal); + +assert.deepEqual(backfill.eventIds, ["$backfill-0", "$backfill-1", "$backfill-2", "$backfill-3", "$backfill-4"]); +assert.equal(calls.backfill.length, 1); + +await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { body: "hello bridge", msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: "$matrix", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: portal.mxid, + sender: { isMe: false, userId: "@user:example" }, + text: "hello bridge", + type: "m.room.message", +} satisfies MatrixClientEvent); +await bridge.flushRemoteEvents(); + +assert.equal(calls.sendMessage.length, 1); +assert.equal(calls.sendMessage[0]?.roomId, portal.mxid); +assert.equal(calls.sendMessage[0]?.userId, bridge.ghostUserId("alice")); +assert.equal(calls.sendMessage[0]?.content.body, "dummy echo: hello bridge"); + +await bridge.stop(); +console.log("dummybridge smoke passed"); + +function memoryStore(): MatrixStore { + const values = new Map(); + return { + async delete(key: string) { + values.delete(key); + }, + async get(key: string) { + return values.get(key) ?? null; + }, + async list(prefix: string) { + return [...values.keys()].filter((key) => key.startsWith(prefix)).sort(); + }, + async set(key: string, value: Uint8Array) { + values.set(key, value); + }, + }; +} diff --git a/examples/dummybridge/tsconfig.json b/examples/dummybridge/tsconfig.json new file mode 100644 index 0000000..f19abaa --- /dev/null +++ b/examples/dummybridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/examples/dummybridge/tsdown.config.ts b/examples/dummybridge/tsdown.config.ts new file mode 100644 index 0000000..065095b --- /dev/null +++ b/examples/dummybridge/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: false, + entry: ["src/index.ts", "test/smoke.ts"], + format: ["esm"], + platform: "node", + sourcemap: true, +}); diff --git a/package.json b/package.json index 1ed79cc..8f8915e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.31.0", "@types/mdast": "^4.0.4", - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index d7b636b..2e8fb54 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -41,7 +41,7 @@ }, "devDependencies": { "@beeper/pickle-chat-adapter": "workspace:*", - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "tsdown": "^0.21.10", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/packages/bridge/LICENSE b/packages/bridge/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/packages/bridge/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/bridge/README.md b/packages/bridge/README.md new file mode 100644 index 0000000..af83331 --- /dev/null +++ b/packages/bridge/README.md @@ -0,0 +1,88 @@ +# @beeper/pickle-bridge + +Bridge-building runtime for Pickle. This package is intentionally separate from +`@beeper/pickle`: Pickle owns the Matrix/WASM SDK, while this package owns +bridgev2-shaped connector interfaces and bridge runtime orchestration. + +```ts +import { loginWithPassword } from "@beeper/pickle/auth"; +import { createBeeperBridge } from "@beeper/pickle-bridge"; +import type { BridgeConnector } from "@beeper/pickle-bridge/types"; + +const account = await loginWithPassword({ + username: process.env.BEEPER_USERNAME!, + password: process.env.BEEPER_PASSWORD!, +}); + +// Replace this stub with your bridge's connector implementation. +const connector: BridgeConnector = { + createLogin: async () => ({ cancel: async () => {}, start: async () => ({ type: "complete", complete: { userLoginId: "example-login" } }) }), + getBridgeInfoVersion: () => ({ capabilities: 1, info: 1 }), + getCapabilities: () => ({}), + getConfig: () => ({}), + getDBMetaTypes: () => ({}), + getLoginFlows: () => [], + getName: () => ({ displayName: "Example", networkId: "example" }), + init: async () => {}, + loadUserLogin: async () => ({ connect: async () => {}, disconnect: async () => {} }), + start: async () => {}, +}; + +const bridge = await createBeeperBridge({ + account, + bridge: "sh-example", + connector, +}); + +await bridge.start(); + +const login = { id: "example-login" }; +await bridge.loadUserLogin(login); +const portal = await bridge.createPortal(login, { + id: "remote-room-id", + info: { name: "Remote room" }, + sender: "alice", +}); + +await bridge.backfillPortal(login, portal); +await bridge.backfillPortal(login, "remote-room-id"); + +bridge.queue(login).message({ + id: "remote-message-id", + portal: "remote-room-id", + sender: "alice", + text: "hello", +}); + +bridge.queue(login).message({ + id: "remote-rich-message-id", + portal, + sender: "alice", + content: { msgtype: "m.notice", body: "custom Matrix content" }, +}); + +bridge.queue(login).backfill({ + portal, + messages: [ + { id: "old-message-1", sender: "alice", text: "older message" }, + { id: "old-message-2", sender: "alice", text: "newer message" }, + ], +}); +``` + +The bridge package is Node-only and uses the same Pickle WASM mechanism as +`@beeper/pickle/node`. Bridge authors do not need to load `wasm_exec.js` or +`pickle.wasm`; the package loads the bundled runtime automatically. + +## Bridge-manager helpers + +`@beeper/pickle-bridge` also exposes bridge-manager-compatible helpers: + +- `createBeeperBridgeManagerClient({ token })` +- `fetchBeeperBridges({ token })` +- `createBeeperAppService({ token, bridge })` +- `createBeeperAppServiceInit({ token, bridge })` + +These mirror the useful `bbctl whoami/register` pieces: fetch the user's +bridges from `https://api./whoami`, then get or register the appservice +through Hungryserv at `/_matrix/asmux/mxauth/appservice/:user/:bridge`. diff --git a/packages/bridge/docs/BRIDGE_TODO.md b/packages/bridge/docs/BRIDGE_TODO.md new file mode 100644 index 0000000..556c18e --- /dev/null +++ b/packages/bridge/docs/BRIDGE_TODO.md @@ -0,0 +1,147 @@ +# Pickle Bridge TODO + +This is the implementation map for `@beeper/pickle-bridge`. Interfaces are named +to match bridgev2 concepts while using TypeScript idioms. + +## Runtime + +- [x] `createBridge(options)` factory. +- [x] `PickleBridge` runtime interface. +- [x] `Bridge.start()` boots Pickle, initializes connector, starts connector. +- [x] `Bridge.stop()` stops subscriptions, connector, loaded network clients, and Matrix client. +- [x] `Bridge.queueRemoteEvent(login, event)` bridgev2-style remote event ingress. +- [x] `Bridge.flushRemoteEvents()` for tests and controlled drains. +- [x] Remote message drain to Matrix sends. +- [x] Appservice initialization at bridge startup. +- [x] Appservice portal room creation. +- [x] Appservice batch backfill. +- [x] Basic live Matrix subscription lifecycle. +- [x] In-memory portal registration and Matrix room fallback portal keys. +- [ ] Remote event worker with retry/backoff. +- [ ] Persistent queue. +- [ ] Background mode. +- [ ] Command processor. +- [ ] Bridge info/capabilities room state publishing. + +## Matrix/WASM + +- [x] Forward `wasmBytes`, `wasmModule`, `wasmUrl` to Pickle. +- [x] Node entrypoint delegates to `@beeper/pickle/node`. +- [ ] Browser/worker examples for `wasmModule` and `wasmUrl`. +- [ ] Direct media helper. +- [x] Appservice-mode Matrix primitives exposed by Pickle WASM. +- [ ] Full bridgev2 database-backed Matrix connector. + +## Network Connector Interfaces + +- [x] `BridgeConnector`. +- [x] `StoppableNetwork`. +- [x] `DirectMediableNetwork`. +- [x] `IdentifierValidatingNetwork`. +- [x] `TransactionIDGeneratingNetwork`. +- [x] `PortalBridgeInfoFillingNetwork`. +- [x] `ConfigValidatingNetwork`. +- [x] `MaxFileSizingNetwork`. +- [x] `NetworkResettingNetwork`. +- [x] `PushParsingNetwork`. +- [ ] Config loader/upgrader implementation. +- [ ] DB metadata registration/migrations. + +## Network API Interfaces + +- [x] `NetworkAPI`. +- [x] `PushableNetworkAPI`. +- [x] `BackgroundSyncingNetworkAPI`. +- [x] `ChatViewingNetworkAPI`. +- [x] `BackfillingNetworkAPI`. +- [x] `StickerImportingNetworkAPI`. +- [x] Matrix outbound handlers: + - [x] message + - [x] edit + - [x] reaction + - [x] reaction remove + - [x] redaction + - [x] read receipt + - [x] typing + - [x] poll + - [x] disappearing timer + - [x] membership + - [x] room name/topic/avatar + - [x] mute/tag/marked unread/delete chat +- [x] Dispatch Pickle message/reaction/redaction/typing events to outbound handlers. +- [ ] Dispatch Pickle edit/read receipt/room state/account data events to outbound handlers. +- [ ] Pending message echo matching. +- [ ] No-echo/no-ack timeout handling. + +## Login Interfaces + +- [x] `LoginProcess`. +- [x] `LoginProcessWithOverride`. +- [x] `LoginProcessDisplayAndWait`. +- [x] `LoginProcessUserInput`. +- [x] `LoginProcessCookies`. +- [x] `LoginFlow`, `LoginStep`, `LoginStepType`. +- [ ] Login session persistence. +- [ ] Provisioning HTTP surface. +- [ ] Reauth/override flow. + +## Remote Event Interfaces + +- [x] `RemoteEvent`. +- [x] `RemoteEventWithContextMutation`. +- [x] `RemoteEventWithUncertainPortalReceiver`. +- [x] `RemotePreHandler`. +- [x] `RemotePostHandler`. +- [x] `RemoteChatInfoChange`. +- [x] `RemoteChatResync`. +- [x] `RemoteChatResyncWithInfo`. +- [x] `RemoteChatResyncBackfill`. +- [x] `RemoteChatResyncBackfillBundle`. +- [x] `RemoteBackfill`. +- [x] `RemoteDeleteOnlyForMe`. +- [x] `RemoteChatDelete`. +- [x] `RemoteChatDeleteWithChildren`. +- [x] `RemoteEventThatMayCreatePortal`. +- [x] `RemoteEventWithTargetMessage`. +- [x] `RemoteEventWithBundledParts`. +- [x] `RemoteEventWithTargetPart`. +- [x] `RemoteEventWithTimestamp`. +- [x] `RemoteEventWithStreamOrder`. +- [x] `RemoteMessage`. +- [x] `RemoteMessageWithTransactionID`. +- [x] `RemoteMessageUpsert`. +- [x] `RemoteEdit`. +- [x] `RemoteReaction`. +- [x] `RemoteReactionRemove`. +- [x] `RemoteMessageRemove`. +- [x] `RemoteReadReceipt`. +- [x] `RemoteDeliveryReceipt`. +- [x] `RemoteMarkUnread`. +- [x] `RemoteTyping`. +- [ ] Portal resolution and room creation from remote events. +- [ ] Message conversion to Matrix event sends. +- [x] Basic remote message conversion to Matrix room sends. +- [ ] Edit/reaction/redaction conversion to Matrix sends. +- [ ] Backfill import. + +## Storage Models + +- [x] Type shells for `BridgeUser`, `UserLogin`, `Portal`, `Ghost`, `Message`, `Reaction`. +- [ ] Persistent stores for users, logins, portals, ghosts, messages, reactions. +- [ ] Metadata codecs and migrations. +- [ ] ID helper utilities. + +## Examples + +- [ ] `examples/echo-bridge`. +- [ ] Port `bots/dummybot` to `@beeper/pickle-bridge`. +- [ ] Minimal QR login bridge example. +- [ ] Minimal cookie login bridge example. + +## Tests + +- [ ] Type conformance tests for golden bridge patterns. +- [x] Runtime start/stop tests in `bridge.test.ts`. +- [ ] WASM option forwarding tests. +- [x] Remote event queue tests in `bridge.test.ts` for queued remote event draining. +- [x] Matrix sync dispatch tests in `bridge.test.ts` for Matrix event dispatch. diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..06d988d --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,79 @@ +{ + "name": "@beeper/pickle-bridge", + "version": "0.1.0", + "description": "Bridge building runtime for Pickle with bridgev2-shaped interfaces", + "type": "module", + "homepage": "https://github.com/beeper/pickle#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/bridge" + }, + "bugs": { + "url": "https://github.com/beeper/pickle/issues" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./beeper": { + "types": "./dist/beeper.d.ts", + "import": "./dist/beeper.js" + }, + "./store": { + "types": "./dist/store.d.ts", + "import": "./dist/store.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs && pnpm build", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@beeper/pickle": "workspace:*", + "@beeper/pickle-state-file": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.18", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "keywords": [ + "matrix", + "mautrix", + "bridgev2", + "bridge", + "wasm" + ], + "engines": { + "node": ">=20" + }, + "license": "MPL-2.0" +} diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts new file mode 100644 index 0000000..58df8d3 --- /dev/null +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -0,0 +1,270 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import { AppserviceWebsocket } from "./appservice-websocket"; +import type { BridgeLogger } from "./types"; + +const servers: Array<{ close(callback?: (error?: Error) => void): void }> = []; +const websockets: AppserviceWebsocket[] = []; + +afterEach(async () => { + for (const websocket of websockets.splice(0)) websocket.stop(); + await Promise.all(servers.splice(0).map((server) => new Promise((resolve, reject) => { + server.close((error?: Error) => error ? reject(error) : resolve()); + }))); +}); + +describe("AppserviceWebsocket", () => { + it("connects to as_sync, dispatches transactions, and acknowledges them", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; + const dispatch = vi.fn(async () => {}); + const connected = new Promise((resolve, reject) => { + wsServer.on("connection", (socket, request) => { + try { + expect(request.url).toBe("/_hungryserv/alice/_matrix/client/unstable/fi.mau.as_sync"); + expect(request.headers.authorization).toBe("Bearer as-token"); + socket.once("message", (raw) => { + const response = JSON.parse(raw.toString()) as { command: string; data: { txn_id: string }; id: number }; + expect(response).toEqual({ + command: "response", + data: { txn_id: "txn-1" }, + id: 7, + }); + resolve(); + }); + socket.send(JSON.stringify({ + command: "transaction", + events: [{ + content: { body: "hi", msgtype: "m.text" }, + event_id: "$event", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }], + id: 7, + txn_id: "txn-1", + })); + } catch (error) { + reject(error); + } + }); + }); + const websocket = createWebsocket(homeserver, { + dispatch, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + await connected; + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$event", + kind: "message", + roomId: "!room:example", + text: "hi", + })); + }); + + it("handles http_proxy appservice transaction requests", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; + const dispatch = vi.fn(async () => {}); + const connected = new Promise((resolve, reject) => { + wsServer.on("connection", (socket) => { + socket.once("message", (raw) => { + try { + const response = JSON.parse(raw.toString()) as { command: string; data: { status: number; body: unknown }; id: number }; + expect(response.command).toBe("response"); + expect(response.id).toBe(9); + expect(response.data.status).toBe(200); + expect(response.data.body).toEqual({}); + resolve(); + } catch (error) { + reject(error); + } + }); + socket.send(JSON.stringify({ + command: "http_proxy", + data: { + body: { + events: [{ + content: { body: "proxied", msgtype: "m.text" }, + event_id: "$proxied", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }], + }, + method: "PUT", + path: "/_matrix/app/v1/transactions/txn-2", + }, + id: 9, + })); + }); + }); + const websocket = createWebsocket(homeserver, { + dispatch, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + await connected; + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$proxied", + kind: "message", + text: "proxied", + })); + }); + + it("reconnects with capped exponential backoff and resets after a stable connection", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + const log = vi.fn() as BridgeLogger; + let connectionCount = 0; + wsServer.on("connection", (socket) => { + connectionCount++; + if (connectionCount <= 4) { + socket.close(); + return; + } + if (connectionCount === 5) { + setTimeout(() => socket.close(), 35); + } + }); + const websocket = createWebsocket(homeserver, { + log, + timing: { + initialReconnectMs: 5, + maxReconnectMs: 20, + pingIntervalMs: 1_000, + pingTimeoutMs: 1_000, + stableConnectionMs: 20, + }, + }); + websockets.push(websocket); + + websocket.start(); + await waitFor(() => connectionCount >= 6); + + const reconnects = log.mock.calls + .filter(([, event]) => event === "appservice_websocket_closed") + .map(([, , data]) => (data as { reconnectMs: number }).reconnectMs); + expect(reconnects).toEqual([5, 10, 20, 20, 5]); + }); + + it("reconnects when a websocket ping is not acknowledged", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + let connectionCount = 0; + wsServer.on("connection", () => { + connectionCount++; + }); + const websocket = createWebsocket(homeserver, { + timing: { + initialReconnectMs: 5, + maxReconnectMs: 10, + pingIntervalMs: 5, + pingTimeoutMs: 5, + stableConnectionMs: 1_000, + }, + }); + websockets.push(websocket); + + websocket.start(); + await waitFor(() => connectionCount >= 2); + }); + + it("does not reconnect after a replacement close command", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + const onClose = vi.fn(); + const onReplaced = vi.fn(); + let connectionCount = 0; + wsServer.on("connection", (socket) => { + connectionCount++; + socket.send(JSON.stringify({ command: "disconnect", status: "conn_replaced" })); + }); + const websocket = createWebsocket(homeserver, { + onClose, + onReplaced, + timing: { + initialReconnectMs: 5, + maxReconnectMs: 10, + pingIntervalMs: 1_000, + pingTimeoutMs: 1_000, + stableConnectionMs: 1_000, + }, + }); + websockets.push(websocket); + + websocket.start(); + await waitFor(() => onReplaced.mock.calls.length > 0); + await delay(30); + + expect(connectionCount).toBe(1); + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ + reconnect: false, + replaced: true, + status: "conn_replaced", + })); + expect(onReplaced).toHaveBeenCalledWith(expect.objectContaining({ + replaced: true, + status: "conn_replaced", + })); + }); +}); + +function createWebsocket( + homeserver: string, + overrides: Partial[0]> = {} +): AppserviceWebsocket { + return new AppserviceWebsocket({ + appservice: { + homeserver, + homeserverDomain: "example", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-dummy", + namespaces: { users: [] }, + senderLocalpart: "dummybot", + url: "", + }, + }, + dispatch: vi.fn(async () => {}), + log: (() => {}) as BridgeLogger, + ...overrides, + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1_000): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) throw new Error("timed out waiting for condition"); + await delay(5); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts new file mode 100644 index 0000000..ae28543 --- /dev/null +++ b/packages/bridge/src/appservice-websocket.ts @@ -0,0 +1,469 @@ +import WebSocket from "ws"; +import type { MatrixAppserviceInitOptions, MatrixClientEvent } from "@beeper/pickle"; +import type { BridgeLogger } from "./types"; + +export interface AppserviceWebsocketOptions { + appservice: MatrixAppserviceInitOptions; + dispatch(event: MatrixClientEvent): Promise; + handleHTTPProxy?(request: HTTPProxyRequest): Promise; + log: BridgeLogger; + onClose?(event: AppserviceWebsocketCloseEvent): void | Promise; + onOpen?(): void | Promise; + onReplaced?(event: AppserviceWebsocketCloseEvent): void | Promise; + timing?: Partial; +} + +export interface AppserviceWebsocketCloseEvent { + code?: number; + reason?: string; + reconnect: boolean; + replaced: boolean; + status?: string; +} + +export interface AppserviceWebsocketTimingOptions { + initialReconnectMs: number; + maxReconnectMs: number; + pingIntervalMs: number; + pingTimeoutMs: number; + stableConnectionMs: number; +} + +export class AppserviceWebsocket { + static readonly defaultTiming: AppserviceWebsocketTimingOptions = { + initialReconnectMs: 2_000, + maxReconnectMs: 120_000, + pingIntervalMs: 180_000, + pingTimeoutMs: 30_000, + stableConnectionMs: 60_000, + }; + + readonly #appservice: MatrixAppserviceInitOptions; + readonly #dispatch: (event: MatrixClientEvent) => Promise; + readonly #handleProxy: ((request: HTTPProxyRequest) => Promise) | undefined; + readonly #log: BridgeLogger; + readonly #onClose: ((event: AppserviceWebsocketCloseEvent) => void | Promise) | undefined; + readonly #onOpen: (() => void | Promise) | undefined; + readonly #onReplaced: ((event: AppserviceWebsocketCloseEvent) => void | Promise) | undefined; + readonly #timing: AppserviceWebsocketTimingOptions; + #closed = false; + #nextPingId = 1; + #pendingPingId: number | null = null; + #pingIntervalTimer: NodeJS.Timeout | null = null; + #pingTimeoutTimer: NodeJS.Timeout | null = null; + #reconnectMs: number; + #reconnectTimer: NodeJS.Timeout | null = null; + #handledCloses = new WeakSet(); + #socket: WebSocket | null = null; + #stableTimer: NodeJS.Timeout | null = null; + + constructor(options: AppserviceWebsocketOptions) { + this.#appservice = options.appservice; + this.#dispatch = options.dispatch; + this.#handleProxy = options.handleHTTPProxy; + this.#log = options.log; + this.#onClose = options.onClose; + this.#onOpen = options.onOpen; + this.#onReplaced = options.onReplaced; + this.#timing = { ...AppserviceWebsocket.defaultTiming, ...options.timing }; + this.#reconnectMs = this.#timing.initialReconnectMs; + } + + start(): void { + this.#closed = false; + this.#clearReconnectTimer(); + this.#connect(); + } + + stop(): void { + this.#closed = true; + this.#clearReconnectTimer(); + this.#clearConnectionTimers(); + this.#socket?.close(); + this.#socket = null; + } + + #connect(): void { + if (this.#closed) return; + const url = websocketURL(this.#appservice.homeserver); + const socket = new WebSocket(url, { + headers: { + Authorization: `Bearer ${this.#appservice.registration.asToken}`, + "User-Agent": "@beeper/pickle-bridge", + "X-Mautrix-Process-ID": `${process.pid}`, + "X-Mautrix-Websocket-Version": "3", + }, + }); + this.#socket = socket; + socket.on("open", () => { + this.#log("info", "appservice_websocket_open", { url }); + this.#clearConnectionTimers(); + this.#pingIntervalTimer = setInterval(() => this.#ping(), this.#timing.pingIntervalMs); + this.#stableTimer = setTimeout(() => { + this.#reconnectMs = this.#timing.initialReconnectMs; + this.#log("debug", "appservice_websocket_stable", { reconnectMs: this.#reconnectMs }); + }, this.#timing.stableConnectionMs); + void Promise.resolve(this.#onOpen?.()).catch((error: unknown) => { + this.#log("warn", "appservice_websocket_open_handler_failed", { error }); + }); + }); + socket.on("message", (data) => { + void this.#handleMessage(data).catch((error: unknown) => { + this.#log("error", "appservice_websocket_message_failed", { error }); + }); + }); + socket.on("close", (code, reason) => { + this.#handleSocketClose(socket, { code, reason: reason.toString() }); + }); + socket.on("error", (error) => { + this.#log("warn", "appservice_websocket_error", { error }); + }); + } + + #handleSocketClose(socket: WebSocket, close: { code?: number; reason?: string; replaced?: boolean; status?: string }): void { + if (this.#handledCloses.has(socket)) return; + this.#handledCloses.add(socket); + this.#clearConnectionTimers(); + if (this.#socket === socket) this.#socket = null; + const status = close.status ?? closeStatusFromReason(close.reason); + const replaced = close.replaced ?? (close.code === 4001 || status === "conn_replaced"); + const reconnect = !this.#closed && !replaced; + const event: AppserviceWebsocketCloseEvent = { + reconnect, + replaced, + }; + if (close.code !== undefined) event.code = close.code; + if (close.reason !== undefined) event.reason = close.reason; + if (status !== undefined) event.status = status; + if (replaced) this.#closed = true; + void Promise.resolve(this.#onClose?.(event)).catch((error: unknown) => { + this.#log("warn", "appservice_websocket_close_handler_failed", { error }); + }); + if (replaced) { + void Promise.resolve(this.#onReplaced?.(event)).catch((error: unknown) => { + this.#log("warn", "appservice_websocket_replaced_handler_failed", { error }); + }); + } + if (this.#closed || !reconnect) return; + const reconnectMs = this.#reconnectMs; + this.#reconnectMs = Math.min(this.#reconnectMs * 2, this.#timing.maxReconnectMs); + this.#log("warn", "appservice_websocket_closed", { + code: close.code, + reconnectMs, + reason: close.reason ?? "", + status, + }); + this.#reconnectTimer = setTimeout(() => this.#connect(), reconnectMs); + } + + #clearConnectionTimers(): void { + if (this.#pingIntervalTimer) clearInterval(this.#pingIntervalTimer); + if (this.#pingTimeoutTimer) clearTimeout(this.#pingTimeoutTimer); + if (this.#stableTimer) clearTimeout(this.#stableTimer); + this.#pendingPingId = null; + this.#pingIntervalTimer = null; + this.#pingTimeoutTimer = null; + this.#stableTimer = null; + } + + #clearReconnectTimer(): void { + if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + + #closeFromCommand(message: WebsocketMessage): boolean { + const status = stringValue(message.status) ?? stringValue(objectValue(message.data)?.status); + const replaced = message.command === "replaced" || status === "conn_replaced"; + if (message.command !== "close" && message.command !== "disconnect" && !replaced) return false; + const socket = this.#socket; + if (!socket) return true; + this.#log("warn", "appservice_websocket_close_command", { command: message.command, replaced, status }); + if (replaced) { + this.#closed = true; + const reason = JSON.stringify({ command: message.command ?? "disconnect", status: status ?? "conn_replaced" }); + socket.close(4001, reason); + this.#handleSocketClose(socket, { code: 4001, reason, replaced, status: status ?? "conn_replaced" }); + return true; + } + socket.close(); + return true; + } + + #handlePingResponse(message: WebsocketMessage): boolean { + if ((message.command !== "response" && message.command !== "error") || message.id !== this.#pendingPingId) return false; + if (this.#pingTimeoutTimer) clearTimeout(this.#pingTimeoutTimer); + this.#pendingPingId = null; + this.#pingTimeoutTimer = null; + return true; + } + + async #handleMessage(data: WebSocket.RawData): Promise { + const message = JSON.parse(data.toString()) as WebsocketMessage; + this.#log("debug", "appservice_websocket_message", { + command: message.command ?? "transaction", + eventCount: message.events?.length, + id: message.id, + txnId: message.txn_id, + }); + try { + if (this.#closeFromCommand(message)) return; + if (this.#handlePingResponse(message)) return; + if (message.command === "connect") return; + if (message.command === "ping") { + this.#send(messageResponse(message, true, message.data ?? { timestamp: Date.now() })); + return; + } + if (message.command === "response" || message.command === "error") return; + if (!message.command || message.command === "transaction") { + for (const raw of message.events ?? []) { + const event = rawMatrixEvent(raw); + this.#log("debug", "appservice_websocket_transaction_event", { + eventId: raw.event_id, + roomId: raw.room_id, + sender: raw.sender, + type: raw.type, + }); + if (event) await this.#dispatch(event); + } + this.#send(messageResponse(message, true, { txn_id: message.txn_id })); + return; + } + if (message.command === "http_proxy") { + const response = await this.#handleHTTPProxy(message.data); + this.#log("debug", "appservice_websocket_http_proxy_response", { id: message.id, status: response.status }); + this.#send(messageResponse(message, true, response)); + return; + } + this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: `unknown websocket command ${message.command}` })); + } catch (error: unknown) { + const messageText = error instanceof Error ? error.message : String(error); + this.#log("error", "appservice_websocket_message_failed", { error: messageText, id: message.id }); + this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: messageText })); + } + } + + async #handleHTTPProxy(data: unknown): Promise { + const request = data as HTTPProxyRequest; + this.#log("debug", "appservice_websocket_http_proxy_request", { + method: request.method ?? "GET", + path: request.path ?? "", + }); + const handled = await this.#handleProxy?.(request); + if (handled) return handled; + const path = request.path ?? ""; + const method = request.method ?? "GET"; + const transactionMatch = /^\/?_matrix\/app\/v1\/transactions\/([^/]+)$/.exec(path); + if (method === "PUT" && transactionMatch) { + const transaction = objectValue(request.body) ?? {}; + const events = Array.isArray(transaction.events) ? transaction.events : []; + this.#log("debug", "appservice_websocket_http_transaction", { + eventCount: events.length, + txnId: transactionMatch[1], + }); + for (const raw of events) { + const event = rawMatrixEvent(raw as RawMatrixEvent); + if (event) await this.#dispatch(event); + } + return jsonHTTPResponse(200, {}); + } + if (method === "GET" && /^\/?_matrix\/app\/v1\/users\//.test(path)) { + return jsonHTTPResponse(200, {}); + } + if (method === "GET" && /^\/?_matrix\/app\/v1\/rooms\//.test(path)) { + return jsonHTTPResponse(404, { errcode: "M_NOT_FOUND", error: "Room alias not handled by this bridge" }); + } + return jsonHTTPResponse(404, { errcode: "M_NOT_FOUND", error: `Unhandled appservice websocket proxy request: ${method} ${path}` }); + } + + #ping(): void { + if (this.#pendingPingId !== null) { + this.#log("warn", "appservice_websocket_ping_stale", { id: this.#pendingPingId }); + this.#socket?.terminate(); + return; + } + const id = this.#nextPingId++; + if (!this.#send({ + command: "ping", + data: { timestamp: Date.now() }, + id, + })) return; + this.#pendingPingId = id; + this.#pingTimeoutTimer = setTimeout(() => { + this.#log("warn", "appservice_websocket_ping_timeout", { id }); + this.#socket?.terminate(); + }, this.#timing.pingTimeoutMs); + } + + send(command: string, data: unknown): boolean { + return this.#send({ command, data }); + } + + #send(message: WebsocketRequest | null): boolean { + if (!message || this.#socket?.readyState !== WebSocket.OPEN) return false; + this.#socket.send(JSON.stringify(message)); + return true; + } +} + +interface WebsocketRequest { + command: string; + data: unknown; + id?: number; +} + +interface WebsocketMessage { + command?: string; + data?: unknown; + events?: RawMatrixEvent[]; + id?: number; + status?: string; + txn_id?: string; +} + +export interface HTTPProxyRequest { + body?: unknown; + escaped_path?: boolean; + headers?: Record; + method?: string; + path?: string; + query?: string; +} + +export interface HTTPProxyResponse { + body?: unknown; + headers: Record; + status: number; +} + +interface RawMatrixEvent { + content?: Record; + event_id?: string; + origin_server_ts?: number; + redacts?: string; + room_id?: string; + sender?: string; + state_key?: string; + type?: string; + unsigned?: Record; +} + +function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null { + if (message.id === undefined || message.id === null || message.command === "response" || message.command === "error") return null; + return { + command: ok ? "response" : "error", + data, + id: message.id, + }; +} + +function jsonHTTPResponse(status: number, body: unknown): HTTPProxyResponse { + return { + body, + headers: { "content-type": ["application/json"] }, + status, + }; +} + +function websocketURL(homeserver: string): string { + const url = new URL(homeserver); + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + url.pathname = joinPath(url.pathname, "_matrix/client/unstable/fi.mau.as_sync"); + return url.toString(); +} + +function closeStatusFromReason(reason: string | undefined): string | undefined { + if (!reason) return undefined; + try { + return stringValue((JSON.parse(reason) as { status?: unknown }).status); + } catch { + return undefined; + } +} + +function joinPath(base: string, suffix: string): string { + return `${base.replace(/\/+$/, "")}/${suffix.replace(/^\/+/, "")}`; +} + +function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null { + const type = raw.type ?? ""; + const content = raw.content ?? {}; + const roomId = raw.room_id; + const eventId = raw.event_id; + const senderId = raw.sender; + const sender = senderId ? { isMe: false, userId: senderId } : undefined; + if (type === "m.room.message" && roomId && eventId && sender) { + return stripUndefined({ + attachments: [], + class: "message", + content, + edited: false, + encrypted: false, + eventId, + kind: "message", + messageType: stringValue(content.msgtype) ?? "m.text", + raw, + roomId, + sender, + text: stringValue(content.body) ?? "", + timestamp: raw.origin_server_ts, + type, + unsigned: raw.unsigned, + }) as MatrixClientEvent; + } + if (type === "m.reaction" && roomId && eventId && sender) { + const relates = objectValue(content["m.relates_to"]); + return stripUndefined({ + added: true, + class: "message", + content, + eventId, + key: stringValue(relates?.key) ?? "", + kind: "reaction", + raw, + relatesTo: stringValue(relates?.event_id) ?? "", + roomId, + sender, + timestamp: raw.origin_server_ts, + type, + unsigned: raw.unsigned, + }) as MatrixClientEvent; + } + if (type === "m.room.redaction" && roomId) { + return genericEvent("redaction", raw, content); + } + if (type === "m.typing") { + return genericEvent("typing", raw, content); + } + return genericEvent("raw", raw, content); +} + +function genericEvent(kind: "raw" | "redaction" | "typing", raw: RawMatrixEvent, content: Record): MatrixClientEvent { + const event = { + class: kind === "typing" ? "ephemeral" : "unknown", + content, + eventId: raw.event_id, + kind, + raw, + roomId: raw.room_id, + sender: raw.sender ? { isMe: false, userId: raw.sender } : undefined, + timestamp: raw.origin_server_ts, + type: raw.type ?? "", + unsigned: raw.unsigned, + }; + return stripUndefined(event) as MatrixClientEvent; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" ? value as Record : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function stripUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value; +} diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts new file mode 100644 index 0000000..d0041a9 --- /dev/null +++ b/packages/bridge/src/beeper.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; + +describe("Beeper bridge manager helpers", () => { + it("fetches bridges from whoami", async () => { + const fetch = vi.fn(async () => jsonResponse({ + user: { + bridges: { + "sh-dummy": { version: "v1" }, + }, + }, + userInfo: { username: "alice" }, + })); + + await expect(fetchBeeperBridges({ baseDomain: "example", fetch: fetch as never, token: "token" })).resolves.toEqual({ + "sh-dummy": { version: "v1" }, + }); + expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.example/whoami"); + }); + + it("registers and normalizes appservice registrations", async () => { + const fetch = vi.fn(async (url: URL, init?: RequestInit) => { + if (String(url) === "https://api.example/whoami") { + return jsonResponse({ + user: { bridges: {} }, + userInfo: { username: "alice" }, + }); + } + if (String(url) === "https://matrix.example/_hungryserv/alice/_matrix/asmux/mxauth/appservice/alice/sh-dummy") { + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toEqual({ + address: "https://bridge.example", + push: true, + self_hosted: true, + }); + return jsonResponse({ + as_token: "as", + hs_token: "hs", + id: "sh-dummy", + namespaces: { + user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }], + }, + rate_limited: false, + sender_localpart: "dummybot", + url: "https://bridge.example", + }); + } + expect(String(url)).toBe("https://api.example/bridgebox/alice/bridge/sh-dummy/bridge_state"); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ authorization: "Bearer as" }); + expect(JSON.parse(String(init?.body))).toEqual({ + info: {}, + isSelfHosted: true, + reason: "SELF_HOST_REGISTERED", + stateEvent: "RUNNING", + }); + return emptyResponse(); + }); + + await expect(createBeeperAppServiceInit({ + address: "https://bridge.example", + baseDomain: "example", + bridge: "sh-dummy", + fetch: fetch as never, + token: "token", + })).resolves.toEqual({ + homeserver: "https://matrix.example/_hungryserv/alice", + homeserverDomain: "beeper.local", + registration: { + asToken: "as", + hsToken: "hs", + id: "sh-dummy", + namespaces: { + users: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }], + }, + rateLimited: false, + senderLocalpart: "dummybot", + url: "https://bridge.example", + }, + }); + }); + + it("can get an existing bridge and appservice through the client", async () => { + const fetch = vi.fn(async (url: URL) => { + if (String(url) === "https://api.example/whoami") { + return jsonResponse({ + user: { bridges: { "sh-dummy": { version: "v1" } } }, + userInfo: { username: "alice" }, + }); + } + return jsonResponse({ + asToken: "as", + hsToken: "hs", + id: "sh-dummy", + namespaces: { users: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] }, + senderLocalpart: "dummybot", + url: "", + }); + }); + const client = createBeeperBridgeManagerClient({ baseDomain: "example", fetch: fetch as never, token: "token" }); + + await expect(client.getBridge("sh-dummy")).resolves.toEqual({ version: "v1" }); + await expect(client.registerAppService({ bridge: "sh-dummy", getOnly: true })).resolves.toMatchObject({ + asToken: "as", + id: "sh-dummy", + }); + }); +}); + +function jsonResponse(data: unknown): Response { + return { + json: async () => data, + ok: true, + status: 200, + statusText: "OK", + text: async () => JSON.stringify(data), + } as Response; +} + +function emptyResponse(): Response { + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + } as Response; +} diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts new file mode 100644 index 0000000..0aa8029 --- /dev/null +++ b/packages/bridge/src/beeper.ts @@ -0,0 +1,291 @@ +import type { MatrixAppserviceInitOptions, MatrixAppserviceNamespace, MatrixAppserviceRegistration } from "@beeper/pickle"; + +export interface BeeperClientOptions { + baseDomain?: string; + fetch?: typeof fetch; + token: string; + username?: string; +} + +export interface BeeperBridgeState { + bridge: string; + bridgeType?: string; + createdAt?: string; + info?: Record; + isSelfHosted?: boolean; + reason?: string; + source?: string; + stateEvent?: string; + username?: string; +} + +export interface BeeperWhoamiBridge { + bridgeState?: BeeperBridgeState; + configHash?: string; + otherVersions?: Array<{ name: string; version: string }>; + remoteState?: Record; + version?: string; +} + +export interface BeeperWhoamiResponse { + user: { + asmuxData?: { login_token?: string }; + bridges: Record; + hungryserv?: BeeperWhoamiBridge; + }; + userInfo: { + bridgeClusterId?: string; + email?: string; + fullName?: string; + hungryUrl?: string; + hungryUrlDirect?: string; + username: string; + [key: string]: unknown; + }; +} + +export interface RegisterAppServiceOptions { + address?: string; + bridge: string; + bridgeType?: string; + getOnly?: boolean; + postState?: boolean; + push?: boolean; + selfHosted?: boolean; +} + +export interface CreateAppServiceOptions extends RegisterAppServiceOptions { + homeserver?: string; + homeserverDomain?: string; +} + +export interface RegisteredAppService { + homeserver: string; + homeserverDomain: string; + registration: MatrixAppserviceRegistration; + whoami: BeeperWhoamiResponse; +} + +export class BeeperBridgeManagerClient { + #baseDomain: string; + #fetch: typeof fetch; + #token: string; + #username: string | undefined; + #whoami: BeeperWhoamiResponse | undefined; + + constructor(options: BeeperClientOptions) { + this.#baseDomain = options.baseDomain ?? "beeper.com"; + this.#fetch = options.fetch ?? fetch; + this.#token = options.token; + this.#username = options.username; + } + + async whoami(): Promise { + if (this.#whoami) return this.#whoami; + const response = await this.#request("api", "GET", "/whoami"); + this.#whoami = response; + this.#username ??= response.userInfo.username; + return response; + } + + async listBridges(): Promise> { + return (await this.whoami()).user.bridges; + } + + async getBridge(bridge: string): Promise { + return (await this.listBridges())[bridge] ?? null; + } + + async getAppService(bridge: string): Promise { + return normalizeRegistration(await this.#hungryRequest("GET", bridge)); + } + + async registerAppService(options: RegisterAppServiceOptions): Promise { + if (options.getOnly) return this.getAppService(options.bridge); + const registration = normalizeRegistration(await this.#hungryRequest("PUT", options.bridge, { + address: options.address, + push: options.push ?? Boolean(options.address), + self_hosted: options.selfHosted ?? true, + })); + if (options.postState !== false) { + const stateOptions: PostBridgeStateOptions = { + asToken: registration.asToken, + bridge: options.bridge, + isSelfHosted: options.selfHosted ?? true, + reason: "SELF_HOST_REGISTERED", + stateEvent: bridgeStateEvent(options), + }; + if (options.bridgeType !== undefined) stateOptions.bridgeType = options.bridgeType; + await this.postBridgeState(stateOptions); + } + return registration; + } + + async postBridgeState(options: PostBridgeStateOptions): Promise { + const whoami = await this.whoami(); + const username = this.#username ?? whoami.userInfo.username; + await this.#request("api", "POST", `/bridgebox/${encodeURIComponent(username)}/bridge/${encodeURIComponent(options.bridge)}/bridge_state`, { + bridgeType: options.bridgeType, + info: options.info ?? {}, + isSelfHosted: options.isSelfHosted ?? true, + reason: options.reason, + stateEvent: options.stateEvent, + }, undefined, options.asToken); + } + + async createAppService(options: CreateAppServiceOptions): Promise { + const whoami = await this.whoami(); + const username = this.#username ?? whoami.userInfo.username; + const registration = await this.registerAppService(options); + return { + homeserver: options.homeserver ?? hungryHomeserver(this.#baseDomain, username), + homeserverDomain: options.homeserverDomain ?? "beeper.local", + registration, + whoami, + }; + } + + async createAppServiceInit(options: CreateAppServiceOptions): Promise { + const appservice = await this.createAppService(options); + return { + homeserver: appservice.homeserver, + homeserverDomain: appservice.homeserverDomain, + registration: appservice.registration, + }; + } + + async #hungryRequest(method: "GET" | "PUT", bridge: string, body?: unknown): Promise { + const whoami = await this.whoami(); + const username = this.#username ?? whoami.userInfo.username; + const path = `/_matrix/asmux/mxauth/appservice/${encodeURIComponent(username)}/${encodeURIComponent(bridge)}`; + return this.#request("hungry", method, path, body, username); + } + + async #request(kind: "api" | "hungry", method: "GET" | "PUT" | "POST", path: string, body?: unknown, username?: string, token?: string): Promise { + const base = kind === "api" ? `https://api.${this.#baseDomain}` : hungryHomeserver(this.#baseDomain, username ?? this.#username ?? ""); + const url = kind === "api" ? new URL(path, base) : new URL(`${base}${path}`); + const init: RequestInit = { + headers: { + "authorization": `Bearer ${token ?? this.#token}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + method, + }; + if (body) init.body = JSON.stringify(body); + const response = await this.#fetch(url, init); + if (!response.ok) { + let detail = response.statusText; + try { + const data = await response.json() as { error?: string }; + detail = data.error ?? detail; + } catch {} + throw new Error(`Beeper bridge manager request failed (${response.status}): ${detail}`); + } + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; + } +} + +export interface PostBridgeStateOptions { + asToken: string; + bridge: string; + bridgeType?: string; + info?: Record; + isSelfHosted?: boolean; + reason: string; + stateEvent: "STARTING" | "RUNNING" | "BAD_CREDENTIALS" | "UNKNOWN_ERROR" | "LOGGED_OUT" | "UNCONFIGURED"; +} + +export function createBeeperBridgeManagerClient(options: BeeperClientOptions): BeeperBridgeManagerClient { + return new BeeperBridgeManagerClient(options); +} + +export async function fetchBeeperBridges(options: BeeperClientOptions): Promise> { + return createBeeperBridgeManagerClient(options).listBridges(); +} + +export async function createBeeperAppService(options: BeeperClientOptions & CreateAppServiceOptions): Promise { + const { baseDomain, fetch: fetchImpl, token, username, ...appserviceOptions } = options; + return createBeeperBridgeManagerClient(clientOptions({ baseDomain, fetch: fetchImpl, token, username })).createAppService(appserviceOptions); +} + +export async function createBeeperAppServiceInit(options: BeeperClientOptions & CreateAppServiceOptions): Promise { + const { baseDomain, fetch: fetchImpl, token, username, ...appserviceOptions } = options; + return createBeeperBridgeManagerClient(clientOptions({ baseDomain, fetch: fetchImpl, token, username })).createAppServiceInit(appserviceOptions); +} + +function bridgeStateEvent(options: RegisterAppServiceOptions): PostBridgeStateOptions["stateEvent"] { + const bridgeType = options.bridgeType ?? ""; + return (bridgeType && bridgeType !== "heisenbridge") || ["androidsms", "imessagecloud", "imessage"].includes(options.bridge) + ? "STARTING" + : "RUNNING"; +} + +function hungryHomeserver(baseDomain: string, username: string): string { + return `https://matrix.${baseDomain}/_hungryserv/${encodeURIComponent(username)}`; +} + +function normalizeRegistration(raw: unknown): MatrixAppserviceRegistration { + const input = raw as Record; + const namespaces = input.namespaces as Record | undefined; + return stripUndefined({ + asToken: stringField(input, "asToken", "as_token"), + ephemeralEvents: booleanField(input, "ephemeralEvents", "ephemeral_events"), + hsToken: stringField(input, "hsToken", "hs_token"), + id: stringField(input, "id"), + msc3202: booleanField(input, "msc3202"), + msc4190: booleanField(input, "msc4190"), + namespaces: stripUndefined({ + aliases: namespaceList(namespaces?.aliases), + rooms: namespaceList(namespaces?.rooms), + users: namespaceList(namespaces?.users ?? namespaces?.user_ids), + }), + protocols: stringList(input.protocols), + rateLimited: booleanField(input, "rateLimited", "rate_limited"), + senderLocalpart: stringField(input, "senderLocalpart", "sender_localpart"), + url: stringField(input, "url"), + }) as MatrixAppserviceRegistration; +} + +function stringField(input: Record, camel: string, snake?: string): string { + const value = input[camel] ?? (snake ? input[snake] : undefined); + if (typeof value !== "string") throw new Error(`Invalid appservice registration: missing ${snake ?? camel}`); + return value; +} + +function booleanField(input: Record, camel: string, snake?: string): boolean | undefined { + const value = input[camel] ?? (snake ? input[snake] : undefined); + return typeof value === "boolean" ? value : undefined; +} + +function namespaceList(value: unknown): MatrixAppserviceNamespace[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.map((item) => { + const ns = item as Record; + return { + exclusive: ns.exclusive === true, + regex: stringField(ns, "regex"), + }; + }); +} + +function stringList(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined; +} + +function stripUndefined>(input: T): T { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as T; +} + +function clientOptions(options: { + baseDomain: string | undefined; + fetch: typeof fetch | undefined; + token: string; + username: string | undefined; +}): BeeperClientOptions { + const output: BeeperClientOptions = { token: options.token }; + if (options.baseDomain !== undefined) output.baseDomain = options.baseDomain; + if (options.fetch !== undefined) output.fetch = options.fetch; + if (options.username !== undefined) output.username = options.username; + return output; +} diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts new file mode 100644 index 0000000..1264518 --- /dev/null +++ b/packages/bridge/src/bridge.test.ts @@ -0,0 +1,1057 @@ +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { RuntimeBridge } from "./bridge"; +import { createRemoteMessage } from "./events"; +import type { BridgeDataStore } from "./store"; +import type { + BridgeConnector, + BridgeContext, + BridgeMatrixConfig, + BackfillQueueResult, + BackfillQueueTask, + FetchMessagesParams, + FetchMessagesResponse, + MatrixMessage, + MessageCheckpoint, + MessageCheckpoints, + NetworkAPI, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, + UserLogin, +} from "./types"; + +describe("RuntimeBridge", () => { + it("boots, initializes connector, subscribes, and stops cleanly", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + expect(client.boot).toHaveBeenCalledOnce(); + expect(connector.init).toHaveBeenCalledOnce(); + expect(connector.start).toHaveBeenCalledOnce(); + expect(client.subscribe).toHaveBeenCalledWith( + { kind: ["message", "reaction", "redaction", "typing"] }, + expect.any(Function), + { live: true } + ); + + await bridge.stop(); + expect(client.close).toHaveBeenCalledOnce(); + expect(client.subscription.stop).toHaveBeenCalledOnce(); + expect(connector.stop).toHaveBeenCalledOnce(); + }); + + it("loads and connects a user login once", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await expect(bridge.loadUserLogin(login)).resolves.toBe(network); + await expect(bridge.loadUserLogin(login)).resolves.toBe(network); + + expect(connector.loadUserLogin).toHaveBeenCalledOnce(); + expect(network.connect).toHaveBeenCalledOnce(); + expect(login.client).toBe(network); + }); + + it("auto-loads persisted user logins on startup", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const dataStore = createFakeBridgeDataStore([{ id: "login:a", remoteName: "Alice", userId: "@alice:example" }]); + const bridge = new RuntimeBridge({ connector, dataStore, matrix: matrixConfig() }, client); + + await bridge.start(); + + expect(dataStore.listUserLogins).toHaveBeenCalledOnce(); + expect(connector.loadUserLogin).toHaveBeenCalledWith(bridge.context, expect.objectContaining({ id: "login:a" })); + expect(network.connect).toHaveBeenCalledWith(expect.objectContaining({ + login: expect.objectContaining({ id: "login:a" }), + })); + }); + + it("persists bridgev2 lifecycle payloads and per-login state", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const dataStore = createFakeBridgeDataStore(); + const bridge = new RuntimeBridge({ connector, dataStore, matrix: matrixConfig() }, client); + + await bridge.start(); + await bridge.loadUserLogin({ id: "login:a", remoteName: "Alice", userId: "@alice:example" }); + + expect(dataStore.setBridgeStatus).toHaveBeenLastCalledWith(expect.objectContaining({ + bridgeState: expect.objectContaining({ + source: "bridge", + state_event: "RUNNING", + timestamp: expect.any(Number), + ttl: 21600, + }), + logins: { + "login:a": expect.objectContaining({ + remote_id: "login:a", + remote_name: "Alice", + state_event: "CONNECTED", + user_id: "@alice:example", + }), + }, + state: "running", + })); + }); + + it("binds login process lifecycle calls to the bridge request context", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const rawProcess = { + cancel: vi.fn(), + start: vi.fn(async () => loginStep("started")), + submitCookies: vi.fn(async () => loginStep("cookies")), + submitUserInput: vi.fn(async () => loginStep("input")), + wait: vi.fn(async () => loginStep("waited")), + }; + connector.createLogin.mockResolvedValue(rawProcess); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + const process = await bridge.createLogin({ id: "@alice:example" }, "password"); + await expect(process.start()).resolves.toMatchObject({ stepId: "started" }); + await expect((process as LoginProcessUserInput).submitUserInput({ username: "alice" })).resolves.toMatchObject({ stepId: "input" }); + await expect((process as LoginProcessCookies).submitCookies({ session: "cookie" })).resolves.toMatchObject({ stepId: "cookies" }); + await expect((process as LoginProcessDisplayAndWait).wait()).resolves.toMatchObject({ stepId: "waited" }); + await process.cancel(); + + expect(connector.createLogin).toHaveBeenCalledWith(bridge.context, { id: "@alice:example" }, "password"); + expect(rawProcess.start).toHaveBeenCalledWith(bridge.context); + expect(rawProcess.submitUserInput).toHaveBeenCalledWith(bridge.context, { username: "alice" }); + expect(rawProcess.submitCookies).toHaveBeenCalledWith(bridge.context, { session: "cookie" }); + expect(rawProcess.wait).toHaveBeenCalledWith(bridge.context); + expect(rawProcess.cancel).toHaveBeenCalledWith(bridge.context); + }); + + it("dispatches Matrix messages to loaded network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + + const result = await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { body: "hello", msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: "$event", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + text: "hello", + type: "m.room.message", + }); + + expect(result).toEqual({ dispatched: true, eventId: "$event", handlers: 1, kind: "message", roomId: "!room:example" }); + const message = network.handleMatrixMessage.mock.calls[0]?.[1] as MatrixMessage; + expect(message.portal.portalKey).toEqual({ id: "remote-room", receiver: login.id }); + expect(message.text).toBe("hello"); + }); + + it("ignores Matrix messages from the bridge user", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + await bridge.loadUserLogin({ id: "login:a" }); + const result = await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { body: "mine", msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: "$event", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: "!room:example", + sender: { isMe: false, userId: "@bridge:example" }, + text: "mine", + type: "m.room.message", + }); + + expect(result.dispatched).toBe(false); + expect(network.handleMatrixMessage).not.toHaveBeenCalled(); + }); + + it("sends queued remote messages to registered Matrix portals", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + bridge.queueRemoteEvent(login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { body: "hello from remote", msgtype: "m.text" }, + type: "m.room.message", + }], + }), + data: {}, + id: "remote-message", + portalKey: { id: "remote-room", receiver: login.id }, + sender: { isFromMe: false, sender: "remote-user" }, + })); + await bridge.flushRemoteEvents(); + + expect(client.raw.request).toHaveBeenCalledWith({ + body: { body: "hello from remote", msgtype: "m.text" }, + method: "PUT", + path: expect.stringContaining("/rooms/!room%3Aexample/send/m.room.message/pickle-bridge-"), + }); + }); + + it("initializes appservice and creates/backfills portal rooms", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector, + matrix: matrixConfig(), + }, client); + + await bridge.start(); + const portal = await bridge.createPortalRoom({ + info: { name: "Remote room" }, + portalKey: { id: "remote-room", receiver: "login:a" }, + userId: "@test_alice:example", + }); + const backfill = await bridge.backfill({ + events: [{ + content: { body: "old", msgtype: "m.text" }, + sender: "@test_alice:example", + timestamp: 1, + }], + roomId: portal.mxid!, + }); + + expect(client.appservice.init).toHaveBeenCalledOnce(); + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + bridge: expect.objectContaining({ networkId: "test" }), + name: "Remote room", + userId: "@test_alice:example", + })); + expect(client.appservice.batchSend).toHaveBeenCalledWith(expect.objectContaining({ + roomId: "!created:example", + })); + expect(backfill.eventIds).toEqual(["$backfilled"]); + }); + + it("adds Beeper room metadata and autojoin members for Beeper bridges", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + beeper: { + bridge: "test", + ownerUserId: "@alice:example", + }, + connector, + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.createManagementRoom({ + name: "Test management", + }); + await bridge.createPortalRoom({ + info: { name: "Remote room" }, + portalKey: { id: "remote-room", receiver: "login:a" }, + userId: "@test_bob:example", + }); + + expect(client.appservice.createManagementRoom).toHaveBeenCalledWith(expect.objectContaining({ + autoJoinInvites: true, + initialMembers: ["@alice:example"], + invite: ["@alice:example"], + })); + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + autoJoinInvites: true, + bridge: expect.objectContaining({ displayName: "Test", networkId: "test" }), + bridgeName: "test", + initialMembers: ["@alice:example"], + invite: ["@alice:example"], + name: "Remote room", + portalKey: { id: "remote-room", receiver: "login:a" }, + userId: "@test_bob:example", + })); + }); + + it("fetches backfill through a loaded network API and imports it through appservice", async () => { + const client = createFakeMatrixClient(); + const network = { + ...createFakeNetworkAPI(), + fetchMessages: vi.fn(async () => ({ + hasMore: false, + messages: [{ + event: createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { body: "historical", msgtype: "m.text" }, + type: "m.room.message", + }], + }), + data: {}, + id: "history-1", + portalKey: { id: "remote-room", receiver: "login:a" }, + sender: { isFromMe: false, sender: "@dummy_alice:example" }, + timestamp: new Date("2026-01-01T00:00:00.000Z"), + }), + }], + })), + }; + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portal = { id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }; + + await bridge.start(); + const result = await bridge.backfillMessages(login, { portal }); + + expect(network.fetchMessages).toHaveBeenCalledWith(expect.objectContaining({ bridge }), { portal }); + expect(client.appservice.batchSend).toHaveBeenCalledWith({ + events: [{ + content: { body: "historical", msgtype: "m.text" }, + sender: "@dummy_alice:example", + timestamp: Date.parse("2026-01-01T00:00:00.000Z"), + }], + roomId: "!room:example", + }); + expect(result.eventIds).toEqual(["$backfilled"]); + }); + + it("forwards bridgev2-style backfill pagination params to the loaded network API", async () => { + const client = createFakeMatrixClient(); + const network = { + ...createFakeNetworkAPI(), + fetchMessages: vi.fn(async (_ctx, params: FetchMessagesParams): Promise => ({ + cursor: "next-cursor", + forward: params.forward, + hasMore: true, + markRead: true, + messages: [], + progress: { + approximate: 0.5, + remainingCount: 10, + totalCount: 20, + }, + })), + }; + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portal = { id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }; + const task: BackfillQueueTask = { + batchCount: 2, + cursor: "prev-cursor", + pending: true, + portalKey: portal.portalKey, + userLoginId: login.id, + }; + + await bridge.start(); + await bridge.backfillMessages(login, { + count: 25, + cursor: "prev-cursor", + forward: true, + portal, + task, + threadRoot: "thread-root", + }); + + expect(network.fetchMessages).toHaveBeenCalledWith(expect.objectContaining({ bridge }), { + count: 25, + cursor: "prev-cursor", + forward: true, + portal, + task, + threadRoot: "thread-root", + }); + expect(client.appservice.batchSend).toHaveBeenCalledWith({ events: [], roomId: "!room:example" }); + }); + + it("defines bridgev2-style backfill queue results and message checkpoint envelopes", () => { + const task = { + batchCount: 3, + bridgeId: "dummy", + completedAt: new Date("2026-01-01T00:10:00.000Z"), + cursor: "cursor", + dispatchedAt: new Date("2026-01-01T00:00:00.000Z"), + done: false, + nextDispatchAt: new Date("2026-01-01T00:11:00.000Z"), + oldestMessageId: "message-oldest", + pending: true, + portalKey: { id: "remote-room", receiver: "login:a" }, + userLoginId: "login:a", + } satisfies BackfillQueueTask; + const result = { + cursor: "next-cursor", + forward: false, + hasMore: true, + markRead: true, + pending: true, + progress: { + approximate: 0.25, + remainingCount: 75, + totalCount: 100, + }, + queued: true, + task, + } satisfies BackfillQueueResult; + const checkpoint = { + eventId: "$event", + eventType: "m.room.message", + reportedBy: "BRIDGE", + retryNum: 0, + roomId: "!room:example", + status: "SUCCESS", + step: "REMOTE", + timestamp: Date.parse("2026-01-01T00:00:00.000Z"), + } satisfies MessageCheckpoint; + const envelope = { + checkpoints: [checkpoint], + } satisfies MessageCheckpoints; + + expect(result).toMatchObject({ + cursor: "next-cursor", + markRead: true, + pending: true, + progress: { approximate: 0.25 }, + queued: true, + }); + expect(envelope.checkpoints).toEqual([checkpoint]); + }); + + it("registers ghosts and manages portal metadata/message requests/status", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + bridge.registerGhost({ displayName: "Alice", id: "alice", mxid: "@dummy_alice:example" }); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: "login:a" } }); + const portal = await bridge.setPortalMetadata({ id: "remote-room", receiver: "login:a" }, { unread: true }); + await bridge.setMessageRequest({ + portalKey: { id: "remote-room", receiver: "login:a" }, + requestedBy: "@alice:example", + status: "pending", + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + await bridge.setBridgeStatus({ message: "limited", state: "degraded", updatedAt: new Date("2026-01-01T00:00:00.000Z") }); + + expect(bridge.getGhost("alice")?.displayName).toBe("Alice"); + expect(portal.metadata).toEqual({ unread: true }); + await expect(bridge.getMessageRequest({ id: "remote-room", receiver: "login:a" })).resolves.toEqual(expect.objectContaining({ + status: "pending", + })); + expect(bridge.getBridgeState()).toBe("degraded"); + expect(bridge.getBridgeStatus()?.message).toBe("limited"); + + await expect(bridge.acceptMessageRequest({ id: "remote-room", receiver: "login:a" })).resolves.toEqual(expect.objectContaining({ + status: "accepted", + })); + }); + + it("wraps user profile and media helpers around the Matrix client", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + await expect(bridge.getUserInfo("@alice:example")).resolves.toEqual({ + avatarUrl: "mxc://example/alice", + displayName: "Alice", + raw: {}, + userId: "@alice:example", + }); + await expect(bridge.getOwnProfile()).resolves.toEqual({ + avatarUrl: "mxc://example/me", + displayName: "Bridge", + }); + await bridge.setOwnProfile({ avatarUrl: "mxc://example/new", displayName: "New Bridge" }); + await expect(bridge.uploadMedia({ bytes: new Uint8Array([1, 2]), contentType: "text/plain", filename: "a.txt" })).resolves.toEqual({ + contentUri: "mxc://example/media", + raw: {}, + }); + await expect(bridge.downloadMedia({ contentUri: "mxc://example/media" })).resolves.toEqual({ + body: new Uint8Array([3, 4]), + bytes: new Uint8Array([3, 4]), + }); + await expect(bridge.sendMedia({ + bytes: new Uint8Array([5]), + contentType: "image/png", + filename: "image.png", + kind: "image", + roomId: "!room:example", + })).resolves.toEqual({ eventId: "$media", raw: {}, roomId: "!room:example" }); + + expect(client.users.get).toHaveBeenCalledWith({ userId: "@alice:example" }); + expect(client.users.setOwnDisplayName).toHaveBeenCalledWith({ displayName: "New Bridge" }); + expect(client.users.setOwnAvatarUrl).toHaveBeenCalledWith({ avatarUrl: "mxc://example/new" }); + expect(client.messages.sendMedia).toHaveBeenCalledWith(expect.objectContaining({ filename: "image.png" })); + }); + + it("creates management rooms and dispatches commands with text replies", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = { + ...createFakeConnector(network), + handleCommand: vi.fn(async () => ({ handled: true, text: "pong" })), + }; + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + const room = await bridge.createManagementRoom({ + invite: ["@alice:example"], + name: "Commands", + topic: "Bridge commands", + }); + const result = await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { body: "test ping verbose", msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: "$cmd", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: room.mxid, + sender: { isMe: false, userId: "@alice:example" }, + text: "test ping verbose", + type: "m.room.message", + }); + + expect(client.appservice.createManagementRoom).toHaveBeenCalledWith(expect.objectContaining({ + invite: ["@alice:example"], + name: "Commands", + })); + expect(result).toEqual({ dispatched: true, eventId: "$cmd", handlers: 1, kind: "message", roomId: "!created:example" }); + expect(connector.handleCommand).toHaveBeenCalledWith( + expect.objectContaining({ bridge }), + expect.objectContaining({ + args: ["verbose"], + command: "ping", + prefix: "test", + room, + }) + ); + expect(network.handleMatrixMessage).not.toHaveBeenCalled(); + expect(client.raw.request).toHaveBeenCalledWith({ + body: { body: "pong", msgtype: "m.notice" }, + method: "PUT", + path: expect.stringContaining("/rooms/!created%3Aexample/send/m.room.message/pickle-bridge-"), + }); + }); + + it("sends management command replies through the appservice bot when registered", async () => { + const client = createFakeMatrixClient(); + const connector = { + ...createFakeConnector(createFakeNetworkAPI()), + handleCommand: vi.fn(async () => ({ handled: true, text: "pong" })), + }; + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector, + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { body: "test ping", msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: "$cmd", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: "!management:example", + sender: { isMe: false, userId: "@bridge:example" }, + text: "test ping", + type: "m.room.message", + }); + + expect(client.appservice.sendMessage).toHaveBeenCalledWith({ + content: { body: "pong", msgtype: "m.notice" }, + roomId: "!management:example", + userId: "@testbot:example", + }); + expect(client.raw.request).not.toHaveBeenCalledWith(expect.objectContaining({ + path: expect.stringContaining("/rooms/!management%3Aexample/send/m.room.message/"), + })); + }); + + it("handles built-in commands before connector command fallback", async () => { + const client = createFakeMatrixClient(); + const connector = { + ...createFakeConnector(createFakeNetworkAPI()), + handleCommand: vi.fn(async () => ({ handled: true, text: "connector help" })), + }; + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + bridge.registerManagementRoom({ mxid: "!management:example" }); + const result = await bridge.dispatchMatrixEvent(messageEvent({ + body: "help", + eventId: "$help", + roomId: "!management:example", + sender: "@alice:example", + })); + + expect(result).toEqual({ dispatched: true, eventId: "$help", handlers: 1, kind: "message", roomId: "!management:example" }); + expect(connector.handleCommand).not.toHaveBeenCalled(); + expect(client.raw.request).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + })); + }); + + it("requires the command prefix outside management rooms and owner implicit management", async () => { + const client = createFakeMatrixClient(); + const connector = { + ...createFakeConnector(createFakeNetworkAPI()), + handleCommand: vi.fn(async () => ({ handled: true, text: "pong" })), + }; + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector, + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "test help", + eventId: "$prefixed", + roomId: "!ordinary:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "help", + eventId: "$unprefixed", + roomId: "!ordinary:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "help", + eventId: "$owner", + roomId: "!owner-dm:example", + sender: "@bridge:example", + })); + + expect(connector.handleCommand).not.toHaveBeenCalled(); + expect(client.appservice.sendMessage).toHaveBeenCalledTimes(2); + expect(client.appservice.sendMessage).toHaveBeenNthCalledWith(1, expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + })); + expect(client.appservice.sendMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + })); + }); + + it("does not treat persisted portal rooms as implicit management rooms", async () => { + const client = createFakeMatrixClient(); + const dataStore = createFakeBridgeDataStore(); + const portal = { id: "remote-room", mxid: "!portal:example", portalKey: { id: "remote-room", receiver: "login:a" } }; + dataStore.listPortals.mockResolvedValue([portal]); + dataStore.listUserLogins.mockResolvedValue([{ id: "login:a", userId: "@bridge:example" }]); + const network = { + ...createFakeNetworkAPI(), + handleMatrixMessage: vi.fn(async () => ({ handled: true })), + }; + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector: createFakeConnector(network), + dataStore, + matrix: matrixConfig(), + }, client); + + await bridge.start(); + const result = await bridge.dispatchMatrixEvent(messageEvent({ + body: "help", + eventId: "$portal-help", + roomId: "!portal:example", + sender: "@bridge:example", + })); + + expect(result).toEqual({ dispatched: true, eventId: "$portal-help", handlers: 1, kind: "message", roomId: "!portal:example" }); + expect(network.handleMatrixMessage).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ portal }) + ); + expect(client.raw.request).not.toHaveBeenCalled(); + }); + + it("promotes and persists management rooms through the built-in command", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const dataStore = { + ...createFakeDataStore(), + setManagementRoom: vi.fn(async () => {}), + }; + const bridge = new RuntimeBridge({ connector, dataStore, matrix: matrixConfig() }, client); + + await bridge.start(); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "test set-management-room", + eventId: "$set", + roomId: "!ordinary:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "help", + eventId: "$help", + roomId: "!ordinary:example", + sender: "@alice:example", + })); + + expect(dataStore.setManagementRoom).toHaveBeenCalledWith({ mxid: "!ordinary:example" }); + expect(client.raw.request).toHaveBeenCalledTimes(2); + expect(client.raw.request).toHaveBeenNthCalledWith(2, expect.objectContaining({ + body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + })); + }); + + it("handles login lifecycle built-ins", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = { + ...createFakeConnector(network), + getLoginFlows: () => [{ description: "Password login", id: "password", name: "Password" }], + }; + const pendingProcess = { + cancel: vi.fn(async () => {}), + start: vi.fn(async () => ({ instructions: "Scan the code", stepId: "qr", type: "display_and_wait" as const })), + }; + const completeProcess = { + cancel: vi.fn(async () => {}), + start: vi.fn(async () => loginStep("completed")), + }; + connector.createLogin + .mockResolvedValueOnce(pendingProcess) + .mockResolvedValueOnce(completeProcess); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + bridge.registerManagementRoom({ mxid: "!management:example" }); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "login password", + eventId: "$login-pending", + roomId: "!management:example", + sender: "@alice:example", + })); + const startedBody = commandReplyBody(client, 0); + const pendingLoginId = /Login started: (login-\S+)/.exec(startedBody)?.[1]; + expect(pendingLoginId).toBeTruthy(); + + await bridge.dispatchMatrixEvent(messageEvent({ + body: `cancel-login ${pendingLoginId}`, + eventId: "$cancel", + roomId: "!management:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "login password", + eventId: "$login-complete", + roomId: "!management:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "list-logins", + eventId: "$list", + roomId: "!management:example", + sender: "@alice:example", + })); + await bridge.dispatchMatrixEvent(messageEvent({ + body: "logout login:a", + eventId: "$logout", + roomId: "!management:example", + sender: "@alice:example", + })); + + expect(pendingProcess.cancel).toHaveBeenCalledOnce(); + expect(connector.loadUserLogin).toHaveBeenCalledWith(expect.objectContaining({ bridge }), expect.objectContaining({ id: "login:a" })); + expect(commandReplyBody(client, 3)).toContain("login:a"); + expect(network.disconnect).toHaveBeenCalledOnce(); + expect(commandReplyBody(client, 4)).toBe("Logged out: login:a"); + }); +}); + +function matrixConfig(): BridgeMatrixConfig { + return { + homeserver: "https://matrix.example", + store: { + delete: async () => {}, + get: async () => null, + list: async () => [], + set: async () => {}, + }, + token: "token", + wasmModule: {} as WebAssembly.Module, + }; +} + +function createFakeBridgeDataStore(logins: UserLogin[] = []): BridgeDataStore & { + listUserLogins: ReturnType; + setBridgeStatus: ReturnType; +} { + return { + deletePortal: vi.fn(async () => {}), + getAccount: vi.fn(async () => null), + getBridgeState: vi.fn(async () => null), + getBridgeStatus: vi.fn(async () => null), + getGhost: vi.fn(async () => null), + getMessage: vi.fn(async () => null), + getMessageRequest: vi.fn(async () => null), + getPortal: vi.fn(async () => null), + getPortalByMXID: vi.fn(async () => null), + getUserLogin: vi.fn(async (id: string) => logins.find((login) => login.id === id) ?? null), + listGhosts: vi.fn(async () => []), + listPortals: vi.fn(async () => []), + listUserLogins: vi.fn(async () => logins), + setAccount: vi.fn(async () => {}), + setBridgeState: vi.fn(async () => {}), + setBridgeStatus: vi.fn(async () => {}), + setGhost: vi.fn(async () => {}), + setManagementRoom: vi.fn(async () => {}), + setMessage: vi.fn(async () => {}), + setMessageRequest: vi.fn(async () => {}), + setPortal: vi.fn(async () => {}), + setUserLogin: vi.fn(async () => {}), + }; +} + +function createFakeConnector(network: FakeNetworkAPI): BridgeConnector & { + init: ReturnType; + loadUserLogin: ReturnType; + start: ReturnType; + stop: ReturnType; +} { + return { + createLogin: vi.fn(async () => ({ cancel: vi.fn(), start: vi.fn() })), + getBridgeInfoVersion: () => ({ capabilities: 1, info: 1 }), + getCapabilities: () => ({}), + getConfig: () => ({}), + getDBMetaTypes: () => ({}), + getLoginFlows: () => [], + getName: () => ({ defaultCommandPrefix: "test", displayName: "Test", networkId: "test" }), + init: vi.fn((_ctx: BridgeContext) => {}), + loadUserLogin: vi.fn(async () => network), + start: vi.fn(), + stop: vi.fn(), + }; +} + +type FakeNetworkAPI = NetworkAPI & { + connect: ReturnType; + disconnect: ReturnType; + handleMatrixMessage: ReturnType; +}; + +function createFakeNetworkAPI(): FakeNetworkAPI { + return { + connect: vi.fn(), + disconnect: vi.fn(), + handleMatrixMessage: vi.fn(), + }; +} + +function loginStep(stepId: string) { + return { + complete: { + userLoginId: "login:a", + }, + instructions: stepId, + stepId, + type: "complete" as const, + }; +} + +function messageEvent(options: { body: string; eventId: string; roomId: string; sender: string }): MatrixMessageEvent { + return { + attachments: [], + class: "message", + content: { body: options.body, msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: options.eventId, + kind: "message", + messageType: "m.text", + raw: {}, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + text: options.body, + type: "m.room.message", + }; +} + +function commandReplyBody(client: ReturnType, index: number): string { + return (client.raw.request as ReturnType).mock.calls[index]?.[0]?.body?.body; +} + +function createFakeDataStore() { + return { + deletePortal: vi.fn(async () => {}), + getAccount: vi.fn(async () => null), + getBridgeState: vi.fn(async () => null), + getBridgeStatus: vi.fn(async () => null), + getGhost: vi.fn(async () => null), + getMessage: vi.fn(async () => null), + getMessageRequest: vi.fn(async () => null), + getPortal: vi.fn(async () => null), + getPortalByMXID: vi.fn(async () => null), + getUserLogin: vi.fn(async () => null), + listGhosts: vi.fn(async () => []), + listPortals: vi.fn(async () => []), + listUserLogins: vi.fn(async () => []), + setAccount: vi.fn(async () => {}), + setBridgeState: vi.fn(async () => {}), + setBridgeStatus: vi.fn(async () => {}), + setGhost: vi.fn(async () => {}), + setManagementRoom: vi.fn(async () => {}), + setMessage: vi.fn(async () => {}), + setMessageRequest: vi.fn(async () => {}), + setPortal: vi.fn(async () => {}), + setUserLogin: vi.fn(async () => {}), + }; +} + +function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { + const subscription = { + catchUp: vi.fn(async () => {}), + done: Promise.resolve(), + stop: vi.fn(async () => {}), + }; + return { + accountData: {} as MatrixClient["accountData"], + appservice: { + batchSend: vi.fn(async () => ({ eventIds: ["$backfilled"], raw: {} })), + createManagementRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createPortalRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + ensureJoined: vi.fn(async () => {}), + ensureRegistered: vi.fn(async () => {}), + init: vi.fn(async () => ({ botUserId: "@testbot:example", id: "test" })), + sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + }, + beeper: {} as MatrixClient["beeper"], + boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@bridge:example" })), + close: vi.fn(async () => {}), + crypto: {} as MatrixClient["crypto"], + logout: vi.fn(async () => {}), + media: { + download: vi.fn(async () => ({ bytes: new Uint8Array([3, 4]) })), + downloadEncrypted: vi.fn(async () => ({ bytes: new Uint8Array([3, 4]) })), + downloadThumbnail: vi.fn(async () => ({ bytes: new Uint8Array([3, 4]) })), + upload: vi.fn(async () => ({ contentUri: "mxc://example/media", raw: {} })), + uploadEncrypted: vi.fn(async () => ({ contentUri: "mxc://example/media", file: {} as never, raw: {} })), + }, + messages: { + edit: vi.fn(), + get: vi.fn(), + list: vi.fn(), + markRead: vi.fn(), + redact: vi.fn(), + send: vi.fn(), + sendMedia: vi.fn(async (options) => ({ eventId: "$media", raw: {}, roomId: options.roomId })), + }, + raw: { + request: vi.fn(async () => ({ body: { event_id: "$sent" }, raw: { event_id: "$sent" }, status: 200 })), + } as unknown as MatrixClient["raw"], + reactions: {} as MatrixClient["reactions"], + receipts: {} as MatrixClient["receipts"], + rooms: {} as MatrixClient["rooms"], + streams: {} as MatrixClient["streams"], + subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), + subscription, + sync: {} as MatrixClient["sync"], + toDevice: {} as MatrixClient["toDevice"], + typing: {} as MatrixClient["typing"], + users: { + get: vi.fn(async ({ userId }) => ({ avatarUrl: "mxc://example/alice", displayName: "Alice", raw: {}, userId })), + getOwnAvatarUrl: vi.fn(async () => ({ avatarUrl: "mxc://example/me" })), + getOwnDisplayName: vi.fn(async () => ({ displayName: "Bridge", raw: {} })), + setOwnAvatarUrl: vi.fn(async () => {}), + setOwnDisplayName: vi.fn(async () => {}), + }, + whoami: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@bridge:example" })), + }; +} diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts new file mode 100644 index 0000000..40f417e --- /dev/null +++ b/packages/bridge/src/bridge.ts @@ -0,0 +1,1681 @@ +import { createMatrixClient } from "@beeper/pickle"; +import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket"; +import { createBeeperAppServiceInit } from "./beeper"; +import { createRemoteMessage } from "./events"; +import type { + BridgeContext, + BridgeLogger, + BridgeRequestContext, + CreateBeeperBridgeOptions, + CreateBridgeOptions, + BridgeBackfillOptions, + BridgeCreateManagementRoomOptions, + BridgeCreatePortalOptions, + BridgeCreatePortalRoomOptions, + BackfillingNetworkAPI, + MatrixAppserviceSendMessageOptions, + LoginProcess, + NetworkAPI, + PickleBridge, + Portal, + PortalKey, + PortalReference, + QueueRemoteEventResult, + RemoteEventQueue, + RemoteEvent, + UserLogin, + BridgeUser, + BridgeSendMediaOptions, + BridgeState, + BridgeStatus, + DownloadMediaOptions, + DownloadMediaResult, + Ghost, + MatrixDispatchResult, + MatrixMessage, + MatrixReaction, + MatrixRedaction, + MatrixTyping, + EventSender, + MatrixIntent, + MatrixCommand, + MatrixCommandResponse, + ConvertedMessage, + ManagementRoom, + MessageRequest, + MessageRequestHandlingNetworkAPI, + RemoteMessage, + RemoteMessageWithTransactionID, + RemoteBackfill, + RemoteChatDelete, + RemoteChatInfoChange, + UserProfile, + UserProfileUpdate, + ResolveIdentifierParams, + ResolveIdentifierResponse, + IdentifierResolvingNetworkAPI, + LoginCookieInput, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, + LoginProcessWithOverride, + LoginStep, + LoginUserInput, + BridgeStateEvent, + BridgeStatePayload, + BridgeBeeperOptions, + BridgeRemoteBackfillOptions, + BridgeRemoteEventOptions, + BridgeRemoteMessageOptions, + BackfillQueueParams, + BackfillQueueResult, + ChatViewingNetworkAPI, + MessageCheckpoint, + MessageCheckpointStatus, + MessageCheckpointStep, +} from "./types"; + +type GenericMatrixEvent = Extract; kind: string }>; + +export function createBridge(options: CreateBridgeOptions): PickleBridge { + return new RuntimeBridge(options, createMatrixClient(options.matrix)); +} + +export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { + if (!options.store) throw new Error("createBeeperBridge requires store outside the Node entrypoint"); + const matrix = { + ...options.matrix, + account: options.account, + homeserver: options.matrix?.homeserver ?? options.account.homeserver, + store: options.store, + token: options.matrix?.token ?? options.account.accessToken, + }; + return createBeeperBridgeWithClient({ ...options, matrix }, createMatrixClient(matrix)); +} + +export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise { + const store = options.store ?? options.matrix?.store; + if (!store) throw new Error("createBeeperBridgeWithClient requires store"); + const matrix = { + ...options.matrix, + account: options.account, + homeserver: options.matrix?.homeserver ?? options.account.homeserver, + store, + token: options.matrix?.token ?? options.account.accessToken, + }; + const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + bridgeType: options.bridgeType, + getOnly: options.getOnly, + homeserverDomain: options.homeserverDomain, + token: options.account.accessToken, + })); + const runtimeOptions: CreateBridgeOptions = { + appservice, + beeper: { + bridge: options.bridge, + ownerUserId: options.account.userId, + ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), + }, + connector: options.connector, + matrix, + }; + if (options.dataStore) runtimeOptions.dataStore = options.dataStore; + return new RuntimeBridge(runtimeOptions, client); +} + +export class RuntimeBridge implements PickleBridge { + readonly connector: CreateBridgeOptions["connector"]; + readonly #appserviceOptions: CreateBridgeOptions["appservice"]; + readonly #beeperOptions: BridgeBeeperOptions | undefined; + readonly #dataStore: CreateBridgeOptions["dataStore"]; + readonly #networkClients = new Map(); + readonly #messages = new Map(); + readonly #ghosts = new Map(); + readonly #messageRequests = new Map(); + readonly #managementRooms = new Map(); + readonly #provisioningLogins = new Map(); + readonly #portalsByKey = new Map(); + readonly #portalsByRoom = new Map(); + readonly #remoteEvents: Array<{ event: RemoteEvent; login: UserLogin }> = []; + readonly #networkClientLoads = new Map>(); + readonly #userLogins = new Map(); + readonly #loginStates = new Map(); + readonly #matrixClient: MatrixClient; + readonly #subscriptions = new Set(); + #appserviceWebsocket: AppserviceWebsocket | null = null; + #bridgeStatus: BridgeStatus | null = null; + #context: BridgeContext | null = null; + #drainPromise: Promise | null = null; + #started = false; + #ownerUserId: string | null = null; + #ownUserId: string | null = null; + + constructor(options: CreateBridgeOptions, client: MatrixClient) { + this.connector = options.connector; + this.#appserviceOptions = options.appservice; + this.#beeperOptions = options.beeper; + this.#dataStore = options.dataStore; + this.#matrixClient = client; + } + + get client(): MatrixClient | null { + return this.#started ? this.#matrixClient : null; + } + + get context(): BridgeContext | null { + return this.#context; + } + + async start(): Promise { + if (this.#started) return; + await this.#loadPersistedStatus(); + await this.setBridgeState("starting"); + const whoami = await this.#matrixClient.boot(); + this.#ownerUserId = whoami.userId; + this.#ownUserId = whoami.userId; + defaultLogger("info", "bridge_matrix_booted", { userId: whoami.userId }); + if (this.#appserviceOptions) { + const result = await this.#matrixClient.appservice.init(this.#appserviceOptions); + defaultLogger("info", "bridge_appservice_initialized", { + botUserId: appserviceBotUserId(this.#appserviceOptions), + homeserver: this.#appserviceOptions.homeserver, + registrationId: this.#appserviceOptions.registration.id, + result, + }); + this.#ownUserId = appserviceBotUserId(this.#appserviceOptions); + } + this.#context = this.#createContext(); + if ("validateConfig" in this.connector && typeof this.connector.validateConfig === "function") { + await this.connector.validateConfig(); + } + await this.connector.init(this.#context); + await this.#loadPersistedPortals(); + await this.#loadPersistedUserLogins(); + await this.connector.start(this.#context); + await this.#subscribeMatrixEvents(); + this.#startAppserviceWebsocket(); + this.#started = true; + await this.setBridgeState("running"); + this.#sendCurrentBridgeStatus(); + this.#scheduleDrain(); + } + + async stop(): Promise { + await this.setBridgeState("stopping"); + for (const login of this.#userLogins.values()) { + await this.#setLoginBridgeState(login, "BRIDGE_UNREACHABLE"); + } + const subscriptions = Array.from(this.#subscriptions); + this.#subscriptions.clear(); + await Promise.allSettled(subscriptions.map((subscription) => subscription.stop())); + const clients = Array.from(this.#networkClients.values()); + this.#networkClients.clear(); + this.#appserviceWebsocket?.stop(); + this.#appserviceWebsocket = null; + await Promise.allSettled(clients.map((client) => client.disconnect())); + if ("stop" in this.connector && typeof this.connector.stop === "function") { + await this.connector.stop(); + } + await this.#matrixClient.close(); + this.#context = null; + this.#started = false; + await this.setBridgeState("stopped"); + } + + async createLogin(user: BridgeUser, flowId: string): Promise { + const process = await this.connector.createLogin(this.#requestContext(), user, flowId); + return bindLoginProcess(process, () => this.#requestContext()); + } + + async createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise { + this.#requestContext(); + const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); + const result = await this.#matrixClient.appservice.createManagementRoom(stripUndefined({ + autoJoinInvites: this.#beeperOptions ? true : undefined, + initialMembers: this.#beeperOptions ? invite : undefined, + invite, + name: options.name, + topic: options.topic, + userId: options.userId, + })); + const room: ManagementRoom = { + metadata: options.metadata, + mxid: result.roomId, + }; + this.registerManagementRoom(room); + return room; + } + + async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { + this.#requestContext(); + const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); + const info = options.info ?? {}; + const name = info.name ?? options.name; + const topic = info.topic ?? options.topic; + const result = await this.#matrixClient.appservice.createPortalRoom(stripUndefined({ + autoJoinInvites: this.#beeperOptions ? true : undefined, + avatarUrl: info.avatar?.mxc ?? options.avatarUrl, + bridge: this.connector.getName(), + bridgeName: this.#beeperOptions?.bridge, + initialMembers: this.#beeperOptions ? invite : undefined, + invite, + isDirect: options.roomType === "dm", + messageRequest: options.messageRequest, + name, + portalKey: options.portalKey, + roomType: options.roomType, + topic, + userId: options.userId, + })); + const portal: Portal = { + id: options.portalKey.id, + metadata: options.metadata, + mxid: result.roomId, + portalKey: options.portalKey, + ...(options.portalKey.receiver ? { receiver: options.portalKey.receiver } : {}), + }; + this.registerPortal(portal); + return portal; + } + + async createPortal(login: UserLogin, options: BridgeCreatePortalOptions): Promise { + const { id, sender, ...roomOptions } = options; + return this.createPortalRoom({ + ...roomOptions, + portalKey: { id, receiver: login.id }, + ...(sender ? { userId: this.ghostUserId(sender) } : {}), + }); + } + + async backfill(options: BridgeBackfillOptions) { + this.#requestContext(); + return this.#matrixClient.appservice.batchSend(options); + } + + async backfillMessages(login: UserLogin, params: Parameters[1]) { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "fetchMessages")) { + throw new Error(`Login ${login.id} does not support backfill`); + } + const response = await (client as BackfillingNetworkAPI).fetchMessages(this.#requestContext(), params); + const portal = params.portal; + if (!portal.mxid) { + throw new Error(`Cannot backfill portal ${portalKeyString(portal.portalKey)} without a Matrix room`); + } + const events = await this.#convertBackfillMessages(portal, response.messages.map((message) => message.event)); + const result = await this.backfill({ events, roomId: portal.mxid }); + if (response.markRead && hasMethod(client, "markChatViewed")) { + await (client as ChatViewingNetworkAPI).markChatViewed(this.#requestContext(), portal); + } + return result; + } + + async backfillPortal(login: UserLogin, portal: PortalReference, params: Omit[1], "portal"> = {}) { + return this.backfillMessages(login, { ...params, portal: this.#resolvePortalReference(login, portal) }); + } + + queue(login: UserLogin): RemoteEventQueue { + return { + backfill: (options) => this.#queueBackfillEvent(login, options), + event: (event) => this.#queueEvent(login, event), + message: (options) => this.#queueMessage(login, options), + }; + } + + #queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult { + return this.queueRemoteEvent(login, this.#remoteMessageEvent(login, options)); + } + + #queueBackfillEvent(login: UserLogin, options: BridgeRemoteBackfillOptions): QueueRemoteEventResult { + const portalKey = this.#portalKeyReference(login, options.portal); + const event: RemoteBackfill = { + getBackfillData: () => Promise.resolve(stripUndefined({ + cursor: options.cursor, + forward: options.forward, + hasMore: options.hasMore, + markRead: options.markRead, + messages: options.messages.map((message) => ({ event: this.#remoteMessageEvent(login, { ...message, portal: message.portal ?? options.portal }) })), + progress: options.progress, + })), + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: true, sender: login.userId ?? this.#ownerUserId ?? "" }), + getType: () => "backfill", + }; + return this.queueRemoteEvent(login, event); + } + + #queueEvent(login: UserLogin, input: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult { + if (!("event" in input)) return this.queueRemoteEvent(login, input); + const portalKey = this.#portalKeyReference(login, input.portal); + const sender = this.#eventSenderReference(login, input.sender ?? { isFromMe: true, sender: login.userId ?? this.#ownerUserId ?? "" }); + return this.queueRemoteEvent(login, { + ...input.event, + getPortalKey: () => portalKey, + getSender: () => sender, + }); + } + + #remoteMessageEvent(login: UserLogin, options: BridgeRemoteMessageOptions): RemoteMessage | RemoteMessageWithTransactionID { + return createRemoteMessage({ + ...options, + convert: options.convert ?? (() => convertedMessageFromOptions(options)), + data: options.data as T, + portalKey: this.#portalKeyReference(login, options.portal), + sender: this.#eventSenderReference(login, options.sender), + }); + } + + async queueBackfill(login: UserLogin, params: BackfillQueueParams): Promise { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "fetchMessages")) { + throw new Error(`Login ${login.id} does not support backfill`); + } + const task = params.task ?? stripUndefined({ + portalKey: params.portal.portalKey, + userLoginId: login.id, + ...(params.cursor !== undefined ? { cursor: params.cursor } : {}), + ...(params.pending !== undefined ? { pending: params.pending } : {}), + }); + await this.#setLoginBridgeState(login, "BACKFILLING", { message: `Backfilling ${portalKeyString(params.portal.portalKey)}` }); + const response = await (client as BackfillingNetworkAPI).fetchMessages(this.#requestContext(), { ...params, task }); + const portal = params.portal; + if (!portal.mxid) { + throw new Error(`Cannot backfill portal ${portalKeyString(portal.portalKey)} without a Matrix room`); + } + const events = await this.#convertBackfillMessages(portal, response.messages.map((message) => message.event)); + await this.backfill({ events, roomId: portal.mxid }); + if ((response.markRead ?? params.markRead) && hasMethod(client, "markChatViewed")) { + await (client as ChatViewingNetworkAPI).markChatViewed(this.#requestContext(), portal); + } + await this.#setLoginBridgeState(login, "CONNECTED"); + return stripUndefined({ + queued: false, + task: stripUndefined({ + ...task, + completedAt: new Date(), + done: !response.hasMore, + pending: false, + ...((response.cursor ?? task.cursor) !== undefined ? { cursor: response.cursor ?? task.cursor } : {}), + }), + ...(response.cursor !== undefined ? { cursor: response.cursor } : {}), + ...(response.forward !== undefined ? { forward: response.forward } : {}), + ...(response.hasMore !== undefined ? { hasMore: response.hasMore } : {}), + ...((response.markRead ?? params.markRead) !== undefined ? { markRead: response.markRead ?? params.markRead } : {}), + ...(params.pending !== undefined ? { pending: params.pending } : {}), + ...((response.progress ?? params.progress) !== undefined ? { progress: response.progress ?? params.progress } : {}), + }); + } + + async loadUserLogin(login: UserLogin): Promise { + const existing = this.#networkClients.get(login.id); + if (existing) return existing; + const loading = this.#networkClientLoads.get(login.id); + if (loading) return loading; + const promise = this.#loadUserLogin(login); + this.#networkClientLoads.set(login.id, promise); + try { + return await promise; + } finally { + this.#networkClientLoads.delete(login.id); + } + } + + async #loadUserLogin(login: UserLogin): Promise { + await this.#setLoginBridgeState(login, "CONNECTING"); + const client = await this.connector.loadUserLogin(this.#requestContext(), login); + await client.connect({ ...this.#requestContext(), login }); + login.client = client; + this.#userLogins.set(login.id, login); + this.#networkClients.set(login.id, client); + if (this.#dataStore && hasMethod(this.#dataStore, "setUserLogin")) { + await this.#dataStore.setUserLogin(login); + } + await this.#setLoginBridgeState(login, "CONNECTED"); + defaultLogger("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); + this.#sendCurrentBridgeStatus(); + return client; + } + + getBridgeState(): BridgeState | null { + return this.#bridgeStatus?.state ?? null; + } + + getBridgeStatus(): BridgeStatus | null { + return this.#bridgeStatus; + } + + async setBridgeState(state: BridgeState): Promise { + await this.setBridgeStatus({ state, updatedAt: new Date() }); + } + + async setBridgeStatus(status: BridgeStatus): Promise { + const bridgeState = status.bridgeState ?? bridgeStatePayload(bridgeStateEvent(status.state), undefined, status); + const logins = status.logins ?? this.#loginStatesRecord(); + this.#bridgeStatus = { ...status, bridgeState, logins }; + if (this.#dataStore && hasMethod(this.#dataStore, "setBridgeStatus")) { + await this.#dataStore.setBridgeStatus(this.#bridgeStatus); + } + if (this.#dataStore && hasMethod(this.#dataStore, "setBridgeState")) { + await this.#dataStore.setBridgeState(status.state); + } + defaultLogger("info", "bridge_state_updated", { state: status.state }); + this.#sendCurrentBridgeStatus(); + } + + sendMessageCheckpoints(checkpoints: MessageCheckpoint[]): boolean { + return this.#sendMessageCheckpoints(checkpoints); + } + + getGhost(id: string): Ghost | null { + return this.#ghosts.get(id) ?? null; + } + + ghostUserId(localId: string): string { + const escaped = escapeMatrixLocalpart(localId); + if (this.#appserviceOptions) { + return ghostUserIdFromRegistration(this.#appserviceOptions, escaped); + } + return `@${escaped}:${domainFromUserID(this.#ownerUserId ?? this.#ownUserId ?? "@bridge:example")}`; + } + + registerGhost(ghost: Ghost): void { + this.#ghosts.set(ghost.id, ghost); + void this.#dataStore?.setGhost(ghost).catch((error: unknown) => { + defaultLogger("warn", "ghost_store_failed", { error }); + }); + } + + getPortal(portalKey: { id: string; receiver?: string }): Portal | null { + return this.#portalsByKey.get(portalKeyString(portalKey)) ?? null; + } + + #portalKeyReference(login: UserLogin, portal: PortalReference): PortalKey { + if (typeof portal === "string") return { id: portal, receiver: login.id }; + if ("portalKey" in portal) return { ...portal.portalKey, receiver: portal.portalKey.receiver ?? portal.receiver ?? login.id }; + return { ...portal, receiver: portal.receiver ?? login.id }; + } + + #resolvePortalReference(login: UserLogin, portal: PortalReference): Portal { + if (typeof portal !== "string" && "portalKey" in portal) return portal; + const portalKey = this.#portalKeyReference(login, portal); + const resolved = this.getPortal(portalKey); + if (!resolved) throw new Error(`No portal registered for ${portalKeyString(portalKey)}`); + return resolved; + } + + #eventSenderReference(login: UserLogin, sender: string | EventSender): EventSender { + return typeof sender === "string" ? { isFromMe: false, sender: this.ghostUserId(sender), senderLogin: login.id } : sender; + } + + getPortalByMXID(mxid: string): Portal | null { + return this.#portalsByRoom.get(mxid) ?? null; + } + + async setPortalMetadata(portalKey: { id: string; receiver?: string }, metadata: unknown): Promise { + const portal = this.getPortal(portalKey); + if (!portal) throw new Error(`No portal registered for ${portalKeyString(portalKey)}`); + const updated = { ...portal, metadata }; + this.registerPortal(updated); + return updated; + } + + async getMessageRequest(portalKey: { id: string; receiver?: string }): Promise { + const key = portalKeyString(portalKey); + return this.#messageRequests.get(key) ?? (await this.#dataStore?.getMessageRequest(key)) ?? null; + } + + async setMessageRequest(request: MessageRequest): Promise { + const key = portalKeyString(request.portalKey); + this.#messageRequests.set(key, request); + await this.#dataStore?.setMessageRequest(request); + } + + async acceptMessageRequest(portalKey: { id: string; receiver?: string }): Promise { + const request = await this.getMessageRequest(portalKey); + if (!request) throw new Error(`No message request for ${portalKeyString(portalKey)}`); + const next: MessageRequest = { ...request, status: "accepted", updatedAt: new Date() }; + const client = next.portalKey.receiver ? this.#networkClients.get(next.portalKey.receiver) : undefined; + const handled = client && hasMethod(client, "handleMessageRequest") + ? await (client as MessageRequestHandlingNetworkAPI).handleMessageRequest(this.#requestContext(), next) + : next; + await this.setMessageRequest(handled); + return handled; + } + + async resolveIdentifier(login: UserLogin, identifier: ResolveIdentifierParams): Promise { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "resolveIdentifier")) { + throw new Error(`Login ${login.id} does not support identifier resolution`); + } + return (client as IdentifierResolvingNetworkAPI).resolveIdentifier(this.#requestContext(), identifier); + } + + getUserInfo(userId: string) { + return this.#matrixClient.users.get({ userId }); + } + + async getOwnProfile(): Promise { + const [displayName, avatarUrl] = await Promise.all([ + this.#matrixClient.users.getOwnDisplayName(), + this.#matrixClient.users.getOwnAvatarUrl(), + ]); + const profile: UserProfile = {}; + if (avatarUrl.avatarUrl !== undefined) profile.avatarUrl = avatarUrl.avatarUrl; + if (displayName.displayName !== undefined) profile.displayName = displayName.displayName; + return profile; + } + + async setOwnProfile(profile: UserProfileUpdate): Promise { + await Promise.all([ + profile.displayName === undefined ? undefined : this.#matrixClient.users.setOwnDisplayName({ displayName: profile.displayName }), + profile.avatarUrl === undefined ? undefined : this.#matrixClient.users.setOwnAvatarUrl({ avatarUrl: profile.avatarUrl }), + ]); + } + + uploadMedia(options: Parameters[0]) { + return this.#matrixClient.media.upload(options); + } + + async downloadMedia(options: DownloadMediaOptions): Promise { + if (hasMethod(this.connector, "download")) { + return this.connector.download(this.#requestContext(), options.contentUri, options.params ?? {}) as Promise; + } + const result = await this.#matrixClient.media.download({ contentUri: options.contentUri }); + return { body: result.bytes, bytes: result.bytes }; + } + + sendMedia(options: BridgeSendMediaOptions): Promise { + return this.#matrixClient.messages.sendMedia(options); + } + + queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult { + this.#remoteEvents.push({ event, login }); + this.#scheduleDrain(); + return { event, queued: true }; + } + + registerPortal(portal: Portal): void { + const key = portalKeyString(portal.portalKey); + const existing = this.#portalsByKey.get(key); + if (existing?.mxid && existing.mxid !== portal.mxid) { + this.#portalsByRoom.delete(existing.mxid); + } + this.#portalsByKey.set(key, portal); + if (portal.mxid) { + this.#portalsByRoom.set(portal.mxid, portal); + } + void this.#dataStore?.setPortal(portal).catch((error: unknown) => { + defaultLogger("warn", "portal_store_failed", { error }); + }); + } + + registerManagementRoom(room: ManagementRoom, persist = true): void { + this.#managementRooms.set(room.mxid, room); + if (!persist) return; + void this.#persistManagementRoom(room).catch((error: unknown) => { + defaultLogger("warn", "management_room_store_failed", { error }); + }); + } + + async flushRemoteEvents(): Promise { + this.#scheduleDrain(); + await this.#drainPromise; + } + + remoteEventBacklog(): readonly { event: RemoteEvent; login: UserLogin }[] { + return this.#remoteEvents; + } + + async dispatchMatrixEvent(event: MatrixClientEvent): Promise { + if (!this.#context) { + throw new Error("Bridge has not been started"); + } + defaultLogger("debug", "matrix_event_received", { + eventId: "eventId" in event ? event.eventId : undefined, + kind: event.kind, + roomId: "roomId" in event ? event.roomId : undefined, + sender: "sender" in event ? event.sender.userId : undefined, + }); + if (event.kind === "message") { + return this.#dispatchMatrixMessage(event); + } + if (event.kind === "reaction") { + return this.#dispatchMatrixReaction(event); + } + if (isGenericEvent(event, "redaction")) { + return this.#dispatchMatrixRedaction(event); + } + if (isGenericEvent(event, "typing")) { + return this.#dispatchMatrixTyping(event); + } + return { dispatched: false, handlers: 0, kind: event.kind }; + } + + #requestContext(): BridgeRequestContext { + if (!this.#context) { + throw new Error("Bridge has not been started"); + } + return this.#context; + } + + #createContext(): BridgeContext { + const context: BridgeContext = { + bridge: this, + client: this.#matrixClient, + log: defaultLogger, + queue: (login) => this.queue(login), + queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), + }; + if (this.#dataStore) context.dataStore = this.#dataStore; + return context; + } + + async #loadPersistedStatus(): Promise { + if (!this.#dataStore || !hasMethod(this.#dataStore, "getBridgeStatus")) return; + const status = await this.#dataStore.getBridgeStatus(); + if (!status) return; + this.#bridgeStatus = status; + for (const [loginId, state] of Object.entries(status.logins ?? {})) { + this.#loginStates.set(loginId, state); + } + } + + async #loadPersistedUserLogins(): Promise { + if (!this.#dataStore || !hasMethod(this.#dataStore, "listUserLogins")) return; + const logins = await this.#dataStore.listUserLogins(); + if (!logins?.length) return; + for (const login of logins) { + try { + await this.loadUserLogin(login); + } catch (error: unknown) { + await this.#setLoginBridgeState(login, "UNKNOWN_ERROR", { error: errorMessage(error) }); + defaultLogger("warn", "user_login_load_failed", { error, loginId: login.id }); + } + } + } + + async #loadPersistedPortals(): Promise { + if (!this.#dataStore || !hasMethod(this.#dataStore, "listPortals")) return; + const portals = await this.#dataStore.listPortals(); + if (!portals?.length) return; + for (const portal of portals) { + this.#portalsByKey.set(portalKeyString(portal.portalKey), portal); + if (portal.mxid) { + this.#portalsByRoom.set(portal.mxid, portal); + } + } + defaultLogger("info", "portals_loaded", { count: portals.length }); + } + + async #setLoginBridgeState(login: UserLogin, stateEvent: BridgeStateEvent, options: { error?: string; message?: string; reason?: string } = {}): Promise { + const payload = bridgeStatePayload(stateEvent, login, options); + this.#loginStates.set(login.id, payload); + if (this.#bridgeStatus) { + this.#bridgeStatus = { ...this.#bridgeStatus, logins: this.#loginStatesRecord() }; + if (this.#dataStore && hasMethod(this.#dataStore, "setBridgeStatus")) { + await this.#dataStore.setBridgeStatus(this.#bridgeStatus); + } + } + } + + #loginStatesRecord(): Record { + return Object.fromEntries(this.#loginStates); + } + + async #subscribeMatrixEvents(): Promise { + const subscription = await this.#matrixClient.subscribe( + { kind: ["message", "reaction", "redaction", "typing"] }, + (event) => void this.dispatchMatrixEvent(event).catch((error: unknown) => { + defaultLogger("error", "matrix_dispatch_failed", { error }); + }), + { live: true } + ); + this.#subscriptions.add(subscription); + } + + #startAppserviceWebsocket(): void { + if (!this.#appserviceOptions) return; + if (hasPushURL(this.#appserviceOptions.registration.url)) { + defaultLogger("info", "appservice_websocket_skipped", { reason: "registration_url_is_push_url" }); + return; + } + defaultLogger("info", "appservice_websocket_starting", { homeserver: this.#appserviceOptions.homeserver }); + this.#appserviceWebsocket = new AppserviceWebsocket({ + appservice: this.#appserviceOptions, + dispatch: (event) => this.dispatchMatrixEvent(event), + handleHTTPProxy: (request) => this.#handleHTTPProxy(request), + log: defaultLogger, + onOpen: () => this.#sendCurrentBridgeStatus(), + }); + this.#appserviceWebsocket.start(); + } + + async #handleHTTPProxy(request: HTTPProxyRequest): Promise { + const path = request.path ?? ""; + const method = request.method ?? "GET"; + defaultLogger("debug", "provisioning_http_request", { method, path }); + if (method === "GET" && path === "/_matrix/provision/v3/capabilities") { + return jsonHTTPResponse(200, provisioningCapabilities(this.connector.getCapabilities())); + } + if (method === "GET" && path === "/_matrix/provision/v3/login/flows") { + return jsonHTTPResponse(200, { flows: this.connector.getLoginFlows() }); + } + if (method === "GET" && path === "/_matrix/provision/v3/logins") { + return jsonHTTPResponse(200, { login_ids: Array.from(this.#networkClients.keys()) }); + } + const startMatch = /^\/_matrix\/provision\/v3\/login\/start\/([^/]+)$/.exec(path); + if (method === "POST" && startMatch) { + const flowId = decodeURIComponent(startMatch[1] ?? ""); + defaultLogger("info", "provisioning_login_start", { flowId }); + const process = await this.createLogin({ id: this.#ownerUserId ?? this.#ownUserId ?? "" }, flowId); + const step = await process.start(); + const loginId = randomID("login"); + this.#provisioningLogins.set(loginId, { nextStep: step, process }); + return jsonHTTPResponse(200, loginStepResponse(loginId, step)); + } + const stepMatch = /^\/_matrix\/provision\/v3\/login\/step\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(path); + if (method === "POST" && stepMatch) { + const loginId = decodeURIComponent(stepMatch[1] ?? ""); + const stepId = decodeURIComponent(stepMatch[2] ?? ""); + const stepType = decodeURIComponent(stepMatch[3] ?? ""); + const login = this.#provisioningLogins.get(loginId); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + if (login.nextStep.stepId !== stepId) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step ID does not match")); + if (login.nextStep.type !== stepType) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step type does not match")); + let nextStep: LoginStep; + if (stepType === "user_input" && hasMethod(login.process, "submitUserInput")) { + nextStep = await (login.process as LoginProcessUserInput).submitUserInput(this.#requestContext(), stringMap(request.body)); + } else if (stepType === "cookies" && hasMethod(login.process, "submitCookies")) { + nextStep = await (login.process as LoginProcessCookies).submitCookies(this.#requestContext(), stringMap(request.body)); + } else if (stepType === "display_and_wait" && hasMethod(login.process, "wait")) { + nextStep = await (login.process as LoginProcessDisplayAndWait).wait(this.#requestContext()); + } else { + return jsonHTTPResponse(400, matrixError("M_BAD_REQUEST", `Unsupported login step type ${stepType}`)); + } + if (nextStep.type === "complete") { + defaultLogger("info", "provisioning_login_complete", { loginId }); + this.#provisioningLogins.delete(loginId); + if (nextStep.complete?.userLogin) await this.loadUserLogin(nextStep.complete.userLogin); + else if (nextStep.complete?.userLoginId) await this.loadUserLogin({ id: nextStep.complete.userLoginId }); + } else { + login.nextStep = nextStep; + } + return jsonHTTPResponse(200, loginStepResponse(loginId, nextStep)); + } + return null; + } + + async #dispatchMatrixMessage(event: MatrixMessageEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + defaultLogger("debug", "matrix_message_ignored_own", { eventId: event.eventId, roomId: event.roomId, sender: event.sender.userId }); + return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; + } + const command = this.#parseManagementCommand(event); + if (command) { + try { + const result = await this.#dispatchMatrixCommand(command); + this.#sendMatrixEventCheckpoint(event, "COMMAND", result.dispatched ? "SUCCESS" : "UNSUPPORTED"); + return result; + } catch (error: unknown) { + this.#sendMatrixEventCheckpoint(event, "COMMAND", "PERM_FAILURE", errorMessage(error)); + throw error; + } + } + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixMessage = { + attachments: event.attachments, + content: event.content, + event, + portal, + sender: event.sender, + text: event.text, + ...(event.threadRoot ? { threadRoot: { id: event.threadRoot } } : {}), + }; + let handlers = 0; + try { + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixMessage")) continue; + handlers += 1; + defaultLogger("debug", "matrix_message_to_network", { eventId: event.eventId, loginHandlers: handlers, roomId: event.roomId }); + await client.handleMatrixMessage(this.#requestContext(), msg); + } + this.#sendMatrixEventCheckpoint(event, "BRIDGE", handlers > 0 ? "SUCCESS" : "UNSUPPORTED"); + } catch (error: unknown) { + this.#sendMatrixEventCheckpoint(event, "BRIDGE", "PERM_FAILURE", errorMessage(error)); + throw error; + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + + async #dispatchMatrixCommand(command: MatrixCommand): Promise { + const builtinResponse = await this.#handleBuiltinCommand(command); + if (builtinResponse) { + await this.#sendCommandReply(command.event.roomId, builtinResponse.content ?? { + body: builtinResponse.text ?? "", + msgtype: "m.notice", + }); + return { dispatched: true, eventId: command.event.eventId, handlers: 1, kind: command.event.kind, roomId: command.event.roomId }; + } + if (!hasMethod(this.connector, "handleCommand")) { + return { dispatched: false, eventId: command.event.eventId, handlers: 0, kind: command.event.kind, roomId: command.event.roomId }; + } + const response = await this.connector.handleCommand(this.#requestContext(), command) as MatrixCommandResponse; + if (response?.text || response?.content) { + await this.#sendCommandReply(command.event.roomId, response.content ?? { + body: response.text, + msgtype: "m.notice", + }); + } + return { dispatched: response?.handled ?? true, eventId: command.event.eventId, handlers: 1, kind: command.event.kind, roomId: command.event.roomId }; + } + + #parseManagementCommand(event: MatrixMessageEvent): MatrixCommand | null { + const explicitRoom = this.#managementRooms.get(event.roomId); + if (!explicitRoom && this.#portalsByRoom.has(event.roomId)) return null; + const text = event.text || stringContent(event.content.body); + if (!text) return null; + const prefix = this.connector.getName().defaultCommandPrefix ?? ""; + const hasPrefix = Boolean(prefix && text.startsWith(prefix)); + const implicitRoom = !explicitRoom && this.#isImplicitManagementEvent(event); + if (!explicitRoom && !implicitRoom && !hasPrefix) return null; + const room = explicitRoom ?? (implicitRoom ? this.#implicitManagementRoom(event) : { mxid: event.roomId }); + const body = hasPrefix ? text.slice(prefix.length).trimStart() : text.trim(); + if (!body) return null; + const [command = "", ...args] = body.split(/\s+/); + if (!command) return null; + defaultLogger("info", "management_command_received", { + args, + command, + eventId: event.eventId, + roomId: event.roomId, + sender: event.sender.userId, + }); + return { + args, + body, + command, + event, + prefix, + room, + sender: event.sender, + text, + }; + } + + #isImplicitManagementEvent(event: MatrixMessageEvent): boolean { + return Boolean(this.#ownerUserId && event.sender.userId === this.#ownerUserId); + } + + #implicitManagementRoom(event: MatrixMessageEvent): ManagementRoom { + const room: ManagementRoom = { mxid: event.roomId }; + this.registerManagementRoom(room); + return room; + } + + async #handleBuiltinCommand(command: MatrixCommand): Promise { + switch (command.command) { + case "help": + return { handled: true, text: this.#managementHelpText(command) }; + case "list-logins": + return { handled: true, text: this.#listLoginsText() }; + case "login": + return this.#handleLoginCommand(command); + case "logout": + return this.#handleLogoutCommand(command); + case "cancel-login": + return this.#handleCancelLoginCommand(command); + case "set-management-room": + return this.#handleSetManagementRoomCommand(command); + default: + return null; + } + } + + #managementHelpText(command: MatrixCommand): string { + const commands = [ + "help", + "list-logins", + "login ", + "logout ", + "cancel-login ", + "set-management-room", + ]; + const prefix = this.connector.getName().defaultCommandPrefix; + const prefixHelp = command.room.mxid === command.event.roomId && this.#managementRooms.has(command.event.roomId) + ? "" + : prefix ? ` Prefix commands with ${prefix} outside management rooms.` : ""; + return `Available commands: ${commands.join(", ")}.${prefixHelp}`; + } + + #listLoginsText(): string { + const logins = Array.from(this.#userLogins.values()); + if (logins.length === 0) return "No logins."; + return logins.map((login) => { + const details = [login.remoteName, login.userId].filter(Boolean).join(" "); + return details ? `${login.id} (${details})` : login.id; + }).join("\n"); + } + + async #handleLoginCommand(command: MatrixCommand): Promise { + const flowId = command.args[0]; + if (!flowId) { + const flows = this.connector.getLoginFlows(); + if (flows.length === 0) return { handled: true, text: "No login flows are available." }; + return { handled: true, text: `Usage: login \nAvailable flows:\n${flows.map((flow) => `${flow.id}: ${flow.name}`).join("\n")}` }; + } + const process = await this.createLogin({ id: command.sender.userId }, flowId); + const step = await process.start(); + if (step.type === "complete" && step.complete?.userLoginId) { + await this.loadUserLogin({ id: step.complete.userLoginId, userId: command.sender.userId }); + return { handled: true, text: `Login complete: ${step.complete.userLoginId}` }; + } + const loginId = randomID("login"); + this.#provisioningLogins.set(loginId, { nextStep: step, process }); + return { handled: true, text: `Login started: ${loginId}\n${loginStepText(step)}` }; + } + + async #handleLogoutCommand(command: MatrixCommand): Promise { + const loginId = command.args[0]; + if (!loginId) return { handled: true, text: "Usage: logout " }; + const client = this.#networkClients.get(loginId); + const login = this.#userLogins.get(loginId); + if (!client && !login) return { handled: true, text: `Login not found: ${loginId}` }; + if (client) await client.disconnect(); + this.#networkClients.delete(loginId); + this.#userLogins.delete(loginId); + await this.#deleteStoredUserLogin(loginId); + this.#sendCurrentBridgeStatus(); + return { handled: true, text: `Logged out: ${loginId}` }; + } + + async #handleCancelLoginCommand(command: MatrixCommand): Promise { + const loginId = command.args[0]; + if (!loginId) return { handled: true, text: "Usage: cancel-login " }; + const login = this.#provisioningLogins.get(loginId); + if (!login) return { handled: true, text: `Login not found: ${loginId}` }; + await login.process.cancel(this.#requestContext()); + this.#provisioningLogins.delete(loginId); + return { handled: true, text: `Cancelled login: ${loginId}` }; + } + + async #handleSetManagementRoomCommand(command: MatrixCommand): Promise { + this.registerManagementRoom(command.room, false); + await this.#persistManagementRoom(command.room); + return { handled: true, text: `Management room registered: ${command.room.mxid}` }; + } + + async #deleteStoredUserLogin(loginId: string): Promise { + if (!this.#dataStore) return; + const store = this.#dataStore as object; + if (hasMethod(store, "deleteUserLogin")) { + await store.deleteUserLogin(loginId); + } else if (hasMethod(store, "removeUserLogin")) { + await store.removeUserLogin(loginId); + } + } + + async #persistManagementRoom(room: ManagementRoom): Promise { + if (!this.#dataStore) return; + const store = this.#dataStore as object; + if (hasMethod(store, "setManagementRoom")) { + await store.setManagementRoom(room); + } else if (hasMethod(store, "registerManagementRoom")) { + await store.registerManagementRoom(room); + } + } + + async #dispatchMatrixReaction(event: MatrixReactionEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; + } + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixReaction = { + content: event.content, + event, + portal, + targetMessage: { id: event.relatesTo }, + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixReaction")) continue; + handlers += 1; + await client.handleMatrixReaction(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + + async #dispatchMatrixRedaction(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId || !event.eventId) { + return roomId + ? { dispatched: false, handlers: 0, kind: event.kind, roomId } + : { dispatched: false, handlers: 0, kind: event.kind }; + } + const msg: MatrixRedaction = { + eventId: event.eventId, + portal: this.#portalForRoom(roomId), + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRedaction")) continue; + handlers += 1; + await client.handleMatrixRedaction(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixTyping(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) { + return { dispatched: false, handlers: 0, kind: event.kind }; + } + const content = event.content; + const userIds = Array.isArray(content.user_ids) + ? content.user_ids.filter((userId: unknown): userId is string => typeof userId === "string") + : []; + let handlers = 0; + for (const userId of userIds) { + if (userId === this.#ownUserId) continue; + const portal = this.#portalForRoom(roomId); + const msg: MatrixTyping = { + portal, + typing: true, + userId, + }; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixTyping")) continue; + handlers += 1; + await client.handleMatrixTyping(this.#requestContext(), msg); + } + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + #portalForRoom(roomId: string): Portal { + const existing = this.#portalsByRoom.get(roomId); + if (existing) return existing; + const portal: Portal = { + id: roomId, + mxid: roomId, + portalKey: { id: roomId }, + }; + this.#portalsByRoom.set(roomId, portal); + return portal; + } + + #portalForRemoteEvent(event: RemoteEvent): Portal | null { + return this.#portalsByKey.get(portalKeyString(event.getPortalKey())) ?? null; + } + + #scheduleDrain(): void { + if (!this.#context) return; + this.#drainPromise ??= this.#drainRemoteEvents().finally(() => { + this.#drainPromise = null; + if (this.#context && this.#remoteEvents.length > 0) this.#scheduleDrain(); + }); + } + + async #drainRemoteEvents(): Promise { + if (!this.#context) return; + while (this.#remoteEvents.length > 0) { + const item = this.#remoteEvents[0]; + if (!item) continue; + await this.#handleRemoteEvent(item.login, item.event); + this.#remoteEvents.shift(); + } + } + + #networkClientsForPortal(portal: Portal): NetworkAPI[] { + const receiver = portal.portalKey.receiver ?? portal.receiver; + if (!receiver) return Array.from(this.#networkClients.values()); + const client = this.#networkClients.get(receiver); + return client ? [client] : []; + } + + async #handleRemoteEvent(_login: UserLogin, event: RemoteEvent): Promise { + const type = event.getType(); + if (type === "message" || type === "message_upsert") { + await this.#handleRemoteMessage(event as RemoteMessage); + return; + } + if (type === "backfill") { + await this.#handleRemoteBackfill(event as RemoteBackfill); + return; + } + if (type === "chat_info_change") { + await this.#handleRemoteChatInfoChange(event as RemoteChatInfoChange); + return; + } + if (type === "chat_delete") { + await this.#handleRemoteChatDelete(event as RemoteChatDelete); + return; + } + this.#context?.log("debug", "remote_event_ignored", { type }); + } + + async #handleRemoteMessage(event: RemoteMessage): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const converted = await event.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); + for (const [index, part] of converted.parts.entries()) { + const sender = event.getSender(); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, part.content, eventTimestamp(event)); + const messageKey = messagePartKey(event.getID(), part.id ?? String(index)); + const message = { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId, + }; + this.#messages.set(messageKey, message); + await this.#dataStore?.setMessage(messageKey, message); + } + } + + async #handleRemoteBackfill(event: RemoteBackfill): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const response = await event.getBackfillData(this.#requestContext(), portal); + const events = await this.#convertBackfillMessages(portal, response.messages.map((message) => message.event)); + await this.backfill({ events, roomId: portal.mxid }); + } + + async #handleRemoteChatInfoChange(event: RemoteChatInfoChange): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal) return; + const change = await event.getChatInfoChange(this.#requestContext()); + const metadata = { + ...(typeof portal.metadata === "object" && portal.metadata !== null ? portal.metadata : {}), + chatInfo: change, + }; + await this.setPortalMetadata(portal.portalKey, metadata); + } + + async #handleRemoteChatDelete(event: RemoteChatDelete): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal) return; + if (event.deleteOnlyForMe()) { + await this.setPortalMetadata(portal.portalKey, { + ...(typeof portal.metadata === "object" && portal.metadata !== null ? portal.metadata : {}), + deletedForMe: true, + }); + return; + } + this.#portalsByKey.delete(portalKeyString(portal.portalKey)); + if (portal.mxid) this.#portalsByRoom.delete(portal.mxid); + await this.#dataStore?.deletePortal(portalKeyString(portal.portalKey)); + } + + async #convertBackfillMessages(portal: Portal, messages: RemoteMessage[]): Promise { + const events: MatrixAppserviceBatchSendOptions["events"] = []; + for (const message of messages) { + const converted = await message.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); + for (const part of converted.parts) { + const event: MatrixAppserviceBatchSendOptions["events"][number] = { + content: part.content, + sender: message.getSender().sender, + }; + const timestamp = eventTimestamp(message); + if (timestamp !== undefined) event.timestamp = timestamp; + events.push(event); + } + } + return events; + } + + #matrixIntent(): MatrixIntent { + return { + client: this.#matrixClient, + sendMessage: async (roomId, content) => { + const type = "m.room.message"; + const transactionId = `pickle-bridge-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const result = await this.#matrixClient.raw.request({ + body: content, + method: "PUT", + path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(type)}/${transactionId}`, + }); + const eventId = eventIdFromRaw(result.body); + return { eventId, raw: result.raw ?? result.body ?? result, roomId }; + }, + }; + } + + async #sendRemoteMessagePart(roomId: string, sender: string, content: Record, timestamp?: number): Promise { + if (this.#appserviceOptions && sender.startsWith("@")) { + const sendOptions = stripUndefined({ + content, + roomId, + timestamp, + userId: sender, + }); + return this.#matrixClient.appservice.sendMessage(sendOptions as MatrixAppserviceSendMessageOptions); + } + return this.#matrixIntent().sendMessage(roomId, content); + } + + async #sendCommandReply(roomId: string, content: Record): Promise { + try { + const sender = this.#appserviceOptions ? appserviceBotUserId(this.#appserviceOptions) : null; + const result = sender + ? await this.#matrixClient.appservice.sendMessage({ content, roomId, userId: sender } as MatrixAppserviceSendMessageOptions) + : await this.#matrixIntent().sendMessage(roomId, content); + defaultLogger("info", "management_command_reply_sent", { eventId: result.eventId, roomId, sender }); + return result; + } catch (error: unknown) { + defaultLogger("error", "management_command_reply_failed", { error, roomId }); + throw error; + } + } + + #sendCurrentBridgeStatus(): void { + const websocket = this.#appserviceWebsocket; + if (!websocket) return; + const bridgeState = this.#bridgeStatus?.bridgeState + ?? bridgeStatePayload(bridgeStateEvent(this.#bridgeStatus?.state ?? "starting")); + const logins = Object.values(this.#bridgeStatus?.logins ?? this.#loginStatesRecord()); + let sent = websocket.send("bridge_status", bridgeState) ? 1 : 0; + for (const loginState of logins) { + if (websocket.send("bridge_status", loginState)) sent += 1; + } + defaultLogger("debug", "bridge_status_sent", { loginCount: logins.length, sent, stateEvent: bridgeState.state_event }); + } + + #sendMatrixEventCheckpoint( + event: MatrixMessageEvent, + step: MessageCheckpointStep, + status: MessageCheckpointStatus, + info?: string + ): boolean { + const checkpoint = stripUndefined({ + eventId: event.eventId, + eventType: event.type, + info, + messageType: event.messageType, + reportedBy: "BRIDGE", + retryNum: 0, + roomId: event.roomId, + status, + step, + timestamp: Date.now(), + }) as MessageCheckpoint; + return this.#sendMessageCheckpoints([checkpoint]); + } + + #sendMessageCheckpoints(checkpoints: MessageCheckpoint[]): boolean { + if (!this.#appserviceWebsocket) return false; + const sent = this.#appserviceWebsocket.send("message_checkpoint", { + checkpoints: checkpoints.map(messageCheckpointPayload), + }); + defaultLogger("debug", "message_checkpoints_sent", { count: checkpoints.length, sent }); + return sent; + } +} + +const defaultLogger: BridgeLogger = (level, message, data) => { + const sink = level === "error" ? console.error : level === "warn" ? console.warn : console.log; + sink(`[pickle-bridge] ${message}`, data ?? ""); +}; + +function isGenericEvent(event: MatrixClientEvent, kind: string): event is GenericMatrixEvent { + return event.kind === kind && "content" in event && typeof event.content === "object" && event.content !== null; +} + +function hasMethod(value: object, method: T): value is object & Record unknown> { + return method in value && typeof (value as Record)[method] === "function"; +} + +function appserviceBotUserId(options: MatrixAppserviceInitOptions): string { + return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; +} + +function ghostUserIdFromRegistration(options: MatrixAppserviceInitOptions, escapedLocalId: string): string { + const botUserId = appserviceBotUserId(options); + for (const namespace of options.registration.namespaces.users ?? []) { + const userId = userIdFromNamespaceRegex(namespace.regex, escapedLocalId); + if (userId && userId !== botUserId) return userId; + } + return `@${options.registration.senderLocalpart}_${escapedLocalId}:${options.homeserverDomain}`; +} + +function userIdFromNamespaceRegex(regex: string, escapedLocalId: string): string | null { + const match = /^@(.+?)(?:\\?\.?[+*]|\[|\(|\$)/.exec(regex); + if (!match?.[1]) return null; + const domainMatch = /:([^:]+)$/.exec(regex); + if (!domainMatch?.[1]) return null; + const prefix = unescapeRegexLiteral(match[1]); + const domain = unescapeRegexLiteral(domainMatch[1].replace(/\$$/, "")); + return `@${prefix}${escapedLocalId}:${domain}`; +} + +function unescapeRegexLiteral(value: string): string { + return value.replace(/\\([\\.^$*+?()[\]{}|/-])/g, "$1"); +} + +function escapeMatrixLocalpart(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9._=-]/g, "_"); +} + +function bridgeStateEvent(state: BridgeState): BridgeStateEvent { + switch (state) { + case "starting": + return "STARTING"; + case "running": + return "RUNNING"; + case "stopping": + case "stopped": + return "BRIDGE_UNREACHABLE"; + case "degraded": + return "TRANSIENT_DISCONNECT"; + case "error": + return "UNKNOWN_ERROR"; + } +} + +function bridgeStatePayload( + stateEvent: BridgeStateEvent, + login?: UserLogin, + options: { error?: string; message?: string; reason?: string; updatedAt?: Date; metadata?: unknown } = {} +): BridgeStatePayload { + const info = typeof options.metadata === "object" && options.metadata !== null + ? options.metadata as Record + : undefined; + return stripUndefined({ + error: options.error, + info, + message: options.message, + reason: options.reason, + remote_id: login?.id, + remote_name: login?.remoteName, + source: "bridge", + state_event: stateEvent, + timestamp: Math.floor((options.updatedAt?.getTime() ?? Date.now()) / 1000), + ttl: bridgeStateTTL(stateEvent), + user_id: login?.userId, + }) as BridgeStatePayload; +} + +function bridgeStateTTL(stateEvent: BridgeStateEvent): number { + switch (stateEvent) { + case "BAD_CREDENTIALS": + case "BRIDGE_UNREACHABLE": + case "LOGGED_OUT": + case "TRANSIENT_DISCONNECT": + case "UNKNOWN_ERROR": + return 3600; + default: + return 21600; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function provisioningCapabilities(capabilities: { provisioning?: { groupCreation?: unknown; resolveIdentifier?: unknown } }): unknown { + const provisioning = capabilities.provisioning; + if (provisioning) { + return { + group_creation: provisioning.groupCreation ?? {}, + resolve_identifier: provisioning.resolveIdentifier ?? {}, + }; + } + return { + group_creation: {}, + resolve_identifier: {}, + }; +} + +function hasPushURL(url: string | undefined): boolean { + return Boolean(url && url !== "websocket"); +} + +function bindLoginProcess(process: LoginProcess, getContext: () => BridgeRequestContext): LoginProcess { + const bound: LoginProcess = { + cancel: (ctx?: BridgeRequestContext) => process.cancel(ctx ?? getContext()), + start: (ctx?: BridgeRequestContext) => process.start(ctx ?? getContext()), + }; + + if (hasMethod(process, "startWithOverride")) { + const processWithOverride = process as LoginProcessWithOverride; + Object.assign(bound, { + startWithOverride: (ctxOrOverride?: BridgeRequestContext | UserLogin, override?: UserLogin) => { + const ctx = override ? ctxOrOverride as BridgeRequestContext | undefined : undefined; + const login = override ?? ctxOrOverride as UserLogin; + return processWithOverride.startWithOverride(ctx ?? getContext(), login); + }, + }); + } + + if (hasMethod(process, "wait")) { + const waitingProcess = process as LoginProcessDisplayAndWait; + Object.assign(bound, { + wait: (ctx?: BridgeRequestContext) => waitingProcess.wait(ctx ?? getContext()), + }); + } + + if (hasMethod(process, "submitUserInput")) { + const inputProcess = process as LoginProcessUserInput; + Object.assign(bound, { + submitUserInput: (ctxOrInput?: BridgeRequestContext | LoginUserInput, input?: LoginUserInput) => { + const ctx = input ? ctxOrInput as BridgeRequestContext | undefined : undefined; + const values = input ?? ctxOrInput as LoginUserInput; + return inputProcess.submitUserInput(ctx ?? getContext(), values); + }, + }); + } + + if (hasMethod(process, "submitCookies")) { + const cookieProcess = process as LoginProcessCookies; + Object.assign(bound, { + submitCookies: (ctxOrCookies?: BridgeRequestContext | LoginCookieInput, cookies?: LoginCookieInput) => { + const ctx = cookies ? ctxOrCookies as BridgeRequestContext | undefined : undefined; + const values = cookies ?? ctxOrCookies as LoginCookieInput; + return cookieProcess.submitCookies(ctx ?? getContext(), values); + }, + }); + } + + return bound; +} + +function portalKeyString(portalKey: { id: string; receiver?: string }): string { + return `${portalKey.receiver ?? ""}\u0000${portalKey.id}`; +} + +function autoJoinInvite(invite: string[] | undefined, ownerUserId: string | undefined): string[] | undefined { + if (!ownerUserId) return invite; + const members = new Set(invite ?? []); + members.add(ownerUserId); + return Array.from(members); +} + +function messagePartKey(messageId: string, partId: string): string { + return `${messageId}\u0000${partId}`; +} + +function eventIdFromRaw(body: unknown): string { + if (body && typeof body === "object" && typeof (body as { event_id?: unknown }).event_id === "string") { + return (body as { event_id: string }).event_id; + } + if (body && typeof body === "object" && typeof (body as { eventId?: unknown }).eventId === "string") { + return (body as { eventId: string }).eventId; + } + return ""; +} + +function eventTimestamp(event: RemoteEvent): number | undefined { + if ("getTimestamp" in event && typeof event.getTimestamp === "function") { + const timestamp = event.getTimestamp(); + return timestamp instanceof Date ? timestamp.getTime() : undefined; + } + return undefined; +} + +function messageCheckpointPayload(checkpoint: MessageCheckpoint): Record { + return stripUndefined({ + client_type: checkpoint.clientType, + client_version: checkpoint.clientVersion, + event_id: checkpoint.eventId, + event_type: checkpoint.eventType, + info: checkpoint.info, + manual_retry_count: checkpoint.manualRetryCount, + message_type: checkpoint.messageType, + original_event_id: checkpoint.originalEventId, + reported_by: checkpoint.reportedBy, + retry_num: checkpoint.retryNum, + room_id: checkpoint.roomId, + status: checkpoint.status, + step: checkpoint.step, + timestamp: checkpoint.timestamp instanceof Date ? checkpoint.timestamp.getTime() : checkpoint.timestamp, + }); +} + +function stringContent(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +type StripUndefined = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +} & { + [K in keyof T as undefined extends T[K] ? K : never]?: Exclude; +}; + +function stripUndefined>(value: T): StripUndefined { + for (const key of Object.keys(value)) { + if (value[key] === undefined) { + delete value[key]; + } + } + return value as StripUndefined; +} + +function domainFromUserID(userId: string): string { + const index = userId.indexOf(":"); + if (index === -1 || index === userId.length - 1) { + throw new Error(`Cannot infer homeserver domain from Matrix user ID ${userId}`); + } + return userId.slice(index + 1); +} + +function beeperAppServiceOptions(input: { + address: string | undefined; + baseDomain: string | undefined; + bridge: string; + bridgeType: string | undefined; + getOnly: boolean | undefined; + homeserverDomain: string | undefined; + token: string; +}) { + const output = { + bridge: input.bridge, + token: input.token, + } as Parameters[0]; + if (input.address !== undefined) output.address = input.address; + if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; + if (input.bridgeType !== undefined) output.bridgeType = input.bridgeType; + if (input.getOnly !== undefined) output.getOnly = input.getOnly; + if (input.homeserverDomain !== undefined) output.homeserverDomain = input.homeserverDomain; + return output; +} + +function jsonHTTPResponse(status: number, body: unknown): HTTPProxyResponse { + return { + body, + headers: { "content-type": ["application/json"] }, + status, + }; +} + +function matrixError(errcode: string, error: string): Record { + return { errcode, error }; +} + +function loginStepResponse(loginId: string, step: LoginStep): Record { + return { + login_id: loginId, + ...loginStepJSON(step), + }; +} + +function loginStepText(step: LoginStep): string { + const lines = [`Step ${step.stepId} (${step.type})`]; + if (step.instructions) lines.push(step.instructions); + return lines.join("\n"); +} + +function loginStepJSON(step: LoginStep): Record { + return stripUndefined({ + complete: step.complete ? stripUndefined({ + user_login_id: step.complete.userLoginId, + }) : undefined, + cookies: step.cookies ? stripUndefined({ + extract_js: step.cookies.extractJs, + fields: step.cookies.fields.map((field) => stripUndefined({ + id: field.id, + pattern: field.pattern, + required: field.required, + sources: field.sources.map((source) => stripUndefined({ + cookie_domain: source.cookieDomain, + name: source.name, + request_url_regex: source.requestUrlRegex, + type: source.type, + })), + })), + url: step.cookies.url, + user_agent: step.cookies.userAgent, + wait_for_url_pattern: step.cookies.waitForUrlPattern, + }) : undefined, + display_and_wait: step.displayAndWait ? stripUndefined({ + data: step.displayAndWait.data, + image_url: step.displayAndWait.imageUrl, + type: step.displayAndWait.type, + }) : undefined, + instructions: step.instructions, + step_id: step.stepId, + type: step.type, + user_input: step.userInput ? { + fields: step.userInput.fields.map((field) => stripUndefined({ + default_value: field.defaultValue, + description: field.description, + id: field.id, + name: field.name, + options: field.options, + pattern: field.pattern, + type: field.type, + })), + } : undefined, + }); +} + +function stringMap(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")); +} + +function randomID(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function convertedMessageFromOptions(options: BridgeRemoteMessageOptions): ConvertedMessage { + if (options.parts) return { parts: options.parts }; + if (options.content) return { parts: [{ content: options.content, type: "m.room.message" }] }; + if (options.text !== undefined) { + return { + parts: [{ + content: { + body: options.text, + msgtype: "m.text", + }, + type: "m.room.message", + }], + }; + } + throw new Error("queueMessage requires text, content, parts, or convert"); +} diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts new file mode 100644 index 0000000..bf8e55c --- /dev/null +++ b/packages/bridge/src/events.ts @@ -0,0 +1,51 @@ +import type { + BridgeRequestContext, + ConvertedMessage, + CreateRemoteMessageOptions, + MatrixIntent, + MessageID, + Portal, + RemoteEventType, + RemoteMessage, + RemoteMessageWithTransactionID, +} from "./types"; + +export function createRemoteMessage(options: CreateRemoteMessageOptions): RemoteMessage | RemoteMessageWithTransactionID { + const timestamp = options.timestamp ?? new Date(); + const streamOrder = options.streamOrder ?? timestamp.getTime(); + const event = { + convertMessage(ctx: BridgeRequestContext, portal: Portal, intent: MatrixIntent): Promise { + return Promise.resolve(options.convert(ctx, portal, intent, options.data)); + }, + getID(): MessageID { + return options.id; + }, + getPortalKey() { + return options.portalKey; + }, + getSender() { + return options.sender; + }, + getStreamOrder() { + return streamOrder; + }, + getTimestamp() { + return timestamp; + }, + getType(): RemoteEventType { + return options.type ?? "message"; + }, + shouldCreatePortal() { + return options.createPortal ?? false; + }, + }; + + const transactionId = options.transactionId; + if (!transactionId) return event; + return { + ...event, + getTransactionID(): string { + return transactionId; + }, + }; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 0000000..e8cb518 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,40 @@ +import { createMatrixClient } from "@beeper/pickle/node"; +import { createFileMatrixStore } from "@beeper/pickle-state-file"; +import { resolve } from "node:path"; +import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; +import { createBridgeDataStore } from "./store"; +import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; + +export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; +export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; +export { createRemoteMessage } from "./events"; +export type * from "./beeper"; +export type * from "./store"; +export type * from "./types"; +export { RuntimeBridge } from "./bridge"; + +export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { + return new RuntimeBridge(options, createMatrixClient(options.matrix)); +} + +export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { + const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + const matrix = { + ...options.matrix, + store, + }; + return createBeeperBridgeWithClient({ + ...options, + dataStore: options.dataStore ?? createBridgeDataStore(store), + matrix, + }, createMatrixClient({ + ...matrix, + account: options.account, + homeserver: matrix.homeserver ?? options.account.homeserver, + token: matrix.token ?? options.account.accessToken, + })); +} + +function defaultDataDir(options: { bridge: string; dataDir?: string }): string { + return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); +} diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts new file mode 100644 index 0000000..370a7a7 --- /dev/null +++ b/packages/bridge/src/node.ts @@ -0,0 +1,40 @@ +import { createMatrixClient } from "@beeper/pickle/node"; +import { createFileMatrixStore } from "@beeper/pickle-state-file"; +import { resolve } from "node:path"; +import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; +import { createBridgeDataStore } from "./store"; +import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; + +export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; +export { createRemoteMessage } from "./events"; +export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; +export type * from "./beeper"; +export type * from "./store"; +export type * from "./types"; +export { RuntimeBridge } from "./bridge"; + +export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { + return new RuntimeBridge(options, createMatrixClient(options.matrix)); +} + +export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { + const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + const matrix = { + ...options.matrix, + store, + }; + return createBeeperBridgeWithClient({ + ...options, + dataStore: options.dataStore ?? createBridgeDataStore(store), + matrix, + }, createMatrixClient({ + ...matrix, + account: options.account, + homeserver: matrix.homeserver ?? options.account.homeserver, + token: matrix.token ?? options.account.accessToken, + })); +} + +function defaultDataDir(options: { bridge: string; dataDir?: string }): string { + return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); +} diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts new file mode 100644 index 0000000..6dd13d6 --- /dev/null +++ b/packages/bridge/src/store.ts @@ -0,0 +1,164 @@ +import type { MatrixStore, MatrixAccount, SentEvent } from "@beeper/pickle"; +import type { BridgeState, BridgeStatus, Ghost, ManagementRoom, MessageRequest, Portal, UserLogin } from "./types"; + +export interface BridgeDataStore { + deletePortal(portalKey: string): Promise; + getAccount(key: string): Promise; + getBridgeState(): Promise; + getBridgeStatus(): Promise; + getGhost(id: string): Promise; + getMessage(key: string): Promise; + getMessageRequest(portalKey: string): Promise; + getPortal(portalKey: string): Promise; + getPortalByMXID(mxid: string): Promise; + getUserLogin(id: string): Promise; + listGhosts(): Promise; + listPortals(): Promise; + listUserLogins(): Promise; + setAccount(key: string, account: MatrixAccount): Promise; + setBridgeState(state: BridgeState): Promise; + setBridgeStatus(status: BridgeStatus): Promise; + setGhost(ghost: Ghost): Promise; + setMessage(key: string, message: SentEvent): Promise; + setMessageRequest(request: MessageRequest): Promise; + setManagementRoom(room: ManagementRoom): Promise; + setPortal(portal: Portal): Promise; + setUserLogin(login: UserLogin): Promise; +} + +export class MatrixBridgeDataStore implements BridgeDataStore { + #store: MatrixStore; + + constructor(store: MatrixStore) { + this.#store = store; + } + + async deletePortal(portalKey: string): Promise { + const portal = await this.getPortal(portalKey); + await this.#store.delete(key("portal", portalKey)); + if (portal?.mxid) await this.#store.delete(key("portal-mxid", portal.mxid)); + } + + getAccount(accountKey: string): Promise { + return this.#get(key("account", accountKey)); + } + + getBridgeState(): Promise { + return this.#get(key("bridge-state", "current")); + } + + getBridgeStatus(): Promise { + return this.#get(key("bridge-status", "current")); + } + + getGhost(id: string): Promise { + return this.#get(key("ghost", id)); + } + + getMessage(messageKey: string): Promise { + return this.#get(key("message", messageKey)); + } + + getMessageRequest(portalKey: string): Promise { + return this.#get(key("message-request", portalKey)); + } + + getPortal(portalKey: string): Promise { + return this.#get(key("portal", portalKey)); + } + + async getPortalByMXID(mxid: string): Promise { + const portalKey = await this.#get(key("portal-mxid", mxid)); + return portalKey ? this.getPortal(portalKey) : null; + } + + getUserLogin(id: string): Promise { + return this.#get(key("user-login", id)); + } + + async listGhosts(): Promise { + const keys = await this.#store.list("pickle-bridge:ghost:"); + const ghosts = await Promise.all(keys.map((item) => this.#get(item))); + return ghosts.filter((item): item is Ghost => item !== null); + } + + async listPortals(): Promise { + const keys = await this.#store.list("pickle-bridge:portal:"); + const portals = await Promise.all(keys.map((item) => this.#get(item))); + return portals.filter((item): item is Portal => item !== null); + } + + async listUserLogins(): Promise { + const keys = await this.#store.list("pickle-bridge:user-login:"); + const logins = await Promise.all(keys.map((item) => this.#get(item))); + return logins.filter((item): item is UserLogin => item !== null); + } + + setAccount(accountKey: string, account: MatrixAccount): Promise { + return this.#set(key("account", accountKey), account); + } + + setBridgeState(state: BridgeState): Promise { + return this.#set(key("bridge-state", "current"), state); + } + + setBridgeStatus(status: BridgeStatus): Promise { + return this.#set(key("bridge-status", "current"), status); + } + + setGhost(ghost: Ghost): Promise { + return this.#set(key("ghost", ghost.id), ghost); + } + + setMessage(messageKey: string, message: SentEvent): Promise { + return this.#set(key("message", messageKey), message); + } + + setMessageRequest(request: MessageRequest): Promise { + return this.#set(key("message-request", portalStoreKey(request)), request); + } + + setManagementRoom(room: ManagementRoom): Promise { + return this.#set(key("management-room", room.mxid), room); + } + + async setPortal(portal: Portal): Promise { + const portalKey = portalStoreKey(portal); + const existing = await this.getPortal(portalKey); + await this.#set(key("portal", portalKey), portal); + if (existing?.mxid && existing.mxid !== portal.mxid) { + await this.#store.delete(key("portal-mxid", existing.mxid)); + } + if (portal.mxid) await this.#set(key("portal-mxid", portal.mxid), portalKey); + } + + setUserLogin(login: UserLogin): Promise { + return this.#set(key("user-login", login.id), serializableLogin(login)); + } + + async #get(storageKey: string): Promise { + const raw = await this.#store.get(storageKey); + return raw ? JSON.parse(new TextDecoder().decode(raw)) as T : null; + } + + async #set(storageKey: string, value: unknown): Promise { + await this.#store.set(storageKey, new TextEncoder().encode(JSON.stringify(value))); + } +} + +export function createBridgeDataStore(store: MatrixStore): BridgeDataStore { + return new MatrixBridgeDataStore(store); +} + +export function portalStoreKey(portal: Pick): string { + return `${portal.portalKey.receiver ?? ""}\u0000${portal.portalKey.id}`; +} + +function key(kind: string, id: string): string { + return `pickle-bridge:${kind}:${id}`; +} + +function serializableLogin(login: UserLogin): UserLogin { + const { client: _client, ...rest } = login; + return rest; +} diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts new file mode 100644 index 0000000..fa21826 --- /dev/null +++ b/packages/bridge/src/types.ts @@ -0,0 +1,1205 @@ +import type { + MatrixAttachment, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceInitOptions, + MatrixAppserviceSendMessageOptions, + MatrixAccount, + MatrixClient, + MatrixClientOptions, + MatrixEventSender, + MatrixMessageEvent, + MatrixReactionEvent, + MatrixStore, + SendMediaMessageOptions, + SentEvent, + UploadMediaOptions, + UploadMediaResult, + UserInfo as MatrixUserInfo, +} from "@beeper/pickle"; +import type { BridgeDataStore } from "./store"; + +export type BridgeID = string; +export type UserID = string; +export type UserLoginID = string; +export type PortalID = string; +export type GhostID = string; +export type MessageID = string; +export type PartID = string; +export type ReactionID = string; +export type AvatarID = string; +export type MediaID = string; +export type PaginationCursor = string; +export type TransactionID = string; +export type RawTransactionID = string; +export type RoomID = string; +export type EventID = string; + +export interface PortalKey { + id: PortalID; + receiver?: UserLoginID; +} + +export type PortalReference = PortalID | PortalKey | Portal; + +export interface BridgeName { + beeperBridgeType?: string; + defaultCommandPrefix?: string; + defaultPort?: number; + displayName: string; + networkIcon?: string; + networkId: string; + networkUrl?: string; +} + +export interface BridgeInfoContent { + [key: string]: unknown; +} + +export interface BridgeConfigPart { + data?: T; + example?: string; + upgrade?: (data: unknown) => T; +} + +export interface DBMetaTypes { + ghost?: () => unknown; + message?: () => unknown; + portal?: () => unknown; + reaction?: () => unknown; + userLogin?: () => unknown; +} + +export interface BridgeConnector { + createLogin(ctx: LoginCreateContext, user: BridgeUser, flowId: string): LoginProcess | Promise; + getBridgeInfoVersion(): BridgeInfoVersion; + getCapabilities(): NetworkGeneralCapabilities; + getConfig(): BridgeConfigPart; + getDBMetaTypes(): DBMetaTypes; + getLoginFlows(): LoginFlow[]; + getName(): BridgeName; + init(ctx: BridgeContext): void | Promise; + loadUserLogin(ctx: LoadUserLoginContext, login: UserLogin): Promise | NetworkAPI; + start(ctx: BridgeStartContext): Promise | void; +} + +export interface CommandHandlingBridgeConnector extends BridgeConnector { + handleCommand(ctx: BridgeRequestContext, command: MatrixCommand): Promise | MatrixCommandResponse; +} + +export interface StoppableNetwork extends BridgeConnector { + stop(): Promise | void; +} + +export interface DirectMediableNetwork extends BridgeConnector { + download(ctx: BridgeRequestContext, mediaId: MediaID, params: Record): Promise; + setUseDirectMedia(): void; +} + +export interface IdentifierValidatingNetwork extends BridgeConnector { + validateUserID(id: UserID): boolean; +} + +export interface TransactionIDGeneratingNetwork extends BridgeConnector { + generateTransactionID(userId: string, roomId: string, eventType: string): RawTransactionID; +} + +export interface PortalBridgeInfoFillingNetwork extends BridgeConnector { + fillPortalBridgeInfo(portal: Portal, content: BridgeInfoContent): void; +} + +export interface ConfigValidatingNetwork extends BridgeConnector { + validateConfig(): Promise | void; +} + +export interface MaxFileSizingNetwork extends BridgeConnector { + setMaxFileSize(maxSize: number): void; +} + +export interface NetworkResettingNetwork extends BridgeConnector { + resetHTTPTransport(): void; + resetNetworkConnections(): void; +} + +export interface PushParsingNetwork extends BridgeConnector { + parsePushNotification(ctx: BridgeRequestContext, data: unknown): Promise; +} + +export interface NetworkAPI { + connect(ctx: ConnectContext): Promise | void; + disconnect(): Promise | void; + getCapabilities?(ctx: BridgeRequestContext, portal: Portal): RoomFeatures; +} + +export interface PushableNetworkAPI extends NetworkAPI { + getPushConfigs(): PushConfig; + registerPushNotifications(ctx: BridgeRequestContext, pushType: PushType, token: string): Promise; +} + +export interface BackgroundSyncingNetworkAPI extends NetworkAPI { + backgroundSync(ctx: BridgeRequestContext, params?: unknown): Promise; +} + +export interface ChatViewingNetworkAPI extends NetworkAPI { + markChatViewed(ctx: BridgeRequestContext, portal: Portal, message?: Message): Promise; +} + +export interface BackfillingNetworkAPI extends NetworkAPI { + fetchMessages(ctx: BridgeRequestContext, params: FetchMessagesParams): Promise; +} + +export interface IdentifierResolvingNetworkAPI extends NetworkAPI { + resolveIdentifier(ctx: BridgeRequestContext, identifier: ResolveIdentifierParams): Promise; +} + +export interface MessageRequestHandlingNetworkAPI extends NetworkAPI { + handleMessageRequest(ctx: BridgeRequestContext, request: MessageRequest): Promise; +} + +export interface StickerImportingNetworkAPI extends NetworkAPI { + downloadImagePack(ctx: BridgeRequestContext, url: string): Promise; + listImagePacks(ctx: BridgeRequestContext): Promise; +} + +export interface MessageHandlingNetworkAPI extends NetworkAPI { + handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise; +} + +export interface EditHandlingNetworkAPI extends NetworkAPI { + handleMatrixEdit(ctx: BridgeRequestContext, msg: MatrixEdit): Promise; +} + +export interface ReactionHandlingNetworkAPI extends NetworkAPI { + handleMatrixReaction(ctx: BridgeRequestContext, msg: MatrixReaction): Promise; + preHandleMatrixReaction?(ctx: BridgeRequestContext, msg: MatrixReaction): Promise; +} + +export interface RedactionHandlingNetworkAPI extends NetworkAPI { + handleMatrixRedaction(ctx: BridgeRequestContext, msg: MatrixRedaction): Promise; +} + +export interface ReadReceiptHandlingNetworkAPI extends NetworkAPI { + handleMatrixReadReceipt(ctx: BridgeRequestContext, msg: MatrixReadReceipt): Promise; +} + +export interface TypingHandlingNetworkAPI extends NetworkAPI { + handleMatrixTyping(ctx: BridgeRequestContext, msg: MatrixTyping): Promise; +} + +export interface ReactionRemoveHandlingNetworkAPI extends NetworkAPI { + handleMatrixReactionRemove(ctx: BridgeRequestContext, msg: MatrixReactionRemove): Promise; +} + +export interface PollHandlingNetworkAPI extends NetworkAPI { + handleMatrixPollStart(ctx: BridgeRequestContext, msg: MatrixPollStart): Promise; + handleMatrixPollVote(ctx: BridgeRequestContext, msg: MatrixPollVote): Promise; +} + +export interface DisappearTimerChangingNetworkAPI extends NetworkAPI { + handleMatrixDisappearTimer(ctx: BridgeRequestContext, msg: MatrixDisappearTimer): Promise; +} + +export interface MembershipHandlingNetworkAPI extends NetworkAPI { + handleMatrixMembership(ctx: BridgeRequestContext, msg: MatrixMembership): Promise; +} + +export interface RoomNameHandlingNetworkAPI extends NetworkAPI { + handleMatrixRoomName(ctx: BridgeRequestContext, msg: MatrixRoomName): Promise; +} + +export interface RoomTopicHandlingNetworkAPI extends NetworkAPI { + handleMatrixRoomTopic(ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise; +} + +export interface RoomAvatarHandlingNetworkAPI extends NetworkAPI { + handleMatrixRoomAvatar(ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise; +} + +export interface MuteHandlingNetworkAPI extends NetworkAPI { + handleMatrixMute(ctx: BridgeRequestContext, msg: MatrixMute): Promise; +} + +export interface TagHandlingNetworkAPI extends NetworkAPI { + handleMatrixTag(ctx: BridgeRequestContext, msg: MatrixTag): Promise; +} + +export interface MarkedUnreadHandlingNetworkAPI extends NetworkAPI { + handleMatrixMarkedUnread(ctx: BridgeRequestContext, msg: MatrixMarkedUnread): Promise; +} + +export interface DeleteChatHandlingNetworkAPI extends NetworkAPI { + handleMatrixDeleteChat(ctx: BridgeRequestContext, msg: MatrixDeleteChat): Promise; +} + +export type MatrixOutboundNetworkAPI = + | MessageHandlingNetworkAPI + | EditHandlingNetworkAPI + | ReactionHandlingNetworkAPI + | RedactionHandlingNetworkAPI + | ReadReceiptHandlingNetworkAPI + | TypingHandlingNetworkAPI + | ReactionRemoveHandlingNetworkAPI + | PollHandlingNetworkAPI + | DisappearTimerChangingNetworkAPI + | MembershipHandlingNetworkAPI + | RoomNameHandlingNetworkAPI + | RoomTopicHandlingNetworkAPI + | RoomAvatarHandlingNetworkAPI + | MuteHandlingNetworkAPI + | TagHandlingNetworkAPI + | MarkedUnreadHandlingNetworkAPI + | DeleteChatHandlingNetworkAPI; + +export interface LoginProcess { + cancel(ctx?: BridgeRequestContext): Promise | void; + start(ctx?: BridgeRequestContext): Promise; +} + +export interface LoginProcessWithOverride extends LoginProcess { + startWithOverride(override: UserLogin): Promise; + startWithOverride(ctx: BridgeRequestContext | undefined, override: UserLogin): Promise; +} + +export interface LoginProcessDisplayAndWait extends LoginProcess { + wait(ctx?: BridgeRequestContext): Promise; +} + +export interface LoginProcessUserInput extends LoginProcess { + submitUserInput(input: LoginUserInput): Promise; + submitUserInput(ctx: BridgeRequestContext | undefined, input: LoginUserInput): Promise; +} + +export interface LoginProcessCookies extends LoginProcess { + submitCookies(cookies: LoginCookieInput): Promise; + submitCookies(ctx: BridgeRequestContext | undefined, cookies: LoginCookieInput): Promise; +} + +export interface LoginFlow { + description: string; + id: string; + name: string; +} + +export type LoginStepType = "user_input" | "cookies" | "display_and_wait" | "complete"; +export type LoginDisplayType = "qr" | "emoji" | "code" | "nothing"; +export type LoginUserInput = Record; +export type LoginCookieInput = Record; +export type LoginInputFieldType = + | "username" + | "password" + | "phone_number" + | "email" + | "2fa_code" + | "token" + | "url" + | "domain" + | "select" + | "captcha_code"; + +export interface LoginStep { + complete?: LoginCompleteParams; + cookies?: LoginCookiesParams; + displayAndWait?: LoginDisplayAndWaitParams; + instructions: string; + stepId: string; + type: LoginStepType; + userInput?: LoginUserInputParams; +} + +export interface LoginDisplayAndWaitParams { + data?: string; + imageUrl?: string; + type: LoginDisplayType; +} + +export type LoginCookieFieldSourceType = "cookie" | "local_storage" | "request_header" | "request_body" | "special"; + +export interface LoginCookieFieldSource { + cookieDomain?: string; + name: string; + requestUrlRegex?: string; + type: LoginCookieFieldSourceType; +} + +export interface LoginCookieField { + id: string; + pattern?: string; + required: boolean; + sources: LoginCookieFieldSource[]; +} + +export interface LoginCookiesParams { + extractJs?: string; + fields: LoginCookieField[]; + url: string; + userAgent?: string; + waitForUrlPattern?: string; +} + +export interface LoginInputDataField { + defaultValue?: string; + description: string; + id: string; + name: string; + options?: string[]; + pattern?: string; + type: LoginInputFieldType; + validate?: (value: string) => string | Promise; +} + +export interface LoginUserInputParams { + fields: LoginInputDataField[]; +} + +export interface LoginCompleteParams { + userLogin?: UserLogin; + userLoginId: UserLoginID; +} + +export type RemoteEventType = + | "unknown" + | "message" + | "message_upsert" + | "edit" + | "reaction" + | "reaction_remove" + | "reaction_sync" + | "message_remove" + | "read_receipt" + | "delivery_receipt" + | "mark_unread" + | "typing" + | "chat_info_change" + | "chat_resync" + | "chat_delete" + | "backfill"; + +export interface RemoteEvent { + addLogContext?(data: Record): Record; + getPortalKey(): PortalKey; + getSender(): EventSender; + getType(): RemoteEventType; +} + +export interface RemoteEventWithContextMutation extends RemoteEvent { + mutateContext(ctx: BridgeRequestContext): BridgeRequestContext; +} + +export interface RemoteEventWithUncertainPortalReceiver extends RemoteEvent { + portalReceiverIsUncertain(): boolean; +} + +export interface RemotePreHandler extends RemoteEvent { + preHandle(ctx: BridgeRequestContext, portal: Portal): Promise | void; +} + +export interface RemotePostHandler extends RemoteEvent { + postHandle(ctx: BridgeRequestContext, portal: Portal): Promise | void; +} + +export interface RemoteChatInfoChange extends RemoteEvent { + getChatInfoChange(ctx: BridgeRequestContext): Promise; +} + +export interface RemoteChatResync extends RemoteEvent {} + +export interface RemoteChatResyncWithInfo extends RemoteChatResync { + getChatInfo(ctx: BridgeRequestContext, portal: Portal): Promise; +} + +export interface RemoteChatResyncBackfill extends RemoteChatResync { + checkNeedsBackfill(ctx: BridgeRequestContext, latestMessage?: Message): Promise; +} + +export interface RemoteChatResyncBackfillBundle extends RemoteChatResyncBackfill { + getBundledBackfillData(): unknown; +} + +export interface RemoteBackfill extends RemoteEvent { + getBackfillData(ctx: BridgeRequestContext, portal: Portal): Promise; +} + +export interface RemoteDeleteOnlyForMe extends RemoteEvent { + deleteOnlyForMe(): boolean; +} + +export interface RemoteChatDelete extends RemoteDeleteOnlyForMe {} + +export interface RemoteChatDeleteWithChildren extends RemoteChatDelete { + deleteChildren(): boolean; +} + +export interface RemoteEventThatMayCreatePortal extends RemoteEvent { + shouldCreatePortal(): boolean; +} + +export interface RemoteEventWithTargetMessage extends RemoteEvent { + getTargetMessage(): MessageID; +} + +export interface RemoteEventWithBundledParts extends RemoteEventWithTargetMessage { + getTargetDBMessage(): Message[]; +} + +export interface RemoteEventWithTargetPart extends RemoteEventWithTargetMessage { + getTargetMessagePart(): PartID; +} + +export interface RemoteEventWithTimestamp extends RemoteEvent { + getTimestamp(): Date; +} + +export interface RemoteEventWithStreamOrder extends RemoteEvent { + getStreamOrder(): number; +} + +export interface RemoteMessage extends RemoteEvent { + convertMessage(ctx: BridgeRequestContext, portal: Portal, intent: MatrixIntent): Promise; + getID(): MessageID; +} + +export interface RemoteMessageWithTransactionID extends RemoteMessage { + getTransactionID(): TransactionID; +} + +export interface RemoteMessageUpsert extends RemoteMessage { + handleExisting(ctx: BridgeRequestContext, portal: Portal, intent: MatrixIntent, existing: Message[]): Promise; +} + +export interface RemoteEdit extends RemoteEventWithTargetMessage { + convertEdit(ctx: BridgeRequestContext, portal: Portal, intent: MatrixIntent, existing: Message[]): Promise; +} + +export interface RemoteReaction extends RemoteEventWithTargetMessage { + getEmoji(): string; + getID(): ReactionID; +} + +export interface RemoteReactionRemove extends RemoteEventWithTargetMessage { + getEmoji?(): string; + getID?(): ReactionID; +} + +export interface RemoteMessageRemove extends RemoteEventWithTargetMessage {} + +export interface RemoteReadReceipt extends RemoteEventWithTargetMessage {} + +export interface RemoteDeliveryReceipt extends RemoteEventWithTargetMessage {} + +export interface RemoteMarkUnread extends RemoteEventWithTargetMessage { + getUnread(): boolean; +} + +export interface RemoteTyping extends RemoteEvent { + getTimeoutMs?(): number; + isTyping(): boolean; +} + +export interface PickleBridge { + readonly client: MatrixClient | null; + readonly connector: BridgeConnector; + readonly context: BridgeContext | null; + acceptMessageRequest(portalKey: PortalKey): Promise; + createLogin(user: BridgeUser, flowId: string): Promise; + createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise; + backfill(options: BridgeBackfillOptions): Promise; + backfillMessages(login: UserLogin, params: FetchMessagesParams): Promise; + backfillPortal(login: UserLogin, portal: PortalReference, params?: Omit): Promise; + queueBackfill(login: UserLogin, params: BackfillQueueParams): Promise; + createPortal(login: UserLogin, options: BridgeCreatePortalOptions): Promise; + createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise; + downloadMedia(options: DownloadMediaOptions): Promise; + flushRemoteEvents(): Promise; + getBridgeState(): BridgeState | null; + getBridgeStatus(): BridgeStatus | null; + getGhost(id: GhostID): Ghost | null; + ghostUserId(localId: string): UserID; + getMessageRequest(portalKey: PortalKey): Promise; + getOwnProfile(): Promise; + getPortal(portalKey: PortalKey): Portal | null; + getPortalByMXID(mxid: RoomID): Portal | null; + getUserInfo(userId: UserID): Promise; + loadUserLogin(login: UserLogin): Promise; + queue(login: UserLogin): RemoteEventQueue; + queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; + registerGhost(ghost: Ghost): void; + registerManagementRoom(room: ManagementRoom): void; + registerPortal(portal: Portal): void; + resolveIdentifier(login: UserLogin, identifier: ResolveIdentifierParams): Promise; + sendMedia(options: BridgeSendMediaOptions): Promise; + setBridgeState(state: BridgeState): Promise; + setBridgeStatus(status: BridgeStatus): Promise; + sendMessageCheckpoints(checkpoints: MessageCheckpoint[]): boolean; + setMessageRequest(request: MessageRequest): Promise; + setOwnProfile(profile: UserProfileUpdate): Promise; + setPortalMetadata(portalKey: PortalKey, metadata: unknown): Promise; + start(): Promise; + stop(): Promise; + uploadMedia(options: UploadMediaOptions): Promise; +} + +export interface CreateBridgeOptions { + appservice?: MatrixAppserviceInitOptions; + beeper?: BridgeBeeperOptions; + connector: BridgeConnector; + dataStore?: BridgeDataStore; + matrix: BridgeMatrixConfig; +} + +export interface BridgeBeeperOptions { + bridge: string; + bridgeType?: string; + ownerUserId?: UserID; +} + +export interface CreateBeeperBridgeOptions extends Omit { + account: MatrixAccount; + address?: string; + baseDomain?: string; + bridge: string; + bridgeType?: string; + getOnly?: boolean; + homeserverDomain?: string; + matrix?: Partial>; + store?: MatrixStore; +} + +export interface BridgeMatrixConfig extends Pick { + store: MatrixStore; +} + +export interface NodeBridgeMatrixConfig extends Omit { + wasmExecPath?: string; + wasmPath?: string; +} + +export interface CreateNodeBridgeOptions extends Omit { + matrix: NodeBridgeMatrixConfig; +} + +export interface CreateNodeBeeperBridgeOptions extends Omit { + dataDir?: string; + matrix?: Partial; +} + +export interface BridgeContext { + bridge: PickleBridge; + client: MatrixClient; + dataStore?: BridgeDataStore; + log: BridgeLogger; + queue(login: UserLogin): RemoteEventQueue; + queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; +} + +export interface BridgeStartContext extends BridgeContext {} + +export interface BridgeRequestContext extends BridgeContext { + signal?: AbortSignal; +} + +export interface LoginCreateContext extends BridgeRequestContext {} + +export interface LoadUserLoginContext extends BridgeRequestContext {} + +export interface ConnectContext extends BridgeRequestContext { + login: UserLogin; +} + +export interface BridgeLogger { + (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown): void; +} + +export interface QueueRemoteEventResult { + event: RemoteEvent; + queued: boolean; +} + +export interface RemoteEventQueue { + backfill(options: BridgeRemoteBackfillOptions): QueueRemoteEventResult; + event(event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; + message(options: BridgeRemoteMessageOptions): QueueRemoteEventResult; +} + +export interface BridgeRemoteBackfillOptions { + cursor?: PaginationCursor; + forward?: boolean; + hasMore?: boolean; + markRead?: boolean; + messages: BridgeRemoteBackfillMessageOptions[]; + portal: PortalReference; + progress?: BackfillProgress; +} + +export interface BridgeRemoteBackfillMessageOptions extends Omit, "portal"> { + portal?: PortalReference; +} + +export interface BridgeCreatePortalRoomOptions { + avatarUrl?: string; + info?: ChatInfo; + invite?: UserID[]; + messageRequest?: boolean; + metadata?: unknown; + name?: string; + portalKey: PortalKey; + roomType?: "dm" | "group_dm" | "default" | "space" | string; + topic?: string; + userId?: string; +} + +export interface BridgeCreatePortalOptions extends Omit { + id: PortalID; + sender?: GhostID; +} + +export interface BridgeBackfillOptions extends MatrixAppserviceBatchSendOptions {} + +export interface BridgeCreateManagementRoomOptions { + invite?: UserID[]; + metadata?: unknown; + name?: string; + topic?: string; + userId?: string; +} + +export interface ManagementRoom { + metadata?: unknown; + mxid: RoomID; +} + +export interface MatrixCommand { + args: string[]; + body: string; + command: string; + event: MatrixMessageEvent; + prefix: string; + room: ManagementRoom; + sender: MatrixEventSender; + text: string; +} + +export interface MatrixCommandResponse { + content?: Record; + handled: boolean; + text?: string; +} + +export type { + MatrixAppserviceInitOptions, + MatrixAppserviceSendMessageOptions, +}; + +export interface MatrixDispatchResult { + dispatched: boolean; + eventId?: EventID; + handlers: number; + kind: string; + roomId?: RoomID; +} + +export interface BridgeInfoVersion { + capabilities: number; + info: number; +} + +export interface NetworkGeneralCapabilities { + aggressiveUpdateInfo?: boolean; + disappearingMessages?: boolean; + implicitReadReceipts?: boolean; + native?: boolean; + provisioning?: ProvisioningCapabilities; +} + +export interface ProvisioningCapabilities { + groupCreation?: Record; + resolveIdentifier?: ResolveIdentifierCapabilities; +} + +export interface ResolveIdentifierCapabilities { + contactList?: boolean; + createDM?: boolean; + lookupPhone?: boolean; + lookupUsername?: boolean; +} + +export interface GroupTypeCapabilities { + name?: GroupFieldCapability; + participants?: GroupFieldCapability; + topic?: GroupFieldCapability; +} + +export interface GroupFieldCapability { + allowed: boolean; + maxLength?: number; + minLength?: number; + required?: boolean; +} + +export interface RoomFeatures { + [key: string]: unknown; + id: string; +} + +export type PushType = "fcm" | "web" | "apns"; + +export interface PushConfig { + apns?: { bundleId: string }; + fcm?: { senderId: string }; + web?: { vapidKey: string }; +} + +export interface PushNotificationParseResult { + data?: unknown; + userLoginId: UserLoginID; +} + +export interface GetMediaResponse { + body: BodyInit | Uint8Array | ArrayBuffer; + contentLength?: number; + contentType?: string; + filename?: string; +} + +export interface ImportedImagePack { + content: Record; + extra?: Record; + shortcode?: string; +} + +export interface ImagePackMetadata { + [key: string]: unknown; +} + +export interface EventSender { + isFromMe: boolean; + sender: UserID; + senderLogin?: UserLoginID; +} + +export interface BridgeUser { + id: string; + metadata?: unknown; +} + +export interface UserLogin { + client?: NetworkAPI; + id: UserLoginID; + metadata?: unknown; + remoteName?: string; + userId?: string; +} + +export interface Portal { + id: PortalID; + metadata?: unknown; + mxid?: string; + portalKey: PortalKey; + receiver?: UserLoginID; + roomType?: "dm" | "group" | "space" | string; +} + +export interface Ghost { + avatar?: Avatar; + displayName?: string; + id: GhostID; + metadata?: unknown; + mxid?: string; +} + +export type BridgeState = "starting" | "running" | "stopping" | "stopped" | "degraded" | "error"; + +export type BridgeStateEvent = + | "STARTING" + | "UNCONFIGURED" + | "RUNNING" + | "BRIDGE_UNREACHABLE" + | "CONNECTING" + | "BACKFILLING" + | "CONNECTED" + | "TRANSIENT_DISCONNECT" + | "BAD_CREDENTIALS" + | "UNKNOWN_ERROR" + | "LOGGED_OUT"; + +export interface RemoteProfile { + avatar?: Avatar; + displayName?: string; + metadata?: unknown; +} + +export interface BridgeStatePayload { + error?: string; + info?: Record; + message?: string; + reason?: string; + remote_id?: UserLoginID; + remote_name?: string; + remote_profile?: RemoteProfile; + source?: string; + state_event: BridgeStateEvent; + timestamp: number; + ttl: number; + user_action?: string; + user_id?: UserID; +} + +export interface BridgeStatus { + bridgeState?: BridgeStatePayload; + logins?: Record; + message?: string; + metadata?: unknown; + state: BridgeState; + updatedAt: Date; +} + +export interface MessageRequest { + metadata?: unknown; + portalKey: PortalKey; + requestedBy?: UserID; + status: "pending" | "accepted" | "rejected"; + updatedAt: Date; +} + +export interface ResolveIdentifierParams { + createDM?: boolean; + identifier: string; + type?: "phone" | "username" | "mxid" | "email"; +} + +export interface ResolveIdentifierResponse { + ghost?: Ghost; + metadata?: unknown; + portal?: Portal; + userId?: UserID; +} + +export interface UserProfile { + avatarUrl?: string; + displayName?: string; +} + +export interface UserProfileUpdate extends UserProfile {} + +export interface DownloadMediaOptions { + contentUri: string; + params?: Record; +} + +export interface DownloadMediaResult extends GetMediaResponse { + bytes?: Uint8Array; +} + +export interface BridgeSendMediaOptions extends SendMediaMessageOptions {} + +export interface Message { + id: MessageID; + metadata?: unknown; + mxid?: string; + partId?: PartID; + senderId?: UserID; + timestamp?: Date; +} + +export interface Reaction { + id?: ReactionID; + metadata?: unknown; + mxid?: string; +} + +export interface ConvertedMessage { + disappearingTimer?: number; + parts: ConvertedMessagePart[]; +} + +export interface ConvertedMessagePart { + content: Record; + extra?: Record; + id?: PartID; + type: string; +} + +export interface ConvertedEdit { + modifiedParts: ConvertedMessagePart[]; +} + +export interface UpsertResult { + deleteExisting?: boolean; + handled?: boolean; +} + +export interface MatrixIntent { + client: MatrixClient; + sendMessage(roomId: RoomID, content: Record): Promise; +} + +export interface CreateRemoteMessageOptions { + convert(ctx: BridgeRequestContext, portal: Portal, intent: MatrixIntent, data: T): Promise | ConvertedMessage; + createPortal?: boolean; + data: T; + id: MessageID; + portalKey: PortalKey; + sender: EventSender; + streamOrder?: number; + timestamp?: Date; + transactionId?: TransactionID; + type?: "message" | "message_upsert"; +} + +export interface BridgeRemoteEventOptions { + event: Omit & Record; + portal: PortalReference; + sender?: GhostID | EventSender; +} + +export interface BridgeRemoteMessageOptions extends Omit>, "portalKey" | "sender"> { + content?: Record; + data?: T; + id: MessageID; + parts?: ConvertedMessagePart[]; + portal: PortalReference; + sender: GhostID | EventSender; + text?: string; +} + +export interface MatrixMessageResponse { + db?: Message; + pending?: boolean; + postSave?: (ctx: BridgeRequestContext, message: Message) => Promise | void; + removePending?: TransactionID; + streamOrder?: number; +} + +export interface MatrixReactionPreResponse { + emoji: string; + maxReactions?: number; + senderId?: UserID; +} + +export interface MatrixMessage { + attachments: MatrixAttachment[]; + content: Record; + event: MatrixMessageEvent; + inputTransactionId?: TransactionID; + portal: Portal; + replyTo?: Message; + sender: MatrixEventSender; + text: string; + threadRoot?: Message; +} + +export interface MatrixEdit extends MatrixMessage { + existing: Message[]; + targetMessage: Message; +} + +export interface MatrixReaction { + content: MatrixReactionEvent["content"]; + event: MatrixReactionEvent; + inputTransactionId?: TransactionID; + portal: Portal; + preHandleResp?: MatrixReactionPreResponse; + targetMessage: Message; +} + +export interface MatrixReactionRemove extends MatrixReaction { + targetReaction: Reaction; +} + +export interface MatrixRedaction { + eventId: EventID; + portal: Portal; + targetMessage?: Message; +} + +export interface MatrixReadReceipt { + portal: Portal; + targetMessage: Message; +} + +export interface MatrixTyping { + portal: Portal; + timeoutMs?: number; + typing: boolean; + userId: string; +} + +export interface MatrixPollStart extends MatrixMessage {} + +export interface MatrixPollVote extends MatrixMessage { + voteTo: Message; +} + +export interface MatrixDisappearTimer { + portal: Portal; + timerSeconds?: number; +} + +export interface MatrixMembership { + action: "invite" | "revoke_invite" | "leave" | "ban" | "kick"; + portal: Portal; + userId: string; +} + +export interface MatrixRoomName { + name?: string; + portal: Portal; +} + +export interface MatrixRoomTopic { + portal: Portal; + topic?: string; +} + +export interface MatrixRoomAvatar { + avatarUrl?: string; + portal: Portal; +} + +export interface MatrixMute { + muted: boolean; + portal: Portal; +} + +export interface MatrixTag { + portal: Portal; + tag: string; + tagged: boolean; +} + +export interface MatrixMarkedUnread { + portal: Portal; + unread: boolean; +} + +export interface MatrixDeleteChat { + onlyForMe?: boolean; + portal: Portal; +} + +export type MessageCheckpointReportedBy = "ASMUX" | "BRIDGE" | "HUNGRYSERV"; +export type MessageCheckpointStatus = + | "SUCCESS" + | "WILL_RETRY" + | "PERM_FAILURE" + | "UNSUPPORTED" + | "TIMEOUT" + | "DELIVERED" + | "DELIVERY_FAILED"; +export type MessageCheckpointStep = + | "CLIENT" + | "HOMESERVER" + | "BRIDGE" + | "DECRYPTED" + | "REMOTE" + | "COMMAND"; + +export interface MessageCheckpoint { + clientType?: string; + clientVersion?: string; + eventId: EventID; + eventType: string; + info?: string; + manualRetryCount?: number; + messageType?: string; + originalEventId?: EventID; + reportedBy: MessageCheckpointReportedBy; + retryNum: number; + roomId: RoomID; + status: MessageCheckpointStatus; + step: MessageCheckpointStep; + timestamp: Date | number; +} + +export interface MessageCheckpoints { + checkpoints: MessageCheckpoint[]; +} + +export interface ChatInfo { + avatar?: Avatar; + name?: string; + participants?: UserID[]; + topic?: string; +} + +export interface ChatInfoChange { + avatar?: Avatar; + name?: string; + participantsAdded?: UserID[]; + participantsRemoved?: UserID[]; + topic?: string; +} + +export interface Avatar { + id?: AvatarID; + mxc?: string; + remove?: boolean; + url?: string; +} + +export interface FetchMessagesParams { + anchorMessage?: Message; + bundledData?: unknown; + count?: number; + cursor?: PaginationCursor; + forward?: boolean; + limit?: number; + portal: Portal; + task?: BackfillQueueTask; + threadRoot?: MessageID; +} + +export interface FetchMessagesResponse { + cursor?: PaginationCursor; + forward?: boolean; + hasMore?: boolean; + markRead?: boolean; + messages: BackfillMessage[]; + progress?: BackfillProgress; +} + +export interface BackfillMessage { + event: RemoteMessage; + reactions?: BackfillReaction[]; +} + +export interface BackfillReaction { + event: RemoteReaction; +} + +export interface BackfillProgress { + approximate?: number; + remainingCount?: number; + totalCount?: number; +} + +export interface BackfillQueueTask { + batchCount?: number; + bridgeId?: BridgeID; + completedAt?: Date; + cursor?: PaginationCursor; + dispatchedAt?: Date; + done?: boolean; + nextDispatchAt?: Date; + oldestMessageId?: MessageID; + pending?: boolean; + portalKey: PortalKey; + userLoginId: UserLoginID; +} + +export interface BackfillQueueParams extends FetchMessagesParams { + login?: UserLogin; + markRead?: boolean; + pending?: boolean; + progress?: BackfillProgress; +} + +export interface BackfillQueueResult { + cursor?: PaginationCursor; + forward?: boolean; + hasMore?: boolean; + markRead?: boolean; + pending?: boolean; + progress?: BackfillProgress; + queued: boolean; + task?: BackfillQueueTask; +} diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json new file mode 100644 index 0000000..39b47ed --- /dev/null +++ b/packages/bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts new file mode 100644 index 0000000..a88cc87 --- /dev/null +++ b/packages/bridge/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts", "src/appservice-websocket.ts"], + format: ["esm"], + dts: { + sourcemap: false, + }, + clean: true, + sourcemap: false, + outExtensions: () => ({ + js: ".js", + dts: ".d.ts", + }), + deps: { + neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], + }, + target: false, +}); diff --git a/packages/bridge/vitest.config.ts b/packages/bridge/vitest.config.ts new file mode 100644 index 0000000..092149c --- /dev/null +++ b/packages/bridge/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: { + "@beeper/pickle/auth": new URL("../pickle/src/auth.ts", import.meta.url).pathname, + "@beeper/pickle/node": new URL("../pickle/src/node.ts", import.meta.url).pathname, + "@beeper/pickle": new URL("../pickle/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + coverage: { + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json-summary"], + }, + environment: "node", + }, +}); diff --git a/packages/chat-adapter/package.json b/packages/chat-adapter/package.json index 8424987..f32ca03 100644 --- a/packages/chat-adapter/package.json +++ b/packages/chat-adapter/package.json @@ -45,7 +45,7 @@ "chat": "^4.0.0" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "chat": "^4.26.0", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 89cd172..7e9eb32 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -40,7 +40,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "tsdown": "^0.21.10", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go new file mode 100644 index 0000000..380d609 --- /dev/null +++ b/packages/pickle/native/internal/core/appservice.go @@ -0,0 +1,616 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type matrixAppservice struct { + appToken string + botUserID id.UserID + host RuntimeHost + homeserver string + homeserverDomain string + stateStore mautrix.StateStore +} + +type MatrixAppserviceNamespace struct { + Exclusive bool `json:"exclusive"` + Regex string `json:"regex"` +} + +type MatrixAppserviceNamespaces struct { + Aliases []MatrixAppserviceNamespace `json:"aliases,omitempty"` + Rooms []MatrixAppserviceNamespace `json:"rooms,omitempty"` + Users []MatrixAppserviceNamespace `json:"users,omitempty"` +} + +type MatrixAppserviceRegistration struct { + AppToken string `json:"asToken"` + EphemeralEvents bool `json:"ephemeralEvents,omitempty"` + HSToken string `json:"hsToken"` + ID string `json:"id"` + MSC3202 bool `json:"msc3202,omitempty"` + MSC4190 bool `json:"msc4190,omitempty"` + Namespaces MatrixAppserviceNamespaces `json:"namespaces"` + Protocols []string `json:"protocols,omitempty"` + RateLimited *bool `json:"rateLimited,omitempty"` + SenderLocalpart string `json:"senderLocalpart"` + URL string `json:"url"` +} + +type MatrixAppserviceInitOptions struct { + Homeserver string `json:"homeserver"` + HomeserverDomain string `json:"homeserverDomain,omitempty"` + Registration MatrixAppserviceRegistration `json:"registration"` +} + +type MatrixAppserviceInfo struct { + BotUserID string `json:"botUserId"` + ID string `json:"id"` +} + +type MatrixAppserviceUserOptions struct { + UserID string `json:"userId"` +} + +type MatrixAppserviceRoomUserOptions struct { + RoomID string `json:"roomId"` + UserID string `json:"userId"` +} + +type MatrixAppserviceCreateRoomOptions struct { + MatrixCreateRoomOptions + UserID string `json:"userId,omitempty"` +} + +type MatrixAppservicePortalKey struct { + ID string `json:"id"` + Receiver string `json:"receiver,omitempty"` +} + +type MatrixAppserviceBridgeName struct { + BeeperBridgeType string `json:"beeperBridgeType,omitempty"` + DefaultCommandPrefix string `json:"defaultCommandPrefix,omitempty"` + DefaultPort int `json:"defaultPort,omitempty"` + DisplayName string `json:"displayName"` + NetworkIcon string `json:"networkIcon,omitempty"` + NetworkID string `json:"networkId"` + NetworkURL string `json:"networkUrl,omitempty"` +} + +type MatrixAppserviceCreatePortalRoomOptions struct { + AvatarURL string `json:"avatarUrl,omitempty"` + AutoJoinInvites bool `json:"autoJoinInvites,omitempty"` + Bridge MatrixAppserviceBridgeName `json:"bridge"` + BridgeName string `json:"bridgeName,omitempty"` + InitialMembers []string `json:"initialMembers,omitempty"` + Invite []string `json:"invite,omitempty"` + IsDirect bool `json:"isDirect,omitempty"` + MessageRequest bool `json:"messageRequest,omitempty"` + Name string `json:"name,omitempty"` + PortalKey MatrixAppservicePortalKey `json:"portalKey"` + RoomType string `json:"roomType,omitempty"` + Topic string `json:"topic,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixAppserviceCreateManagementRoomOptions struct { + AutoJoinInvites bool `json:"autoJoinInvites,omitempty"` + InitialMembers []string `json:"initialMembers,omitempty"` + Invite []string `json:"invite,omitempty"` + Name string `json:"name,omitempty"` + Topic string `json:"topic,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixAppserviceSendMessageOptions struct { + Content OutboundEvent `json:"content" tstype:"{ [key: string]: unknown }"` + EventType string `json:"eventType,omitempty"` + RoomID string `json:"roomId"` + Timestamp int64 `json:"timestamp,omitempty"` + TransactionID string `json:"transactionId,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixAppserviceBatchEvent struct { + Content OutboundEvent `json:"content" tstype:"{ [key: string]: unknown }"` + EventID string `json:"eventId,omitempty"` + EventType string `json:"eventType,omitempty"` + RoomID string `json:"roomId,omitempty"` + Sender string `json:"sender"` + StateKey *string `json:"stateKey,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +type MatrixAppserviceBatchSendOptions struct { + Events []MatrixAppserviceBatchEvent `json:"events"` + Forward bool `json:"forward,omitempty"` + ForwardIfNoMessages bool `json:"forwardIfNoMessages,omitempty"` + MarkReadBy string `json:"markReadBy,omitempty"` + RoomID string `json:"roomId"` + SendNotification bool `json:"sendNotification,omitempty"` +} + +type MatrixAppserviceBatchSendResult struct { + EventIDs []string `json:"eventIds"` + Raw any `json:"raw"` +} + +func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceInitOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.Homeserver == "" { + return nil, errors.New("homeserver is required") + } + if req.Registration.AppToken == "" || req.Registration.SenderLocalpart == "" || req.Registration.ID == "" { + return nil, errors.New("registration id, asToken and senderLocalpart are required") + } + homeserverDomain := req.HomeserverDomain + if homeserverDomain == "" { + var err error + homeserverDomain, err = homeserverDomainFromURL(req.Homeserver) + if err != nil { + return nil, err + } + } + as := &matrixAppservice{ + appToken: req.Registration.AppToken, + botUserID: id.NewUserID(req.Registration.SenderLocalpart, homeserverDomain), + host: c.host, + homeserver: req.Homeserver, + homeserverDomain: homeserverDomain, + stateStore: mautrix.NewMemoryStateStore(), + } + c.appservice = as + return json.Marshal(MatrixAppserviceInfo{BotUserID: as.botUserID.String(), ID: req.Registration.ID}) +} + +func (c *Core) handleAppserviceEnsureRegistered(ctx context.Context, payload []byte) ([]byte, error) { + intent, _, err := c.appserviceIntent(payload) + if err != nil { + return nil, err + } + return c.emptyIfNil(c.appservice.ensureRegistered(ctx, intent)) +} + +func (c *Core) handleAppserviceEnsureJoined(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceRoomUserOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + return c.emptyIfNil(c.appservice.ensureJoined(ctx, intent, id.RoomID(req.RoomID))) +} + +func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceCreateRoomOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err + } + createReq := makeCreateRoomRequest(req.MatrixCreateRoomOptions) + resp, err := intent.CreateRoom(ctx, createReq) + if err != nil { + return nil, err + } + return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) +} + +func (c *Core) handleAppserviceCreatePortalRoom(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceCreatePortalRoomOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err + } + createReq := c.appservice.makePortalCreateRoomRequest(req, intent.UserID) + resp, err := intent.CreateRoom(ctx, createReq) + if err != nil { + return nil, err + } + return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) +} + +func (c *Core) handleAppserviceCreateManagementRoom(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceCreateManagementRoomOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err + } + createReq := c.appservice.makeManagementCreateRoomRequest(req) + resp, err := intent.CreateRoom(ctx, createReq) + if err != nil { + return nil, err + } + return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) +} + +func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCreatePortalRoomOptions, _ id.UserID) *mautrix.ReqCreateRoom { + bridgeBot := as.botUserID + roomType := req.RoomType + if roomType == "" && req.IsDirect { + roomType = "dm" + } else if roomType == "" { + roomType = "default" + } + localRoomID := as.deterministicPortalRoomID(req.PortalKey) + bridgeName := req.BridgeName + if bridgeName == "" { + bridgeName = req.Bridge.NetworkID + } + createReq := &mautrix.ReqCreateRoom{ + BeeperBridgeAccountID: req.PortalKey.Receiver, + BeeperBridgeName: bridgeName, + BeeperLocalRoomID: localRoomID, + CreationContent: map[string]any{}, + InitialState: make([]*event.Event, 0, 5), + Invite: toUserIDs(req.Invite), + IsDirect: req.IsDirect, + MeowRoomID: localRoomID, + Name: req.Name, + PowerLevelOverride: defaultBridgePowerLevels(bridgeBot), + Preset: "private_chat", + Topic: req.Topic, + Visibility: "private", + } + if req.AutoJoinInvites { + createReq.BeeperAutoJoinInvites = true + createReq.BeeperInitialMembers = toUserIDs(req.InitialMembers) + createReq.Invite = appendMissingUserIDs(createReq.Invite, createReq.BeeperInitialMembers...) + } + if roomType == "space" { + createReq.CreationContent["type"] = event.RoomTypeSpace + } + bridgeInfoStateKey := bridgeName + if bridgeInfoStateKey == "" { + bridgeInfoStateKey = req.Bridge.NetworkID + } + bridgeInfo := bridgeInfoContent(req, bridgeBot, roomType) + createReq.InitialState = append(createReq.InitialState, + bridgeStateEvent(event.StateHalfShotBridge, bridgeInfoStateKey, bridgeInfo), + bridgeStateEvent(event.StateBridge, bridgeInfoStateKey, bridgeInfo), + functionalMembersStateEvent(bridgeBot), + ) + return createReq +} + +func (as *matrixAppservice) makeManagementCreateRoomRequest(req MatrixAppserviceCreateManagementRoomOptions) *mautrix.ReqCreateRoom { + createReq := &mautrix.ReqCreateRoom{ + Invite: toUserIDs(req.Invite), + IsDirect: false, + Name: req.Name, + Preset: "private_chat", + Topic: req.Topic, + Visibility: "private", + } + if req.AutoJoinInvites { + createReq.BeeperAutoJoinInvites = true + createReq.BeeperInitialMembers = toUserIDs(req.InitialMembers) + createReq.Invite = appendMissingUserIDs(createReq.Invite, createReq.BeeperInitialMembers...) + } + return createReq +} + +func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID { + return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain)) +} + +func defaultBridgePowerLevels(bridgeBot id.UserID) *event.PowerLevelsEventContent { + return &event.PowerLevelsEventContent{ + Events: map[string]int{ + event.StateBridge.Type: 100, + event.StateHalfShotBridge.Type: 100, + event.StateTombstone.Type: 100, + event.StateServerACL.Type: 100, + event.StateEncryption.Type: 100, + }, + Users: map[id.UserID]int{ + bridgeBot: 9001, + }, + } +} + +func bridgeInfoContent(req MatrixAppserviceCreatePortalRoomOptions, bridgeBot id.UserID, roomType string) event.BridgeEventContent { + bridgeType := req.Bridge.BeeperBridgeType + if bridgeType == "" { + bridgeType = req.Bridge.NetworkID + } + content := event.BridgeEventContent{ + BridgeBot: bridgeBot, + Creator: bridgeBot, + Protocol: event.BridgeInfoSection{ + ID: bridgeType, + DisplayName: req.Bridge.DisplayName, + AvatarURL: id.ContentURIString(req.Bridge.NetworkIcon), + ExternalURL: req.Bridge.NetworkURL, + }, + Channel: event.BridgeInfoSection{ + ID: req.PortalKey.ID, + DisplayName: req.Name, + AvatarURL: id.ContentURIString(req.AvatarURL), + Receiver: req.PortalKey.Receiver, + MessageRequest: req.MessageRequest, + }, + BeeperRoomTypeV2: roomType, + } + if req.IsDirect || roomType == "dm" || roomType == "group_dm" { + content.BeeperRoomType = "dm" + } + return content +} + +func bridgeStateEvent(eventType event.Type, stateKey string, content event.BridgeEventContent) *event.Event { + return &event.Event{ + Type: eventType, + StateKey: &stateKey, + Content: event.Content{Parsed: &content}, + } +} + +func functionalMembersStateEvent(bridgeBot id.UserID) *event.Event { + stateKey := "" + return &event.Event{ + Type: event.StateElementFunctionalMembers, + StateKey: &stateKey, + Content: event.Content{Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{bridgeBot}, + }}, + } +} + +func appendMissingUserIDs(input []id.UserID, userIDs ...id.UserID) []id.UserID { + for _, userID := range userIDs { + if userID == "" { + continue + } + found := false + for _, existing := range input { + if existing == userID { + found = true + break + } + } + if !found { + input = append(input, userID) + } + } + return input +} + +func (c *Core) handleAppserviceSendMessage(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceSendMessageOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureJoined(ctx, intent, id.RoomID(req.RoomID)); err != nil { + return nil, err + } + eventType := req.EventType + if eventType == "" { + eventType = event.EventMessage.Type + } + resp, err := intent.SendMessageEvent(ctx, id.RoomID(req.RoomID), event.NewEventType(eventType), req.Content, mautrix.ReqSendEvent{ + Timestamp: req.Timestamp, + TransactionID: req.TransactionID, + }) + if err != nil { + return nil, err + } + return json.Marshal(MatrixRawMessage{Raw: resp, EventID: resp.EventID.String(), RoomID: req.RoomID}) +} + +func (c *Core) handleAppserviceBatchSend(ctx context.Context, payload []byte) ([]byte, error) { + as, err := c.requireAppservice() + if err != nil { + return nil, err + } + var req MatrixAppserviceBatchSendOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + events := make([]*event.Event, 0, len(req.Events)) + for _, evt := range req.Events { + eventType := evt.EventType + if eventType == "" { + eventType = event.EventMessage.Type + } + events = append(events, &event.Event{ + Content: event.Content{Raw: evt.Content}, + ID: id.EventID(evt.EventID), + RoomID: id.RoomID(evt.RoomID), + Sender: id.UserID(evt.Sender), + StateKey: evt.StateKey, + Timestamp: evt.Timestamp, + Type: event.NewEventType(eventType), + }) + } + bot, err := as.client(as.botUserID) + if err != nil { + return nil, err + } + resp, err := bot.BeeperBatchSend(ctx, id.RoomID(req.RoomID), &mautrix.ReqBeeperBatchSend{ + Events: events, + Forward: req.Forward, + ForwardIfNoMessages: req.ForwardIfNoMessages, + MarkReadBy: id.UserID(req.MarkReadBy), + SendNotification: req.SendNotification, + }) + if err != nil { + return nil, err + } + eventIDs := make([]string, 0, len(resp.EventIDs)) + for _, eventID := range resp.EventIDs { + eventIDs = append(eventIDs, eventID.String()) + } + return json.Marshal(MatrixAppserviceBatchSendResult{EventIDs: eventIDs, Raw: resp}) +} + +func (c *Core) requireAppservice() (*matrixAppservice, error) { + if c.appservice == nil { + return nil, errors.New("appservice is not initialized") + } + return c.appservice, nil +} + +func (c *Core) requireAppserviceIntent(userID string) (*mautrix.Client, error) { + as, err := c.requireAppservice() + if err != nil { + return nil, err + } + if userID == "" { + userID = as.botUserID.String() + } + return as.client(id.UserID(userID)) +} + +func (c *Core) appserviceIntent(payload []byte) (*mautrix.Client, MatrixAppserviceUserOptions, error) { + var req MatrixAppserviceUserOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, req, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + return intent, req, err +} + +func makeCreateRoomRequest(req MatrixCreateRoomOptions) *mautrix.ReqCreateRoom { + invitees := toUserIDs(req.Invite) + initialState := make([]*event.Event, 0, len(req.InitialState)) + for _, state := range req.InitialState { + stateKey := state.StateKey + initialState = append(initialState, &event.Event{ + Type: event.NewEventType(state.Type), + StateKey: &stateKey, + Content: event.Content{Raw: state.Content}, + }) + } + return &mautrix.ReqCreateRoom{ + CreationContent: req.CreationContent, + InitialState: initialState, + Invite: invitees, + IsDirect: req.IsDirect, + Name: req.Name, + Preset: req.Preset, + RoomAliasName: req.RoomAliasName, + RoomVersion: id.RoomVersion(req.RoomVersion), + Topic: req.Topic, + Visibility: req.Visibility, + } +} + +func (as *matrixAppservice) client(userID id.UserID) (*mautrix.Client, error) { + if userID == "" { + userID = as.botUserID + } + cli, err := mautrix.NewClient(as.homeserver, userID, as.appToken) + if err != nil { + return nil, err + } + configureHTTPClient(cli, as.host) + cli.SetAppServiceUserID = true + cli.StateStore = as.stateStore + return cli, nil +} + +func (as *matrixAppservice) ensureRegistered(ctx context.Context, cli *mautrix.Client) error { + _, err := cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "register"), &mautrix.ReqRegister[any]{ + Username: appserviceLocalpart(cli.UserID), + Type: mautrix.AuthTypeAppservice, + InhibitLogin: true, + }, nil) + if err != nil && !errors.Is(err, mautrix.MUserInUse) { + return err + } + return nil +} + +func (as *matrixAppservice) ensureJoined(ctx context.Context, cli *mautrix.Client, roomID id.RoomID) error { + if cli.StateStore != nil && cli.StateStore.IsInRoom(ctx, roomID, cli.UserID) { + return nil + } + if err := as.ensureRegistered(ctx, cli); err != nil { + return err + } + resp, err := cli.JoinRoomByID(ctx, roomID) + if err != nil { + bot, botErr := as.client(as.botUserID) + if botErr != nil { + return botErr + } + if _, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}); inviteErr != nil { + return inviteErr + } + resp, err = cli.JoinRoomByID(ctx, roomID) + if err != nil { + return err + } + } + if cli.StateStore != nil { + return cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin) + } + return nil +} + +func appserviceLocalpart(userID id.UserID) string { + localpart, _, err := userID.Parse() + if err != nil { + return string(userID) + } + return localpart +} + +func homeserverDomainFromURL(rawURL string) (string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", err + } + hostname := parsed.Hostname() + if hostname == "" { + return "", fmt.Errorf("failed to derive homeserverDomain from %q", rawURL) + } + return hostname, nil +} + +func toUserIDs(input []string) []id.UserID { + output := make([]id.UserID, 0, len(input)) + for _, userID := range input { + if userID != "" { + output = append(output, id.UserID(userID)) + } + } + return output +} diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go new file mode 100644 index 0000000..8bdd910 --- /dev/null +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "testing" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { + appservice := &matrixAppservice{ + botUserID: id.UserID("@testbot:example"), + homeserverDomain: "example", + } + req := MatrixAppserviceCreatePortalRoomOptions{ + AutoJoinInvites: true, + Bridge: MatrixAppserviceBridgeName{ + BeeperBridgeType: "test", + DisplayName: "Test", + NetworkID: "test", + }, + BridgeName: "test", + InitialMembers: []string{"@alice:example"}, + Invite: []string{"@alice:example"}, + Name: "Remote room", + PortalKey: MatrixAppservicePortalKey{ID: "remote-room", Receiver: "login:a"}, + } + createReq := appservice.makePortalCreateRoomRequest(req, id.UserID("@test_bob:example")) + + if createReq.BeeperLocalRoomID != id.RoomID("!remote-room.login:a:example") { + t.Fatalf("unexpected local room ID: %s", createReq.BeeperLocalRoomID) + } + if createReq.MeowRoomID != createReq.BeeperLocalRoomID { + t.Fatalf("expected fi.mau room ID to match local room ID, got %s", createReq.MeowRoomID) + } + assertHasUserID(t, createReq.Invite, "@alice:example") + assertHasUserID(t, createReq.BeeperInitialMembers, "@alice:example") + if createReq.PowerLevelOverride == nil || createReq.PowerLevelOverride.Users[id.UserID("@testbot:example")] != 9001 { + t.Fatalf("expected bridge bot power level override, got %#v", createReq.PowerLevelOverride) + } + if createReq.PowerLevelOverride.Events[event.StateBridge.Type] != 100 { + t.Fatalf("expected m.bridge power level override, got %#v", createReq.PowerLevelOverride.Events) + } + assertHasBridgeState(t, createReq, event.StateBridge.Type) + assertHasBridgeState(t, createReq, event.StateHalfShotBridge.Type) +} + +func assertHasUserID(t *testing.T, users []id.UserID, expected id.UserID) { + t.Helper() + for _, userID := range users { + if userID == expected { + return + } + } + t.Fatalf("expected %s in %v", expected, users) +} + +func assertHasBridgeState(t *testing.T, req *mautrix.ReqCreateRoom, eventType string) { + t.Helper() + for _, state := range req.InitialState { + if state.Type.Type == eventType { + if state.StateKey == nil || *state.StateKey != "test" { + t.Fatalf("unexpected state key for %s: %#v", eventType, state.StateKey) + } + content, ok := state.Content.Parsed.(*event.BridgeEventContent) + if !ok { + t.Fatalf("expected mautrix bridge event content in %s, got %#v", eventType, state.Content.Parsed) + } + if content.BridgeBot != id.UserID("@testbot:example") { + t.Fatalf("unexpected bridgebot in %s: %#v", eventType, content) + } + return + } + } + t.Fatalf("missing %s initial state", eventType) +} diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index fa6f3a2..5f51130 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -16,6 +16,7 @@ import ( type Core struct { client *mautrix.Client + appservice *matrixAppservice crypto *cryptohelper.CryptoHelper cryptoStore crypto.Store backupKey *backup.MegolmBackupKey @@ -82,6 +83,22 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleGetCryptoStatus() case opRawRequest: return c.handleRawRequest(ctx, payload) + case opInitAppservice: + return c.handleInitAppservice(ctx, payload) + case opAppserviceEnsureRegistered: + return c.handleAppserviceEnsureRegistered(ctx, payload) + case opAppserviceEnsureJoined: + return c.handleAppserviceEnsureJoined(ctx, payload) + case opAppserviceCreateRoom: + return c.handleAppserviceCreateRoom(ctx, payload) + case opAppserviceCreatePortalRoom: + return c.handleAppserviceCreatePortalRoom(ctx, payload) + case opAppserviceCreateManagementRoom: + return c.handleAppserviceCreateManagementRoom(ctx, payload) + case opAppserviceSendMessage: + return c.handleAppserviceSendMessage(ctx, payload) + case opAppserviceBatchSend: + return c.handleAppserviceBatchSend(ctx, payload) case opApplySyncResponse: return c.handleApplySyncResponse(ctx, payload) case opGetAccountData: diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 21db18f..ee0d481 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -17,6 +17,22 @@ const ( opGetCryptoStatus = "get_crypto_status" // ts:operation rawRequest raw_request MatrixRawRequestOptions MatrixRawRequestResult opRawRequest = "raw_request" + // ts:operation initAppservice init_appservice MatrixAppserviceInitOptions MatrixAppserviceInfo + opInitAppservice = "init_appservice" + // ts:operation appserviceEnsureRegistered appservice_ensure_registered MatrixAppserviceUserOptions void + opAppserviceEnsureRegistered = "appservice_ensure_registered" + // ts:operation appserviceEnsureJoined appservice_ensure_joined MatrixAppserviceRoomUserOptions void + opAppserviceEnsureJoined = "appservice_ensure_joined" + // ts:operation appserviceCreateRoom appservice_create_room MatrixAppserviceCreateRoomOptions MatrixCreateRoomResult + opAppserviceCreateRoom = "appservice_create_room" + // ts:operation appserviceCreatePortalRoom appservice_create_portal_room MatrixAppserviceCreatePortalRoomOptions MatrixCreateRoomResult + opAppserviceCreatePortalRoom = "appservice_create_portal_room" + // ts:operation appserviceCreateManagementRoom appservice_create_management_room MatrixAppserviceCreateManagementRoomOptions MatrixCreateRoomResult + opAppserviceCreateManagementRoom = "appservice_create_management_room" + // ts:operation appserviceSendMessage appservice_send_message MatrixAppserviceSendMessageOptions MatrixRawMessage + opAppserviceSendMessage = "appservice_send_message" + // ts:operation appserviceBatchSend appservice_batch_send MatrixAppserviceBatchSendOptions MatrixAppserviceBatchSendResult + opAppserviceBatchSend = "appservice_batch_send" // ts:operation applySyncResponse apply_sync_response MatrixApplySyncResponseOptions void opApplySyncResponse = "apply_sync_response" // ts:operation getAccountData get_account_data MatrixGetAccountDataOptions MatrixAccountDataResult diff --git a/packages/pickle/package.json b/packages/pickle/package.json index 4cf8d04..8e88446 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -64,7 +64,7 @@ "typecheck": "npm run generate:types && tsc --noEmit" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "tsdown": "^0.21.10", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/packages/pickle/src/auth.test.ts b/packages/pickle/src/auth.test.ts index d32c855..89f9c2d 100644 --- a/packages/pickle/src/auth.test.ts +++ b/packages/pickle/src/auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { loginWithMatrixPassword, loginWithMatrixToken } from "./auth"; +import { loginWithMatrixPassword, loginWithMatrixToken, loginWithPassword } from "./auth"; describe("matrix auth", () => { it("logs in with token and verifies whoami", async () => { @@ -65,8 +65,49 @@ describe("matrix auth", () => { type: "m.login.password", }); }); + + it("logs in with password using Beeper defaults", async () => { + const fetchImpl = passwordFetch("@bot:beeper.com"); + + await loginWithPassword({ + fetch: fetchImpl as typeof fetch, + password: "secret", + username: "bot", + }); + + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://matrix.beeper.com/_matrix/client/v3/login"); + }); + + it("lets explicit homeserver override Beeper defaults", async () => { + const fetchImpl = passwordFetch("@bot:example.com"); + + await loginWithPassword({ + fetch: fetchImpl as typeof fetch, + homeserver: "https://matrix.example.com", + password: "secret", + username: "bot", + }); + + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://matrix.example.com/_matrix/client/v3/login"); + }); }); +function passwordFetch(userId: string) { + return vi.fn(async (url: URL | string) => { + if (String(url).endsWith("/_matrix/client/v3/login")) { + return Response.json({ + access_token: "access", + device_id: "DEVICE", + user_id: userId, + }); + } + return Response.json({ + device_id: "DEVICE", + user_id: userId, + }); + }); +} + async function requestBody(fetchImpl: ReturnType, index: number) { const init = fetchImpl.mock.calls[index]?.[1] as RequestInit; return JSON.parse(String(init.body)); diff --git a/packages/pickle/src/auth.ts b/packages/pickle/src/auth.ts index 96d4191..ef4a90a 100644 --- a/packages/pickle/src/auth.ts +++ b/packages/pickle/src/auth.ts @@ -16,6 +16,13 @@ export interface MatrixPasswordAuthOptions extends MatrixAuthOptions { username: string; } +export interface PasswordAuthOptions extends Omit { + baseDomain?: string; + homeserver?: string; + password?: string; + username?: string; +} + export interface MatrixTokenAuthOptions extends MatrixAuthOptions { token: string; type?: "m.login.token" | "org.matrix.login.jwt"; @@ -32,6 +39,18 @@ export async function loginWithMatrixPassword(options: MatrixPasswordAuthOptions }); } +export function loginWithPassword(options: PasswordAuthOptions = {}): Promise { + const username = options.username ?? process.env.BEEPER_USERNAME ?? process.env.MATRIX_USERNAME; + const password = options.password ?? process.env.BEEPER_PASSWORD ?? process.env.MATRIX_PASSWORD; + if (!username) throw new Error("loginWithPassword requires username, BEEPER_USERNAME, or MATRIX_USERNAME"); + if (!password) throw new Error("loginWithPassword requires password, BEEPER_PASSWORD, or MATRIX_PASSWORD"); + return loginWithMatrixPassword({ + ...authOptions(options), + password, + username, + }); +} + export async function loginWithMatrixToken(options: MatrixTokenAuthOptions): Promise { return loginWithMatrix(options, { token: options.token, @@ -96,3 +115,13 @@ function readRequiredString(value: unknown, key: string, label: string): string } return field; } + +function authOptions(options: PasswordAuthOptions): MatrixAuthOptions { + const output: MatrixAuthOptions = { + homeserver: options.homeserver ?? process.env.MATRIX_HOMESERVER ?? `https://matrix.${options.baseDomain ?? process.env.BEEPER_BASE_DOMAIN ?? "beeper.com"}`, + }; + if (options.fetch !== undefined) output.fetch = options.fetch; + if (options.initialDeviceDisplayName !== undefined) output.initialDeviceDisplayName = options.initialDeviceDisplayName; + if (options.metadata !== undefined) output.metadata = options.metadata; + return output; +} diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index afc28a6..347c4d5 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -70,10 +70,23 @@ import type { UploadMediaResult, UserInfo, } from "./types"; +import type { + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, +} from "./runtime-types"; export interface MatrixClient { - beeper: MatrixBeeper; accountData: MatrixAccountData; + appservice: MatrixAppservice; + beeper: MatrixBeeper; boot(): Promise; close(): Promise; crypto: MatrixCrypto; @@ -97,6 +110,17 @@ export interface MatrixClient { whoami(): Promise; } +export interface MatrixAppservice { + batchSend(options: MatrixAppserviceBatchSendOptions): Promise; + createManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise; + createPortalRoom(options: MatrixAppserviceCreatePortalRoomOptions): Promise; + createRoom(options: MatrixAppserviceCreateRoomOptions): Promise; + ensureJoined(options: MatrixAppserviceRoomUserOptions): Promise; + ensureRegistered(options: MatrixAppserviceUserOptions): Promise; + init(options: MatrixAppserviceInitOptions): Promise; + sendMessage(options: MatrixAppserviceSendMessageOptions): Promise; +} + export interface MatrixRaw { request(options: RawRequestOptions): Promise; } diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 8b80b76..8ee0c31 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -1,5 +1,6 @@ import type { MatrixAccountData, + MatrixAppservice, MatrixBeeper, MatrixClient, MatrixCrypto, @@ -37,6 +38,7 @@ export function createMatrixClient(options: MatrixClientOptions): MatrixClient { class DefaultMatrixClient implements MatrixClient { readonly accountData: MatrixAccountData; + readonly appservice: MatrixAppservice; readonly beeper: MatrixBeeper; readonly crypto: MatrixCrypto; readonly media: MatrixMedia; @@ -69,6 +71,19 @@ class DefaultMatrixClient implements MatrixClient { set: (opts) => this.#withCore((core) => core.setAccountData(opts)), setRoom: (opts) => this.#withCore((core) => core.setRoomAccountData(opts)), }; + this.appservice = { + batchSend: (opts) => this.#withCore((core) => core.appserviceBatchSend(opts)), + createManagementRoom: (opts) => this.#withCore((core) => core.appserviceCreateManagementRoom(stripUndefined(opts))), + createPortalRoom: (opts) => this.#withCore((core) => core.appserviceCreatePortalRoom(stripUndefined(opts))), + createRoom: (opts) => this.#withCore((core) => core.appserviceCreateRoom(stripUndefined(opts))), + ensureJoined: (opts) => this.#withCore((core) => core.appserviceEnsureJoined(opts)), + ensureRegistered: (opts) => this.#withCore((core) => core.appserviceEnsureRegistered(opts)), + init: (opts) => this.#withCore((core) => core.initAppservice(opts)), + sendMessage: (opts) => this.#withCore(async (core) => { + const result = await core.appserviceSendMessage(stripUndefined(opts)); + return { eventId: result.eventId, raw: result.raw, roomId: result.roomId }; + }), + }; this.beeper = { ephemeral: { send: (opts) => diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 2d8a850..a44364c 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -3,6 +3,16 @@ import type { MatrixAccountDataResult, MatrixApplySyncResponseOptions, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, MatrixBanUserOptions, MatrixBeeperStreamOptions, MatrixCoreInitOptions, @@ -85,6 +95,14 @@ export interface MatrixCoreOperations { logout(): Promise; getCryptoStatus(): Promise; rawRequest(options: MatrixRawRequestOptions): Promise; + initAppservice(options: MatrixAppserviceInitOptions): Promise; + appserviceEnsureRegistered(options: MatrixAppserviceUserOptions): Promise; + appserviceEnsureJoined(options: MatrixAppserviceRoomUserOptions): Promise; + appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise; + appserviceCreatePortalRoom(options: MatrixAppserviceCreatePortalRoomOptions): Promise; + appserviceCreateManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise; + appserviceSendMessage(options: MatrixAppserviceSendMessageOptions): Promise; + appserviceBatchSend(options: MatrixAppserviceBatchSendOptions): Promise; applySyncResponse(options: MatrixApplySyncResponseOptions): Promise; getAccountData(options: MatrixGetAccountDataOptions): Promise; setAccountData(options: MatrixSetAccountDataOptions): Promise; @@ -172,6 +190,38 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("raw_request", options); } + initAppservice(options: MatrixAppserviceInitOptions): Promise { + return this.call("init_appservice", options); + } + + appserviceEnsureRegistered(options: MatrixAppserviceUserOptions): Promise { + return this.call("appservice_ensure_registered", options); + } + + appserviceEnsureJoined(options: MatrixAppserviceRoomUserOptions): Promise { + return this.call("appservice_ensure_joined", options); + } + + appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise { + return this.call("appservice_create_room", options); + } + + appserviceCreatePortalRoom(options: MatrixAppserviceCreatePortalRoomOptions): Promise { + return this.call("appservice_create_portal_room", options); + } + + appserviceCreateManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise { + return this.call("appservice_create_management_room", options); + } + + appserviceSendMessage(options: MatrixAppserviceSendMessageOptions): Promise { + return this.call("appservice_send_message", options); + } + + appserviceBatchSend(options: MatrixAppserviceBatchSendOptions): Promise { + return this.call("appservice_batch_send", options); + } + applySyncResponse(options: MatrixApplySyncResponseOptions): Promise { return this.call("apply_sync_response", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 448a59f..f0cd0e8 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -16,6 +16,112 @@ export interface MatrixEncryptedFile { v: "v2"; } +export interface MatrixAppserviceNamespace { + exclusive: boolean; + regex: string; +} +export interface MatrixAppserviceNamespaces { + aliases?: MatrixAppserviceNamespace[]; + rooms?: MatrixAppserviceNamespace[]; + users?: MatrixAppserviceNamespace[]; +} +export interface MatrixAppserviceRegistration { + asToken: string; + ephemeralEvents?: boolean; + hsToken: string; + id: string; + msc3202?: boolean; + msc4190?: boolean; + namespaces: MatrixAppserviceNamespaces; + protocols?: string[]; + rateLimited?: boolean; + senderLocalpart: string; + url: string; +} +export interface MatrixAppserviceInitOptions { + homeserver: string; + homeserverDomain?: string; + registration: MatrixAppserviceRegistration; +} +export interface MatrixAppserviceInfo { + botUserId: string; + id: string; +} +export interface MatrixAppserviceUserOptions { + userId: string; +} +export interface MatrixAppserviceRoomUserOptions { + roomId: string; + userId: string; +} +export interface MatrixAppserviceCreateRoomOptions extends MatrixCreateRoomOptions { + userId?: string; +} +export interface MatrixAppservicePortalKey { + id: string; + receiver?: string; +} +export interface MatrixAppserviceBridgeName { + beeperBridgeType?: string; + defaultCommandPrefix?: string; + defaultPort?: number /* int */; + displayName: string; + networkIcon?: string; + networkId: string; + networkUrl?: string; +} +export interface MatrixAppserviceCreatePortalRoomOptions { + avatarUrl?: string; + autoJoinInvites?: boolean; + bridge: MatrixAppserviceBridgeName; + bridgeName?: string; + initialMembers?: string[]; + invite?: string[]; + isDirect?: boolean; + messageRequest?: boolean; + name?: string; + portalKey: MatrixAppservicePortalKey; + roomType?: string; + topic?: string; + userId?: string; +} +export interface MatrixAppserviceCreateManagementRoomOptions { + autoJoinInvites?: boolean; + initialMembers?: string[]; + invite?: string[]; + name?: string; + topic?: string; + userId?: string; +} +export interface MatrixAppserviceSendMessageOptions { + content: { [key: string]: unknown }; + eventType?: string; + roomId: string; + timestamp?: number /* int64 */; + transactionId?: string; + userId?: string; +} +export interface MatrixAppserviceBatchEvent { + content: { [key: string]: unknown }; + eventId?: string; + eventType?: string; + roomId?: string; + sender: string; + stateKey?: string; + timestamp?: number /* int64 */; +} +export interface MatrixAppserviceBatchSendOptions { + events: MatrixAppserviceBatchEvent[]; + forward?: boolean; + forwardIfNoMessages?: boolean; + markReadBy?: string; + roomId: string; + sendNotification?: boolean; +} +export interface MatrixAppserviceBatchSendResult { + eventIds: string[]; + raw: unknown; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index df269d1..2dd1df5 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -4,6 +4,7 @@ export { onInvite, onMessage, onRawEvent, onReaction } from "./helpers"; export type { MatrixClient, MatrixAccountData, + MatrixAppservice, MatrixBeeper, MatrixMedia, MatrixMessages, @@ -17,6 +18,24 @@ export type { MatrixToDevice, MatrixUsers, } from "./client-types"; +export type { + MatrixAppserviceBatchEvent, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceBridgeName, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceNamespace, + MatrixAppserviceNamespaces, + MatrixAppservicePortalKey, + MatrixAppserviceRegistration, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, +} from "./runtime-types"; export type { ApplySyncResponseOptions, AccountDataOptions, diff --git a/packages/pickle/src/node.ts b/packages/pickle/src/node.ts index 71732c5..57e5033 100644 --- a/packages/pickle/src/node.ts +++ b/packages/pickle/src/node.ts @@ -35,6 +35,10 @@ class NodeMatrixClient implements MatrixClient { return this.#namespace("accountData"); } + get appservice() { + return this.#namespace("appservice"); + } + get beeper() { return this.#namespace("beeper"); } diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 9a0ff4f..f73e818 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -9,6 +9,22 @@ import type { MatrixCoreOperations } from "./generated-runtime-operations"; export type { MatrixAccountDataResult, + MatrixAppserviceBatchEvent, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceBridgeName, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceNamespace, + MatrixAppserviceNamespaces, + MatrixAppservicePortalKey, + MatrixAppserviceRegistration, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, MatrixBeeperStreamOptions, diff --git a/packages/state-file/package.json b/packages/state-file/package.json index 4bd33ee..f2a5f08 100644 --- a/packages/state-file/package.json +++ b/packages/state-file/package.json @@ -27,7 +27,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/state-indexeddb/package.json b/packages/state-indexeddb/package.json index d48788e..5a6d469 100644 --- a/packages/state-indexeddb/package.json +++ b/packages/state-indexeddb/package.json @@ -36,7 +36,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "fake-indexeddb": "^6.2.5", "tsdown": "^0.21.10", diff --git a/packages/state-memory/package.json b/packages/state-memory/package.json index 3159844..a1db7ad 100644 --- a/packages/state-memory/package.json +++ b/packages/state-memory/package.json @@ -40,7 +40,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/state-simple/package.json b/packages/state-simple/package.json index cb6077c..39e3203 100644 --- a/packages/state-simple/package.json +++ b/packages/state-simple/package.json @@ -27,7 +27,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/state-sqlite/package.json b/packages/state-sqlite/package.json index 3f6c09c..0f092da 100644 --- a/packages/state-sqlite/package.json +++ b/packages/state-sqlite/package.json @@ -27,7 +27,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^22.5.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d13110f..7f1b63d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,13 @@ importers: version: 0.6.0 '@changesets/cli': specifier: ^2.31.0 - version: 2.31.0(@types/node@25.6.0) + version: 2.31.0(@types/node@20.19.39) '@types/mdast': specifier: ^4.0.4 version: 4.0.4 '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -31,7 +31,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) bots/dummybot: dependencies: @@ -92,14 +92,36 @@ importers: specifier: ^4.85.0 version: 4.87.0 + examples/dummybridge: + dependencies: + '@beeper/pickle': + specifier: workspace:* + version: link:../../packages/pickle + '@beeper/pickle-bridge': + specifier: workspace:* + version: link:../../packages/bridge + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../../packages/state-file + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/ai-sdk: devDependencies: '@beeper/pickle-chat-adapter': specifier: workspace:* version: link:../chat-adapter '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) @@ -108,7 +130,38 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + + packages/bridge: + dependencies: + '@beeper/pickle': + specifier: workspace:* + version: link:../pickle + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../state-file + ws: + specifier: ^8.18.0 + version: 8.18.0 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.1.5(vitest@4.1.5) + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/chat-adapter: dependencies: @@ -123,8 +176,8 @@ importers: version: 7.1.0 devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 chat: specifier: ^4.26.0 version: 4.26.0 @@ -136,7 +189,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/cloudflare: dependencies: @@ -145,8 +198,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) @@ -155,13 +208,13 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/pickle: devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) @@ -170,7 +223,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/state-file: dependencies: @@ -179,8 +232,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -192,7 +245,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/state-indexeddb: dependencies: @@ -201,8 +254,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -223,8 +276,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -236,7 +289,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/state-simple: dependencies: @@ -245,8 +298,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -258,7 +311,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages/state-sqlite: dependencies: @@ -267,8 +320,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^22.5.0 + version: 22.19.17 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -280,7 +333,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) packages: @@ -1093,6 +1146,12 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -1102,6 +1161,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/coverage-v8@4.1.5': resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} peerDependencies: @@ -2030,6 +2092,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -2289,7 +2354,7 @@ snapshots: transitivePeerDependencies: - encoding - '@changesets/cli@2.31.0(@types/node@25.6.0)': + '@changesets/cli@2.31.0(@types/node@20.19.39)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -2305,7 +2370,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@25.6.0) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.39) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -2705,12 +2770,12 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': + '@inquirer/external-editor@1.0.3(@types/node@20.19.39)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 20.19.39 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -2872,6 +2937,14 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -2880,6 +2953,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2892,7 +2969,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) '@vitest/expect@4.1.5': dependencies: @@ -2903,6 +2980,22 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7))': dependencies: '@vitest/spy': 4.1.5 @@ -3970,6 +4063,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@6.21.0: {} + undici-types@7.19.2: {} undici@7.24.8: {} @@ -4023,6 +4118,30 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 20.19.39 + esbuild: 0.27.7 + fsevents: 2.3.3 + + vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + esbuild: 0.27.7 + fsevents: 2.3.3 + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7): dependencies: lightningcss: 1.32.0 @@ -4035,6 +4154,64 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.39 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.17 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + transitivePeerDependencies: + - msw + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)): dependencies: '@vitest/expect': 4.1.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 073f294..c6f3430 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "packages/ai-sdk" + - "packages/bridge" - "packages/chat-adapter" - "packages/cloudflare" - "packages/pickle" diff --git a/tsconfig.base.json b/tsconfig.base.json index d6aa64f..0ebc686 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,9 @@ "@beeper/pickle/beeper/auth": ["packages/pickle/src/beeper/auth.ts"], "@beeper/pickle/streams": ["packages/pickle/src/streams/index.ts"], "@beeper/pickle-ai-sdk": ["packages/ai-sdk/src/index.ts"], + "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], + "@beeper/pickle-bridge/node": ["packages/bridge/src/node.ts"], + "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], "@beeper/pickle-cloudflare": ["packages/cloudflare/src/index.ts"], "@beeper/pickle-state-file": ["packages/state-file/src/index.ts"],