From 0d70469cf86a1fa9453b2e545d8aa7ca953e8cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 19:28:39 +0200 Subject: [PATCH 01/21] wip --- packages/bridge/LICENSE | 1 + packages/bridge/README.md | 45 ++ packages/bridge/docs/BRIDGE_TODO.md | 143 +++++ packages/bridge/package.json | 68 +++ packages/bridge/src/bridge.test.ts | 223 ++++++++ packages/bridge/src/bridge.ts | 361 ++++++++++++ packages/bridge/src/events.ts | 49 ++ packages/bridge/src/index.ts | 3 + packages/bridge/src/node.ts | 10 + packages/bridge/src/types.ts | 855 ++++++++++++++++++++++++++++ packages/bridge/tsconfig.json | 8 + packages/bridge/tsdown.config.ts | 19 + packages/bridge/vitest.config.ts | 18 + pnpm-lock.yaml | 22 + pnpm-workspace.yaml | 1 + tsconfig.base.json | 3 + 16 files changed, 1829 insertions(+) create mode 100644 packages/bridge/LICENSE create mode 100644 packages/bridge/README.md create mode 100644 packages/bridge/docs/BRIDGE_TODO.md create mode 100644 packages/bridge/package.json create mode 100644 packages/bridge/src/bridge.test.ts create mode 100644 packages/bridge/src/bridge.ts create mode 100644 packages/bridge/src/events.ts create mode 100644 packages/bridge/src/index.ts create mode 100644 packages/bridge/src/node.ts create mode 100644 packages/bridge/src/types.ts create mode 100644 packages/bridge/tsconfig.json create mode 100644 packages/bridge/tsdown.config.ts create mode 100644 packages/bridge/vitest.config.ts diff --git a/packages/bridge/LICENSE b/packages/bridge/LICENSE new file mode 100644 index 0000000..289de81 --- /dev/null +++ b/packages/bridge/LICENSE @@ -0,0 +1 @@ +Mozilla Public License Version 2.0 diff --git a/packages/bridge/README.md b/packages/bridge/README.md new file mode 100644 index 0000000..4996882 --- /dev/null +++ b/packages/bridge/README.md @@ -0,0 +1,45 @@ +# @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 { createBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; + +const bridge = createBridge({ + matrix: { + homeserver: process.env.MATRIX_HOMESERVER!, + token: process.env.MATRIX_ACCESS_TOKEN!, + store, + }, + connector, +}); + +await bridge.start(); + +const login = { id: "example-login" }; +await bridge.loadUserLogin(login); +bridge.registerPortal({ + id: "remote-room-id", + mxid: "!matrix-room:example.com", + portalKey: { id: "remote-room-id", receiver: login.id }, +}); + +bridge.queueRemoteEvent(login, createRemoteMessage({ + data: { text: "hello" }, + id: "remote-message-id", + portalKey: { id: "remote-room-id", receiver: login.id }, + sender: { isFromMe: false, sender: "remote-user-id" }, + convert: (_ctx, _portal, _intent, data) => ({ + parts: [{ + type: "m.room.message", + content: { msgtype: "m.text", body: data.text }, + }], + }), +})); +``` + +The Node entrypoint uses the same Pickle WASM mechanism as `@beeper/pickle/node`. +Browser and worker callers can import from `@beeper/pickle-bridge` and provide +`wasmBytes`, `wasmModule`, or `wasmUrl`. diff --git a/packages/bridge/docs/BRIDGE_TODO.md b/packages/bridge/docs/BRIDGE_TODO.md new file mode 100644 index 0000000..f88794a --- /dev/null +++ b/packages/bridge/docs/BRIDGE_TODO.md @@ -0,0 +1,143 @@ +# 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] 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. +- [ ] Appservice-mode Matrix connector, if Pickle grows appservice APIs. + +## 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. +- [ ] Runtime start/stop tests. +- [ ] WASM option forwarding tests. +- [ ] Remote event queue tests. +- [ ] Matrix sync dispatch tests. diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..aada0e7 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,68 @@ +{ + "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" + }, + "./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", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@beeper/pickle": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "@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/bridge.test.ts b/packages/bridge/src/bridge.test.ts new file mode 100644 index 0000000..9699816 --- /dev/null +++ b/packages/bridge/src/bridge.test.ts @@ -0,0 +1,223 @@ +import type { MatrixClient, MatrixClientEvent, MatrixSubscription } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { RuntimeBridge } from "./bridge"; +import { createRemoteMessage } from "./events"; +import type { + BridgeConnector, + BridgeContext, + BridgeMatrixConfig, + MatrixMessage, + NetworkAPI, + 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("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-"), + }); + }); +}); + +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 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: () => ({ 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 createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { + const subscription = { + catchUp: vi.fn(async () => {}), + done: Promise.resolve(), + stop: vi.fn(async () => {}), + }; + return { + accountData: {} as MatrixClient["accountData"], + 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: {} as MatrixClient["media"], + messages: {} as MatrixClient["messages"], + 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: {} as MatrixClient["users"], + 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..4964764 --- /dev/null +++ b/packages/bridge/src/bridge.ts @@ -0,0 +1,361 @@ +import { createMatrixClient } from "@beeper/pickle"; +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import type { + BridgeContext, + BridgeLogger, + BridgeRequestContext, + CreateBridgeOptions, + LoginProcess, + NetworkAPI, + PickleBridge, + Portal, + QueueRemoteEventResult, + RemoteEvent, + UserLogin, + BridgeUser, + MatrixDispatchResult, + MatrixMessage, + MatrixReaction, + MatrixRedaction, + MatrixTyping, + MatrixIntent, + RemoteMessage, +} from "./types"; + +type GenericMatrixEvent = Extract; kind: string }>; + +export function createBridge(options: CreateBridgeOptions): PickleBridge { + return new RuntimeBridge(options, createMatrixClient(options.matrix)); +} + +export class RuntimeBridge implements PickleBridge { + readonly connector: CreateBridgeOptions["connector"]; + readonly #networkClients = new Map(); + readonly #messages = new Map(); + readonly #portalsByKey = new Map(); + readonly #portalsByRoom = new Map(); + readonly #remoteEvents: Array<{ event: RemoteEvent; login: UserLogin }> = []; + readonly #matrixClient: MatrixClient; + readonly #subscriptions = new Set(); + #context: BridgeContext | null = null; + #drainPromise: Promise | null = null; + #started = false; + #ownUserId: string | null = null; + + constructor(options: CreateBridgeOptions, client: MatrixClient) { + this.connector = options.connector; + 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; + const whoami = await this.#matrixClient.boot(); + this.#ownUserId = whoami.userId; + 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.connector.start(this.#context); + await this.#subscribeMatrixEvents(); + this.#started = true; + } + + async stop(): Promise { + 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(); + 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; + } + + async createLogin(user: BridgeUser, flowId: string): Promise { + return this.connector.createLogin(this.#requestContext(), user, flowId); + } + + async loadUserLogin(login: UserLogin): Promise { + const existing = this.#networkClients.get(login.id); + if (existing) return existing; + const client = await this.connector.loadUserLogin(this.#requestContext(), login); + login.client = client; + this.#networkClients.set(login.id, client); + await client.connect({ ...this.#requestContext(), login }); + return client; + } + + queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult { + this.#remoteEvents.push({ event, login }); + this.#scheduleDrain(); + return { event, queued: true }; + } + + registerPortal(portal: Portal): void { + this.#portalsByKey.set(portalKeyString(portal.portalKey), portal); + if (portal.mxid) { + this.#portalsByRoom.set(portal.mxid, portal); + } + } + + async flushRemoteEvents(): Promise { + await this.#drainRemoteEvents(); + } + + 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"); + } + 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 { + return { + bridge: this, + client: this.#matrixClient, + log: defaultLogger, + queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), + }; + } + + 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); + } + + async #dispatchMatrixMessage(event: MatrixMessageEvent): 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: MatrixMessage = { + attachments: event.attachments, + content: event.content, + event, + portal, + sender: event.sender, + text: event.text, + ...(event.threadRoot ? { threadRoot: { id: event.threadRoot } } : {}), + }; + let handlers = 0; + for (const client of this.#networkClients.values()) { + if (!hasMethod(client, "handleMatrixMessage")) continue; + handlers += 1; + await client.handleMatrixMessage(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + + 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.#networkClients.values()) { + 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.#networkClients.values()) { + 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 msg: MatrixTyping = { + portal: this.#portalForRoom(roomId), + typing: true, + userId, + }; + for (const client of this.#networkClients.values()) { + 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 { + this.#drainPromise ??= this.#drainRemoteEvents().finally(() => { + this.#drainPromise = null; + if (this.#remoteEvents.length > 0) this.#scheduleDrain(); + }); + } + + async #drainRemoteEvents(): Promise { + if (!this.#context) return; + while (this.#remoteEvents.length > 0) { + const item = this.#remoteEvents.shift(); + if (!item) continue; + await this.#handleRemoteEvent(item.login, item.event); + } + } + + async #handleRemoteEvent(_login: UserLogin, event: RemoteEvent): Promise { + const type = event.getType(); + if (type === "message" || type === "message_upsert") { + await this.#handleRemoteMessage(event as RemoteMessage); + 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 sent = await this.#matrixIntent().sendMessage(portal.mxid, part.content); + this.#messages.set(messagePartKey(event.getID(), part.id ?? String(index)), { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId, + }); + } + } + + #matrixIntent(): MatrixIntent { + return { + client: this.#matrixClient, + sendMessage: async (roomId, content) => { + const type = typeof content.msgtype === "string" ? "m.room.message" : "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 }; + }, + }; + } +} + +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 portalKeyString(portalKey: { id: string; receiver?: string }): string { + return `${portalKey.receiver ?? ""}\u0000${portalKey.id}`; +} + +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 ""; +} diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts new file mode 100644 index 0000000..a08e804 --- /dev/null +++ b/packages/bridge/src/events.ts @@ -0,0 +1,49 @@ +import type { + BridgeRequestContext, + ConvertedMessage, + CreateRemoteMessageOptions, + MatrixIntent, + MessageID, + Portal, + RemoteEventType, + RemoteMessage, + RemoteMessageWithTransactionID, +} from "./types"; + +export function createRemoteMessage(options: CreateRemoteMessageOptions): RemoteMessage | RemoteMessageWithTransactionID { + 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 options.streamOrder ?? options.timestamp?.getTime() ?? Date.now(); + }, + getTimestamp() { + return options.timestamp ?? new Date(); + }, + 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..da44918 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,3 @@ +export { createBridge, RuntimeBridge } from "./bridge"; +export { createRemoteMessage } from "./events"; +export type * from "./types"; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts new file mode 100644 index 0000000..8a94098 --- /dev/null +++ b/packages/bridge/src/node.ts @@ -0,0 +1,10 @@ +import { createMatrixClient } from "@beeper/pickle/node"; +import { RuntimeBridge } from "./bridge"; +export { createRemoteMessage } from "./events"; +import type { CreateNodeBridgeOptions, PickleBridge } from "./types"; + +export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { + return new RuntimeBridge(options, createMatrixClient(options.matrix)); +} + +export type * from "./types"; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts new file mode 100644 index 0000000..2ad130d --- /dev/null +++ b/packages/bridge/src/types.ts @@ -0,0 +1,855 @@ +import type { + MatrixAttachment, + MatrixClient, + MatrixClientOptions, + MatrixEventSender, + MatrixMessageEvent, + MatrixReactionEvent, + MatrixStore, + SentEvent, +} from "@beeper/pickle"; + +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 TransactionID = string; +export type RawTransactionID = string; +export type RoomID = string; +export type EventID = string; + +export interface PortalKey { + id: PortalID; + receiver?: UserLoginID; +} + +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 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 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(): Promise | void; + start(ctx: BridgeRequestContext): Promise; +} + +export interface LoginProcessWithOverride extends LoginProcess { + startWithOverride(ctx: BridgeRequestContext, override: UserLogin): Promise; +} + +export interface LoginProcessDisplayAndWait extends LoginProcess { + wait(ctx: BridgeRequestContext): Promise; +} + +export interface LoginProcessUserInput extends LoginProcess { + submitUserInput(ctx: BridgeRequestContext, input: Record): Promise; +} + +export interface LoginProcessCookies extends LoginProcess { + submitCookies(ctx: BridgeRequestContext, cookies: Record): 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 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; + createLogin(user: BridgeUser, flowId: string): Promise; + flushRemoteEvents(): Promise; + loadUserLogin(login: UserLogin): Promise; + queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; + registerPortal(portal: Portal): void; + start(): Promise; + stop(): Promise; +} + +export interface CreateBridgeOptions { + connector: BridgeConnector; + matrix: BridgeMatrixConfig; +} + +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 BridgeContext { + bridge: PickleBridge; + client: MatrixClient; + log: BridgeLogger; + 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 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 { + id: GhostID; + metadata?: unknown; + mxid?: string; +} + +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 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 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; + forward?: boolean; + limit?: number; + portal: Portal; +} + +export interface FetchMessagesResponse { + forward?: boolean; + hasMore?: boolean; + messages: BackfillMessage[]; +} + +export interface BackfillMessage { + event: RemoteMessage; + reactions?: BackfillReaction[]; +} + +export interface BackfillReaction { + event: RemoteReaction; +} 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..95a52ef --- /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"], + format: ["esm"], + dts: { + sourcemap: false, + }, + clean: true, + sourcemap: false, + outExtensions: () => ({ + js: ".js", + dts: ".d.ts", + }), + deps: { + neverBundle: ["@beeper/pickle", "@beeper/pickle/node"], + }, + target: false, +}); diff --git a/packages/bridge/vitest.config.ts b/packages/bridge/vitest.config.ts new file mode 100644 index 0000000..b43d055 --- /dev/null +++ b/packages/bridge/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: { + "@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/pnpm-lock.yaml b/pnpm-lock.yaml index d13110f..8e99b6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,28 @@ importers: 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)) + packages/bridge: + dependencies: + '@beeper/pickle': + specifier: workspace:* + version: link:../pickle + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.6.0 + '@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@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + packages/chat-adapter: dependencies: '@beeper/pickle': 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"], From 0328fe0af3c5bdda000f2ab7593edba8846e8518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 19:45:32 +0200 Subject: [PATCH 02/21] Add dummybridge example and Beeper appservice helpers Introduce a minimal TypeScript Pickle example bridge (examples/dummybridge) including connector, index, env loader, file-backed state (FileState/MatrixState), smoke test, build and config files. Add Beeper bridge-manager client and helpers (packages/bridge/src/beeper.ts) with tests and export them from the bridge package. Update RuntimeBridge to initialize appservice on startup, create/backfill portal rooms via appservice, and send remote messages through appservice ghost users when appropriate. Also update docs, tests, and package exports to expose the new Beeper APIs. --- examples/dummybridge/.env.example | 18 + examples/dummybridge/README.md | 46 +++ examples/dummybridge/package.json | 23 ++ examples/dummybridge/src/connector.ts | 195 +++++++++ examples/dummybridge/src/env.ts | 32 ++ examples/dummybridge/src/index.ts | 158 ++++++++ examples/dummybridge/src/store.ts | 143 +++++++ examples/dummybridge/test/smoke.ts | 157 ++++++++ examples/dummybridge/tsconfig.json | 8 + examples/dummybridge/tsdown.config.ts | 9 + packages/bridge/README.md | 46 ++- packages/bridge/docs/BRIDGE_TODO.md | 6 +- packages/bridge/package.json | 4 + packages/bridge/src/beeper.test.ts | 106 +++++ packages/bridge/src/beeper.ts | 246 ++++++++++++ packages/bridge/src/bridge.test.ts | 54 +++ packages/bridge/src/bridge.ts | 85 +++- packages/bridge/src/index.ts | 2 + packages/bridge/src/node.ts | 2 + packages/bridge/src/types.ts | 30 ++ packages/bridge/tsdown.config.ts | 2 +- .../pickle/native/internal/core/appservice.go | 373 ++++++++++++++++++ packages/pickle/native/internal/core/core.go | 13 + .../pickle/native/internal/core/operations.go | 12 + packages/pickle/src/client-types.ts | 22 +- packages/pickle/src/client.ts | 13 + .../src/generated-runtime-operations.ts | 38 ++ .../pickle/src/generated-runtime-types.ts | 77 ++++ packages/pickle/src/index.ts | 15 + packages/pickle/src/node.ts | 4 + packages/pickle/src/runtime-types.ts | 12 + pnpm-lock.yaml | 19 + 32 files changed, 1961 insertions(+), 9 deletions(-) create mode 100644 examples/dummybridge/.env.example create mode 100644 examples/dummybridge/README.md create mode 100644 examples/dummybridge/package.json create mode 100644 examples/dummybridge/src/connector.ts create mode 100644 examples/dummybridge/src/env.ts create mode 100644 examples/dummybridge/src/index.ts create mode 100644 examples/dummybridge/src/store.ts create mode 100644 examples/dummybridge/test/smoke.ts create mode 100644 examples/dummybridge/tsconfig.json create mode 100644 examples/dummybridge/tsdown.config.ts create mode 100644 packages/bridge/src/beeper.test.ts create mode 100644 packages/bridge/src/beeper.ts create mode 100644 packages/pickle/native/internal/core/appservice.go diff --git a/examples/dummybridge/.env.example b/examples/dummybridge/.env.example new file mode 100644 index 0000000..b28e33a --- /dev/null +++ b/examples/dummybridge/.env.example @@ -0,0 +1,18 @@ +MATRIX_HOMESERVER=https://matrix.example +MATRIX_ACCESS_TOKEN=syt_your_appservice_or_bot_token +MATRIX_SERVER_NAME=example + +# Optional: use Beeper bridge-manager-compatible APIs to fetch/register the appservice. +# BEEPER_ACCESS_TOKEN=your_beeper_access_token +# BEEPER_BASE_DOMAIN=beeper.com + +# Local registration fallback when BEEPER_ACCESS_TOKEN is not set. +DUMMYBRIDGE_AS_ID=dummybridge +DUMMYBRIDGE_AS_TOKEN=generate-a-long-random-token +DUMMYBRIDGE_HS_TOKEN=generate-another-long-random-token +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/README.md b/examples/dummybridge/README.md new file mode 100644 index 0000000..640f015 --- /dev/null +++ b/examples/dummybridge/README.md @@ -0,0 +1,46 @@ +# 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 +- backfill historical events +- receive Matrix messages and echo them back through appservice ghost users +- fetch/register Beeper appservice credentials with bridge-manager-compatible helpers + +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`, fill in a homeserver, token, server name, and appservice registration fields, then run: + +```sh +pnpm --filter @beeper/pickle-example-dummybridge start +``` + +If `BEEPER_ACCESS_TOKEN` is set, the example uses `createBeeperAppServiceInit()` to fetch/register the appservice through Beeper's bridge-manager-compatible Hungryserv endpoints. Without it, the example uses the local `DUMMYBRIDGE_AS_*` registration fields. + +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 +``` diff --git a/examples/dummybridge/package.json b/examples/dummybridge/package.json new file mode 100644 index 0000000..c116af6 --- /dev/null +++ b/examples/dummybridge/package.json @@ -0,0 +1,23 @@ +{ + "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:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "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..82160c6 --- /dev/null +++ b/examples/dummybridge/src/connector.ts @@ -0,0 +1,195 @@ +import { createRemoteMessage } from "@beeper/pickle-bridge"; +import type { + BridgeConfigPart, + BridgeConnector, + BridgeContext, + BridgeRequestContext, + BridgeUser, + DBMetaTypes, + FetchMessagesResponse, + LoginFlow, + LoginProcess, + LoginStep, + MatrixMessage, + MatrixMessageResponse, + NetworkAPI, + NetworkGeneralCapabilities, + UserLogin, +} from "@beeper/pickle-bridge/types"; + +export const LOGIN_ID = "dummy-login"; +export const PORTAL_ID = "dummy-room"; + +export interface DummyConnectorOptions { + senderLocalpart?: string; + serverName?: string; +} + +export function makeGhostMxid(localId: string, serverName: string, senderLocalpart = "dummybridgebot"): string { + const escaped = localId.toLowerCase().replace(/[^a-z0-9._=-]/g, "_"); + return `@${senderLocalpart}_${escaped}:${serverName}`; +} + +export class DummyConnector implements BridgeConnector { + #options: DummyConnectorOptions; + + constructor(options: DummyConnectorOptions = {}) { + this.#options = options; + } + + createLogin(_ctx: BridgeRequestContext, _user: BridgeUser, _flowId: string): LoginProcess { + return new DummyLoginProcess(); + } + + 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", + }]; + } + + getName() { + return { + beeperBridgeType: "dummybridge", + defaultCommandPrefix: "dummy", + displayName: "Pickle DummyBridge", + networkId: "dummybridge", + }; + } + + init(ctx: BridgeContext): void { + ctx.log("info", "dummybridge_init", {}); + } + + loadUserLogin(_ctx: BridgeRequestContext, login: UserLogin): NetworkAPI { + const options: DummyNetworkOptions = { login }; + if (this.#options.senderLocalpart !== undefined) options.senderLocalpart = this.#options.senderLocalpart; + if (this.#options.serverName !== undefined) options.serverName = this.#options.serverName; + return new DummyNetworkAPI(options); + } + + start(ctx: BridgeContext): void { + ctx.log("info", "dummybridge_start", {}); + } + + stop(): void {} +} + +class DummyLoginProcess implements LoginProcess { + cancel(): void {} + + async start(): Promise { + return { + complete: { + userLogin: { id: LOGIN_ID }, + userLoginId: LOGIN_ID, + }, + instructions: "DummyBridge creates a local dummy login without external auth.", + stepId: "complete", + type: "complete", + }; + } +} + +interface DummyNetworkOptions { + login?: UserLogin; + senderLocalpart?: string; + serverName?: string; +} + +export class DummyNetworkAPI implements NetworkAPI { + #login: UserLogin; + #senderLocalpart: string; + #serverName: string; + + constructor(options: DummyNetworkOptions = {}) { + this.#login = options.login ?? { id: LOGIN_ID }; + this.#senderLocalpart = options.senderLocalpart ?? "dummybridgebot"; + this.#serverName = options.serverName ?? "example"; + } + + connect(ctx: BridgeRequestContext): void { + ctx.log("info", "dummy_network_connected", { login: this.#login.id }); + } + + disconnect(): void {} + + async fetchMessages(): Promise { + return { + hasMore: false, + messages: [ + { + event: this.#remoteMessage({ + body: "DummyBridge historical hello", + id: "dummy-history-1", + timestamp: Date.now() - 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 }) { + const portalKey = { id: options.portalId ?? PORTAL_ID, receiver: this.#login.id }; + const sender = makeGhostMxid("alice", this.#serverName, this.#senderLocalpart); + return createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { + body: options.body, + msgtype: "m.text", + }, + type: "m.room.message", + }], + }), + data: {}, + id: options.id, + portalKey, + sender: { + isFromMe: false, + sender, + }, + timestamp: new Date(options.timestamp ?? Date.now()), + }); + } +} + +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..4d74f33 --- /dev/null +++ b/examples/dummybridge/src/env.ts @@ -0,0 +1,32 @@ +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(); + 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..00e7b31 --- /dev/null +++ b/examples/dummybridge/src/index.ts @@ -0,0 +1,158 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createBeeperAppServiceInit, createBridge } from "@beeper/pickle-bridge/node"; +import type { BeeperClientOptions, CreateAppServiceOptions } from "@beeper/pickle-bridge/node"; +import type { Portal } from "@beeper/pickle-bridge/types"; +import { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; +import { loadEnv, optionalEnv, requiredEnv } from "./env"; +import { FileState, MatrixState } from "./store"; + +const root = dirname(fileURLToPath(import.meta.url)); +const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve(root, ".."); +const dataDir = resolve(sourceRoot, ".data"); + +await loadEnv(resolve(sourceRoot, ".env")); + +const homeserver = requiredEnv("MATRIX_HOMESERVER"); +const token = requiredEnv("MATRIX_ACCESS_TOKEN"); +const serverName = requiredEnv("MATRIX_SERVER_NAME"); +const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; + +const appservice = process.env.BEEPER_ACCESS_TOKEN + ? await createBeeperAppServiceInit(beeperAppServiceOptions({ + address: optionalEnv("DUMMYBRIDGE_URL"), + baseDomain: optionalEnv("BEEPER_BASE_DOMAIN", "beeper.com"), + bridge: optionalEnv("DUMMYBRIDGE_AS_ID", "dummybridge") ?? "dummybridge", + homeserver, + homeserverDomain: serverName, + token: requiredEnv("BEEPER_ACCESS_TOKEN"), + })) + : localAppService({ + homeserver, + id: optionalEnv("DUMMYBRIDGE_AS_ID", "dummybridge") ?? "dummybridge", + senderLocalpart, + serverName, + url: optionalEnv("DUMMYBRIDGE_URL", "http://localhost:29300") ?? "http://localhost:29300", + }); + +const state = new FileState(resolve(dataDir, "state.json")); +await state.connect(); +await mkdir(dataDir, { recursive: true }); + +const bridge = createBridge({ + appservice, + connector: new DummyConnector({ senderLocalpart, serverName }), + matrix: { + homeserver, + store: new MatrixState(state, "dummybridge-matrix"), + token, + wasmPath: resolve(sourceRoot, "../../packages/pickle/dist/pickle.wasm"), + }, +}); + +await bridge.start(); +const login = { id: LOGIN_ID }; +await bridge.loadUserLogin(login); + +const ghostMxid = makeGhostMxid("alice", serverName, senderLocalpart); +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.createPortalRoom({ + invite: inviteUser ? [inviteUser] : [], + name: "Pickle DummyBridge", + portalKey: { id: PORTAL_ID, receiver: login.id }, + topic: "A TypeScript bridge built with Pickle.", + userId: ghostMxid, + }); + console.log(`created portal ${portal.mxid}`); +} + +if (portal?.mxid && optionalEnv("DUMMYBRIDGE_BACKFILL_ON_START") === "1") { + await bridge.backfill({ + events: [{ + content: { + body: "DummyBridge backfilled hello", + msgtype: "m.text", + }, + sender: ghostMxid, + timestamp: Date.now() - 60_000, + }], + roomId: portal.mxid, + }); + console.log(`backfilled ${portal.mxid}`); +} + +console.log("dummybridge running"); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, async () => { + await bridge.stop(); + await state.disconnect(); + process.exit(0); + }); +} + +function localAppService(options: { + homeserver: string; + id: string; + senderLocalpart: string; + serverName: string; + url: string; +}) { + return { + homeserver: options.homeserver, + homeserverDomain: options.serverName, + registration: { + asToken: requiredEnv("DUMMYBRIDGE_AS_TOKEN"), + hsToken: requiredEnv("DUMMYBRIDGE_HS_TOKEN"), + id: options.id, + namespaces: { + aliases: [], + rooms: [], + users: [{ + exclusive: true, + regex: `@${options.senderLocalpart}_.*:${escapeRegex(options.serverName)}`, + }], + }, + rateLimited: false, + senderLocalpart: options.senderLocalpart, + url: options.url, + }, + }; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function beeperAppServiceOptions(input: { + address: string | undefined; + baseDomain: string | undefined; + bridge: string; + homeserver: string; + homeserverDomain: string; + token: string; +}): BeeperClientOptions & CreateAppServiceOptions { + const output: BeeperClientOptions & CreateAppServiceOptions = { + bridge: input.bridge, + homeserver: input.homeserver, + homeserverDomain: input.homeserverDomain, + token: input.token, + }; + if (input.address !== undefined) output.address = input.address; + if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; + return output; +} diff --git a/examples/dummybridge/src/store.ts b/examples/dummybridge/src/store.ts new file mode 100644 index 0000000..a2d667c --- /dev/null +++ b/examples/dummybridge/src/store.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { MatrixStore } from "@beeper/pickle"; + +interface StateData { + locks: Record; + state: Record; +} + +export class FileState { + #connected = false; + #path: string; + #data: StateData = { + locks: {}, + state: {}, + }; + + constructor(path: string) { + this.#path = path; + } + + async acquireLock(threadId: string, ttlMs: number): Promise<{ expiresAt: number; threadId: string; token: string } | null> { + this.#ensureConnected(); + this.#cleanExpiredLocks(); + const existing = this.#data.locks[threadId]; + if (existing && existing.expiresAt > Date.now()) return null; + const lock = { expiresAt: Date.now() + ttlMs, threadId, token: randomUUID() }; + this.#data.locks[threadId] = lock; + await this.#save(); + return lock; + } + + async connect(): Promise { + if (this.#connected) return; + try { + this.#data = JSON.parse(await readFile(this.#path, "utf8")) as StateData; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } + this.#data.locks ??= {}; + this.#data.state ??= {}; + this.#connected = true; + this.#cleanExpiredLocks(); + await this.#save(); + } + + async delete(key: string): Promise { + this.#ensureConnected(); + delete this.#data.state[key]; + await this.#save(); + } + + async disconnect(): Promise { + if (!this.#connected) return; + await this.#save(); + this.#connected = false; + } + + async get(key: string): Promise { + this.#ensureConnected(); + return (this.#data.state[key] as T | undefined) ?? null; + } + + async releaseLock(lock: { threadId: string; token: string }): Promise { + this.#ensureConnected(); + const current = this.#data.locks[lock.threadId]; + if (current?.token === lock.token) { + delete this.#data.locks[lock.threadId]; + await this.#save(); + } + } + + async set(key: string, value: unknown): Promise { + this.#ensureConnected(); + this.#data.state[key] = value; + await this.#save(); + } + + #cleanExpiredLocks(): void { + const now = Date.now(); + for (const [threadId, lock] of Object.entries(this.#data.locks)) { + if (lock.expiresAt <= now) delete this.#data.locks[threadId]; + } + } + + #ensureConnected(): void { + if (!this.#connected) throw new Error("FileState is not connected"); + } + + async #save(): Promise { + await mkdir(dirname(this.#path), { recursive: true }); + await writeFile(this.#path, `${JSON.stringify(this.#data, null, 2)}\n`); + } +} + +export class MatrixState implements MatrixStore { + #indexKey: string; + #state: FileState; + #valuePrefix: string; + + constructor(state: FileState, namespace = "matrix") { + this.#state = state; + this.#indexKey = `${namespace}:index`; + this.#valuePrefix = `${namespace}:value:`; + } + + async delete(key: string): Promise { + await this.#state.delete(this.#key(key)); + const keys = new Set(await this.#index()); + if (keys.delete(key)) await this.#writeIndex(keys); + } + + async get(key: string): Promise { + const value = await this.#state.get(this.#key(key)); + return value ? Uint8Array.from(Buffer.from(value, "base64")) : null; + } + + async list(prefix: string): Promise { + return (await this.#index()).filter((key) => key.startsWith(prefix)).sort(); + } + + async set(key: string, value: Uint8Array): Promise { + await this.#state.set(this.#key(key), Buffer.from(value).toString("base64")); + const keys = new Set(await this.#index()); + if (!keys.has(key)) { + keys.add(key); + await this.#writeIndex(keys); + } + } + + async #index(): Promise { + return (await this.#state.get(this.#indexKey)) ?? []; + } + + #key(key: string): string { + return `${this.#valuePrefix}${key}`; + } + + async #writeIndex(keys: Set): Promise { + await this.#state.set(this.#indexKey, [...keys].sort()); + } +} diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts new file mode 100644 index 0000000..631918b --- /dev/null +++ b/examples/dummybridge/test/smoke.ts @@ -0,0 +1,157 @@ +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, makeGhostMxid } 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 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({ senderLocalpart: "dummybridgebot", serverName: "example" }) 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 ghost = makeGhostMxid("alice", "example", "dummybridgebot"); +const portal = await bridge.createPortalRoom({ + name: "Pickle DummyBridge", + portalKey: { id: PORTAL_ID, receiver: login.id }, + userId: ghost, +}); + +assert.equal(portal.mxid, "!dummy:example"); +assert.equal(calls.createRoom[0]?.userId, ghost); + +const backfill = await bridge.backfill({ + events: [{ + content: { body: "old dummy message", msgtype: "m.text" }, + sender: ghost, + timestamp: Date.now() - 60_000, + }], + roomId: portal.mxid, +}); + +assert.deepEqual(backfill.eventIds, ["$backfill-0"]); +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, ghost); +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/packages/bridge/README.md b/packages/bridge/README.md index 4996882..bcaaac7 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -5,9 +5,23 @@ Bridge-building runtime for Pickle. This package is intentionally separate from bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts -import { createBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; +import { createBeeperAppServiceInit, createBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; + +const appservice = process.env.BEEPER_ACCESS_TOKEN + ? await createBeeperAppServiceInit({ + bridge: "sh-example", + homeserver: process.env.MATRIX_HOMESERVER!, + homeserverDomain: process.env.MATRIX_SERVER_NAME!, + token: process.env.BEEPER_ACCESS_TOKEN, + }) + : { + homeserver: process.env.MATRIX_HOMESERVER!, + homeserverDomain: process.env.MATRIX_SERVER_NAME!, + registration, + }; const bridge = createBridge({ + appservice, matrix: { homeserver: process.env.MATRIX_HOMESERVER!, token: process.env.MATRIX_ACCESS_TOKEN!, @@ -20,17 +34,26 @@ await bridge.start(); const login = { id: "example-login" }; await bridge.loadUserLogin(login); -bridge.registerPortal({ - id: "remote-room-id", - mxid: "!matrix-room:example.com", +const portal = await bridge.createPortalRoom({ + name: "Remote room", portalKey: { id: "remote-room-id", receiver: login.id }, + userId: "@example_alice:example.com", +}); + +await bridge.backfill({ + roomId: portal.mxid!, + events: [{ + sender: "@example_alice:example.com", + timestamp: Date.now() - 60_000, + content: { msgtype: "m.text", body: "historical hello" }, + }], }); bridge.queueRemoteEvent(login, createRemoteMessage({ data: { text: "hello" }, id: "remote-message-id", portalKey: { id: "remote-room-id", receiver: login.id }, - sender: { isFromMe: false, sender: "remote-user-id" }, + sender: { isFromMe: false, sender: "@example_alice:example.com" }, convert: (_ctx, _portal, _intent, data) => ({ parts: [{ type: "m.room.message", @@ -43,3 +66,16 @@ bridge.queueRemoteEvent(login, createRemoteMessage({ The Node entrypoint uses the same Pickle WASM mechanism as `@beeper/pickle/node`. Browser and worker callers can import from `@beeper/pickle-bridge` and provide `wasmBytes`, `wasmModule`, or `wasmUrl`. + +## 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 index f88794a..a8cbdc0 100644 --- a/packages/bridge/docs/BRIDGE_TODO.md +++ b/packages/bridge/docs/BRIDGE_TODO.md @@ -12,6 +12,9 @@ to match bridgev2 concepts while using TypeScript idioms. - [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. @@ -26,7 +29,8 @@ to match bridgev2 concepts while using TypeScript idioms. - [x] Node entrypoint delegates to `@beeper/pickle/node`. - [ ] Browser/worker examples for `wasmModule` and `wasmUrl`. - [ ] Direct media helper. -- [ ] Appservice-mode Matrix connector, if Pickle grows appservice APIs. +- [x] Appservice-mode Matrix primitives exposed by Pickle WASM. +- [ ] Full bridgev2 database-backed Matrix connector. ## Network Connector Interfaces diff --git a/packages/bridge/package.json b/packages/bridge/package.json index aada0e7..2fb9d5d 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -24,6 +24,10 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js" }, + "./beeper": { + "types": "./dist/beeper.d.ts", + "import": "./dist/beeper.js" + }, "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts new file mode 100644 index 0000000..a2c6b6b --- /dev/null +++ b/packages/bridge/src/beeper.test.ts @@ -0,0 +1,106 @@ +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" }, + }); + } + expect(String(url)).toBe("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", + }); + }); + + 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", + } as Response; +} diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts new file mode 100644 index 0000000..95a3bf8 --- /dev/null +++ b/packages/bridge/src/beeper.ts @@ -0,0 +1,246 @@ +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; + getOnly?: 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); + return normalizeRegistration(await this.#hungryRequest("PUT", options.bridge, { + address: options.address, + push: options.push ?? Boolean(options.address), + self_hosted: options.selfHosted ?? true, + })); + } + + async createAppService(options: CreateAppServiceOptions): Promise { + const whoami = await this.whoami(); + const registration = await this.registerAppService(options); + return { + homeserver: options.homeserver ?? hungryHomeserver(this.#baseDomain, whoami.userInfo.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", path: string, body?: unknown, username?: 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 ${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}`); + } + return response.json() as Promise; + } +} + +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 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 index 9699816..def9257 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -139,6 +139,52 @@ describe("RuntimeBridge", () => { 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({ + 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.createRoom).toHaveBeenCalledWith(expect.objectContaining({ + name: "Remote room", + userId: "@test_alice:example", + })); + expect(client.appservice.batchSend).toHaveBeenCalledWith(expect.objectContaining({ + roomId: "!created:example", + })); + expect(backfill.eventIds).toEqual(["$backfilled"]); + }); }); function matrixConfig(): BridgeMatrixConfig { @@ -198,6 +244,14 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip }; return { accountData: {} as MatrixClient["accountData"], + appservice: { + batchSend: vi.fn(async () => ({ eventIds: ["$backfilled"], raw: {} })), + 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 () => {}), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 4964764..a9c4965 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -5,6 +5,10 @@ import type { BridgeLogger, BridgeRequestContext, CreateBridgeOptions, + BridgeBackfillOptions, + BridgeCreatePortalRoomOptions, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceSendMessageOptions, LoginProcess, NetworkAPI, PickleBridge, @@ -30,6 +34,7 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge { export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; + readonly #appserviceOptions: CreateBridgeOptions["appservice"]; readonly #networkClients = new Map(); readonly #messages = new Map(); readonly #portalsByKey = new Map(); @@ -44,6 +49,7 @@ export class RuntimeBridge implements PickleBridge { constructor(options: CreateBridgeOptions, client: MatrixClient) { this.connector = options.connector; + this.#appserviceOptions = options.appservice; this.#matrixClient = client; } @@ -59,6 +65,9 @@ export class RuntimeBridge implements PickleBridge { if (this.#started) return; const whoami = await this.#matrixClient.boot(); this.#ownUserId = whoami.userId; + if (this.#appserviceOptions) { + await this.#matrixClient.appservice.init(this.#appserviceOptions); + } this.#context = this.#createContext(); if ("validateConfig" in this.connector && typeof this.connector.validateConfig === "function") { await this.connector.validateConfig(); @@ -88,6 +97,49 @@ export class RuntimeBridge implements PickleBridge { return this.connector.createLogin(this.#requestContext(), user, flowId); } + async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { + this.#requestContext(); + const createOptions = stripUndefined({ + beeperAutoJoinInvites: options.beeperAutoJoinInvites, + beeperBridgeAccountId: options.beeperBridgeAccountId, + beeperBridgeName: options.beeperBridgeName, + beeperInitialMembers: options.beeperInitialMembers, + beeperLocalRoomId: options.beeperLocalRoomId, + creationContent: options.creationContent, + initialState: options.initialState?.map((state) => ({ + content: state.content, + stateKey: state.stateKey ?? "", + type: state.type, + })), + invite: options.invite, + isDirect: options.isDirect, + meowCreateTs: options.meowCreateTs, + meowRoomId: options.meowRoomId, + name: options.name, + preset: options.preset, + roomAliasName: options.roomAliasName, + roomVersion: options.roomVersion, + topic: options.topic, + userId: options.userId, + visibility: options.visibility, + }); + const result = await this.#matrixClient.appservice.createRoom(createOptions as MatrixAppserviceCreateRoomOptions); + 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 backfill(options: BridgeBackfillOptions) { + this.#requestContext(); + return this.#matrixClient.appservice.batchSend(options); + } + async loadUserLogin(login: UserLogin): Promise { const existing = this.#networkClients.get(login.id); if (existing) return existing; @@ -302,7 +354,8 @@ export class RuntimeBridge implements PickleBridge { } const converted = await event.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); for (const [index, part] of converted.parts.entries()) { - const sent = await this.#matrixIntent().sendMessage(portal.mxid, part.content); + const sender = event.getSender(); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, part.content, eventTimestamp(event)); this.#messages.set(messagePartKey(event.getID(), part.id ?? String(index)), { eventId: sent.eventId, raw: sent.raw, @@ -327,6 +380,19 @@ export class RuntimeBridge implements PickleBridge { }, }; } + + 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); + } } const defaultLogger: BridgeLogger = (level, message, data) => { @@ -359,3 +425,20 @@ function eventIdFromRaw(body: unknown): string { } 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 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/index.ts b/packages/bridge/src/index.ts index da44918..289984a 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,3 +1,5 @@ export { createBridge, RuntimeBridge } from "./bridge"; +export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createRemoteMessage } from "./events"; +export type * from "./beeper"; export type * from "./types"; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index 8a94098..9cc37b5 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,5 +1,6 @@ import { createMatrixClient } from "@beeper/pickle/node"; import { RuntimeBridge } from "./bridge"; +export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createRemoteMessage } from "./events"; import type { CreateNodeBridgeOptions, PickleBridge } from "./types"; @@ -8,3 +9,4 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { } export type * from "./types"; +export type * from "./beeper"; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 2ad130d..5cc7ceb 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -1,7 +1,13 @@ import type { MatrixAttachment, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInitOptions, + MatrixAppserviceSendMessageOptions, MatrixClient, MatrixClientOptions, + CreateRoomOptions, MatrixEventSender, MatrixMessageEvent, MatrixReactionEvent, @@ -470,6 +476,8 @@ export interface PickleBridge { readonly connector: BridgeConnector; readonly context: BridgeContext | null; createLogin(user: BridgeUser, flowId: string): Promise; + backfill(options: BridgeBackfillOptions): Promise; + createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise; flushRemoteEvents(): Promise; loadUserLogin(login: UserLogin): Promise; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; @@ -479,6 +487,7 @@ export interface PickleBridge { } export interface CreateBridgeOptions { + appservice?: MatrixAppserviceInitOptions; connector: BridgeConnector; matrix: BridgeMatrixConfig; } @@ -526,6 +535,27 @@ export interface QueueRemoteEventResult { queued: boolean; } +export interface BridgeCreatePortalRoomOptions extends CreateRoomOptions { + beeperAutoJoinInvites?: boolean; + beeperBridgeAccountId?: string; + beeperBridgeName?: string; + beeperInitialMembers?: string[]; + beeperLocalRoomId?: string; + metadata?: unknown; + meowCreateTs?: number; + meowRoomId?: string; + portalKey: PortalKey; + userId?: string; +} + +export interface BridgeBackfillOptions extends MatrixAppserviceBatchSendOptions {} + +export type { + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInitOptions, + MatrixAppserviceSendMessageOptions, +}; + export interface MatrixDispatchResult { dispatched: boolean; eventId?: EventID; diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 95a52ef..959c0cf 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts"], + entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts", "src/beeper.ts"], format: ["esm"], dts: { sourcemap: false, diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go new file mode 100644 index 0000000..f8b2789 --- /dev/null +++ b/packages/pickle/native/internal/core/appservice.go @@ -0,0 +1,373 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type matrixAppservice struct { + appToken string + botUserID id.UserID + 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"` + 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 + BeeperAutoJoinInvites bool `json:"beeperAutoJoinInvites,omitempty"` + BeeperBridgeAccountID string `json:"beeperBridgeAccountId,omitempty"` + BeeperBridgeName string `json:"beeperBridgeName,omitempty"` + BeeperInitialMembers []string `json:"beeperInitialMembers,omitempty"` + BeeperLocalRoomID string `json:"beeperLocalRoomId,omitempty"` + MeowCreateTS int64 `json:"meowCreateTs,omitempty"` + MeowRoomID string `json:"meowRoomId,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 == "" || req.HomeserverDomain == "" { + return nil, errors.New("homeserver and homeserverDomain are required") + } + if req.Registration.AppToken == "" || req.Registration.SenderLocalpart == "" || req.Registration.ID == "" { + return nil, errors.New("registration id, asToken and senderLocalpart are required") + } + as := &matrixAppservice{ + appToken: req.Registration.AppToken, + botUserID: id.NewUserID(req.Registration.SenderLocalpart, req.HomeserverDomain), + homeserver: req.Homeserver, + homeserverDomain: req.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) + createReq.MeowRoomID = id.RoomID(req.MeowRoomID) + createReq.MeowCreateTS = req.MeowCreateTS + createReq.BeeperInitialMembers = toUserIDs(req.BeeperInitialMembers) + createReq.BeeperAutoJoinInvites = req.BeeperAutoJoinInvites + createReq.BeeperLocalRoomID = id.RoomID(req.BeeperLocalRoomID) + createReq.BeeperBridgeName = req.BeeperBridgeName + createReq.BeeperBridgeAccountID = req.BeeperBridgeAccountID + 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) 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 + } + 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 err + } + if _, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}); inviteErr != nil { + return err + } + 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 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/core.go b/packages/pickle/native/internal/core/core.go index fa6f3a2..4e92892 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,18 @@ 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 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..1318d21 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -17,6 +17,18 @@ 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 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/src/client-types.ts b/packages/pickle/src/client-types.ts index afc28a6..48dbe6b 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -70,10 +70,21 @@ import type { UploadMediaResult, UserInfo, } from "./types"; +import type { + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + 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 +108,15 @@ export interface MatrixClient { whoami(): Promise; } +export interface MatrixAppservice { + batchSend(options: MatrixAppserviceBatchSendOptions): 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..48d98da 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,17 @@ 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)), + 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..a19df55 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -3,6 +3,14 @@ import type { MatrixAccountDataResult, MatrixApplySyncResponseOptions, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, MatrixBanUserOptions, MatrixBeeperStreamOptions, MatrixCoreInitOptions, @@ -85,6 +93,12 @@ 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; + appserviceSendMessage(options: MatrixAppserviceSendMessageOptions): Promise; + appserviceBatchSend(options: MatrixAppserviceBatchSendOptions): Promise; applySyncResponse(options: MatrixApplySyncResponseOptions): Promise; getAccountData(options: MatrixGetAccountDataOptions): Promise; setAccountData(options: MatrixSetAccountDataOptions): Promise; @@ -172,6 +186,30 @@ 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); + } + + 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..f546c17 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -16,6 +16,83 @@ 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 { + beeperAutoJoinInvites?: boolean; + beeperBridgeAccountId?: string; + beeperBridgeName?: string; + beeperInitialMembers?: string[]; + beeperLocalRoomId?: string; + meowCreateTs?: number /* int64 */; + meowRoomId?: 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..25eb888 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,20 @@ export type { MatrixToDevice, MatrixUsers, } from "./client-types"; +export type { + MatrixAppserviceBatchEvent, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceNamespace, + MatrixAppserviceNamespaces, + 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..a4f5206 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -9,6 +9,18 @@ import type { MatrixCoreOperations } from "./generated-runtime-operations"; export type { MatrixAccountDataResult, + MatrixAppserviceBatchEvent, + MatrixAppserviceBatchSendOptions, + MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateRoomOptions, + MatrixAppserviceInfo, + MatrixAppserviceInitOptions, + MatrixAppserviceNamespace, + MatrixAppserviceNamespaces, + MatrixAppserviceRegistration, + MatrixAppserviceRoomUserOptions, + MatrixAppserviceSendMessageOptions, + MatrixAppserviceUserOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, MatrixBeeperStreamOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e99b6b..1e031bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,25 @@ 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 + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.6.0 + 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': From 3cc73ad76e5957c70a814c598fc3d9eda48c6385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 19:48:56 +0200 Subject: [PATCH 03/21] Introduce createBeeperBridge and update example Add a new createBeeperBridge helper to the bridge runtime that boots a Matrix client, infers homeserver domain, and initializes/fetches Beeper appservice registration via createBeeperAppServiceInit. Export the new API from package entry points and provide corresponding node wrapper. Introduce CreateBeeperBridgeOptions and CreateNodeBeeperBridgeOptions types and factor option-building helpers. Refactor the dummybridge example to use createBeeperBridge (simplified .env.example and README) and remove the local appservice fallback and related helper code. --- examples/dummybridge/.env.example | 10 ++-- examples/dummybridge/README.md | 6 +-- examples/dummybridge/src/index.ts | 88 +++++-------------------------- packages/bridge/README.md | 19 ++----- packages/bridge/src/bridge.ts | 48 +++++++++++++++++ packages/bridge/src/index.ts | 2 +- packages/bridge/src/node.ts | 8 ++- packages/bridge/src/types.ts | 13 +++++ 8 files changed, 91 insertions(+), 103 deletions(-) diff --git a/examples/dummybridge/.env.example b/examples/dummybridge/.env.example index b28e33a..a8e78e1 100644 --- a/examples/dummybridge/.env.example +++ b/examples/dummybridge/.env.example @@ -2,14 +2,10 @@ MATRIX_HOMESERVER=https://matrix.example MATRIX_ACCESS_TOKEN=syt_your_appservice_or_bot_token MATRIX_SERVER_NAME=example -# Optional: use Beeper bridge-manager-compatible APIs to fetch/register the appservice. -# BEEPER_ACCESS_TOKEN=your_beeper_access_token +# Optional bridge-manager-compatible settings. # BEEPER_BASE_DOMAIN=beeper.com - -# Local registration fallback when BEEPER_ACCESS_TOKEN is not set. -DUMMYBRIDGE_AS_ID=dummybridge -DUMMYBRIDGE_AS_TOKEN=generate-a-long-random-token -DUMMYBRIDGE_HS_TOKEN=generate-another-long-random-token +# DUMMYBRIDGE_BRIDGE_NAME=dummybridge +# DUMMYBRIDGE_URL=https://bridge.example DUMMYBRIDGE_SENDER_LOCALPART=dummybridgebot # Optional live actions after startup. diff --git a/examples/dummybridge/README.md b/examples/dummybridge/README.md index 640f015..36d04e5 100644 --- a/examples/dummybridge/README.md +++ b/examples/dummybridge/README.md @@ -11,7 +11,7 @@ It demonstrates the bridge shape needed to: - create or register a portal room - backfill historical events - receive Matrix messages and echo them back through appservice ghost users -- fetch/register Beeper appservice credentials with bridge-manager-compatible helpers +- fetch/register Beeper appservice credentials from the Matrix login Source lives in `src/*.ts`; the runnable files are built into `dist`. @@ -25,13 +25,13 @@ The smoke test uses a fake Matrix client, so it does not need a homeserver. ## Live run -Copy `.env.example` to `.env`, fill in a homeserver, token, server name, and appservice registration fields, then run: +Copy `.env.example` to `.env`, fill in a homeserver, Matrix access token, and server name, then run: ```sh pnpm --filter @beeper/pickle-example-dummybridge start ``` -If `BEEPER_ACCESS_TOKEN` is set, the example uses `createBeeperAppServiceInit()` to fetch/register the appservice through Beeper's bridge-manager-compatible Hungryserv endpoints. Without it, the example uses the local `DUMMYBRIDGE_AS_*` registration fields. +`createBeeperBridge()` boots the Pickle Matrix client, uses the Matrix token to fetch/register the Beeper appservice through the bridge-manager-compatible Hungryserv endpoints, then starts the bridge runtime with the computed appservice registration. To create a portal at startup: diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index 00e7b31..957e4a7 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,8 +1,7 @@ import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createBeeperAppServiceInit, createBridge } from "@beeper/pickle-bridge/node"; -import type { BeeperClientOptions, CreateAppServiceOptions } from "@beeper/pickle-bridge/node"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { Portal } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; @@ -19,37 +18,30 @@ const token = requiredEnv("MATRIX_ACCESS_TOKEN"); const serverName = requiredEnv("MATRIX_SERVER_NAME"); const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; -const appservice = process.env.BEEPER_ACCESS_TOKEN - ? await createBeeperAppServiceInit(beeperAppServiceOptions({ - address: optionalEnv("DUMMYBRIDGE_URL"), - baseDomain: optionalEnv("BEEPER_BASE_DOMAIN", "beeper.com"), - bridge: optionalEnv("DUMMYBRIDGE_AS_ID", "dummybridge") ?? "dummybridge", - homeserver, - homeserverDomain: serverName, - token: requiredEnv("BEEPER_ACCESS_TOKEN"), - })) - : localAppService({ - homeserver, - id: optionalEnv("DUMMYBRIDGE_AS_ID", "dummybridge") ?? "dummybridge", - senderLocalpart, - serverName, - url: optionalEnv("DUMMYBRIDGE_URL", "http://localhost:29300") ?? "http://localhost:29300", - }); - const state = new FileState(resolve(dataDir, "state.json")); await state.connect(); await mkdir(dataDir, { recursive: true }); -const bridge = createBridge({ - appservice, +const bridge = await createBeeperBridge(beeperBridgeOptions()); + +function beeperBridgeOptions(): Parameters[0] { + const address = optionalEnv("DUMMYBRIDGE_URL"); + const baseDomain = optionalEnv("BEEPER_BASE_DOMAIN", "beeper.com"); + const output: Parameters[0] = { + bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "dummybridge") ?? "dummybridge", connector: new DummyConnector({ senderLocalpart, serverName }), + homeserverDomain: serverName, matrix: { homeserver, store: new MatrixState(state, "dummybridge-matrix"), token, wasmPath: resolve(sourceRoot, "../../packages/pickle/dist/pickle.wasm"), }, -}); + }; + if (address !== undefined) output.address = address; + if (baseDomain !== undefined) output.baseDomain = baseDomain; + return output; +} await bridge.start(); const login = { id: LOGIN_ID }; @@ -104,55 +96,3 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { process.exit(0); }); } - -function localAppService(options: { - homeserver: string; - id: string; - senderLocalpart: string; - serverName: string; - url: string; -}) { - return { - homeserver: options.homeserver, - homeserverDomain: options.serverName, - registration: { - asToken: requiredEnv("DUMMYBRIDGE_AS_TOKEN"), - hsToken: requiredEnv("DUMMYBRIDGE_HS_TOKEN"), - id: options.id, - namespaces: { - aliases: [], - rooms: [], - users: [{ - exclusive: true, - regex: `@${options.senderLocalpart}_.*:${escapeRegex(options.serverName)}`, - }], - }, - rateLimited: false, - senderLocalpart: options.senderLocalpart, - url: options.url, - }, - }; -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function beeperAppServiceOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - homeserver: string; - homeserverDomain: string; - token: string; -}): BeeperClientOptions & CreateAppServiceOptions { - const output: BeeperClientOptions & CreateAppServiceOptions = { - bridge: input.bridge, - homeserver: input.homeserver, - homeserverDomain: input.homeserverDomain, - token: input.token, - }; - if (input.address !== undefined) output.address = input.address; - if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; - return output; -} diff --git a/packages/bridge/README.md b/packages/bridge/README.md index bcaaac7..7600a6d 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -5,23 +5,10 @@ Bridge-building runtime for Pickle. This package is intentionally separate from bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts -import { createBeeperAppServiceInit, createBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; +import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; -const appservice = process.env.BEEPER_ACCESS_TOKEN - ? await createBeeperAppServiceInit({ - bridge: "sh-example", - homeserver: process.env.MATRIX_HOMESERVER!, - homeserverDomain: process.env.MATRIX_SERVER_NAME!, - token: process.env.BEEPER_ACCESS_TOKEN, - }) - : { - homeserver: process.env.MATRIX_HOMESERVER!, - homeserverDomain: process.env.MATRIX_SERVER_NAME!, - registration, - }; - -const bridge = createBridge({ - appservice, +const bridge = await createBeeperBridge({ + bridge: "sh-example", matrix: { homeserver: process.env.MATRIX_HOMESERVER!, token: process.env.MATRIX_ACCESS_TOKEN!, diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index a9c4965..6bde854 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,9 +1,11 @@ import { createMatrixClient } from "@beeper/pickle"; import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import { createBeeperAppServiceInit } from "./beeper"; import type { BridgeContext, BridgeLogger, BridgeRequestContext, + CreateBeeperBridgeOptions, CreateBridgeOptions, BridgeBackfillOptions, BridgeCreatePortalRoomOptions, @@ -32,6 +34,23 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); } +export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { + const client = createMatrixClient(options.matrix); + const whoami = await client.boot(); + const token = options.token ?? options.matrix.token; + if (!token) throw new Error("createBeeperBridge requires a Matrix access token"); + const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + getOnly: options.getOnly, + homeserver: options.matrix.homeserver, + homeserverDomain: options.homeserverDomain ?? domainFromUserID(whoami.userId), + token, + })); + return new RuntimeBridge({ appservice, connector: options.connector, matrix: options.matrix }, client); +} + export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; readonly #appserviceOptions: CreateBridgeOptions["appservice"]; @@ -442,3 +461,32 @@ function stripUndefined>(value: T): T { } return value; } + +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; + getOnly: boolean | undefined; + homeserver: string | undefined; + homeserverDomain: string; + token: string; +}) { + const output = { + bridge: input.bridge, + homeserverDomain: input.homeserverDomain, + token: input.token, + } as Parameters[0]; + if (input.address !== undefined) output.address = input.address; + if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; + if (input.getOnly !== undefined) output.getOnly = input.getOnly; + if (input.homeserver !== undefined) output.homeserver = input.homeserver; + return output; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 289984a..bb63a38 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,4 +1,4 @@ -export { createBridge, RuntimeBridge } from "./bridge"; +export { createBeeperBridge, createBridge, RuntimeBridge } from "./bridge"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createRemoteMessage } from "./events"; export type * from "./beeper"; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index 9cc37b5..da5c66b 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,12 +1,16 @@ import { createMatrixClient } from "@beeper/pickle/node"; -import { RuntimeBridge } from "./bridge"; +import { RuntimeBridge, createBeeperBridge as createRuntimeBeeperBridge } from "./bridge"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createRemoteMessage } from "./events"; -import type { CreateNodeBridgeOptions, PickleBridge } from "./types"; +import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); } +export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { + return createRuntimeBeeperBridge(options); +} + export type * from "./types"; export type * from "./beeper"; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 5cc7ceb..074ca1d 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -492,6 +492,15 @@ export interface CreateBridgeOptions { matrix: BridgeMatrixConfig; } +export interface CreateBeeperBridgeOptions extends Omit { + address?: string; + baseDomain?: string; + bridge: string; + getOnly?: boolean; + homeserverDomain?: string; + token?: string; +} + export interface BridgeMatrixConfig extends Pick { store: MatrixStore; } @@ -505,6 +514,10 @@ export interface CreateNodeBridgeOptions extends Omit { + matrix: NodeBridgeMatrixConfig; +} + export interface BridgeContext { bridge: PickleBridge; client: MatrixClient; From f2b00eced39625e260764ba2f05a948cde304ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 19:56:31 +0200 Subject: [PATCH 04/21] Add Bridge data store and Beeper auth flows Introduce a persistent BridgeDataStore and MatrixBridgeDataStore (createBridgeDataStore) and wire it into the runtime so portals, messages and user logins can be persisted. Add high-level APIs to create bridges from a Beeper token or username/password (createBeeperBridgeFromToken / createBeeperBridgeFromPassword) and corresponding node helpers that default to a file-backed Matrix store. Update RuntimeBridge to accept an optional dataStore and persist relevant state; export store types/creators. Update example dummybridge to use BEEPER_* env vars and the new state-file package (remove the example FileState), and update package exports, deps, tsdown and vitest config accordingly. --- examples/dummybridge/.env.example | 10 +- examples/dummybridge/README.md | 4 +- examples/dummybridge/package.json | 3 +- examples/dummybridge/src/index.ts | 43 +++----- examples/dummybridge/src/store.ts | 143 --------------------------- packages/bridge/README.md | 18 ++-- packages/bridge/package.json | 7 +- packages/bridge/src/bridge.ts | 159 +++++++++++++++++++++++++++++- packages/bridge/src/index.ts | 4 +- packages/bridge/src/node.ts | 48 ++++++++- packages/bridge/src/store.ts | 101 +++++++++++++++++++ packages/bridge/src/types.ts | 24 +++++ packages/bridge/tsdown.config.ts | 4 +- packages/bridge/vitest.config.ts | 1 + pnpm-lock.yaml | 6 ++ 15 files changed, 379 insertions(+), 196 deletions(-) delete mode 100644 examples/dummybridge/src/store.ts create mode 100644 packages/bridge/src/store.ts diff --git a/examples/dummybridge/.env.example b/examples/dummybridge/.env.example index a8e78e1..df0b0ed 100644 --- a/examples/dummybridge/.env.example +++ b/examples/dummybridge/.env.example @@ -1,11 +1,15 @@ -MATRIX_HOMESERVER=https://matrix.example -MATRIX_ACCESS_TOKEN=syt_your_appservice_or_bot_token -MATRIX_SERVER_NAME=example +# Option 1: easiest if you already have a Beeper/Matrix token. +BEEPER_ACCESS_TOKEN=your_beeper_access_token + +# Option 2: login with username/password instead. The resulting session is cached. +# 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 +# MATRIX_SERVER_NAME=beeper.local DUMMYBRIDGE_SENDER_LOCALPART=dummybridgebot # Optional live actions after startup. diff --git a/examples/dummybridge/README.md b/examples/dummybridge/README.md index 36d04e5..56f5cbb 100644 --- a/examples/dummybridge/README.md +++ b/examples/dummybridge/README.md @@ -25,13 +25,13 @@ The smoke test uses a fake Matrix client, so it does not need a homeserver. ## Live run -Copy `.env.example` to `.env`, fill in a homeserver, Matrix access token, and server name, then run: +Copy `.env.example` to `.env`, then fill in either `BEEPER_ACCESS_TOKEN` or `BEEPER_USERNAME`/`BEEPER_PASSWORD`: ```sh pnpm --filter @beeper/pickle-example-dummybridge start ``` -`createBeeperBridge()` boots the Pickle Matrix client, uses the Matrix token to fetch/register the Beeper appservice through the bridge-manager-compatible Hungryserv endpoints, then starts the bridge runtime with the computed appservice registration. +`createBeeperBridgeFromToken()` and `createBeeperBridgeFromPassword()` fetch/register the Beeper appservice through the bridge-manager-compatible Hungryserv endpoints, infer the Hungryserv Matrix homeserver, use the default file-backed state package, and start the bridge runtime with the computed appservice registration. Password login sessions are cached in the bridge data store. To create a portal at startup: diff --git a/examples/dummybridge/package.json b/examples/dummybridge/package.json index c116af6..0f9d6d7 100644 --- a/examples/dummybridge/package.json +++ b/examples/dummybridge/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@beeper/pickle": "workspace:*", - "@beeper/pickle-bridge": "workspace:*" + "@beeper/pickle-bridge": "workspace:*", + "@beeper/pickle-state-file": "workspace:*" }, "devDependencies": { "@types/node": "^25.3.2", diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index 957e4a7..0acc6a6 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,47 +1,33 @@ -import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createBeeperBridge } from "@beeper/pickle-bridge/node"; +import { createBeeperBridgeFromPassword, createBeeperBridgeFromToken } from "@beeper/pickle-bridge/node"; import type { Portal } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; -import { FileState, MatrixState } from "./store"; const root = dirname(fileURLToPath(import.meta.url)); const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve(root, ".."); -const dataDir = resolve(sourceRoot, ".data"); await loadEnv(resolve(sourceRoot, ".env")); -const homeserver = requiredEnv("MATRIX_HOMESERVER"); -const token = requiredEnv("MATRIX_ACCESS_TOKEN"); -const serverName = requiredEnv("MATRIX_SERVER_NAME"); +const serverName = optionalEnv("MATRIX_SERVER_NAME", "beeper.local") ?? "beeper.local"; const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; -const state = new FileState(resolve(dataDir, "state.json")); -await state.connect(); -await mkdir(dataDir, { recursive: true }); - -const bridge = await createBeeperBridge(beeperBridgeOptions()); - -function beeperBridgeOptions(): Parameters[0] { - const address = optionalEnv("DUMMYBRIDGE_URL"); - const baseDomain = optionalEnv("BEEPER_BASE_DOMAIN", "beeper.com"); - const output: Parameters[0] = { +const bridgeOptions = { bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "dummybridge") ?? "dummybridge", connector: new DummyConnector({ senderLocalpart, serverName }), homeserverDomain: serverName, - matrix: { - homeserver, - store: new MatrixState(state, "dummybridge-matrix"), - token, - wasmPath: resolve(sourceRoot, "../../packages/pickle/dist/pickle.wasm"), - }, - }; - if (address !== undefined) output.address = address; - if (baseDomain !== undefined) output.baseDomain = baseDomain; - return output; -} +}; +const bridge = process.env.BEEPER_USERNAME && process.env.BEEPER_PASSWORD + ? await createBeeperBridgeFromPassword({ + ...bridgeOptions, + password: requiredEnv("BEEPER_PASSWORD"), + username: requiredEnv("BEEPER_USERNAME"), + }) + : await createBeeperBridgeFromToken({ + ...bridgeOptions, + token: requiredEnv("BEEPER_ACCESS_TOKEN"), + }); await bridge.start(); const login = { id: LOGIN_ID }; @@ -92,7 +78,6 @@ console.log("dummybridge running"); for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, async () => { await bridge.stop(); - await state.disconnect(); process.exit(0); }); } diff --git a/examples/dummybridge/src/store.ts b/examples/dummybridge/src/store.ts deleted file mode 100644 index a2d667c..0000000 --- a/examples/dummybridge/src/store.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import type { MatrixStore } from "@beeper/pickle"; - -interface StateData { - locks: Record; - state: Record; -} - -export class FileState { - #connected = false; - #path: string; - #data: StateData = { - locks: {}, - state: {}, - }; - - constructor(path: string) { - this.#path = path; - } - - async acquireLock(threadId: string, ttlMs: number): Promise<{ expiresAt: number; threadId: string; token: string } | null> { - this.#ensureConnected(); - this.#cleanExpiredLocks(); - const existing = this.#data.locks[threadId]; - if (existing && existing.expiresAt > Date.now()) return null; - const lock = { expiresAt: Date.now() + ttlMs, threadId, token: randomUUID() }; - this.#data.locks[threadId] = lock; - await this.#save(); - return lock; - } - - async connect(): Promise { - if (this.#connected) return; - try { - this.#data = JSON.parse(await readFile(this.#path, "utf8")) as StateData; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; - } - this.#data.locks ??= {}; - this.#data.state ??= {}; - this.#connected = true; - this.#cleanExpiredLocks(); - await this.#save(); - } - - async delete(key: string): Promise { - this.#ensureConnected(); - delete this.#data.state[key]; - await this.#save(); - } - - async disconnect(): Promise { - if (!this.#connected) return; - await this.#save(); - this.#connected = false; - } - - async get(key: string): Promise { - this.#ensureConnected(); - return (this.#data.state[key] as T | undefined) ?? null; - } - - async releaseLock(lock: { threadId: string; token: string }): Promise { - this.#ensureConnected(); - const current = this.#data.locks[lock.threadId]; - if (current?.token === lock.token) { - delete this.#data.locks[lock.threadId]; - await this.#save(); - } - } - - async set(key: string, value: unknown): Promise { - this.#ensureConnected(); - this.#data.state[key] = value; - await this.#save(); - } - - #cleanExpiredLocks(): void { - const now = Date.now(); - for (const [threadId, lock] of Object.entries(this.#data.locks)) { - if (lock.expiresAt <= now) delete this.#data.locks[threadId]; - } - } - - #ensureConnected(): void { - if (!this.#connected) throw new Error("FileState is not connected"); - } - - async #save(): Promise { - await mkdir(dirname(this.#path), { recursive: true }); - await writeFile(this.#path, `${JSON.stringify(this.#data, null, 2)}\n`); - } -} - -export class MatrixState implements MatrixStore { - #indexKey: string; - #state: FileState; - #valuePrefix: string; - - constructor(state: FileState, namespace = "matrix") { - this.#state = state; - this.#indexKey = `${namespace}:index`; - this.#valuePrefix = `${namespace}:value:`; - } - - async delete(key: string): Promise { - await this.#state.delete(this.#key(key)); - const keys = new Set(await this.#index()); - if (keys.delete(key)) await this.#writeIndex(keys); - } - - async get(key: string): Promise { - const value = await this.#state.get(this.#key(key)); - return value ? Uint8Array.from(Buffer.from(value, "base64")) : null; - } - - async list(prefix: string): Promise { - return (await this.#index()).filter((key) => key.startsWith(prefix)).sort(); - } - - async set(key: string, value: Uint8Array): Promise { - await this.#state.set(this.#key(key), Buffer.from(value).toString("base64")); - const keys = new Set(await this.#index()); - if (!keys.has(key)) { - keys.add(key); - await this.#writeIndex(keys); - } - } - - async #index(): Promise { - return (await this.#state.get(this.#indexKey)) ?? []; - } - - #key(key: string): string { - return `${this.#valuePrefix}${key}`; - } - - async #writeIndex(keys: Set): Promise { - await this.#state.set(this.#indexKey, [...keys].sort()); - } -} diff --git a/packages/bridge/README.md b/packages/bridge/README.md index 7600a6d..c201d94 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -5,18 +5,22 @@ Bridge-building runtime for Pickle. This package is intentionally separate from bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts -import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; +import { createBeeperBridgeFromToken, createRemoteMessage } from "@beeper/pickle-bridge/node"; -const bridge = await createBeeperBridge({ +const bridge = await createBeeperBridgeFromToken({ bridge: "sh-example", - matrix: { - homeserver: process.env.MATRIX_HOMESERVER!, - token: process.env.MATRIX_ACCESS_TOKEN!, - store, - }, + token: process.env.BEEPER_ACCESS_TOKEN!, connector, }); +// Or: +// const bridge = await createBeeperBridgeFromPassword({ +// bridge: "sh-example", +// username: process.env.BEEPER_USERNAME!, +// password: process.env.BEEPER_PASSWORD!, +// connector, +// }); + await bridge.start(); const login = { id: "example-login" }; diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 2fb9d5d..423f7d7 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -28,6 +28,10 @@ "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" @@ -49,7 +53,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@beeper/pickle": "workspace:*" + "@beeper/pickle": "workspace:*", + "@beeper/pickle-state-file": "workspace:*" }, "devDependencies": { "@types/node": "^25.3.2", diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 6bde854..9a6e92c 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,11 +1,14 @@ +import { loginWithMatrixPassword } from "@beeper/pickle/auth"; import { createMatrixClient } from "@beeper/pickle"; -import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; -import { createBeeperAppServiceInit } from "./beeper"; +import type { MatrixAccount, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import { createBeeperAppService, createBeeperAppServiceInit } from "./beeper"; import type { BridgeContext, BridgeLogger, BridgeRequestContext, CreateBeeperBridgeOptions, + CreateBeeperBridgeFromPasswordOptions, + CreateBeeperBridgeFromTokenOptions, CreateBridgeOptions, BridgeBackfillOptions, BridgeCreatePortalRoomOptions, @@ -51,9 +54,48 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr return new RuntimeBridge({ appservice, connector: options.connector, matrix: options.matrix }, client); } +export async function createBeeperBridgeFromToken(options: CreateBeeperBridgeFromTokenOptions): Promise { + const manager = await createBeeperAppService(beeperTokenAppServiceOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + getOnly: options.getOnly, + token: options.token, + })); + return createBeeperBridge(beeperBridgeOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + connector: options.connector, + getOnly: true, + homeserverDomain: options.homeserverDomain ?? manager.homeserverDomain, + matrix: bridgeMatrixConfig(options.matrix, manager.homeserver, options.token), + token: options.token, + })); +} + +export async function createBeeperBridgeFromPassword(options: CreateBeeperBridgeFromPasswordOptions): Promise { + const accountKey = `beeper:${options.baseDomain ?? "beeper.com"}:${options.username}`; + const existing = await options.dataStore?.getAccount(accountKey); + const account = existing ?? await loginWithMatrixPassword(passwordLoginOptions(options)); + if (!existing) await options.dataStore?.setAccount(accountKey, account); + return createBeeperBridgeFromToken(beeperPasswordBridgeOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + connector: options.connector, + dataStore: options.dataStore, + getOnly: options.getOnly, + homeserverDomain: options.homeserverDomain, + matrix: passwordBridgeMatrixConfig(options.matrix, account), + token: account.accessToken, + })); +} + export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; readonly #appserviceOptions: CreateBridgeOptions["appservice"]; + readonly #dataStore: CreateBridgeOptions["dataStore"]; readonly #networkClients = new Map(); readonly #messages = new Map(); readonly #portalsByKey = new Map(); @@ -69,6 +111,7 @@ export class RuntimeBridge implements PickleBridge { constructor(options: CreateBridgeOptions, client: MatrixClient) { this.connector = options.connector; this.#appserviceOptions = options.appservice; + this.#dataStore = options.dataStore; this.#matrixClient = client; } @@ -165,6 +208,7 @@ export class RuntimeBridge implements PickleBridge { const client = await this.connector.loadUserLogin(this.#requestContext(), login); login.client = client; this.#networkClients.set(login.id, client); + await this.#dataStore?.setUserLogin(login); await client.connect({ ...this.#requestContext(), login }); return client; } @@ -180,6 +224,9 @@ export class RuntimeBridge implements PickleBridge { if (portal.mxid) { this.#portalsByRoom.set(portal.mxid, portal); } + void this.#dataStore?.setPortal(portal).catch((error: unknown) => { + defaultLogger("warn", "portal_store_failed", { error }); + }); } async flushRemoteEvents(): Promise { @@ -217,12 +264,14 @@ export class RuntimeBridge implements PickleBridge { } #createContext(): BridgeContext { - return { + const context: BridgeContext = { bridge: this, client: this.#matrixClient, log: defaultLogger, queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), }; + if (this.#dataStore) context.dataStore = this.#dataStore; + return context; } async #subscribeMatrixEvents(): Promise { @@ -375,11 +424,14 @@ export class RuntimeBridge implements PickleBridge { 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)); - this.#messages.set(messagePartKey(event.getID(), part.id ?? String(index)), { + 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); } } @@ -490,3 +542,100 @@ function beeperAppServiceOptions(input: { if (input.homeserver !== undefined) output.homeserver = input.homeserver; return output; } + +function bridgeMatrixConfig(matrix: CreateBeeperBridgeFromTokenOptions["matrix"], homeserver: string, token: string): CreateBeeperBridgeOptions["matrix"] { + const output = { + ...matrix, + homeserver: matrix?.homeserver ?? homeserver, + token: matrix?.token ?? token, + }; + if (!output.store) throw new Error("createBeeperBridgeFromToken requires a Matrix store"); + return output as CreateBeeperBridgeOptions["matrix"]; +} + +function passwordLoginOptions(options: CreateBeeperBridgeFromPasswordOptions): Parameters[0] { + const output: Parameters[0] = { + homeserver: options.matrix?.homeserver ?? `https://matrix.${options.baseDomain ?? "beeper.com"}`, + initialDeviceDisplayName: "Pickle Bridge", + password: options.password, + username: options.username, + }; + if (options.matrix?.fetch !== undefined) output.fetch = options.matrix.fetch; + return output; +} + +function passwordBridgeMatrixConfig(matrix: CreateBeeperBridgeFromPasswordOptions["matrix"], account: MatrixAccount): NonNullable { + if (!matrix?.store) throw new Error("createBeeperBridgeFromPassword requires a Matrix store"); + return { + ...matrix, + account, + homeserver: matrix.homeserver ?? account.homeserver, + store: matrix.store, + token: account.accessToken, + }; +} + +function beeperPasswordBridgeOptions(input: { + address: string | undefined; + baseDomain: string | undefined; + bridge: string; + connector: CreateBeeperBridgeOptions["connector"]; + dataStore: CreateBeeperBridgeOptions["dataStore"] | undefined; + getOnly: boolean | undefined; + homeserverDomain: string | undefined; + matrix: NonNullable; + token: string; +}): CreateBeeperBridgeFromTokenOptions { + const output: CreateBeeperBridgeFromTokenOptions = { + bridge: input.bridge, + connector: input.connector, + matrix: input.matrix, + token: input.token, + }; + if (input.address !== undefined) output.address = input.address; + if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; + if (input.dataStore !== undefined) output.dataStore = input.dataStore; + if (input.getOnly !== undefined) output.getOnly = input.getOnly; + if (input.homeserverDomain !== undefined) output.homeserverDomain = input.homeserverDomain; + return output; +} + +function beeperTokenAppServiceOptions(input: { + address: string | undefined; + baseDomain: string | undefined; + bridge: string; + getOnly: boolean | 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.getOnly !== undefined) output.getOnly = input.getOnly; + return output; +} + +function beeperBridgeOptions(input: { + address: string | undefined; + baseDomain: string | undefined; + bridge: string; + connector: CreateBeeperBridgeOptions["connector"]; + getOnly: boolean; + homeserverDomain: string; + matrix: CreateBeeperBridgeOptions["matrix"]; + token: string; +}) { + const output = { + bridge: input.bridge, + connector: input.connector, + getOnly: input.getOnly, + homeserverDomain: input.homeserverDomain, + matrix: input.matrix, + token: input.token, + } as CreateBeeperBridgeOptions; + if (input.address !== undefined) output.address = input.address; + if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; + return output; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index bb63a38..ffa6799 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,5 +1,7 @@ -export { createBeeperBridge, createBridge, RuntimeBridge } from "./bridge"; +export { createBeeperBridge, createBeeperBridgeFromPassword, createBeeperBridgeFromToken, createBridge, RuntimeBridge } from "./bridge"; +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"; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index da5c66b..ee0de87 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,8 +1,23 @@ import { createMatrixClient } from "@beeper/pickle/node"; -import { RuntimeBridge, createBeeperBridge as createRuntimeBeeperBridge } from "./bridge"; +import { createFileMatrixStore } from "@beeper/pickle-state-file"; +import { resolve } from "node:path"; +import { + RuntimeBridge, + createBeeperBridge as createRuntimeBeeperBridge, + createBeeperBridgeFromPassword as createRuntimeBeeperBridgeFromPassword, + createBeeperBridgeFromToken as createRuntimeBeeperBridgeFromToken, +} from "./bridge"; +import { createBridgeDataStore } from "./store"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; +export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export { createRemoteMessage } from "./events"; -import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; +import type { + CreateNodeBeeperBridgeFromPasswordOptions, + CreateNodeBeeperBridgeFromTokenOptions, + CreateNodeBeeperBridgeOptions, + CreateNodeBridgeOptions, + PickleBridge, +} from "./types"; export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); @@ -12,5 +27,34 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) return createRuntimeBeeperBridge(options); } +export async function createBeeperBridgeFromToken(options: CreateNodeBeeperBridgeFromTokenOptions): Promise { + const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + return createRuntimeBeeperBridgeFromToken({ + ...options, + dataStore: options.dataStore ?? createBridgeDataStore(store), + matrix: { + ...options.matrix, + store, + }, + }); +} + +export async function createBeeperBridgeFromPassword(options: CreateNodeBeeperBridgeFromPasswordOptions): Promise { + const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + return createRuntimeBeeperBridgeFromPassword({ + ...options, + dataStore: options.dataStore ?? createBridgeDataStore(store), + matrix: { + ...options.matrix, + store, + }, + }); +} + +function defaultDataDir(options: { bridge: string; dataDir?: string }): string { + return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); +} + export type * from "./types"; export type * from "./beeper"; +export type * from "./store"; diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts new file mode 100644 index 0000000..523195a --- /dev/null +++ b/packages/bridge/src/store.ts @@ -0,0 +1,101 @@ +import type { MatrixStore, MatrixAccount, SentEvent } from "@beeper/pickle"; +import type { Portal, UserLogin } from "./types"; + +export interface BridgeDataStore { + deletePortal(portalKey: string): Promise; + getAccount(key: string): Promise; + getMessage(key: string): Promise; + getPortal(portalKey: string): Promise; + getPortalByMXID(mxid: string): Promise; + getUserLogin(id: string): Promise; + listPortals(): Promise; + setAccount(key: string, account: MatrixAccount): Promise; + setMessage(key: string, message: SentEvent): 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)); + } + + getMessage(messageKey: string): Promise { + return this.#get(key("message", messageKey)); + } + + 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 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); + } + + setAccount(accountKey: string, account: MatrixAccount): Promise { + return this.#set(key("account", accountKey), account); + } + + setMessage(messageKey: string, message: SentEvent): Promise { + return this.#set(key("message", messageKey), message); + } + + async setPortal(portal: Portal): Promise { + const portalKey = portalStoreKey(portal); + await this.#set(key("portal", portalKey), portal); + 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 index 074ca1d..7dac994 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -14,6 +14,7 @@ import type { MatrixStore, SentEvent, } from "@beeper/pickle"; +import type { BridgeDataStore } from "./store"; export type BridgeID = string; export type UserID = string; @@ -489,6 +490,7 @@ export interface PickleBridge { export interface CreateBridgeOptions { appservice?: MatrixAppserviceInitOptions; connector: BridgeConnector; + dataStore?: BridgeDataStore; matrix: BridgeMatrixConfig; } @@ -501,6 +503,17 @@ export interface CreateBeeperBridgeOptions extends Omit { + matrix?: Partial> & Pick; + token: string; +} + +export interface CreateBeeperBridgeFromPasswordOptions extends Omit { + baseDomain?: string; + password: string; + username: string; +} + export interface BridgeMatrixConfig extends Pick { store: MatrixStore; } @@ -518,9 +531,20 @@ export interface CreateNodeBeeperBridgeOptions extends Omit { + dataDir?: string; + matrix?: Partial; +} + +export interface CreateNodeBeeperBridgeFromPasswordOptions extends Omit { + dataDir?: string; + matrix?: Partial; +} + export interface BridgeContext { bridge: PickleBridge; client: MatrixClient; + dataStore?: BridgeDataStore; log: BridgeLogger; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; } diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 959c0cf..bd57831 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts", "src/beeper.ts"], + entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts"], format: ["esm"], dts: { sourcemap: false, @@ -13,7 +13,7 @@ export default defineConfig({ dts: ".d.ts", }), deps: { - neverBundle: ["@beeper/pickle", "@beeper/pickle/node"], + neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file"], }, target: false, }); diff --git a/packages/bridge/vitest.config.ts b/packages/bridge/vitest.config.ts index b43d055..092149c 100644 --- a/packages/bridge/vitest.config.ts +++ b/packages/bridge/vitest.config.ts @@ -3,6 +3,7 @@ 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, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e031bc..204fb58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@beeper/pickle-bridge': specifier: workspace:* version: link:../../packages/bridge + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../../packages/state-file devDependencies: '@types/node': specifier: ^25.3.2 @@ -134,6 +137,9 @@ importers: '@beeper/pickle': specifier: workspace:* version: link:../pickle + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../state-file devDependencies: '@types/node': specifier: ^25.3.2 From 4de7bc9128578f43db9b432937faa27576fac9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:02:18 +0200 Subject: [PATCH 05/21] Use account-based login for Beeper bridge Replace token/password entrypoints with a unified account-based flow: add loginWithPassword helper and accept a MatrixAccount on createBeeperBridge. Remove createBeeperBridgeFromToken/createBeeperBridgeFromPassword plumbing and consolidate token/homeserver handling from the account. Update types, node helpers, README and example to use loginWithPassword/createBeeperBridge, adjust .env.example to prefer username/password, and add tests for password login behavior. Also introduce loginWithBeeperPassword helper and minor refactors around appservice initialization and runtime options. --- examples/dummybridge/.env.example | 9 +- examples/dummybridge/README.md | 4 +- examples/dummybridge/src/index.ts | 21 ++-- packages/bridge/README.md | 20 ++-- packages/bridge/src/bridge.ts | 165 ++++------------------------- packages/bridge/src/index.ts | 2 +- packages/bridge/src/node.ts | 22 +--- packages/bridge/src/types.ts | 26 +---- packages/pickle/src/auth.test.ts | 43 +++++++- packages/pickle/src/auth.ts | 29 +++++ packages/pickle/src/beeper/auth.ts | 23 +++- 11 files changed, 138 insertions(+), 226 deletions(-) diff --git a/examples/dummybridge/.env.example b/examples/dummybridge/.env.example index df0b0ed..cc0f338 100644 --- a/examples/dummybridge/.env.example +++ b/examples/dummybridge/.env.example @@ -1,15 +1,10 @@ -# Option 1: easiest if you already have a Beeper/Matrix token. -BEEPER_ACCESS_TOKEN=your_beeper_access_token - -# Option 2: login with username/password instead. The resulting session is cached. -# BEEPER_USERNAME=your_username_or_mxid -# BEEPER_PASSWORD=your_password +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 -# MATRIX_SERVER_NAME=beeper.local DUMMYBRIDGE_SENDER_LOCALPART=dummybridgebot # Optional live actions after startup. diff --git a/examples/dummybridge/README.md b/examples/dummybridge/README.md index 56f5cbb..d1ed99a 100644 --- a/examples/dummybridge/README.md +++ b/examples/dummybridge/README.md @@ -25,13 +25,13 @@ 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 either `BEEPER_ACCESS_TOKEN` or `BEEPER_USERNAME`/`BEEPER_PASSWORD`: +Copy `.env.example` to `.env`, then fill in `BEEPER_USERNAME` and `BEEPER_PASSWORD`: ```sh pnpm --filter @beeper/pickle-example-dummybridge start ``` -`createBeeperBridgeFromToken()` and `createBeeperBridgeFromPassword()` fetch/register the Beeper appservice through the bridge-manager-compatible Hungryserv endpoints, infer the Hungryserv Matrix homeserver, use the default file-backed state package, and start the bridge runtime with the computed appservice registration. Password login sessions are cached in the bridge data store. +`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: diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index 0acc6a6..ee8c167 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createBeeperBridgeFromPassword, createBeeperBridgeFromToken } from "@beeper/pickle-bridge/node"; +import { loginWithPassword } from "@beeper/pickle/auth"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { Portal } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; @@ -10,24 +11,18 @@ const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve await loadEnv(resolve(sourceRoot, ".env")); -const serverName = optionalEnv("MATRIX_SERVER_NAME", "beeper.local") ?? "beeper.local"; +const serverName = "beeper.local"; const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; const bridgeOptions = { + account: await loginWithPassword({ + password: requiredEnv("BEEPER_PASSWORD"), + username: requiredEnv("BEEPER_USERNAME"), + }), bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "dummybridge") ?? "dummybridge", connector: new DummyConnector({ senderLocalpart, serverName }), - homeserverDomain: serverName, }; -const bridge = process.env.BEEPER_USERNAME && process.env.BEEPER_PASSWORD - ? await createBeeperBridgeFromPassword({ - ...bridgeOptions, - password: requiredEnv("BEEPER_PASSWORD"), - username: requiredEnv("BEEPER_USERNAME"), - }) - : await createBeeperBridgeFromToken({ - ...bridgeOptions, - token: requiredEnv("BEEPER_ACCESS_TOKEN"), - }); +const bridge = await createBeeperBridge(bridgeOptions); await bridge.start(); const login = { id: LOGIN_ID }; diff --git a/packages/bridge/README.md b/packages/bridge/README.md index c201d94..7e51b15 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -5,22 +5,20 @@ Bridge-building runtime for Pickle. This package is intentionally separate from bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts -import { createBeeperBridgeFromToken, createRemoteMessage } from "@beeper/pickle-bridge/node"; +import { loginWithPassword } from "@beeper/pickle/auth"; +import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; -const bridge = await createBeeperBridgeFromToken({ +const account = await loginWithPassword({ + username: process.env.BEEPER_USERNAME!, + password: process.env.BEEPER_PASSWORD!, +}); + +const bridge = await createBeeperBridge({ + account, bridge: "sh-example", - token: process.env.BEEPER_ACCESS_TOKEN!, connector, }); -// Or: -// const bridge = await createBeeperBridgeFromPassword({ -// bridge: "sh-example", -// username: process.env.BEEPER_USERNAME!, -// password: process.env.BEEPER_PASSWORD!, -// connector, -// }); - await bridge.start(); const login = { id: "example-login" }; diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 9a6e92c..1e5a659 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,14 +1,11 @@ -import { loginWithMatrixPassword } from "@beeper/pickle/auth"; import { createMatrixClient } from "@beeper/pickle"; -import type { MatrixAccount, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; -import { createBeeperAppService, createBeeperAppServiceInit } from "./beeper"; +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import { createBeeperAppServiceInit } from "./beeper"; import type { BridgeContext, BridgeLogger, BridgeRequestContext, CreateBeeperBridgeOptions, - CreateBeeperBridgeFromPasswordOptions, - CreateBeeperBridgeFromTokenOptions, CreateBridgeOptions, BridgeBackfillOptions, BridgeCreatePortalRoomOptions, @@ -38,58 +35,29 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge { } export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { - const client = createMatrixClient(options.matrix); - const whoami = await client.boot(); - const token = options.token ?? options.matrix.token; - if (!token) throw new Error("createBeeperBridge requires a Matrix access token"); + const matrix = { + ...options.matrix, + account: options.account, + homeserver: options.matrix.homeserver ?? options.account.homeserver, + token: options.matrix.token ?? options.account.accessToken, + }; + const client = createMatrixClient(matrix); const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, getOnly: options.getOnly, - homeserver: options.matrix.homeserver, - homeserverDomain: options.homeserverDomain ?? domainFromUserID(whoami.userId), - token, - })); - return new RuntimeBridge({ appservice, connector: options.connector, matrix: options.matrix }, client); -} - -export async function createBeeperBridgeFromToken(options: CreateBeeperBridgeFromTokenOptions): Promise { - const manager = await createBeeperAppService(beeperTokenAppServiceOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - getOnly: options.getOnly, - token: options.token, + homeserver: matrix.homeserver, + homeserverDomain: options.homeserverDomain ?? domainFromUserID(options.account.userId), + token: options.account.accessToken, })); - return createBeeperBridge(beeperBridgeOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, + const runtimeOptions: CreateBridgeOptions = { + appservice, connector: options.connector, - getOnly: true, - homeserverDomain: options.homeserverDomain ?? manager.homeserverDomain, - matrix: bridgeMatrixConfig(options.matrix, manager.homeserver, options.token), - token: options.token, - })); -} - -export async function createBeeperBridgeFromPassword(options: CreateBeeperBridgeFromPasswordOptions): Promise { - const accountKey = `beeper:${options.baseDomain ?? "beeper.com"}:${options.username}`; - const existing = await options.dataStore?.getAccount(accountKey); - const account = existing ?? await loginWithMatrixPassword(passwordLoginOptions(options)); - if (!existing) await options.dataStore?.setAccount(accountKey, account); - return createBeeperBridgeFromToken(beeperPasswordBridgeOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - connector: options.connector, - dataStore: options.dataStore, - getOnly: options.getOnly, - homeserverDomain: options.homeserverDomain, - matrix: passwordBridgeMatrixConfig(options.matrix, account), - token: account.accessToken, - })); + matrix, + }; + if (options.dataStore) runtimeOptions.dataStore = options.dataStore; + return new RuntimeBridge(runtimeOptions, client); } export class RuntimeBridge implements PickleBridge { @@ -542,100 +510,3 @@ function beeperAppServiceOptions(input: { if (input.homeserver !== undefined) output.homeserver = input.homeserver; return output; } - -function bridgeMatrixConfig(matrix: CreateBeeperBridgeFromTokenOptions["matrix"], homeserver: string, token: string): CreateBeeperBridgeOptions["matrix"] { - const output = { - ...matrix, - homeserver: matrix?.homeserver ?? homeserver, - token: matrix?.token ?? token, - }; - if (!output.store) throw new Error("createBeeperBridgeFromToken requires a Matrix store"); - return output as CreateBeeperBridgeOptions["matrix"]; -} - -function passwordLoginOptions(options: CreateBeeperBridgeFromPasswordOptions): Parameters[0] { - const output: Parameters[0] = { - homeserver: options.matrix?.homeserver ?? `https://matrix.${options.baseDomain ?? "beeper.com"}`, - initialDeviceDisplayName: "Pickle Bridge", - password: options.password, - username: options.username, - }; - if (options.matrix?.fetch !== undefined) output.fetch = options.matrix.fetch; - return output; -} - -function passwordBridgeMatrixConfig(matrix: CreateBeeperBridgeFromPasswordOptions["matrix"], account: MatrixAccount): NonNullable { - if (!matrix?.store) throw new Error("createBeeperBridgeFromPassword requires a Matrix store"); - return { - ...matrix, - account, - homeserver: matrix.homeserver ?? account.homeserver, - store: matrix.store, - token: account.accessToken, - }; -} - -function beeperPasswordBridgeOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - connector: CreateBeeperBridgeOptions["connector"]; - dataStore: CreateBeeperBridgeOptions["dataStore"] | undefined; - getOnly: boolean | undefined; - homeserverDomain: string | undefined; - matrix: NonNullable; - token: string; -}): CreateBeeperBridgeFromTokenOptions { - const output: CreateBeeperBridgeFromTokenOptions = { - bridge: input.bridge, - connector: input.connector, - matrix: input.matrix, - token: input.token, - }; - if (input.address !== undefined) output.address = input.address; - if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; - if (input.dataStore !== undefined) output.dataStore = input.dataStore; - if (input.getOnly !== undefined) output.getOnly = input.getOnly; - if (input.homeserverDomain !== undefined) output.homeserverDomain = input.homeserverDomain; - return output; -} - -function beeperTokenAppServiceOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - getOnly: boolean | 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.getOnly !== undefined) output.getOnly = input.getOnly; - return output; -} - -function beeperBridgeOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - connector: CreateBeeperBridgeOptions["connector"]; - getOnly: boolean; - homeserverDomain: string; - matrix: CreateBeeperBridgeOptions["matrix"]; - token: string; -}) { - const output = { - bridge: input.bridge, - connector: input.connector, - getOnly: input.getOnly, - homeserverDomain: input.homeserverDomain, - matrix: input.matrix, - token: input.token, - } as CreateBeeperBridgeOptions; - if (input.address !== undefined) output.address = input.address; - if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; - return output; -} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index ffa6799..15a6ac6 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,4 +1,4 @@ -export { createBeeperBridge, createBeeperBridgeFromPassword, createBeeperBridgeFromToken, createBridge, RuntimeBridge } from "./bridge"; +export { createBeeperBridge, createBridge, RuntimeBridge } from "./bridge"; export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createRemoteMessage } from "./events"; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index ee0de87..5c1b5cc 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -4,16 +4,12 @@ import { resolve } from "node:path"; import { RuntimeBridge, createBeeperBridge as createRuntimeBeeperBridge, - createBeeperBridgeFromPassword as createRuntimeBeeperBridgeFromPassword, - createBeeperBridgeFromToken as createRuntimeBeeperBridgeFromToken, } from "./bridge"; import { createBridgeDataStore } from "./store"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export { createRemoteMessage } from "./events"; import type { - CreateNodeBeeperBridgeFromPasswordOptions, - CreateNodeBeeperBridgeFromTokenOptions, CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge, @@ -24,24 +20,8 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { } export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { - return createRuntimeBeeperBridge(options); -} - -export async function createBeeperBridgeFromToken(options: CreateNodeBeeperBridgeFromTokenOptions): Promise { - const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); - return createRuntimeBeeperBridgeFromToken({ - ...options, - dataStore: options.dataStore ?? createBridgeDataStore(store), - matrix: { - ...options.matrix, - store, - }, - }); -} - -export async function createBeeperBridgeFromPassword(options: CreateNodeBeeperBridgeFromPasswordOptions): Promise { const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); - return createRuntimeBeeperBridgeFromPassword({ + return createRuntimeBeeperBridge({ ...options, dataStore: options.dataStore ?? createBridgeDataStore(store), matrix: { diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 7dac994..596000e 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -5,6 +5,7 @@ import type { MatrixAppserviceCreateRoomOptions, MatrixAppserviceInitOptions, MatrixAppserviceSendMessageOptions, + MatrixAccount, MatrixClient, MatrixClientOptions, CreateRoomOptions, @@ -494,24 +495,14 @@ export interface CreateBridgeOptions { matrix: BridgeMatrixConfig; } -export interface CreateBeeperBridgeOptions extends Omit { +export interface CreateBeeperBridgeOptions extends Omit { + account: MatrixAccount; address?: string; baseDomain?: string; bridge: string; getOnly?: boolean; homeserverDomain?: string; - token?: string; -} - -export interface CreateBeeperBridgeFromTokenOptions extends Omit { - matrix?: Partial> & Pick; - token: string; -} - -export interface CreateBeeperBridgeFromPasswordOptions extends Omit { - baseDomain?: string; - password: string; - username: string; + matrix: Partial> & Pick; } export interface BridgeMatrixConfig extends Pick { @@ -528,15 +519,6 @@ export interface CreateNodeBridgeOptions extends Omit { - matrix: NodeBridgeMatrixConfig; -} - -export interface CreateNodeBeeperBridgeFromTokenOptions extends Omit { - dataDir?: string; - matrix?: Partial; -} - -export interface CreateNodeBeeperBridgeFromPasswordOptions extends Omit { dataDir?: string; matrix?: Partial; } 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..edaa0c2 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 or BEEPER_USERNAME"); + if (!password) throw new Error("loginWithPassword requires password or BEEPER_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/beeper/auth.ts b/packages/pickle/src/beeper/auth.ts index bae2166..e991c10 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -1,4 +1,4 @@ -import { loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; +import { loginWithPassword, loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; export type BeeperEnvironment = "production" | "staging" | "dev" | "local"; @@ -11,6 +11,15 @@ export interface BeeperAuthOptions { metadata?: Record; } +export interface BeeperPasswordAuthOptions { + baseDomain?: string; + fetch?: typeof fetch; + initialDeviceDisplayName?: string; + metadata?: Record; + password: string; + username: string; +} + export interface BeeperAuthStartResult { expires?: string; raw: unknown; @@ -49,6 +58,18 @@ export async function createBeeperLogin(options: BeeperAuthOptions): Promise { + const loginOptions: Parameters[0] = { + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle", + metadata: { ...options.metadata, beeper: true }, + password: options.password, + username: options.username, + }; + if (options.baseDomain !== undefined) loginOptions.baseDomain = options.baseDomain; + if (options.fetch !== undefined) loginOptions.fetch = options.fetch; + return loginWithPassword(loginOptions); +} + async function getLoginCode(options: BeeperAuthOptions): Promise { const code = options.getLoginCode ? await options.getLoginCode() : promptForLoginCode(); if (!code) { From d602704669413e368344ef4bacf9742e33a7bbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:13:26 +0200 Subject: [PATCH 06/21] Add management rooms and command handling Add support for management rooms and matrix-side command handling across the bridge. Introduces CommandHandlingBridgeConnector and implements command dispatching, text replies and management-room creation in RuntimeBridge; updates the dummy example connector to handle commands and several new login flows (password, cookies, local_storage, display_and_wait). Persist and expose bridge state, ghosts and message requests via the data store; add backfillMessages, remote backfill/chat-info/delete handling, media upload/download wrappers and identifier/message-request APIs. Bind login process lifecycle calls to the bridge request context and update tests to cover the new behaviours. --- examples/dummybridge/README.md | 16 ++ examples/dummybridge/src/connector.ts | 288 +++++++++++++++++--- examples/dummybridge/src/index.ts | 35 ++- packages/bridge/README.md | 9 +- packages/bridge/src/bridge.test.ts | 237 +++++++++++++++- packages/bridge/src/bridge.ts | 371 +++++++++++++++++++++++++- packages/bridge/src/node.ts | 20 +- packages/bridge/src/store.ts | 49 +++- packages/bridge/src/types.ts | 133 ++++++++- packages/pickle/src/beeper/auth.ts | 23 +- 10 files changed, 1091 insertions(+), 90 deletions(-) diff --git a/examples/dummybridge/README.md b/examples/dummybridge/README.md index d1ed99a..db3ab84 100644 --- a/examples/dummybridge/README.md +++ b/examples/dummybridge/README.md @@ -9,6 +9,7 @@ It demonstrates the bridge shape needed to: - 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 @@ -44,3 +45,18 @@ 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/src/connector.ts b/examples/dummybridge/src/connector.ts index 82160c6..6c64062 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -1,17 +1,22 @@ import { createRemoteMessage } from "@beeper/pickle-bridge"; import type { BridgeConfigPart, - BridgeConnector, BridgeContext, BridgeRequestContext, BridgeUser, + CommandHandlingBridgeConnector, DBMetaTypes, FetchMessagesResponse, LoginFlow, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, LoginProcess, LoginStep, MatrixMessage, MatrixMessageResponse, + MatrixCommand, + MatrixCommandResponse, NetworkAPI, NetworkGeneralCapabilities, UserLogin, @@ -30,15 +35,16 @@ export function makeGhostMxid(localId: string, serverName: string, senderLocalpa return `@${senderLocalpart}_${escaped}:${serverName}`; } -export class DummyConnector implements BridgeConnector { +export class DummyConnector implements CommandHandlingBridgeConnector { #options: DummyConnectorOptions; + #roomCounter = 0; constructor(options: DummyConnectorOptions = {}) { this.#options = options; } - createLogin(_ctx: BridgeRequestContext, _user: BridgeUser, _flowId: string): LoginProcess { - return new DummyLoginProcess(); + createLogin(_ctx: BridgeRequestContext, _user: BridgeUser, flowId: string): LoginProcess { + return new DummyLoginProcess(flowId); } getBridgeInfoVersion() { @@ -64,11 +70,33 @@ export class DummyConnector implements BridgeConnector { } getLoginFlows(): LoginFlow[] { - return [{ - description: "Create the built-in dummy login.", - id: "dummy", - name: "Dummy", - }]; + 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() { @@ -84,6 +112,79 @@ export class DummyConnector implements BridgeConnector { 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 ghostMxid = makeGhostMxid("alice", this.#options.serverName ?? "example", this.#options.senderLocalpart); + const portal = await ctx.bridge.createPortalRoom({ + invite: [command.sender.userId], + name, + portalKey: { id: portalId, receiver: login.id }, + topic: "Created from the DummyBridge management room.", + userId: ghostMxid, + }); + return reply(`created ${portal.mxid} for ${portalId}`); + } + case "message": { + const text = command.args.join(" ") || "hello from DummyBridge"; + ctx.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage({ + body: text, + id: `dummy-command-${Date.now()}`, + portalId: PORTAL_ID, + })); + 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.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage({ + body: `dummy message ${index + 1}/${count}`, + id: `dummy-command-${Date.now()}-${index}`, + portalId: PORTAL_ID, + })); + } + return reply(`queued ${count} messages`); + } + case "ghost": { + const localId = command.args[0] ?? "alice"; + return reply(makeGhostMxid(localId, this.#options.serverName ?? "example", this.#options.senderLocalpart)); + } + 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(makeGhostMxid(command.args[0] ?? "alice", this.#options.serverName ?? "example", this.#options.senderLocalpart)); + default: + return reply(`unknown command: ${command.command}`); + } + } + loadUserLogin(_ctx: BridgeRequestContext, login: UserLogin): NetworkAPI { const options: DummyNetworkOptions = { login }; if (this.#options.senderLocalpart !== undefined) options.senderLocalpart = this.#options.senderLocalpart; @@ -96,12 +197,89 @@ export class DummyConnector implements BridgeConnector { } stop(): void {} + + #remoteMessage(options: { body: string; id: string; portalId?: string; timestamp?: number }) { + const messageOptions: Parameters[0] = { + ...options, + }; + if (this.#options.senderLocalpart !== undefined) messageOptions.senderLocalpart = this.#options.senderLocalpart; + if (this.#options.serverName !== undefined) messageOptions.serverName = this.#options.serverName; + return remoteMessage(messageOptions); + } +} + +function reply(text: string): MatrixCommandResponse { + return { handled: true, text }; } -class DummyLoginProcess implements LoginProcess { - cancel(): void {} +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 }, @@ -112,6 +290,45 @@ class DummyLoginProcess implements LoginProcess { 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 { @@ -166,30 +383,39 @@ export class DummyNetworkAPI implements NetworkAPI { } #remoteMessage(options: { body: string; id: string; portalId?: string; timestamp?: number }) { - const portalKey = { id: options.portalId ?? PORTAL_ID, receiver: this.#login.id }; - const sender = makeGhostMxid("alice", this.#serverName, this.#senderLocalpart); - return createRemoteMessage({ - convert: () => ({ - parts: [{ - content: { - body: options.body, - msgtype: "m.text", - }, - type: "m.room.message", - }], - }), - data: {}, - id: options.id, - portalKey, - sender: { - isFromMe: false, - sender, - }, - timestamp: new Date(options.timestamp ?? Date.now()), + return remoteMessage({ + ...options, + loginId: this.#login.id, + senderLocalpart: this.#senderLocalpart, + serverName: this.#serverName, }); } } +function remoteMessage(options: { body: string; id: string; loginId?: string; portalId?: string; senderLocalpart?: string; serverName?: string; timestamp?: number }) { + const portalKey = { id: options.portalId ?? PORTAL_ID, receiver: options.loginId ?? LOGIN_ID }; + const sender = makeGhostMxid("alice", options.serverName ?? "example", options.senderLocalpart); + return createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { + body: options.body, + msgtype: "m.text", + }, + type: "m.room.message", + }], + }), + data: {}, + id: options.id, + portalKey, + sender: { + isFromMe: false, + sender, + }, + timestamp: new Date(options.timestamp ?? Date.now()), + }); +} + function stringBody(value: unknown): string | null { return typeof value === "string" ? value : null; } diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index ee8c167..24596bd 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -11,15 +11,16 @@ const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve await loadEnv(resolve(sourceRoot, ".env")); -const serverName = "beeper.local"; const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; +const account = await loginWithPassword({ + password: requiredEnv("BEEPER_PASSWORD"), + username: requiredEnv("BEEPER_USERNAME"), +}); +const serverName = domainFromUserId(account.userId); const bridgeOptions = { - account: await loginWithPassword({ - password: requiredEnv("BEEPER_PASSWORD"), - username: requiredEnv("BEEPER_USERNAME"), - }), - bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "dummybridge") ?? "dummybridge", + account, + bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge") ?? "sh-dummybridge", connector: new DummyConnector({ senderLocalpart, serverName }), }; const bridge = await createBeeperBridge(bridgeOptions); @@ -28,6 +29,20 @@ await bridge.start(); const login = { id: LOGIN_ID }; 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 ghostMxid = makeGhostMxid("alice", serverName, senderLocalpart); const existingRoomId = optionalEnv("DUMMYBRIDGE_PORTAL_ROOM_ID"); let portal: Portal | null = null; @@ -76,3 +91,11 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { process.exit(0); }); } + +function domainFromUserId(userId: string): string { + const index = userId.indexOf(":"); + if (index === -1 || index === userId.length - 1) { + throw new Error(`Cannot infer Matrix server name from ${userId}`); + } + return userId.slice(index + 1); +} diff --git a/packages/bridge/README.md b/packages/bridge/README.md index 7e51b15..b281e06 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -29,14 +29,7 @@ const portal = await bridge.createPortalRoom({ userId: "@example_alice:example.com", }); -await bridge.backfill({ - roomId: portal.mxid!, - events: [{ - sender: "@example_alice:example.com", - timestamp: Date.now() - 60_000, - content: { msgtype: "m.text", body: "historical hello" }, - }], -}); +await bridge.backfillMessages(login, { portal }); bridge.queueRemoteEvent(login, createRemoteMessage({ data: { text: "hello" }, diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index def9257..a221842 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -8,6 +8,9 @@ import type { BridgeMatrixConfig, MatrixMessage, NetworkAPI, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, UserLogin, } from "./types"; @@ -50,6 +53,35 @@ describe("RuntimeBridge", () => { expect(login.client).toBe(network); }); + 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(); @@ -185,6 +217,172 @@ describe("RuntimeBridge", () => { })); expect(backfill.eventIds).toEqual(["$backfilled"]); }); + + 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("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.createRoom).toHaveBeenCalledWith(expect.objectContaining({ + invite: ["@alice:example"], + isDirect: false, + 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-"), + }); + }); }); function matrixConfig(): BridgeMatrixConfig { @@ -214,7 +412,7 @@ function createFakeConnector(network: FakeNetworkAPI): BridgeConnector & { getConfig: () => ({}), getDBMetaTypes: () => ({}), getLoginFlows: () => [], - getName: () => ({ displayName: "Test", networkId: "test" }), + getName: () => ({ defaultCommandPrefix: "test", displayName: "Test", networkId: "test" }), init: vi.fn((_ctx: BridgeContext) => {}), loadUserLogin: vi.fn(async () => network), start: vi.fn(), @@ -236,6 +434,17 @@ function createFakeNetworkAPI(): FakeNetworkAPI { }; } +function loginStep(stepId: string) { + return { + complete: { + userLoginId: "login:a", + }, + instructions: stepId, + stepId, + type: "complete" as const, + }; +} + function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { const subscription = { catchUp: vi.fn(async () => {}), @@ -257,8 +466,22 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip close: vi.fn(async () => {}), crypto: {} as MatrixClient["crypto"], logout: vi.fn(async () => {}), - media: {} as MatrixClient["media"], - messages: {} as MatrixClient["messages"], + 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"], @@ -271,7 +494,13 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip sync: {} as MatrixClient["sync"], toDevice: {} as MatrixClient["toDevice"], typing: {} as MatrixClient["typing"], - users: {} as MatrixClient["users"], + 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 index 1e5a659..7fd555f 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,5 +1,5 @@ import { createMatrixClient } from "@beeper/pickle"; -import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import type { MatrixAppserviceBatchSendOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; import { createBeeperAppServiceInit } from "./beeper"; import type { BridgeContext, @@ -8,7 +8,9 @@ import type { CreateBeeperBridgeOptions, CreateBridgeOptions, BridgeBackfillOptions, + BridgeCreateManagementRoomOptions, BridgeCreatePortalRoomOptions, + BackfillingNetworkAPI, MatrixAppserviceCreateRoomOptions, MatrixAppserviceSendMessageOptions, LoginProcess, @@ -19,13 +21,38 @@ import type { RemoteEvent, UserLogin, BridgeUser, + BridgeSendMediaOptions, + BridgeState, + BridgeStatus, + DownloadMediaOptions, + DownloadMediaResult, + Ghost, MatrixDispatchResult, MatrixMessage, MatrixReaction, MatrixRedaction, MatrixTyping, MatrixIntent, + MatrixCommand, + MatrixCommandResponse, + ManagementRoom, + MessageRequest, + MessageRequestHandlingNetworkAPI, RemoteMessage, + RemoteBackfill, + RemoteChatDelete, + RemoteChatInfoChange, + UserProfile, + UserProfileUpdate, + ResolveIdentifierParams, + ResolveIdentifierResponse, + IdentifierResolvingNetworkAPI, + LoginCookieInput, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, + LoginProcessWithOverride, + LoginUserInput, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -41,14 +68,23 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr homeserver: options.matrix.homeserver ?? options.account.homeserver, token: options.matrix.token ?? options.account.accessToken, }; - const client = createMatrixClient(matrix); + return createBeeperBridgeWithClient({ ...options, matrix }, createMatrixClient(matrix)); +} + +export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise { + const matrix = { + ...options.matrix, + account: options.account, + homeserver: options.matrix.homeserver ?? options.account.homeserver, + token: options.matrix.token ?? options.account.accessToken, + }; const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, getOnly: options.getOnly, homeserver: matrix.homeserver, - homeserverDomain: options.homeserverDomain ?? domainFromUserID(options.account.userId), + homeserverDomain: domainFromUserID(options.account.userId), token: options.account.accessToken, })); const runtimeOptions: CreateBridgeOptions = { @@ -66,11 +102,15 @@ export class RuntimeBridge implements PickleBridge { 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 #portalsByKey = new Map(); readonly #portalsByRoom = new Map(); readonly #remoteEvents: Array<{ event: RemoteEvent; login: UserLogin }> = []; readonly #matrixClient: MatrixClient; readonly #subscriptions = new Set(); + #bridgeStatus: BridgeStatus | null = null; #context: BridgeContext | null = null; #drainPromise: Promise | null = null; #started = false; @@ -93,6 +133,7 @@ export class RuntimeBridge implements PickleBridge { async start(): Promise { if (this.#started) return; + await this.setBridgeState("starting"); const whoami = await this.#matrixClient.boot(); this.#ownUserId = whoami.userId; if (this.#appserviceOptions) { @@ -106,9 +147,11 @@ export class RuntimeBridge implements PickleBridge { await this.connector.start(this.#context); await this.#subscribeMatrixEvents(); this.#started = true; + await this.setBridgeState("running"); } async stop(): Promise { + await this.setBridgeState("stopping"); const subscriptions = Array.from(this.#subscriptions); this.#subscriptions.clear(); await Promise.allSettled(subscriptions.map((subscription) => subscription.stop())); @@ -121,10 +164,40 @@ export class RuntimeBridge implements PickleBridge { await this.#matrixClient.close(); this.#context = null; this.#started = false; + await this.setBridgeState("stopped"); } async createLogin(user: BridgeUser, flowId: string): Promise { - return this.connector.createLogin(this.#requestContext(), user, flowId); + const process = await this.connector.createLogin(this.#requestContext(), user, flowId); + return bindLoginProcess(process, () => this.#requestContext()); + } + + async createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise { + this.#requestContext(); + const createOptions = stripUndefined({ + creationContent: options.creationContent, + initialState: options.initialState?.map((state) => ({ + content: state.content, + stateKey: state.stateKey ?? "", + type: state.type, + })), + invite: options.invite, + isDirect: false, + name: options.name, + preset: options.preset, + roomAliasName: options.roomAliasName, + roomVersion: options.roomVersion, + topic: options.topic, + userId: options.userId, + visibility: options.visibility, + }); + const result = await this.#matrixClient.appservice.createRoom(createOptions as MatrixAppserviceCreateRoomOptions); + const room: ManagementRoom = { + metadata: options.metadata, + mxid: result.roomId, + }; + this.registerManagementRoom(room); + return room; } async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { @@ -170,6 +243,20 @@ export class RuntimeBridge implements PickleBridge { 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)); + return this.backfill({ events, roomId: portal.mxid }); + } + async loadUserLogin(login: UserLogin): Promise { const existing = this.#networkClients.get(login.id); if (existing) return existing; @@ -181,6 +268,120 @@ export class RuntimeBridge implements PickleBridge { 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 { + this.#bridgeStatus = status; + await this.#dataStore?.setBridgeStatus(status); + await this.#dataStore?.setBridgeState(status.state); + } + + getGhost(id: string): Ghost | null { + return this.#ghosts.get(id) ?? null; + } + + 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; + } + + 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(); @@ -197,6 +398,10 @@ export class RuntimeBridge implements PickleBridge { }); } + registerManagementRoom(room: ManagementRoom): void { + this.#managementRooms.set(room.mxid, room); + } + async flushRemoteEvents(): Promise { await this.#drainRemoteEvents(); } @@ -257,6 +462,10 @@ export class RuntimeBridge implements PickleBridge { if (event.sender.isMe || event.sender.userId === this.#ownUserId) { return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; } + const command = this.#parseManagementCommand(event); + if (command) { + return this.#dispatchMatrixCommand(command); + } const portal = this.#portalForRoom(event.roomId); const msg: MatrixMessage = { attachments: event.attachments, @@ -276,6 +485,42 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; } + async #dispatchMatrixCommand(command: MatrixCommand): Promise { + 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.#matrixIntent().sendMessage(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 room = this.#managementRooms.get(event.roomId); + if (!room) return null; + const text = event.text || stringContent(event.content.body); + if (!text) return null; + const prefix = this.connector.getName().defaultCommandPrefix ?? ""; + const body = prefix && text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text.trim(); + if (!body) return null; + const [command = "", ...args] = body.split(/\s+/); + if (!command) return null; + return { + args, + body, + command, + event, + prefix, + room, + sender: event.sender, + text, + }; + } + 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 }; @@ -380,6 +625,18 @@ export class RuntimeBridge implements PickleBridge { 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 }); } @@ -403,6 +660,59 @@ export class RuntimeBridge implements PickleBridge { } } + 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, @@ -447,6 +757,55 @@ function hasMethod(value: object, method: T): value is object return method in value && typeof (value as Record)[method] === "function"; } +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}`; } @@ -473,6 +832,10 @@ function eventTimestamp(event: RemoteEvent): number | undefined { return undefined; } +function stringContent(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function stripUndefined>(value: T): T { for (const key of Object.keys(value)) { if (value[key] === undefined) { diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index 5c1b5cc..ff7bd82 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -3,7 +3,7 @@ import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; import { RuntimeBridge, - createBeeperBridge as createRuntimeBeeperBridge, + createBeeperBridgeWithClient, } from "./bridge"; import { createBridgeDataStore } from "./store"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; @@ -21,14 +21,20 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); - return createRuntimeBeeperBridge({ + const matrix = { + ...options.matrix, + store, + }; + return createBeeperBridgeWithClient({ ...options, dataStore: options.dataStore ?? createBridgeDataStore(store), - matrix: { - ...options.matrix, - 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 { diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 523195a..5902255 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -1,16 +1,25 @@ import type { MatrixStore, MatrixAccount, SentEvent } from "@beeper/pickle"; -import type { Portal, UserLogin } from "./types"; +import type { BridgeState, BridgeStatus, Ghost, 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; 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; setPortal(portal: Portal): Promise; setUserLogin(login: UserLogin): Promise; } @@ -32,10 +41,26 @@ export class MatrixBridgeDataStore implements BridgeDataStore { 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)); } @@ -49,6 +74,12 @@ export class MatrixBridgeDataStore implements BridgeDataStore { 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))); @@ -59,10 +90,26 @@ export class MatrixBridgeDataStore implements BridgeDataStore { 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); + } + async setPortal(portal: Portal): Promise { const portalKey = portalStoreKey(portal); await this.#set(key("portal", portalKey), portal); diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 596000e..34befbc 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -13,7 +13,11 @@ import type { MatrixMessageEvent, MatrixReactionEvent, MatrixStore, + SendMediaMessageOptions, SentEvent, + UploadMediaOptions, + UploadMediaResult, + UserInfo as MatrixUserInfo, } from "@beeper/pickle"; import type { BridgeDataStore } from "./store"; @@ -78,6 +82,10 @@ export interface BridgeConnector { 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; } @@ -139,6 +147,14 @@ 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; @@ -234,24 +250,27 @@ export type MatrixOutboundNetworkAPI = | DeleteChatHandlingNetworkAPI; export interface LoginProcess { - cancel(): Promise | void; - start(ctx: BridgeRequestContext): Promise; + cancel(ctx?: BridgeRequestContext): Promise | void; + start(ctx?: BridgeRequestContext): Promise; } export interface LoginProcessWithOverride extends LoginProcess { - startWithOverride(ctx: BridgeRequestContext, override: UserLogin): Promise; + startWithOverride(override: UserLogin): Promise; + startWithOverride(ctx: BridgeRequestContext | undefined, override: UserLogin): Promise; } export interface LoginProcessDisplayAndWait extends LoginProcess { - wait(ctx: BridgeRequestContext): Promise; + wait(ctx?: BridgeRequestContext): Promise; } export interface LoginProcessUserInput extends LoginProcess { - submitUserInput(ctx: BridgeRequestContext, input: Record): Promise; + submitUserInput(input: LoginUserInput): Promise; + submitUserInput(ctx: BridgeRequestContext | undefined, input: LoginUserInput): Promise; } export interface LoginProcessCookies extends LoginProcess { - submitCookies(ctx: BridgeRequestContext, cookies: Record): Promise; + submitCookies(cookies: LoginCookieInput): Promise; + submitCookies(ctx: BridgeRequestContext | undefined, cookies: LoginCookieInput): Promise; } export interface LoginFlow { @@ -262,6 +281,8 @@ export interface LoginFlow { 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" @@ -477,15 +498,37 @@ 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; createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise; + downloadMedia(options: DownloadMediaOptions): Promise; flushRemoteEvents(): Promise; + getBridgeState(): BridgeState | null; + getBridgeStatus(): BridgeStatus | null; + getGhost(id: GhostID): Ghost | null; + getMessageRequest(portalKey: PortalKey): Promise; + getOwnProfile(): Promise; + getPortal(portalKey: PortalKey): Portal | null; + getPortalByMXID(mxid: RoomID): Portal | null; + getUserInfo(userId: UserID): Promise; loadUserLogin(login: UserLogin): Promise; 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; + 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 { @@ -501,7 +544,6 @@ export interface CreateBeeperBridgeOptions extends Omit> & Pick; } @@ -569,6 +611,33 @@ export interface BridgeCreatePortalRoomOptions extends CreateRoomOptions { export interface BridgeBackfillOptions extends MatrixAppserviceBatchSendOptions {} +export interface BridgeCreateManagementRoomOptions extends Omit { + metadata?: unknown; + 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 { MatrixAppserviceCreateRoomOptions, MatrixAppserviceInitOptions, @@ -685,11 +754,61 @@ export interface Portal { } export interface Ghost { + avatar?: Avatar; + displayName?: string; id: GhostID; metadata?: unknown; mxid?: string; } +export type BridgeState = "starting" | "running" | "stopping" | "stopped" | "degraded" | "error"; + +export interface BridgeStatus { + 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; diff --git a/packages/pickle/src/beeper/auth.ts b/packages/pickle/src/beeper/auth.ts index e991c10..bae2166 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -1,4 +1,4 @@ -import { loginWithPassword, loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; +import { loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; export type BeeperEnvironment = "production" | "staging" | "dev" | "local"; @@ -11,15 +11,6 @@ export interface BeeperAuthOptions { metadata?: Record; } -export interface BeeperPasswordAuthOptions { - baseDomain?: string; - fetch?: typeof fetch; - initialDeviceDisplayName?: string; - metadata?: Record; - password: string; - username: string; -} - export interface BeeperAuthStartResult { expires?: string; raw: unknown; @@ -58,18 +49,6 @@ export async function createBeeperLogin(options: BeeperAuthOptions): Promise { - const loginOptions: Parameters[0] = { - initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle", - metadata: { ...options.metadata, beeper: true }, - password: options.password, - username: options.username, - }; - if (options.baseDomain !== undefined) loginOptions.baseDomain = options.baseDomain; - if (options.fetch !== undefined) loginOptions.fetch = options.fetch; - return loginWithPassword(loginOptions); -} - async function getLoginCode(options: BeeperAuthOptions): Promise { const code = options.getLoginCode ? await options.getLoginCode() : promptForLoginCode(); if (!code) { From e3bf84d55d03a8e3aeafe65ff960312c48096bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:17:47 +0200 Subject: [PATCH 07/21] Post bridge state and add bridgeType support Add support for posting bridge state to the bridge manager and introduce a bridgeType option. registerAppService now posts a bridge_state POST after hungry registration (unless postState is false) and a new postBridgeState method and PostBridgeStateOptions type were added. The internal request helper now accepts POST and an optional token and safely handles empty responses. bridgeType was threaded through types, beeper, bridge, and index/entry exports; bridgeStateEvent logic selects STARTING/RUNNING based on bridgeType and known bridge names. The Node entrypoint was consolidated into the package index (exports adjusted) and createBeeperBridge/createBridge now auto-wire matrix clients and default file stores; examples updated to import from the package root and handle optional address. Tests and README were updated to reflect the new behaviour, and a .gitignore was added to the dummybridge example. --- examples/dummybridge/.gitignore | 1 + examples/dummybridge/src/index.ts | 8 +++-- packages/bridge/README.md | 8 ++--- packages/bridge/src/beeper.test.ts | 53 +++++++++++++++++++++--------- packages/bridge/src/beeper.ts | 52 ++++++++++++++++++++++++++--- packages/bridge/src/bridge.ts | 3 ++ packages/bridge/src/index.ts | 35 +++++++++++++++++++- packages/bridge/src/node.ts | 45 ++----------------------- packages/bridge/src/types.ts | 1 + 9 files changed, 136 insertions(+), 70 deletions(-) create mode 100644 examples/dummybridge/.gitignore 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/src/index.ts b/examples/dummybridge/src/index.ts index 24596bd..3665565 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,8 +1,8 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge } from "@beeper/pickle-bridge/node"; -import type { Portal } from "@beeper/pickle-bridge/types"; +import { createBeeperBridge } from "@beeper/pickle-bridge"; +import type { CreateNodeBeeperBridgeOptions, Portal } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; @@ -18,11 +18,13 @@ const account = await loginWithPassword({ }); const serverName = domainFromUserId(account.userId); -const bridgeOptions = { +const bridgeOptions: CreateNodeBeeperBridgeOptions = { account, bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge") ?? "sh-dummybridge", connector: new DummyConnector({ senderLocalpart, serverName }), }; +const bridgeAddress = optionalEnv("DUMMYBRIDGE_URL"); +if (bridgeAddress !== undefined) bridgeOptions.address = bridgeAddress; const bridge = await createBeeperBridge(bridgeOptions); await bridge.start(); diff --git a/packages/bridge/README.md b/packages/bridge/README.md index b281e06..5c918e9 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -6,7 +6,7 @@ bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge/node"; +import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge"; const account = await loginWithPassword({ username: process.env.BEEPER_USERNAME!, @@ -45,9 +45,9 @@ bridge.queueRemoteEvent(login, createRemoteMessage({ })); ``` -The Node entrypoint uses the same Pickle WASM mechanism as `@beeper/pickle/node`. -Browser and worker callers can import from `@beeper/pickle-bridge` and provide -`wasmBytes`, `wasmModule`, or `wasmUrl`. +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 diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts index a2c6b6b..d0041a9 100644 --- a/packages/bridge/src/beeper.test.ts +++ b/packages/bridge/src/beeper.test.ts @@ -26,24 +26,35 @@ describe("Beeper bridge manager helpers", () => { userInfo: { username: "alice" }, }); } - expect(String(url)).toBe("https://matrix.example/_hungryserv/alice/_matrix/asmux/mxauth/appservice/alice/sh-dummy"); - expect(init?.method).toBe("PUT"); + 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({ - 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", + info: {}, + isSelfHosted: true, + reason: "SELF_HOST_REGISTERED", + stateEvent: "RUNNING", }); + return emptyResponse(); }); await expect(createBeeperAppServiceInit({ @@ -102,5 +113,15 @@ function jsonResponse(data: unknown): Response { 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 index 95a3bf8..30ea15a 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -47,7 +47,9 @@ export interface BeeperWhoamiResponse { export interface RegisterAppServiceOptions { address?: string; bridge: string; + bridgeType?: string; getOnly?: boolean; + postState?: boolean; push?: boolean; selfHosted?: boolean; } @@ -100,11 +102,35 @@ export class BeeperBridgeManagerClient { async registerAppService(options: RegisterAppServiceOptions): Promise { if (options.getOnly) return this.getAppService(options.bridge); - return normalizeRegistration(await this.#hungryRequest("PUT", 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 { @@ -134,12 +160,12 @@ export class BeeperBridgeManagerClient { return this.#request("hungry", method, path, body, username); } - async #request(kind: "api" | "hungry", method: "GET" | "PUT", path: string, body?: unknown, username?: string): Promise { + 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 ${this.#token}`, + "authorization": `Bearer ${token ?? this.#token}`, ...(body ? { "content-type": "application/json" } : {}), }, method, @@ -154,10 +180,21 @@ export class BeeperBridgeManagerClient { } catch {} throw new Error(`Beeper bridge manager request failed (${response.status}): ${detail}`); } - return response.json() as Promise; + 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); } @@ -176,6 +213,13 @@ export async function createBeeperAppServiceInit(options: BeeperClientOptions & 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)}`; } diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 7fd555f..53be34a 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -82,6 +82,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, + bridgeType: options.bridgeType, getOnly: options.getOnly, homeserver: matrix.homeserver, homeserverDomain: domainFromUserID(options.account.userId), @@ -857,6 +858,7 @@ function beeperAppServiceOptions(input: { address: string | undefined; baseDomain: string | undefined; bridge: string; + bridgeType: string | undefined; getOnly: boolean | undefined; homeserver: string | undefined; homeserverDomain: string; @@ -869,6 +871,7 @@ function beeperAppServiceOptions(input: { } 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.homeserver !== undefined) output.homeserver = input.homeserver; return output; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 15a6ac6..bd73bb4 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,7 +1,40 @@ -export { createBeeperBridge, createBridge, RuntimeBridge } from "./bridge"; +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.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 index ff7bd82..6a52646 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,46 +1,7 @@ -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"; +export { createBeeperBridge, createBridge, RuntimeBridge } from "./index"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; -export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export { createRemoteMessage } from "./events"; -import type { - CreateNodeBeeperBridgeOptions, - CreateNodeBridgeOptions, - PickleBridge, -} from "./types"; - -export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { - return new RuntimeBridge(options, createMatrixClient(options.matrix)); -} - -export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { - const 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"); -} - -export type * from "./types"; +export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export type * from "./beeper"; export type * from "./store"; +export type * from "./types"; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 34befbc..e1cfbab 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -543,6 +543,7 @@ export interface CreateBeeperBridgeOptions extends Omit> & Pick; } From 7fb44697b533187e795d0e281a86553767294f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:23:59 +0200 Subject: [PATCH 08/21] Add AppserviceWebsocket and integrate into bridge Introduce AppserviceWebsocket (src/appservice-websocket.ts) to handle appservice websocket connections, parse transactions/http_proxy requests, dispatch Matrix events to the bridge, and manage ping/reconnect behavior. Add unit tests (src/appservice-websocket.test.ts) covering normal transactions and http_proxy handling. Wire the websocket into the runtime bridge (start/stop and dispatch integration), and add a guard to skip starting when a push URL is configured. Add runtime/dev dependencies for ws and @types/ws, update tsdown config to include the new module and avoid bundling ws, and update the pnpm lockfile accordingly. --- packages/bridge/package.json | 4 +- .../bridge/src/appservice-websocket.test.ts | 151 ++++++++++ packages/bridge/src/appservice-websocket.ts | 284 ++++++++++++++++++ packages/bridge/src/bridge.ts | 22 +- packages/bridge/tsdown.config.ts | 4 +- pnpm-lock.yaml | 13 + 6 files changed, 472 insertions(+), 6 deletions(-) create mode 100644 packages/bridge/src/appservice-websocket.test.ts create mode 100644 packages/bridge/src/appservice-websocket.ts diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 423f7d7..a6697ed 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -54,10 +54,12 @@ }, "dependencies": { "@beeper/pickle": "workspace:*", - "@beeper/pickle-state-file": "workspace:*" + "@beeper/pickle-state-file": "workspace:*", + "ws": "^8.18.0" }, "devDependencies": { "@types/node": "^25.3.2", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", "typescript": "^5.7.2", diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts new file mode 100644 index 0000000..f0581cf --- /dev/null +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -0,0 +1,151 @@ +import { createServer } from "node:http"; +import { 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 }> = []; + +afterEach(async () => { + 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 = new AppserviceWebsocket({ + appservice: { + homeserver, + homeserverDomain: "example", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-dummy", + namespaces: { users: [] }, + senderLocalpart: "dummybot", + url: "", + }, + }, + dispatch, + log: (() => {}) as BridgeLogger, + }); + + websocket.start(); + await connected; + websocket.stop(); + + 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 = new AppserviceWebsocket({ + appservice: { + homeserver, + homeserverDomain: "example", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-dummy", + namespaces: { users: [] }, + senderLocalpart: "dummybot", + url: "", + }, + }, + dispatch, + log: (() => {}) as BridgeLogger, + }); + + websocket.start(); + await connected; + websocket.stop(); + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$proxied", + kind: "message", + text: "proxied", + })); + }); +}); diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts new file mode 100644 index 0000000..24e8868 --- /dev/null +++ b/packages/bridge/src/appservice-websocket.ts @@ -0,0 +1,284 @@ +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; + log: BridgeLogger; +} + +export class AppserviceWebsocket { + readonly #appservice: MatrixAppserviceInitOptions; + readonly #dispatch: (event: MatrixClientEvent) => Promise; + readonly #log: BridgeLogger; + #closed = false; + #pingTimer: NodeJS.Timeout | null = null; + #reconnectTimer: NodeJS.Timeout | null = null; + #socket: WebSocket | null = null; + + constructor(options: AppserviceWebsocketOptions) { + this.#appservice = options.appservice; + this.#dispatch = options.dispatch; + this.#log = options.log; + } + + start(): void { + this.#closed = false; + this.#connect(); + } + + stop(): void { + this.#closed = true; + if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); + if (this.#pingTimer) clearInterval(this.#pingTimer); + this.#reconnectTimer = null; + this.#pingTimer = null; + this.#socket?.close(); + this.#socket = null; + } + + #connect(): void { + 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.#pingTimer = setInterval(() => this.#ping(), 180_000); + }); + socket.on("message", (data) => { + void this.#handleMessage(data).catch((error: unknown) => { + this.#log("error", "appservice_websocket_message_failed", { error }); + }); + }); + socket.on("close", (code, reason) => { + if (this.#pingTimer) clearInterval(this.#pingTimer); + this.#pingTimer = null; + this.#socket = null; + if (this.#closed) return; + this.#log("warn", "appservice_websocket_closed", { code, reason: reason.toString() }); + this.#reconnectTimer = setTimeout(() => this.#connect(), 2_000); + }); + socket.on("error", (error) => { + this.#log("warn", "appservice_websocket_error", { error }); + }); + } + + async #handleMessage(data: WebSocket.RawData): Promise { + const message = JSON.parse(data.toString()) as WebsocketMessage; + if (message.command === "connect") return; + if (message.command === "ping") { + this.#send(messageResponse(message, true, message.data ?? { timestamp: Date.now() })); + return; + } + if (!message.command || message.command === "transaction") { + for (const raw of message.events ?? []) { + const event = rawMatrixEvent(raw); + if (event) await this.#dispatch(event); + } + this.#send(messageResponse(message, true, { txn_id: message.txn_id })); + return; + } + if (message.command === "http_proxy") { + this.#send(messageResponse(message, true, await this.#handleHTTPProxy(message.data))); + return; + } + this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: `unknown websocket command ${message.command}` })); + } + + async #handleHTTPProxy(data: unknown): Promise { + const request = data as HTTPProxyRequest; + 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 : []; + 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 { + this.#send({ + command: "ping", + data: { timestamp: Date.now() }, + id: Date.now(), + }); + } + + #send(message: WebsocketRequest | null): void { + if (!message || this.#socket?.readyState !== WebSocket.OPEN) return; + this.#socket.send(JSON.stringify(message)); + } +} + +interface WebsocketRequest { + command: string; + data: unknown; + id?: number; +} + +interface WebsocketMessage { + command?: string; + data?: unknown; + events?: RawMatrixEvent[]; + id?: number; + txn_id?: string; +} + +interface HTTPProxyRequest { + body?: unknown; + escaped_path?: boolean; + headers?: Record; + method?: string; + path?: string; + query?: string; +} + +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 || 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 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/bridge.ts b/packages/bridge/src/bridge.ts index 53be34a..264a8d3 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,5 +1,6 @@ import { createMatrixClient } from "@beeper/pickle"; import type { MatrixAppserviceBatchSendOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; +import { AppserviceWebsocket } from "./appservice-websocket"; import { createBeeperAppServiceInit } from "./beeper"; import type { BridgeContext, @@ -84,7 +85,6 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp bridge: options.bridge, bridgeType: options.bridgeType, getOnly: options.getOnly, - homeserver: matrix.homeserver, homeserverDomain: domainFromUserID(options.account.userId), token: options.account.accessToken, })); @@ -111,6 +111,7 @@ export class RuntimeBridge implements PickleBridge { readonly #remoteEvents: Array<{ event: RemoteEvent; login: UserLogin }> = []; readonly #matrixClient: MatrixClient; readonly #subscriptions = new Set(); + #appserviceWebsocket: AppserviceWebsocket | null = null; #bridgeStatus: BridgeStatus | null = null; #context: BridgeContext | null = null; #drainPromise: Promise | null = null; @@ -147,6 +148,7 @@ export class RuntimeBridge implements PickleBridge { await this.connector.init(this.#context); await this.connector.start(this.#context); await this.#subscribeMatrixEvents(); + this.#startAppserviceWebsocket(); this.#started = true; await this.setBridgeState("running"); } @@ -158,6 +160,8 @@ export class RuntimeBridge implements PickleBridge { 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(); @@ -459,6 +463,16 @@ export class RuntimeBridge implements PickleBridge { this.#subscriptions.add(subscription); } + #startAppserviceWebsocket(): void { + if (!this.#appserviceOptions || hasPushURL(this.#appserviceOptions.registration.url)) return; + this.#appserviceWebsocket = new AppserviceWebsocket({ + appservice: this.#appserviceOptions, + dispatch: (event) => this.dispatchMatrixEvent(event), + log: defaultLogger, + }); + this.#appserviceWebsocket.start(); + } + async #dispatchMatrixMessage(event: MatrixMessageEvent): Promise { if (event.sender.isMe || event.sender.userId === this.#ownUserId) { return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; @@ -758,6 +772,10 @@ function hasMethod(value: object, method: T): value is object return method in value && typeof (value as Record)[method] === "function"; } +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()), @@ -860,7 +878,6 @@ function beeperAppServiceOptions(input: { bridge: string; bridgeType: string | undefined; getOnly: boolean | undefined; - homeserver: string | undefined; homeserverDomain: string; token: string; }) { @@ -873,6 +890,5 @@ function beeperAppServiceOptions(input: { 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.homeserver !== undefined) output.homeserver = input.homeserver; return output; } diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index bd57831..a88cc87 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ 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"], + 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, @@ -13,7 +13,7 @@ export default defineConfig({ dts: ".d.ts", }), deps: { - neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file"], + neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], }, target: false, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 204fb58..0ae3ca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,10 +140,16 @@ importers: '@beeper/pickle-state-file': specifier: workspace:* version: link:../state-file + ws: + specifier: ^8.18.0 + version: 8.18.0 devDependencies: '@types/node': specifier: ^25.3.2 version: 25.6.0 + '@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) @@ -1149,6 +1155,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: @@ -2927,6 +2936,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 From f4ba3f69c00906e96776cf0794806a121df60e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:39:38 +0200 Subject: [PATCH 09/21] Add appservice provisioning, status & dummy chats Introduce HTTP proxy handling and richer logging for the appservice websocket, including onOpen hooks and message/http proxy debug logs. Implement provisioning endpoints and login flow handling in the bridge (start/step/complete), plus bridge status broadcasting over the websocket and management command replies sent via the appservice bot when available. Add implicit management room creation, command parsing/logging, and helper utilities for JSON responses and login step serialization. Update example dummy bridge to support configurable bridge name/sender, create/backfill multiple dummy chats on startup, and generate multiple historical messages. Adjust createBeeperBridge/store types and validation to require/accept a store in Node usage. Add a test verifying management command replies go through the appservice bot. --- examples/dummybridge/src/connector.ts | 17 +- examples/dummybridge/src/index.ts | 49 ++-- packages/bridge/src/appservice-websocket.ts | 50 +++- packages/bridge/src/bridge.test.ts | 50 ++++ packages/bridge/src/bridge.ts | 272 +++++++++++++++++++- packages/bridge/src/index.ts | 2 +- packages/bridge/src/types.ts | 3 +- 7 files changed, 400 insertions(+), 43 deletions(-) diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index 6c64062..bbc6d38 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -24,6 +24,7 @@ import type { 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 interface DummyConnectorOptions { senderLocalpart?: string; @@ -357,15 +358,13 @@ export class DummyNetworkAPI implements NetworkAPI { async fetchMessages(): Promise { return { hasMore: false, - messages: [ - { - event: this.#remoteMessage({ - body: "DummyBridge historical hello", - id: "dummy-history-1", - timestamp: Date.now() - 60_000, - }), - }, - ], + 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, + }), + })), }; } diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index 3665565..c36e269 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -3,7 +3,7 @@ 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 { DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; +import { DUMMY_CHAT_IDS, DummyConnector, LOGIN_ID, PORTAL_ID, makeGhostMxid } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; const root = dirname(fileURLToPath(import.meta.url)); @@ -11,16 +11,18 @@ const sourceRoot = root.endsWith("/dist/src") ? resolve(root, "../..") : resolve await loadEnv(resolve(sourceRoot, ".env")); -const senderLocalpart = optionalEnv("DUMMYBRIDGE_SENDER_LOCALPART", "dummybridgebot") ?? "dummybridgebot"; const account = await loginWithPassword({ password: requiredEnv("BEEPER_PASSWORD"), username: requiredEnv("BEEPER_USERNAME"), }); const serverName = domainFromUserId(account.userId); +const bridgeName = optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge2") ?? "sh-dummybridge"; +const senderLocalpart = `${bridgeName}bot`; const bridgeOptions: CreateNodeBeeperBridgeOptions = { account, - bridge: optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge") ?? "sh-dummybridge", + bridge: bridgeName, + bridgeType: "dummybridge-js", connector: new DummyConnector({ senderLocalpart, serverName }), }; const bridgeAddress = optionalEnv("DUMMYBRIDGE_URL"); @@ -28,7 +30,11 @@ if (bridgeAddress !== undefined) bridgeOptions.address = bridgeAddress; const bridge = await createBeeperBridge(bridgeOptions); await bridge.start(); -const login = { id: LOGIN_ID }; +const login = { + id: LOGIN_ID, + remoteName: "Dummy Account", + userId: account.userId, +}; await bridge.loadUserLogin(login); const existingManagementRoomId = optionalEnv("DUMMYBRIDGE_MANAGEMENT_ROOM_ID"); @@ -71,20 +77,29 @@ if (existingRoomId) { } if (portal?.mxid && optionalEnv("DUMMYBRIDGE_BACKFILL_ON_START") === "1") { - await bridge.backfill({ - events: [{ - content: { - body: "DummyBridge backfilled hello", - msgtype: "m.text", - }, - sender: ghostMxid, - timestamp: Date.now() - 60_000, - }], - roomId: portal.mxid, - }); + await bridge.backfillMessages(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.createPortalRoom({ + invite: [account.userId], + name: `Pickle ${titleCase(portalId)}`, + portalKey: { id: portalId, receiver: login.id }, + topic: "A dummy chat created by the TypeScript Pickle bridge.", + userId: ghostMxid, + }); + await bridge.backfillMessages(login, { portal: 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) { @@ -101,3 +116,7 @@ function domainFromUserId(userId: string): string { } return userId.slice(index + 1); } + +function titleCase(value: string): string { + return value.replace(/^dummy-chat-/, "").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index 24e8868..aff443f 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -5,13 +5,17 @@ import type { BridgeLogger } from "./types"; export interface AppserviceWebsocketOptions { appservice: MatrixAppserviceInitOptions; dispatch(event: MatrixClientEvent): Promise; + handleHTTPProxy?(request: HTTPProxyRequest): Promise; log: BridgeLogger; + onOpen?(): void | Promise; } export class AppserviceWebsocket { readonly #appservice: MatrixAppserviceInitOptions; readonly #dispatch: (event: MatrixClientEvent) => Promise; + readonly #handleProxy: ((request: HTTPProxyRequest) => Promise) | undefined; readonly #log: BridgeLogger; + readonly #onOpen: (() => void | Promise) | undefined; #closed = false; #pingTimer: NodeJS.Timeout | null = null; #reconnectTimer: NodeJS.Timeout | null = null; @@ -20,7 +24,9 @@ export class AppserviceWebsocket { constructor(options: AppserviceWebsocketOptions) { this.#appservice = options.appservice; this.#dispatch = options.dispatch; + this.#handleProxy = options.handleHTTPProxy; this.#log = options.log; + this.#onOpen = options.onOpen; } start(): void { @@ -52,6 +58,9 @@ export class AppserviceWebsocket { socket.on("open", () => { this.#log("info", "appservice_websocket_open", { url }); this.#pingTimer = setInterval(() => this.#ping(), 180_000); + 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) => { @@ -63,7 +72,7 @@ export class AppserviceWebsocket { this.#pingTimer = null; this.#socket = null; if (this.#closed) return; - this.#log("warn", "appservice_websocket_closed", { code, reason: reason.toString() }); + this.#log("warn", "appservice_websocket_closed", { code, reconnectMs: 2_000, reason: reason.toString() }); this.#reconnectTimer = setTimeout(() => this.#connect(), 2_000); }); socket.on("error", (error) => { @@ -73,6 +82,12 @@ export class AppserviceWebsocket { 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, + }); if (message.command === "connect") return; if (message.command === "ping") { this.#send(messageResponse(message, true, message.data ?? { timestamp: Date.now() })); @@ -81,13 +96,21 @@ export class AppserviceWebsocket { 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") { - this.#send(messageResponse(message, true, await this.#handleHTTPProxy(message.data))); + 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}` })); @@ -95,12 +118,22 @@ export class AppserviceWebsocket { 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); @@ -124,9 +157,14 @@ export class AppserviceWebsocket { }); } - #send(message: WebsocketRequest | null): void { - if (!message || this.#socket?.readyState !== WebSocket.OPEN) return; + 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; } } @@ -144,7 +182,7 @@ interface WebsocketMessage { txn_id?: string; } -interface HTTPProxyRequest { +export interface HTTPProxyRequest { body?: unknown; escaped_path?: boolean; headers?: Record; @@ -153,7 +191,7 @@ interface HTTPProxyRequest { query?: string; } -interface HTTPProxyResponse { +export interface HTTPProxyResponse { body?: unknown; headers: Record; status: number; diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index a221842..55a0a1a 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -383,6 +383,56 @@ describe("RuntimeBridge", () => { 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/"), + })); + }); }); function matrixConfig(): BridgeMatrixConfig { diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 264a8d3..23ca60b 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,6 +1,6 @@ import { createMatrixClient } from "@beeper/pickle"; -import type { MatrixAppserviceBatchSendOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; -import { AppserviceWebsocket } from "./appservice-websocket"; +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 type { BridgeContext, @@ -53,6 +53,7 @@ import type { LoginProcessDisplayAndWait, LoginProcessUserInput, LoginProcessWithOverride, + LoginStep, LoginUserInput, } from "./types"; @@ -63,21 +64,26 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge { } 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, - token: options.matrix.token ?? options.account.accessToken, + 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, - token: options.matrix.token ?? options.account.accessToken, + homeserver: options.matrix?.homeserver ?? options.account.homeserver, + store, + token: options.matrix?.token ?? options.account.accessToken, }; const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, @@ -106,9 +112,11 @@ export class RuntimeBridge implements PickleBridge { 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 #userLogins = new Map(); readonly #matrixClient: MatrixClient; readonly #subscriptions = new Set(); #appserviceWebsocket: AppserviceWebsocket | null = null; @@ -116,6 +124,7 @@ export class RuntimeBridge implements PickleBridge { #context: BridgeContext | null = null; #drainPromise: Promise | null = null; #started = false; + #ownerUserId: string | null = null; #ownUserId: string | null = null; constructor(options: CreateBridgeOptions, client: MatrixClient) { @@ -137,9 +146,18 @@ export class RuntimeBridge implements PickleBridge { if (this.#started) return; 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) { - await this.#matrixClient.appservice.init(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") { @@ -151,6 +169,7 @@ export class RuntimeBridge implements PickleBridge { this.#startAppserviceWebsocket(); this.#started = true; await this.setBridgeState("running"); + this.#sendCurrentBridgeStatus(); } async stop(): Promise { @@ -267,9 +286,12 @@ export class RuntimeBridge implements PickleBridge { if (existing) return existing; const client = await this.connector.loadUserLogin(this.#requestContext(), login); login.client = client; + this.#userLogins.set(login.id, login); this.#networkClients.set(login.id, client); await this.#dataStore?.setUserLogin(login); await client.connect({ ...this.#requestContext(), login }); + defaultLogger("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); + this.#sendCurrentBridgeStatus(); return client; } @@ -289,6 +311,8 @@ export class RuntimeBridge implements PickleBridge { this.#bridgeStatus = status; await this.#dataStore?.setBridgeStatus(status); await this.#dataStore?.setBridgeState(status.state); + defaultLogger("info", "bridge_state_updated", { state: status.state }); + this.#sendCurrentBridgeStatus(); } getGhost(id: string): Ghost | null { @@ -419,6 +443,12 @@ export class RuntimeBridge implements PickleBridge { 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); } @@ -464,17 +494,80 @@ export class RuntimeBridge implements PickleBridge { } #startAppserviceWebsocket(): void { - if (!this.#appserviceOptions || hasPushURL(this.#appserviceOptions.registration.url)) return; + 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); @@ -495,6 +588,7 @@ export class RuntimeBridge implements PickleBridge { for (const client of this.#networkClients.values()) { 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); } return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; @@ -506,7 +600,7 @@ export class RuntimeBridge implements PickleBridge { } const response = await this.connector.handleCommand(this.#requestContext(), command) as MatrixCommandResponse; if (response?.text || response?.content) { - await this.#matrixIntent().sendMessage(command.event.roomId, response.content ?? { + await this.#sendCommandReply(command.event.roomId, response.content ?? { body: response.text, msgtype: "m.notice", }); @@ -515,8 +609,9 @@ export class RuntimeBridge implements PickleBridge { } #parseManagementCommand(event: MatrixMessageEvent): MatrixCommand | null { - const room = this.#managementRooms.get(event.roomId); - if (!room) return null; + const explicitRoom = this.#managementRooms.get(event.roomId); + if (!explicitRoom && this.#portalsByRoom.has(event.roomId)) return null; + const room = explicitRoom ?? this.#implicitManagementRoom(event); const text = event.text || stringContent(event.content.body); if (!text) return null; const prefix = this.connector.getName().defaultCommandPrefix ?? ""; @@ -524,6 +619,13 @@ export class RuntimeBridge implements PickleBridge { 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, @@ -536,6 +638,12 @@ export class RuntimeBridge implements PickleBridge { }; } + #implicitManagementRoom(event: MatrixMessageEvent): ManagementRoom { + const room: ManagementRoom = { mxid: event.roomId }; + this.registerManagementRoom(room); + return 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 }; @@ -757,6 +865,32 @@ export class RuntimeBridge implements PickleBridge { } 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 logins = Array.from(this.#userLogins.values()); + const stateEvent = bridgeStateEvent(this.#bridgeStatus?.state ?? (logins.length > 0 ? "running" : "starting")); + let sent = websocket.send("bridge_status", bridgeStatePayload(stateEvent)) ? 1 : 0; + for (const login of logins) { + if (websocket.send("bridge_status", bridgeStatePayload("CONNECTED", login))) sent += 1; + } + defaultLogger("debug", "bridge_status_sent", { loginCount: logins.length, sent, stateEvent }); + } } const defaultLogger: BridgeLogger = (level, message, data) => { @@ -772,6 +906,50 @@ function hasMethod(value: object, method: T): value is object return method in value && typeof (value as Record)[method] === "function"; } +function appserviceBotUserId(options: MatrixAppserviceInitOptions): string { + return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; +} + +function bridgeStateEvent(state: BridgeState): string { + 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: string, login?: UserLogin): Record { + return stripUndefined({ + remote_id: login?.id, + remote_name: login?.remoteName, + user_id: login?.userId, + state_event: stateEvent, + timestamp: Math.floor(Date.now() / 1000), + }); +} + +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"); } @@ -892,3 +1070,75 @@ function beeperAppServiceOptions(input: { if (input.getOnly !== undefined) output.getOnly = input.getOnly; 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 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)}`; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index bd73bb4..e8cb518 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -18,7 +18,7 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { } export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { - const store = options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); const matrix = { ...options.matrix, store, diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index e1cfbab..bcffaf4 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -545,7 +545,8 @@ export interface CreateBeeperBridgeOptions extends Omit> & Pick; + matrix?: Partial>; + store?: MatrixStore; } export interface BridgeMatrixConfig extends Pick { From 0c7efb155724c31acb6d4969b6c04242dd3d9738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 20:49:19 +0200 Subject: [PATCH 10/21] Websocket backoff, bridge state and commands Add robust AppserviceWebsocket features and bridge lifecycle/command handling. The websocket now supports configurable timing, capped exponential reconnect backoff, ping/ping-timeout detection, replacement-close handling, and improved close/reconnect bookkeeping; tests for reconnect, ping timeouts, and replacement behavior were added and helper utilities extracted. RuntimeBridge now persists and restores bridge status and user logins, auto-loads persisted logins on startup, tracks per-login bridge state, supports bridgev2-style backfill queueing and message checkpoints, and implements built-in management commands (help, login, logout, cancel-login, set-management-room) with persistence for management rooms; numerous tests and helper mocks were added. Related updates include new/extended types, store interactions, and small API helpers for error/timestamp/TTL handling to support the above. --- .../bridge/src/appservice-websocket.test.ts | 175 ++++++-- packages/bridge/src/appservice-websocket.ts | 171 +++++++- packages/bridge/src/bridge.test.ts | 403 +++++++++++++++++- packages/bridge/src/bridge.ts | 373 ++++++++++++++-- packages/bridge/src/store.ts | 7 + packages/bridge/src/types.ts | 124 ++++++ 6 files changed, 1184 insertions(+), 69 deletions(-) diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index f0581cf..dfac0e1 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -6,8 +6,10 @@ 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()); }))); @@ -52,26 +54,14 @@ describe("AppserviceWebsocket", () => { } }); }); - const websocket = new AppserviceWebsocket({ - appservice: { - homeserver, - homeserverDomain: "example", - registration: { - asToken: "as-token", - hsToken: "hs-token", - id: "sh-dummy", - namespaces: { users: [] }, - senderLocalpart: "dummybot", - url: "", - }, - }, + const websocket = createWebsocket(homeserver, { dispatch, log: (() => {}) as BridgeLogger, }); + websockets.push(websocket); websocket.start(); await connected; - websocket.stop(); expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ eventId: "$event", @@ -121,26 +111,14 @@ describe("AppserviceWebsocket", () => { })); }); }); - const websocket = new AppserviceWebsocket({ - appservice: { - homeserver, - homeserverDomain: "example", - registration: { - asToken: "as-token", - hsToken: "hs-token", - id: "sh-dummy", - namespaces: { users: [] }, - senderLocalpart: "dummybot", - url: "", - }, - }, + const websocket = createWebsocket(homeserver, { dispatch, log: (() => {}) as BridgeLogger, }); + websockets.push(websocket); websocket.start(); await connected; - websocket.stop(); expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ eventId: "$proxied", @@ -148,4 +126,145 @@ describe("AppserviceWebsocket", () => { 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 index aff443f..4018bda 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -7,44 +7,84 @@ export interface AppserviceWebsocketOptions { 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; - #pingTimer: NodeJS.Timeout | null = null; + #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; - if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); - if (this.#pingTimer) clearInterval(this.#pingTimer); - this.#reconnectTimer = null; - this.#pingTimer = null; + 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: { @@ -57,7 +97,12 @@ export class AppserviceWebsocket { this.#socket = socket; socket.on("open", () => { this.#log("info", "appservice_websocket_open", { url }); - this.#pingTimer = setInterval(() => this.#ping(), 180_000); + 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 }); }); @@ -68,18 +113,90 @@ export class AppserviceWebsocket { }); }); socket.on("close", (code, reason) => { - if (this.#pingTimer) clearInterval(this.#pingTimer); - this.#pingTimer = null; - this.#socket = null; - if (this.#closed) return; - this.#log("warn", "appservice_websocket_closed", { code, reconnectMs: 2_000, reason: reason.toString() }); - this.#reconnectTimer = setTimeout(() => this.#connect(), 2_000); + 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", { @@ -88,11 +205,14 @@ export class AppserviceWebsocket { id: message.id, txnId: message.txn_id, }); + 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); @@ -150,11 +270,22 @@ export class AppserviceWebsocket { } #ping(): void { - this.#send({ + 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: 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 { @@ -179,6 +310,7 @@ interface WebsocketMessage { data?: unknown; events?: RawMatrixEvent[]; id?: number; + status?: string; txn_id?: string; } @@ -233,6 +365,15 @@ function websocketURL(homeserver: string): string { 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(/^\/+/, "")}`; } diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 55a0a1a..8e5e494 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -1,12 +1,19 @@ -import type { MatrixClient, MatrixClientEvent, MatrixSubscription } from "@beeper/pickle"; +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, @@ -53,6 +60,51 @@ describe("RuntimeBridge", () => { 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()); @@ -261,6 +313,108 @@ describe("RuntimeBridge", () => { 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()); @@ -433,6 +587,177 @@ describe("RuntimeBridge", () => { 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("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 { @@ -449,6 +774,35 @@ function matrixConfig(): BridgeMatrixConfig { }; } +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 () => {}), + 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; @@ -495,6 +849,53 @@ function loginStep(stepId: string) { }; } +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 () => []), + setAccount: vi.fn(async () => {}), + setBridgeState: vi.fn(async () => {}), + setBridgeStatus: vi.fn(async () => {}), + setGhost: 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 () => {}), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 23ca60b..445262a 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -55,6 +55,14 @@ import type { LoginProcessWithOverride, LoginStep, LoginUserInput, + BridgeStateEvent, + BridgeStatePayload, + BackfillQueueParams, + BackfillQueueResult, + ChatViewingNetworkAPI, + MessageCheckpoint, + MessageCheckpointStatus, + MessageCheckpointStep, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -117,6 +125,7 @@ export class RuntimeBridge implements PickleBridge { readonly #portalsByRoom = new Map(); readonly #remoteEvents: Array<{ event: RemoteEvent; login: UserLogin }> = []; readonly #userLogins = new Map(); + readonly #loginStates = new Map(); readonly #matrixClient: MatrixClient; readonly #subscriptions = new Set(); #appserviceWebsocket: AppserviceWebsocket | null = null; @@ -144,6 +153,7 @@ export class RuntimeBridge implements PickleBridge { async start(): Promise { if (this.#started) return; + await this.#loadPersistedStatus(); await this.setBridgeState("starting"); const whoami = await this.#matrixClient.boot(); this.#ownerUserId = whoami.userId; @@ -164,6 +174,7 @@ export class RuntimeBridge implements PickleBridge { await this.connector.validateConfig(); } await this.connector.init(this.#context); + await this.#loadPersistedUserLogins(); await this.connector.start(this.#context); await this.#subscribeMatrixEvents(); this.#startAppserviceWebsocket(); @@ -174,6 +185,9 @@ export class RuntimeBridge implements PickleBridge { 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())); @@ -278,18 +292,67 @@ export class RuntimeBridge implements PickleBridge { 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)); - return this.backfill({ events, roomId: portal.mxid }); + 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 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 ?? { + cursor: params.cursor, + pending: params.pending, + portalKey: params.portal.portalKey, + userLoginId: login.id, + }; + 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 { + cursor: response.cursor, + forward: response.forward, + hasMore: response.hasMore, + markRead: response.markRead ?? params.markRead, + pending: params.pending, + progress: response.progress ?? params.progress, + queued: false, + task: { + ...task, + completedAt: new Date(), + cursor: response.cursor ?? task.cursor, + done: !response.hasMore, + pending: false, + }, + }; } async loadUserLogin(login: UserLogin): Promise { const existing = this.#networkClients.get(login.id); if (existing) return existing; + await this.#setLoginBridgeState(login, "CONNECTING"); const client = await this.connector.loadUserLogin(this.#requestContext(), login); login.client = client; this.#userLogins.set(login.id, login); this.#networkClients.set(login.id, client); - await this.#dataStore?.setUserLogin(login); + if (this.#dataStore && hasMethod(this.#dataStore, "setUserLogin")) { + await this.#dataStore.setUserLogin(login); + } await client.connect({ ...this.#requestContext(), login }); + await this.#setLoginBridgeState(login, "CONNECTED"); defaultLogger("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); this.#sendCurrentBridgeStatus(); return client; @@ -308,13 +371,23 @@ export class RuntimeBridge implements PickleBridge { } async setBridgeStatus(status: BridgeStatus): Promise { - this.#bridgeStatus = status; - await this.#dataStore?.setBridgeStatus(status); - await this.#dataStore?.setBridgeState(status.state); + 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; } @@ -427,8 +500,12 @@ export class RuntimeBridge implements PickleBridge { }); } - registerManagementRoom(room: ManagementRoom): void { + 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 { @@ -482,6 +559,45 @@ export class RuntimeBridge implements PickleBridge { 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 #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"] }, @@ -572,7 +688,14 @@ export class RuntimeBridge implements PickleBridge { } const command = this.#parseManagementCommand(event); if (command) { - return this.#dispatchMatrixCommand(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 = { @@ -585,16 +708,30 @@ export class RuntimeBridge implements PickleBridge { ...(event.threadRoot ? { threadRoot: { id: event.threadRoot } } : {}), }; let handlers = 0; - for (const client of this.#networkClients.values()) { - 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); + try { + for (const client of this.#networkClients.values()) { + 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 }; } @@ -611,11 +748,14 @@ export class RuntimeBridge implements PickleBridge { #parseManagementCommand(event: MatrixMessageEvent): MatrixCommand | null { const explicitRoom = this.#managementRooms.get(event.roomId); if (!explicitRoom && this.#portalsByRoom.has(event.roomId)) return null; - const room = explicitRoom ?? this.#implicitManagementRoom(event); const text = event.text || stringContent(event.content.body); if (!text) return null; const prefix = this.connector.getName().defaultCommandPrefix ?? ""; - const body = prefix && text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text.trim(); + 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; @@ -638,12 +778,128 @@ export class RuntimeBridge implements PickleBridge { }; } + #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 }; @@ -883,13 +1139,44 @@ export class RuntimeBridge implements PickleBridge { #sendCurrentBridgeStatus(): void { const websocket = this.#appserviceWebsocket; if (!websocket) return; - const logins = Array.from(this.#userLogins.values()); - const stateEvent = bridgeStateEvent(this.#bridgeStatus?.state ?? (logins.length > 0 ? "running" : "starting")); - let sent = websocket.send("bridge_status", bridgeStatePayload(stateEvent)) ? 1 : 0; - for (const login of logins) { - if (websocket.send("bridge_status", bridgeStatePayload("CONNECTED", login))) sent += 1; + 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 }); + 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; } } @@ -910,7 +1197,7 @@ function appserviceBotUserId(options: MatrixAppserviceInitOptions): string { return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; } -function bridgeStateEvent(state: BridgeState): string { +function bridgeStateEvent(state: BridgeState): BridgeStateEvent { switch (state) { case "starting": return "STARTING"; @@ -926,14 +1213,44 @@ function bridgeStateEvent(state: BridgeState): string { } } -function bridgeStatePayload(stateEvent: string, login?: UserLogin): Record { +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, - user_id: login?.userId, + source: "bridge", state_event: stateEvent, - timestamp: Math.floor(Date.now() / 1000), - }); + 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 { @@ -1090,6 +1407,12 @@ function loginStepResponse(loginId: string, step: LoginStep): Record { return stripUndefined({ complete: step.complete ? stripUndefined({ diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 5902255..0fe3990 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -14,6 +14,7 @@ export interface BridgeDataStore { getUserLogin(id: string): Promise; listGhosts(): Promise; listPortals(): Promise; + listUserLogins(): Promise; setAccount(key: string, account: MatrixAccount): Promise; setBridgeState(state: BridgeState): Promise; setBridgeStatus(status: BridgeStatus): Promise; @@ -86,6 +87,12 @@ export class MatrixBridgeDataStore implements BridgeDataStore { 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); } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index bcffaf4..b3e7f47 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -31,6 +31,7 @@ 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; @@ -503,6 +504,7 @@ export interface PickleBridge { createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise; backfill(options: BridgeBackfillOptions): Promise; backfillMessages(login: UserLogin, params: FetchMessagesParams): Promise; + queueBackfill(login: UserLogin, params: BackfillQueueParams): Promise; createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise; downloadMedia(options: DownloadMediaOptions): Promise; flushRemoteEvents(): Promise; @@ -523,6 +525,7 @@ export interface PickleBridge { 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; @@ -765,7 +768,44 @@ export interface Ghost { 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; @@ -980,6 +1020,44 @@ export interface MatrixDeleteChat { 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; @@ -1004,15 +1082,23 @@ export interface Avatar { 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 { @@ -1023,3 +1109,41 @@ export interface BackfillMessage { 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; +} From ea3826fdd18a05390e35bcedd23016335a5bf09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 21:03:00 +0200 Subject: [PATCH 11/21] Use bridge.ghostUserId and centralize ghost logic Move ghost MXID generation into the bridge API and update connectors/examples accordingly. - Remove makeGhostMxid and per-connector sender/server options; DummyConnector now calls ctx.bridge.ghostUserId and injects a ghostUserId function into DummyNetworkAPI. - Simplify remote message helpers to accept ghostUserId callbacks and adjust signatures/usages across the example and test code. - Add RuntimeBridge.ghostUserId and helper functions to derive ghost IDs from appservice registration (ghostUserIdFromRegistration, userIdFromNamespaceRegex, escapeMatrixLocalpart, unescapeRegexLiteral). - Improve backfill task/response handling to use stripUndefined and produce a cleaner task/result shape; add messageCheckpointPayload helper. - Make homeserverDomain optional in CreateBeeperBridgeOptions and propagate it properly when present. - Native side: set host on matrixAppservice and configure HTTP client with host when initializing appservice. These changes centralize ghost user ID logic in the bridge, simplify connector configuration, and ensure consistent ghost ID generation from appservice registrations. --- examples/dummybridge/src/connector.ts | 62 ++++--------- examples/dummybridge/src/index.ts | 19 +--- examples/dummybridge/test/smoke.ts | 6 +- packages/bridge/src/bridge.ts | 90 +++++++++++++++---- packages/bridge/src/types.ts | 2 + .../pickle/native/internal/core/appservice.go | 3 + 6 files changed, 102 insertions(+), 80 deletions(-) diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index bbc6d38..fe7aa53 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -26,24 +26,9 @@ 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 interface DummyConnectorOptions { - senderLocalpart?: string; - serverName?: string; -} - -export function makeGhostMxid(localId: string, serverName: string, senderLocalpart = "dummybridgebot"): string { - const escaped = localId.toLowerCase().replace(/[^a-z0-9._=-]/g, "_"); - return `@${senderLocalpart}_${escaped}:${serverName}`; -} - export class DummyConnector implements CommandHandlingBridgeConnector { - #options: DummyConnectorOptions; #roomCounter = 0; - constructor(options: DummyConnectorOptions = {}) { - this.#options = options; - } - createLogin(_ctx: BridgeRequestContext, _user: BridgeUser, flowId: string): LoginProcess { return new DummyLoginProcess(flowId); } @@ -133,19 +118,18 @@ export class DummyConnector implements CommandHandlingBridgeConnector { const name = command.args.join(" ") || "Pickle DummyBridge"; const portalId = `dummy-room-${++this.#roomCounter}`; const login = { id: LOGIN_ID }; - const ghostMxid = makeGhostMxid("alice", this.#options.serverName ?? "example", this.#options.senderLocalpart); const portal = await ctx.bridge.createPortalRoom({ invite: [command.sender.userId], name, portalKey: { id: portalId, receiver: login.id }, topic: "Created from the DummyBridge management room.", - userId: ghostMxid, + userId: ctx.bridge.ghostUserId("alice"), }); return reply(`created ${portal.mxid} for ${portalId}`); } case "message": { const text = command.args.join(" ") || "hello from DummyBridge"; - ctx.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage({ + ctx.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage(ctx, { body: text, id: `dummy-command-${Date.now()}`, portalId: PORTAL_ID, @@ -155,7 +139,7 @@ export class DummyConnector implements CommandHandlingBridgeConnector { 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.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage({ + ctx.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage(ctx, { body: `dummy message ${index + 1}/${count}`, id: `dummy-command-${Date.now()}-${index}`, portalId: PORTAL_ID, @@ -165,7 +149,7 @@ export class DummyConnector implements CommandHandlingBridgeConnector { } case "ghost": { const localId = command.args[0] ?? "alice"; - return reply(makeGhostMxid(localId, this.#options.serverName ?? "example", this.#options.senderLocalpart)); + return reply(ctx.bridge.ghostUserId(localId)); } case "kick-me": await ctx.client.raw.request({ @@ -180,17 +164,14 @@ export class DummyConnector implements CommandHandlingBridgeConnector { case "cat": return reply("=^._.^="); case "avatar": - return reply(makeGhostMxid(command.args[0] ?? "alice", this.#options.serverName ?? "example", this.#options.senderLocalpart)); + return reply(ctx.bridge.ghostUserId(command.args[0] ?? "alice")); default: return reply(`unknown command: ${command.command}`); } } - loadUserLogin(_ctx: BridgeRequestContext, login: UserLogin): NetworkAPI { - const options: DummyNetworkOptions = { login }; - if (this.#options.senderLocalpart !== undefined) options.senderLocalpart = this.#options.senderLocalpart; - if (this.#options.serverName !== undefined) options.serverName = this.#options.serverName; - return new DummyNetworkAPI(options); + loadUserLogin(ctx: BridgeRequestContext, login: UserLogin): NetworkAPI { + return new DummyNetworkAPI({ ghostUserId: (localId) => ctx.bridge.ghostUserId(localId), login }); } start(ctx: BridgeContext): void { @@ -199,13 +180,11 @@ export class DummyConnector implements CommandHandlingBridgeConnector { stop(): void {} - #remoteMessage(options: { body: string; id: string; portalId?: string; timestamp?: number }) { - const messageOptions: Parameters[0] = { + #remoteMessage(ctx: BridgeRequestContext, options: { body: string; id: string; portalId?: string; timestamp?: number }) { + return remoteMessage({ ...options, - }; - if (this.#options.senderLocalpart !== undefined) messageOptions.senderLocalpart = this.#options.senderLocalpart; - if (this.#options.serverName !== undefined) messageOptions.serverName = this.#options.serverName; - return remoteMessage(messageOptions); + ghostUserId: (localId) => ctx.bridge.ghostUserId(localId), + }); } } @@ -333,20 +312,17 @@ class DummyLoginProcess implements LoginProcess, LoginProcessUserInput, LoginPro } interface DummyNetworkOptions { + ghostUserId(localId: string): string; login?: UserLogin; - senderLocalpart?: string; - serverName?: string; } export class DummyNetworkAPI implements NetworkAPI { + #ghostUserId: (localId: string) => string; #login: UserLogin; - #senderLocalpart: string; - #serverName: string; - constructor(options: DummyNetworkOptions = {}) { + constructor(options: DummyNetworkOptions) { + this.#ghostUserId = options.ghostUserId; this.#login = options.login ?? { id: LOGIN_ID }; - this.#senderLocalpart = options.senderLocalpart ?? "dummybridgebot"; - this.#serverName = options.serverName ?? "example"; } connect(ctx: BridgeRequestContext): void { @@ -384,16 +360,14 @@ export class DummyNetworkAPI implements NetworkAPI { #remoteMessage(options: { body: string; id: string; portalId?: string; timestamp?: number }) { return remoteMessage({ ...options, + ghostUserId: this.#ghostUserId, loginId: this.#login.id, - senderLocalpart: this.#senderLocalpart, - serverName: this.#serverName, }); } } -function remoteMessage(options: { body: string; id: string; loginId?: string; portalId?: string; senderLocalpart?: string; serverName?: string; timestamp?: number }) { +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 }; - const sender = makeGhostMxid("alice", options.serverName ?? "example", options.senderLocalpart); return createRemoteMessage({ convert: () => ({ parts: [{ @@ -409,7 +383,7 @@ function remoteMessage(options: { body: string; id: string; loginId?: string; po portalKey, sender: { isFromMe: false, - sender, + sender: options.ghostUserId("alice"), }, timestamp: new Date(options.timestamp ?? Date.now()), }); diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index c36e269..f782c12 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -3,7 +3,7 @@ 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, makeGhostMxid } from "./connector"; +import { DUMMY_CHAT_IDS, DummyConnector, LOGIN_ID, PORTAL_ID } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; const root = dirname(fileURLToPath(import.meta.url)); @@ -15,15 +15,13 @@ const account = await loginWithPassword({ password: requiredEnv("BEEPER_PASSWORD"), username: requiredEnv("BEEPER_USERNAME"), }); -const serverName = domainFromUserId(account.userId); const bridgeName = optionalEnv("DUMMYBRIDGE_BRIDGE_NAME", "sh-dummybridge2") ?? "sh-dummybridge"; -const senderLocalpart = `${bridgeName}bot`; const bridgeOptions: CreateNodeBeeperBridgeOptions = { account, bridge: bridgeName, bridgeType: "dummybridge-js", - connector: new DummyConnector({ senderLocalpart, serverName }), + connector: new DummyConnector(), }; const bridgeAddress = optionalEnv("DUMMYBRIDGE_URL"); if (bridgeAddress !== undefined) bridgeOptions.address = bridgeAddress; @@ -51,7 +49,6 @@ if (existingManagementRoomId) { console.log(`created management room ${room.mxid}`); } -const ghostMxid = makeGhostMxid("alice", serverName, senderLocalpart); const existingRoomId = optionalEnv("DUMMYBRIDGE_PORTAL_ROOM_ID"); let portal: Portal | null = null; @@ -71,7 +68,7 @@ if (existingRoomId) { name: "Pickle DummyBridge", portalKey: { id: PORTAL_ID, receiver: login.id }, topic: "A TypeScript bridge built with Pickle.", - userId: ghostMxid, + userId: bridge.ghostUserId("alice"), }); console.log(`created portal ${portal.mxid}`); } @@ -90,7 +87,7 @@ if (optionalEnv("DUMMYBRIDGE_CREATE_DUMMY_CHATS", "1") === "1") { name: `Pickle ${titleCase(portalId)}`, portalKey: { id: portalId, receiver: login.id }, topic: "A dummy chat created by the TypeScript Pickle bridge.", - userId: ghostMxid, + userId: bridge.ghostUserId("alice"), }); await bridge.backfillMessages(login, { portal: room }); console.log(`created and backfilled dummy chat ${room.mxid}`); @@ -109,14 +106,6 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { }); } -function domainFromUserId(userId: string): string { - const index = userId.indexOf(":"); - if (index === -1 || index === userId.length - 1) { - throw new Error(`Cannot infer Matrix server name from ${userId}`); - } - return userId.slice(index + 1); -} - 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 index 631918b..2daf57b 100644 --- a/examples/dummybridge/test/smoke.ts +++ b/examples/dummybridge/test/smoke.ts @@ -2,7 +2,7 @@ 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, makeGhostMxid } from "../src/connector"; +import { DummyConnector, LOGIN_ID, PORTAL_ID } from "../src/connector"; interface SmokeCalls { appserviceInit: MatrixAppserviceInitOptions[]; @@ -76,7 +76,7 @@ const appservice: MatrixAppserviceInitOptions = { const bridge = new RuntimeBridge({ appservice, - connector: new DummyConnector({ senderLocalpart: "dummybridgebot", serverName: "example" }) as BridgeConnector, + connector: new DummyConnector() as BridgeConnector, matrix: { homeserver: "https://matrix.example", store: memoryStore(), @@ -91,7 +91,7 @@ assert.equal(calls.appserviceInit.length, 1); const login = { id: LOGIN_ID }; await bridge.loadUserLogin(login); -const ghost = makeGhostMxid("alice", "example", "dummybridgebot"); +const ghost = bridge.ghostUserId("alice"); const portal = await bridge.createPortalRoom({ name: "Pickle DummyBridge", portalKey: { id: PORTAL_ID, receiver: login.id }, diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 445262a..551dc86 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -99,7 +99,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp bridge: options.bridge, bridgeType: options.bridgeType, getOnly: options.getOnly, - homeserverDomain: domainFromUserID(options.account.userId), + homeserverDomain: options.homeserverDomain, token: options.account.accessToken, })); const runtimeOptions: CreateBridgeOptions = { @@ -304,12 +304,12 @@ export class RuntimeBridge implements PickleBridge { if (!hasMethod(client, "fetchMessages")) { throw new Error(`Login ${login.id} does not support backfill`); } - const task = params.task ?? { - cursor: params.cursor, - pending: params.pending, + 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; @@ -322,22 +322,22 @@ export class RuntimeBridge implements PickleBridge { await (client as ChatViewingNetworkAPI).markChatViewed(this.#requestContext(), portal); } await this.#setLoginBridgeState(login, "CONNECTED"); - return { - cursor: response.cursor, - forward: response.forward, - hasMore: response.hasMore, - markRead: response.markRead ?? params.markRead, - pending: params.pending, - progress: response.progress ?? params.progress, + return stripUndefined({ queued: false, - task: { + task: stripUndefined({ ...task, completedAt: new Date(), - cursor: response.cursor ?? task.cursor, 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 { @@ -392,6 +392,14 @@ export class RuntimeBridge implements PickleBridge { 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) => { @@ -1197,6 +1205,33 @@ 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": @@ -1346,6 +1381,25 @@ function eventTimestamp(event: RemoteEvent): number | 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 : ""; } @@ -1373,18 +1427,18 @@ function beeperAppServiceOptions(input: { bridge: string; bridgeType: string | undefined; getOnly: boolean | undefined; - homeserverDomain: string; + homeserverDomain: string | undefined; token: string; }) { const output = { bridge: input.bridge, - homeserverDomain: input.homeserverDomain, 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; } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index b3e7f47..7737a59 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -511,6 +511,7 @@ export interface PickleBridge { 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; @@ -548,6 +549,7 @@ export interface CreateBeeperBridgeOptions extends Omit>; store?: MatrixStore; } diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index f8b2789..358d911 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -14,6 +14,7 @@ import ( type matrixAppservice struct { appToken string botUserID id.UserID + host RuntimeHost homeserver string homeserverDomain string stateStore mautrix.StateStore @@ -123,6 +124,7 @@ func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte as := &matrixAppservice{ appToken: req.Registration.AppToken, botUserID: id.NewUserID(req.Registration.SenderLocalpart, req.HomeserverDomain), + host: c.host, homeserver: req.Homeserver, homeserverDomain: req.HomeserverDomain, stateStore: mautrix.NewMemoryStateStore(), @@ -310,6 +312,7 @@ func (as *matrixAppservice) client(userID id.UserID) (*mautrix.Client, error) { if err != nil { return nil, err } + configureHTTPClient(cli, as.host) cli.SetAppServiceUserID = true cli.StateStore = as.stateStore return cli, nil From 78f6fe085a823211699283962042db7139bbfe18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 21:24:46 +0200 Subject: [PATCH 12/21] Add Beeper portal metadata and auto-join Introduce Beeper support across the bridge and native appservice: add BridgeBeeperOptions to CreateBridgeOptions, thread beeper options into RuntimeBridge, and automatically include the Beeper owner in room invites. Extend createManagementRoom/createPortalRoom to populate beeper-specific create options (auto-join, initial members, portal metadata) and add an autoJoinInvite helper. On the native side, extend MatrixAppserviceCreateRoomOptions with beeper portal types and implement applyBeeperPortalCreateDefaults to populate local room IDs, power level overrides, initial bridge state, and invites. Add unit tests for both the bridge JS and native Go logic, and update generated/runtime TypeScript types and exports to include the new portal and portal key types. --- packages/bridge/src/bridge.test.ts | 91 +++++++++ packages/bridge/src/bridge.ts | 61 +++++- packages/bridge/src/types.ts | 10 + .../pickle/native/internal/core/appservice.go | 177 +++++++++++++++++- .../native/internal/core/appservice_test.go | 90 +++++++++ .../pickle/src/generated-runtime-types.ts | 20 ++ packages/pickle/src/index.ts | 2 + packages/pickle/src/runtime-types.ts | 2 + 8 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 packages/pickle/native/internal/core/appservice_test.go diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 8e5e494..d2a644a 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -270,6 +270,66 @@ describe("RuntimeBridge", () => { 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({ + name: "Remote room", + portalKey: { id: "remote-room", receiver: "login:a" }, + userId: "@test_bob:example", + }); + + expect(client.appservice.createRoom).toHaveBeenNthCalledWith(1, expect.objectContaining({ + beeperAutoJoinInvites: true, + beeperInitialMembers: ["@alice:example"], + invite: ["@alice:example"], + isDirect: false, + })); + expect(client.appservice.createRoom).toHaveBeenNthCalledWith(2, expect.objectContaining({ + beeperAutoJoinInvites: true, + beeperBridgeAccountId: "login:a", + beeperBridgeName: "test", + beeperInitialMembers: ["@alice:example"], + beeperPortal: { + bridgeType: "test", + channelId: "remote-room", + channelName: "Remote room", + networkId: "test", + networkName: "Test", + portalKey: { id: "remote-room", receiver: "login:a" }, + receiver: "login:a", + }, + invite: ["@alice:example"], + name: "Remote room", + userId: "@test_bob:example", + })); + }); + it("fetches backfill through a loaded network API and imports it through appservice", async () => { const client = createFakeMatrixClient(); const network = { @@ -665,6 +725,37 @@ describe("RuntimeBridge", () => { })); }); + it("does not treat persisted portal rooms as implicit management rooms", async () => { + const client = createFakeMatrixClient(); + const dataStore = createFakeDataStore(); + const portal = { id: "remote-room", mxid: "!portal:example", portalKey: { id: "remote-room", receiver: "login:a" } }; + dataStore.listPortals.mockResolvedValue([portal]); + const network = { + ...createFakeNetworkAPI(), + handleMatrixMessage: vi.fn(async () => ({ handled: true })), + }; + const bridge = new RuntimeBridge({ + 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()); diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 551dc86..f107cdc 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -57,6 +57,7 @@ import type { LoginUserInput, BridgeStateEvent, BridgeStatePayload, + BridgeBeeperOptions, BackfillQueueParams, BackfillQueueResult, ChatViewingNetworkAPI, @@ -104,6 +105,11 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp })); const runtimeOptions: CreateBridgeOptions = { appservice, + beeper: { + bridge: options.bridge, + ownerUserId: options.account.userId, + ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), + }, connector: options.connector, matrix, }; @@ -114,6 +120,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp 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(); @@ -139,6 +146,7 @@ export class RuntimeBridge implements PickleBridge { constructor(options: CreateBridgeOptions, client: MatrixClient) { this.connector = options.connector; this.#appserviceOptions = options.appservice; + this.#beeperOptions = options.beeper; this.#dataStore = options.dataStore; this.#matrixClient = client; } @@ -174,6 +182,7 @@ export class RuntimeBridge implements PickleBridge { 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(); @@ -212,14 +221,17 @@ export class RuntimeBridge implements PickleBridge { async createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise { this.#requestContext(); + const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); const createOptions = stripUndefined({ + beeperAutoJoinInvites: this.#beeperOptions ? true : undefined, + beeperInitialMembers: this.#beeperOptions ? invite : undefined, creationContent: options.creationContent, initialState: options.initialState?.map((state) => ({ content: state.content, stateKey: state.stateKey ?? "", type: state.type, })), - invite: options.invite, + invite, isDirect: false, name: options.name, preset: options.preset, @@ -240,19 +252,36 @@ export class RuntimeBridge implements PickleBridge { async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { this.#requestContext(); + const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); + const network = this.connector.getName(); const createOptions = stripUndefined({ - beeperAutoJoinInvites: options.beeperAutoJoinInvites, - beeperBridgeAccountId: options.beeperBridgeAccountId, - beeperBridgeName: options.beeperBridgeName, - beeperInitialMembers: options.beeperInitialMembers, + beeperAutoJoinInvites: options.beeperAutoJoinInvites ?? (this.#beeperOptions ? true : undefined), + beeperBridgeAccountId: options.beeperBridgeAccountId ?? (this.#beeperOptions ? options.portalKey.receiver : undefined), + beeperBridgeName: options.beeperBridgeName ?? this.#beeperOptions?.bridge, + beeperInitialMembers: options.beeperInitialMembers ?? (this.#beeperOptions ? invite : undefined), beeperLocalRoomId: options.beeperLocalRoomId, + beeperPortal: this.#beeperOptions ? stripUndefined({ + bridgeType: this.#beeperOptions.bridgeType ?? network.beeperBridgeType ?? network.networkId, + channelAvatarUrl: options.avatarUrl, + channelId: options.portalKey.id, + channelName: options.name, + isDirect: options.isDirect, + messageRequest: options.messageRequest, + networkAvatarUrl: network.networkIcon, + networkId: network.networkId, + networkName: network.displayName, + networkUrl: network.networkUrl, + portalKey: options.portalKey, + receiver: options.portalKey.receiver, + roomType: options.roomType, + }) : undefined, creationContent: options.creationContent, initialState: options.initialState?.map((state) => ({ content: state.content, stateKey: state.stateKey ?? "", type: state.type, })), - invite: options.invite, + invite, isDirect: options.isDirect, meowCreateTs: options.meowCreateTs, meowRoomId: options.meowRoomId, @@ -591,6 +620,19 @@ export class RuntimeBridge implements PickleBridge { } } + 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); @@ -1359,6 +1401,13 @@ 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}`; } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 7737a59..bbcf2aa 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -537,11 +537,18 @@ export interface PickleBridge { 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; @@ -604,15 +611,18 @@ export interface QueueRemoteEventResult { } export interface BridgeCreatePortalRoomOptions extends CreateRoomOptions { + avatarUrl?: string; beeperAutoJoinInvites?: boolean; beeperBridgeAccountId?: string; beeperBridgeName?: string; beeperInitialMembers?: string[]; beeperLocalRoomId?: string; + messageRequest?: boolean; metadata?: unknown; meowCreateTs?: number; meowRoomId?: string; portalKey: PortalKey; + roomType?: "dm" | "group_dm" | "default" | "space" | string; userId?: string; } diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 358d911..0ae8eb9 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "maunium.net/go/mautrix" @@ -67,14 +68,36 @@ type MatrixAppserviceRoomUserOptions struct { type MatrixAppserviceCreateRoomOptions struct { MatrixCreateRoomOptions - BeeperAutoJoinInvites bool `json:"beeperAutoJoinInvites,omitempty"` - BeeperBridgeAccountID string `json:"beeperBridgeAccountId,omitempty"` - BeeperBridgeName string `json:"beeperBridgeName,omitempty"` - BeeperInitialMembers []string `json:"beeperInitialMembers,omitempty"` - BeeperLocalRoomID string `json:"beeperLocalRoomId,omitempty"` - MeowCreateTS int64 `json:"meowCreateTs,omitempty"` - MeowRoomID string `json:"meowRoomId,omitempty"` - UserID string `json:"userId,omitempty"` + BeeperAutoJoinInvites bool `json:"beeperAutoJoinInvites,omitempty"` + BeeperBridgeAccountID string `json:"beeperBridgeAccountId,omitempty"` + BeeperBridgeName string `json:"beeperBridgeName,omitempty"` + BeeperInitialMembers []string `json:"beeperInitialMembers,omitempty"` + BeeperLocalRoomID string `json:"beeperLocalRoomId,omitempty"` + BeeperPortal *MatrixAppserviceBeeperPortalCreateOptions `json:"beeperPortal,omitempty"` + MeowCreateTS int64 `json:"meowCreateTs,omitempty"` + MeowRoomID string `json:"meowRoomId,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixAppservicePortalKey struct { + ID string `json:"id"` + Receiver string `json:"receiver,omitempty"` +} + +type MatrixAppserviceBeeperPortalCreateOptions struct { + BridgeType string `json:"bridgeType,omitempty"` + ChannelAvatarURL string `json:"channelAvatarUrl,omitempty"` + ChannelID string `json:"channelId"` + ChannelName string `json:"channelName,omitempty"` + IsDirect bool `json:"isDirect,omitempty"` + MessageRequest bool `json:"messageRequest,omitempty"` + NetworkAvatarURL string `json:"networkAvatarUrl,omitempty"` + NetworkID string `json:"networkId"` + NetworkName string `json:"networkName"` + NetworkURL string `json:"networkUrl,omitempty"` + PortalKey *MatrixAppservicePortalKey `json:"portalKey,omitempty"` + Receiver string `json:"receiver,omitempty"` + RoomType string `json:"roomType,omitempty"` } type MatrixAppserviceSendMessageOptions struct { @@ -173,6 +196,7 @@ func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ( createReq.BeeperLocalRoomID = id.RoomID(req.BeeperLocalRoomID) createReq.BeeperBridgeName = req.BeeperBridgeName createReq.BeeperBridgeAccountID = req.BeeperBridgeAccountID + c.applyBeeperPortalCreateDefaults(createReq, req) resp, err := intent.CreateRoom(ctx, createReq) if err != nil { return nil, err @@ -180,6 +204,143 @@ func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ( return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) } +func (c *Core) applyBeeperPortalCreateDefaults(createReq *mautrix.ReqCreateRoom, req MatrixAppserviceCreateRoomOptions) { + if req.BeeperPortal == nil { + return + } + as, err := c.requireAppservice() + if err != nil { + return + } + portal := req.BeeperPortal + bridgeBot := as.botUserID + creator := id.UserID(req.UserID) + if creator == "" { + creator = bridgeBot + } + receiver := portal.Receiver + if receiver == "" { + receiver = req.BeeperBridgeAccountID + } + channelID := portal.ChannelID + if channelID == "" { + channelID = req.BeeperBridgeAccountID + } + portalID := channelID + if portal.PortalKey != nil && portal.PortalKey.ID != "" { + portalID = portal.PortalKey.ID + if receiver == "" { + receiver = portal.PortalKey.Receiver + } + } + if createReq.BeeperLocalRoomID == "" && portalID != "" { + createReq.BeeperLocalRoomID = id.RoomID(fmt.Sprintf("!%s.%s:%s", portalID, receiver, as.homeserverDomain)) + } + if createReq.MeowRoomID == "" { + createReq.MeowRoomID = createReq.BeeperLocalRoomID + } + if createReq.BeeperBridgeName == "" { + createReq.BeeperBridgeName = req.BeeperBridgeName + } + if createReq.BeeperBridgeAccountID == "" { + createReq.BeeperBridgeAccountID = receiver + } + createReq.PowerLevelOverride = defaultBridgePowerLevels(bridgeBot) + bridgeInfoStateKey := createReq.BeeperBridgeName + if bridgeInfoStateKey == "" { + bridgeInfoStateKey = portal.NetworkID + } + if portal.RoomType == "" { + if portal.IsDirect { + portal.RoomType = "dm" + } else { + portal.RoomType = "default" + } + } + bridgeInfo := bridgeInfoContent(portal, bridgeBot, creator) + createReq.InitialState = append(createReq.InitialState, + bridgeStateEvent(event.StateHalfShotBridge, bridgeInfoStateKey, bridgeInfo), + bridgeStateEvent(event.StateBridge, bridgeInfoStateKey, bridgeInfo), + functionalMembersStateEvent(bridgeBot), + ) + if createReq.BeeperAutoJoinInvites { + createReq.Invite = appendMissingUserIDs(createReq.Invite, bridgeBot) + createReq.BeeperInitialMembers = appendMissingUserIDs(createReq.BeeperInitialMembers, bridgeBot) + } +} + +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(portal *MatrixAppserviceBeeperPortalCreateOptions, bridgeBot id.UserID, creator id.UserID) event.BridgeEventContent { + bridgeType := portal.BridgeType + if bridgeType == "" { + bridgeType = portal.NetworkID + } + content := event.BridgeEventContent{ + BridgeBot: bridgeBot, + Creator: creator, + Protocol: event.BridgeInfoSection{ + ID: bridgeType, + DisplayName: portal.NetworkName, + AvatarURL: id.ContentURIString(portal.NetworkAvatarURL), + ExternalURL: portal.NetworkURL, + }, + Channel: event.BridgeInfoSection{ + ID: portal.ChannelID, + DisplayName: portal.ChannelName, + AvatarURL: id.ContentURIString(portal.ChannelAvatarURL), + Receiver: portal.Receiver, + MessageRequest: portal.MessageRequest, + }, + BeeperRoomTypeV2: portal.RoomType, + } + if portal.IsDirect { + 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, userID id.UserID) []id.UserID { + for _, existing := range input { + if existing == userID { + return input + } + } + return append(input, userID) +} + func (c *Core) handleAppserviceSendMessage(ctx context.Context, payload []byte) ([]byte, error) { var req MatrixAppserviceSendMessageOptions if err := json.Unmarshal(payload, &req); err != nil { 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..49bb756 --- /dev/null +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -0,0 +1,90 @@ +package core + +import ( + "testing" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestApplyBeeperPortalCreateDefaultsBuildsBridgeRoomRequest(t *testing.T) { + core := New(nil) + core.appservice = &matrixAppservice{ + botUserID: id.UserID("@testbot:example"), + homeserverDomain: "example", + } + req := MatrixAppserviceCreateRoomOptions{ + MatrixCreateRoomOptions: MatrixCreateRoomOptions{ + Invite: []string{"@alice:example"}, + }, + BeeperAutoJoinInvites: true, + BeeperBridgeAccountID: "login:a", + BeeperBridgeName: "test", + BeeperInitialMembers: []string{"@alice:example"}, + BeeperPortal: &MatrixAppserviceBeeperPortalCreateOptions{ + BridgeType: "test", + ChannelID: "remote-room", + ChannelName: "Remote room", + NetworkID: "test", + NetworkName: "Test", + PortalKey: &MatrixAppservicePortalKey{ID: "remote-room", Receiver: "login:a"}, + Receiver: "login:a", + }, + UserID: "@test_bob:example", + } + createReq := makeCreateRoomRequest(req.MatrixCreateRoomOptions) + createReq.BeeperInitialMembers = toUserIDs(req.BeeperInitialMembers) + createReq.BeeperAutoJoinInvites = req.BeeperAutoJoinInvites + createReq.BeeperBridgeName = req.BeeperBridgeName + createReq.BeeperBridgeAccountID = req.BeeperBridgeAccountID + + core.applyBeeperPortalCreateDefaults(createReq, req) + + 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, "@testbot:example") + assertHasUserID(t, createReq.BeeperInitialMembers, "@testbot: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/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index f546c17..83fa61b 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -60,10 +60,30 @@ export interface MatrixAppserviceCreateRoomOptions extends MatrixCreateRoomOptio beeperBridgeName?: string; beeperInitialMembers?: string[]; beeperLocalRoomId?: string; + beeperPortal?: MatrixAppserviceBeeperPortalCreateOptions; meowCreateTs?: number /* int64 */; meowRoomId?: string; userId?: string; } +export interface MatrixAppservicePortalKey { + id: string; + receiver?: string; +} +export interface MatrixAppserviceBeeperPortalCreateOptions { + bridgeType?: string; + channelAvatarUrl?: string; + channelId: string; + channelName?: string; + isDirect?: boolean; + messageRequest?: boolean; + networkAvatarUrl?: string; + networkId: string; + networkName: string; + networkUrl?: string; + portalKey?: MatrixAppservicePortalKey; + receiver?: string; + roomType?: string; +} export interface MatrixAppserviceSendMessageOptions { content: { [key: string]: unknown }; eventType?: string; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 25eb888..f29ff46 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -22,11 +22,13 @@ export type { MatrixAppserviceBatchEvent, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, + MatrixAppserviceBeeperPortalCreateOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, MatrixAppserviceNamespace, MatrixAppserviceNamespaces, + MatrixAppservicePortalKey, MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index a4f5206..a1c7700 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -12,11 +12,13 @@ export type { MatrixAppserviceBatchEvent, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, + MatrixAppserviceBeeperPortalCreateOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, MatrixAppserviceNamespace, MatrixAppserviceNamespaces, + MatrixAppservicePortalKey, MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, From 286a0bb09c98f554b4192f73d566965bbd401fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 21:25:40 +0200 Subject: [PATCH 13/21] Update bridge.test.ts --- packages/bridge/src/bridge.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index d2a644a..d4ca4a9 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -727,14 +727,27 @@ describe("RuntimeBridge", () => { it("does not treat persisted portal rooms as implicit management rooms", async () => { const client = createFakeMatrixClient(); - const dataStore = createFakeDataStore(); + 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(), From 2c58dc03e7dea8b5de02371028e32fd084fd203b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 21:42:10 +0200 Subject: [PATCH 14/21] Add portal/management room APIs and bridge v2 support Introduce new portal/management room APIs and migrate to bridge v2 payloads across the stack. Bridge package: switch createPortalRoom to accept an info object (name/avatar/topic), update createManagementRoom/createPortalRoom callsites, refine stripUndefined typing. Pickle runtime: add MatrixAppserviceCreatePortalRoomOptions and MatrixAppserviceCreateManagementRoomOptions types, expose createPortalRoom/createManagementRoom on the client, and wire new core operations. Native core (Go): implement handlers and helper builders for appservice_create_portal_room and appservice_create_management_room, replace legacy beeper portal fields with the new bridge/portal shape, and add deterministicPortalRoomID and helper utilities. Update tests and README to reflect the new signatures and behaviors. --- packages/bridge/README.md | 2 +- packages/bridge/src/bridge.test.ts | 39 ++- packages/bridge/src/bridge.ts | 84 ++---- packages/bridge/src/types.ts | 20 +- .../pickle/native/internal/core/appservice.go | 252 +++++++++++------- .../native/internal/core/appservice_test.go | 45 ++-- packages/pickle/native/internal/core/core.go | 4 + .../pickle/native/internal/core/operations.go | 4 + packages/pickle/src/client-types.ts | 4 + packages/pickle/src/client.ts | 2 + .../src/generated-runtime-operations.ts | 12 + .../pickle/src/generated-runtime-types.ts | 47 ++-- packages/pickle/src/index.ts | 4 +- packages/pickle/src/runtime-types.ts | 4 +- 14 files changed, 281 insertions(+), 242 deletions(-) diff --git a/packages/bridge/README.md b/packages/bridge/README.md index 5c918e9..2112e7d 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -24,7 +24,7 @@ await bridge.start(); const login = { id: "example-login" }; await bridge.loadUserLogin(login); const portal = await bridge.createPortalRoom({ - name: "Remote room", + info: { name: "Remote room" }, portalKey: { id: "remote-room-id", receiver: login.id }, userId: "@example_alice:example.com", }); diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index d4ca4a9..f04dfaa 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -246,7 +246,7 @@ describe("RuntimeBridge", () => { await bridge.start(); const portal = await bridge.createPortalRoom({ - name: "Remote room", + info: { name: "Remote room" }, portalKey: { id: "remote-room", receiver: "login:a" }, userId: "@test_alice:example", }); @@ -260,7 +260,8 @@ describe("RuntimeBridge", () => { }); expect(client.appservice.init).toHaveBeenCalledOnce(); - expect(client.appservice.createRoom).toHaveBeenCalledWith(expect.objectContaining({ + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + bridge: expect.objectContaining({ networkId: "test" }), name: "Remote room", userId: "@test_alice:example", })); @@ -299,33 +300,24 @@ describe("RuntimeBridge", () => { name: "Test management", }); await bridge.createPortalRoom({ - name: "Remote room", + info: { name: "Remote room" }, portalKey: { id: "remote-room", receiver: "login:a" }, userId: "@test_bob:example", }); - expect(client.appservice.createRoom).toHaveBeenNthCalledWith(1, expect.objectContaining({ - beeperAutoJoinInvites: true, - beeperInitialMembers: ["@alice:example"], + expect(client.appservice.createManagementRoom).toHaveBeenCalledWith(expect.objectContaining({ + autoJoinInvites: true, + initialMembers: ["@alice:example"], invite: ["@alice:example"], - isDirect: false, })); - expect(client.appservice.createRoom).toHaveBeenNthCalledWith(2, expect.objectContaining({ - beeperAutoJoinInvites: true, - beeperBridgeAccountId: "login:a", - beeperBridgeName: "test", - beeperInitialMembers: ["@alice:example"], - beeperPortal: { - bridgeType: "test", - channelId: "remote-room", - channelName: "Remote room", - networkId: "test", - networkName: "Test", - portalKey: { id: "remote-room", receiver: "login:a" }, - receiver: "login:a", - }, + 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", })); }); @@ -575,9 +567,8 @@ describe("RuntimeBridge", () => { type: "m.room.message", }); - expect(client.appservice.createRoom).toHaveBeenCalledWith(expect.objectContaining({ + expect(client.appservice.createManagementRoom).toHaveBeenCalledWith(expect.objectContaining({ invite: ["@alice:example"], - isDirect: false, name: "Commands", })); expect(result).toEqual({ dispatched: true, eventId: "$cmd", handlers: 1, kind: "message", roomId: "!created:example" }); @@ -1010,6 +1001,8 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip 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 () => {}), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index f107cdc..1af4e9e 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -12,7 +12,6 @@ import type { BridgeCreateManagementRoomOptions, BridgeCreatePortalRoomOptions, BackfillingNetworkAPI, - MatrixAppserviceCreateRoomOptions, MatrixAppserviceSendMessageOptions, LoginProcess, NetworkAPI, @@ -222,26 +221,14 @@ export class RuntimeBridge implements PickleBridge { async createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise { this.#requestContext(); const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); - const createOptions = stripUndefined({ - beeperAutoJoinInvites: this.#beeperOptions ? true : undefined, - beeperInitialMembers: this.#beeperOptions ? invite : undefined, - creationContent: options.creationContent, - initialState: options.initialState?.map((state) => ({ - content: state.content, - stateKey: state.stateKey ?? "", - type: state.type, - })), + const result = await this.#matrixClient.appservice.createManagementRoom(stripUndefined({ + autoJoinInvites: this.#beeperOptions ? true : undefined, + initialMembers: this.#beeperOptions ? invite : undefined, invite, - isDirect: false, name: options.name, - preset: options.preset, - roomAliasName: options.roomAliasName, - roomVersion: options.roomVersion, topic: options.topic, userId: options.userId, - visibility: options.visibility, - }); - const result = await this.#matrixClient.appservice.createRoom(createOptions as MatrixAppserviceCreateRoomOptions); + })); const room: ManagementRoom = { metadata: options.metadata, mxid: result.roomId, @@ -253,47 +240,22 @@ export class RuntimeBridge implements PickleBridge { async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { this.#requestContext(); const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); - const network = this.connector.getName(); - const createOptions = stripUndefined({ - beeperAutoJoinInvites: options.beeperAutoJoinInvites ?? (this.#beeperOptions ? true : undefined), - beeperBridgeAccountId: options.beeperBridgeAccountId ?? (this.#beeperOptions ? options.portalKey.receiver : undefined), - beeperBridgeName: options.beeperBridgeName ?? this.#beeperOptions?.bridge, - beeperInitialMembers: options.beeperInitialMembers ?? (this.#beeperOptions ? invite : undefined), - beeperLocalRoomId: options.beeperLocalRoomId, - beeperPortal: this.#beeperOptions ? stripUndefined({ - bridgeType: this.#beeperOptions.bridgeType ?? network.beeperBridgeType ?? network.networkId, - channelAvatarUrl: options.avatarUrl, - channelId: options.portalKey.id, - channelName: options.name, - isDirect: options.isDirect, - messageRequest: options.messageRequest, - networkAvatarUrl: network.networkIcon, - networkId: network.networkId, - networkName: network.displayName, - networkUrl: network.networkUrl, - portalKey: options.portalKey, - receiver: options.portalKey.receiver, - roomType: options.roomType, - }) : undefined, - creationContent: options.creationContent, - initialState: options.initialState?.map((state) => ({ - content: state.content, - stateKey: state.stateKey ?? "", - type: state.type, - })), + const info = options.info ?? {}; + const result = await this.#matrixClient.appservice.createPortalRoom(stripUndefined({ + autoJoinInvites: this.#beeperOptions ? true : undefined, + avatarUrl: info.avatar?.mxc, + bridge: this.connector.getName(), + bridgeName: this.#beeperOptions?.bridge, + initialMembers: this.#beeperOptions ? invite : undefined, invite, - isDirect: options.isDirect, - meowCreateTs: options.meowCreateTs, - meowRoomId: options.meowRoomId, - name: options.name, - preset: options.preset, - roomAliasName: options.roomAliasName, - roomVersion: options.roomVersion, - topic: options.topic, + isDirect: options.roomType === "dm", + messageRequest: options.messageRequest, + name: info.name, + portalKey: options.portalKey, + roomType: options.roomType, + topic: info.topic, userId: options.userId, - visibility: options.visibility, - }); - const result = await this.#matrixClient.appservice.createRoom(createOptions as MatrixAppserviceCreateRoomOptions); + })); const portal: Portal = { id: options.portalKey.id, metadata: options.metadata, @@ -1453,13 +1415,19 @@ function stringContent(value: unknown): string { return typeof value === "string" ? value : ""; } -function stripUndefined>(value: T): T { +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; + return value as StripUndefined; } function domainFromUserID(userId: string): string { diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index bbcf2aa..39a02e0 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -2,13 +2,11 @@ import type { MatrixAttachment, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, - MatrixAppserviceCreateRoomOptions, MatrixAppserviceInitOptions, MatrixAppserviceSendMessageOptions, MatrixAccount, MatrixClient, MatrixClientOptions, - CreateRoomOptions, MatrixEventSender, MatrixMessageEvent, MatrixReactionEvent, @@ -610,17 +608,11 @@ export interface QueueRemoteEventResult { queued: boolean; } -export interface BridgeCreatePortalRoomOptions extends CreateRoomOptions { - avatarUrl?: string; - beeperAutoJoinInvites?: boolean; - beeperBridgeAccountId?: string; - beeperBridgeName?: string; - beeperInitialMembers?: string[]; - beeperLocalRoomId?: string; +export interface BridgeCreatePortalRoomOptions { + info?: ChatInfo; + invite?: UserID[]; messageRequest?: boolean; metadata?: unknown; - meowCreateTs?: number; - meowRoomId?: string; portalKey: PortalKey; roomType?: "dm" | "group_dm" | "default" | "space" | string; userId?: string; @@ -628,8 +620,11 @@ export interface BridgeCreatePortalRoomOptions extends CreateRoomOptions { export interface BridgeBackfillOptions extends MatrixAppserviceBatchSendOptions {} -export interface BridgeCreateManagementRoomOptions extends Omit { +export interface BridgeCreateManagementRoomOptions { + invite?: UserID[]; metadata?: unknown; + name?: string; + topic?: string; userId?: string; } @@ -656,7 +651,6 @@ export interface MatrixCommandResponse { } export type { - MatrixAppserviceCreateRoomOptions, MatrixAppserviceInitOptions, MatrixAppserviceSendMessageOptions, }; diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 0ae8eb9..68348c8 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -68,15 +68,7 @@ type MatrixAppserviceRoomUserOptions struct { type MatrixAppserviceCreateRoomOptions struct { MatrixCreateRoomOptions - BeeperAutoJoinInvites bool `json:"beeperAutoJoinInvites,omitempty"` - BeeperBridgeAccountID string `json:"beeperBridgeAccountId,omitempty"` - BeeperBridgeName string `json:"beeperBridgeName,omitempty"` - BeeperInitialMembers []string `json:"beeperInitialMembers,omitempty"` - BeeperLocalRoomID string `json:"beeperLocalRoomId,omitempty"` - BeeperPortal *MatrixAppserviceBeeperPortalCreateOptions `json:"beeperPortal,omitempty"` - MeowCreateTS int64 `json:"meowCreateTs,omitempty"` - MeowRoomID string `json:"meowRoomId,omitempty"` - UserID string `json:"userId,omitempty"` + UserID string `json:"userId,omitempty"` } type MatrixAppservicePortalKey struct { @@ -84,20 +76,39 @@ type MatrixAppservicePortalKey struct { Receiver string `json:"receiver,omitempty"` } -type MatrixAppserviceBeeperPortalCreateOptions struct { - BridgeType string `json:"bridgeType,omitempty"` - ChannelAvatarURL string `json:"channelAvatarUrl,omitempty"` - ChannelID string `json:"channelId"` - ChannelName string `json:"channelName,omitempty"` - IsDirect bool `json:"isDirect,omitempty"` - MessageRequest bool `json:"messageRequest,omitempty"` - NetworkAvatarURL string `json:"networkAvatarUrl,omitempty"` - NetworkID string `json:"networkId"` - NetworkName string `json:"networkName"` - NetworkURL string `json:"networkUrl,omitempty"` - PortalKey *MatrixAppservicePortalKey `json:"portalKey,omitempty"` - Receiver string `json:"receiver,omitempty"` - RoomType string `json:"roomType,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 { @@ -189,14 +200,6 @@ func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ( return nil, err } createReq := makeCreateRoomRequest(req.MatrixCreateRoomOptions) - createReq.MeowRoomID = id.RoomID(req.MeowRoomID) - createReq.MeowCreateTS = req.MeowCreateTS - createReq.BeeperInitialMembers = toUserIDs(req.BeeperInitialMembers) - createReq.BeeperAutoJoinInvites = req.BeeperAutoJoinInvites - createReq.BeeperLocalRoomID = id.RoomID(req.BeeperLocalRoomID) - createReq.BeeperBridgeName = req.BeeperBridgeName - createReq.BeeperBridgeAccountID = req.BeeperBridgeAccountID - c.applyBeeperPortalCreateDefaults(createReq, req) resp, err := intent.CreateRoom(ctx, createReq) if err != nil { return nil, err @@ -204,69 +207,114 @@ func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ( return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) } -func (c *Core) applyBeeperPortalCreateDefaults(createReq *mautrix.ReqCreateRoom, req MatrixAppserviceCreateRoomOptions) { - if req.BeeperPortal == nil { - return +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 } - as, err := c.requireAppservice() + intent, err := c.requireAppserviceIntent(req.UserID) if err != nil { - return + return nil, err } - portal := req.BeeperPortal - bridgeBot := as.botUserID - creator := id.UserID(req.UserID) - if creator == "" { - creator = bridgeBot - } - receiver := portal.Receiver - if receiver == "" { - receiver = req.BeeperBridgeAccountID - } - channelID := portal.ChannelID - if channelID == "" { - channelID = req.BeeperBridgeAccountID - } - portalID := channelID - if portal.PortalKey != nil && portal.PortalKey.ID != "" { - portalID = portal.PortalKey.ID - if receiver == "" { - receiver = portal.PortalKey.Receiver - } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err } - if createReq.BeeperLocalRoomID == "" && portalID != "" { - createReq.BeeperLocalRoomID = id.RoomID(fmt.Sprintf("!%s.%s:%s", portalID, receiver, as.homeserverDomain)) + createReq := c.appservice.makePortalCreateRoomRequest(req, intent.UserID) + resp, err := intent.CreateRoom(ctx, createReq) + if err != nil { + return nil, err } - if createReq.MeowRoomID == "" { - createReq.MeowRoomID = createReq.BeeperLocalRoomID + 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 } - if createReq.BeeperBridgeName == "" { - createReq.BeeperBridgeName = req.BeeperBridgeName + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err } - if createReq.BeeperBridgeAccountID == "" { - createReq.BeeperBridgeAccountID = receiver + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err } - createReq.PowerLevelOverride = defaultBridgePowerLevels(bridgeBot) - bridgeInfoStateKey := createReq.BeeperBridgeName - if bridgeInfoStateKey == "" { - bridgeInfoStateKey = portal.NetworkID + createReq := c.appservice.makeManagementCreateRoomRequest(req) + resp, err := intent.CreateRoom(ctx, createReq) + if err != nil { + return nil, err } - if portal.RoomType == "" { - if portal.IsDirect { - portal.RoomType = "dm" - } else { - portal.RoomType = "default" - } + 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(portal, bridgeBot, creator) + bridgeInfo := bridgeInfoContent(req, bridgeBot, roomType) createReq.InitialState = append(createReq.InitialState, bridgeStateEvent(event.StateHalfShotBridge, bridgeInfoStateKey, bridgeInfo), bridgeStateEvent(event.StateBridge, bridgeInfoStateKey, bridgeInfo), functionalMembersStateEvent(bridgeBot), ) - if createReq.BeeperAutoJoinInvites { - createReq.Invite = appendMissingUserIDs(createReq.Invite, bridgeBot) - createReq.BeeperInitialMembers = appendMissingUserIDs(createReq.BeeperInitialMembers, 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 { @@ -284,30 +332,30 @@ func defaultBridgePowerLevels(bridgeBot id.UserID) *event.PowerLevelsEventConten } } -func bridgeInfoContent(portal *MatrixAppserviceBeeperPortalCreateOptions, bridgeBot id.UserID, creator id.UserID) event.BridgeEventContent { - bridgeType := portal.BridgeType +func bridgeInfoContent(req MatrixAppserviceCreatePortalRoomOptions, bridgeBot id.UserID, roomType string) event.BridgeEventContent { + bridgeType := req.Bridge.BeeperBridgeType if bridgeType == "" { - bridgeType = portal.NetworkID + bridgeType = req.Bridge.NetworkID } content := event.BridgeEventContent{ BridgeBot: bridgeBot, - Creator: creator, + Creator: bridgeBot, Protocol: event.BridgeInfoSection{ ID: bridgeType, - DisplayName: portal.NetworkName, - AvatarURL: id.ContentURIString(portal.NetworkAvatarURL), - ExternalURL: portal.NetworkURL, + DisplayName: req.Bridge.DisplayName, + AvatarURL: id.ContentURIString(req.Bridge.NetworkIcon), + ExternalURL: req.Bridge.NetworkURL, }, Channel: event.BridgeInfoSection{ - ID: portal.ChannelID, - DisplayName: portal.ChannelName, - AvatarURL: id.ContentURIString(portal.ChannelAvatarURL), - Receiver: portal.Receiver, - MessageRequest: portal.MessageRequest, + ID: req.PortalKey.ID, + DisplayName: req.Name, + AvatarURL: id.ContentURIString(req.AvatarURL), + Receiver: req.PortalKey.Receiver, + MessageRequest: req.MessageRequest, }, - BeeperRoomTypeV2: portal.RoomType, + BeeperRoomTypeV2: roomType, } - if portal.IsDirect { + if req.IsDirect || roomType == "dm" || roomType == "group_dm" { content.BeeperRoomType = "dm" } return content @@ -332,13 +380,23 @@ func functionalMembersStateEvent(bridgeBot id.UserID) *event.Event { } } -func appendMissingUserIDs(input []id.UserID, userID id.UserID) []id.UserID { - for _, existing := range input { - if existing == userID { - return input +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 append(input, userID) + return input } func (c *Core) handleAppserviceSendMessage(ctx context.Context, payload []byte) ([]byte, error) { diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index 49bb756..8bdd910 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -8,38 +8,25 @@ import ( "maunium.net/go/mautrix/id" ) -func TestApplyBeeperPortalCreateDefaultsBuildsBridgeRoomRequest(t *testing.T) { - core := New(nil) - core.appservice = &matrixAppservice{ +func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { + appservice := &matrixAppservice{ botUserID: id.UserID("@testbot:example"), homeserverDomain: "example", } - req := MatrixAppserviceCreateRoomOptions{ - MatrixCreateRoomOptions: MatrixCreateRoomOptions{ - Invite: []string{"@alice:example"}, + req := MatrixAppserviceCreatePortalRoomOptions{ + AutoJoinInvites: true, + Bridge: MatrixAppserviceBridgeName{ + BeeperBridgeType: "test", + DisplayName: "Test", + NetworkID: "test", }, - BeeperAutoJoinInvites: true, - BeeperBridgeAccountID: "login:a", - BeeperBridgeName: "test", - BeeperInitialMembers: []string{"@alice:example"}, - BeeperPortal: &MatrixAppserviceBeeperPortalCreateOptions{ - BridgeType: "test", - ChannelID: "remote-room", - ChannelName: "Remote room", - NetworkID: "test", - NetworkName: "Test", - PortalKey: &MatrixAppservicePortalKey{ID: "remote-room", Receiver: "login:a"}, - Receiver: "login:a", - }, - UserID: "@test_bob:example", + BridgeName: "test", + InitialMembers: []string{"@alice:example"}, + Invite: []string{"@alice:example"}, + Name: "Remote room", + PortalKey: MatrixAppservicePortalKey{ID: "remote-room", Receiver: "login:a"}, } - createReq := makeCreateRoomRequest(req.MatrixCreateRoomOptions) - createReq.BeeperInitialMembers = toUserIDs(req.BeeperInitialMembers) - createReq.BeeperAutoJoinInvites = req.BeeperAutoJoinInvites - createReq.BeeperBridgeName = req.BeeperBridgeName - createReq.BeeperBridgeAccountID = req.BeeperBridgeAccountID - - core.applyBeeperPortalCreateDefaults(createReq, req) + 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) @@ -47,8 +34,8 @@ func TestApplyBeeperPortalCreateDefaultsBuildsBridgeRoomRequest(t *testing.T) { 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, "@testbot:example") - assertHasUserID(t, createReq.BeeperInitialMembers, "@testbot:example") + 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) } diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 4e92892..5f51130 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -91,6 +91,10 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e 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: diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 1318d21..ee0d481 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -25,6 +25,10 @@ const ( 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 diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index 48dbe6b..347c4d5 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -73,6 +73,8 @@ import type { import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, @@ -110,6 +112,8 @@ export interface MatrixClient { 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; diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 48d98da..8ee0c31 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -73,6 +73,8 @@ class DefaultMatrixClient implements MatrixClient { }; 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)), diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index a19df55..a44364c 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -5,6 +5,8 @@ import type { MatrixApplySyncResponseOptions, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, @@ -97,6 +99,8 @@ export interface MatrixCoreOperations { 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; @@ -202,6 +206,14 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations 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); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 83fa61b..1e07319 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -55,34 +55,43 @@ export interface MatrixAppserviceRoomUserOptions { userId: string; } export interface MatrixAppserviceCreateRoomOptions extends MatrixCreateRoomOptions { - beeperAutoJoinInvites?: boolean; - beeperBridgeAccountId?: string; - beeperBridgeName?: string; - beeperInitialMembers?: string[]; - beeperLocalRoomId?: string; - beeperPortal?: MatrixAppserviceBeeperPortalCreateOptions; - meowCreateTs?: number /* int64 */; - meowRoomId?: string; userId?: string; } export interface MatrixAppservicePortalKey { id: string; receiver?: string; } -export interface MatrixAppserviceBeeperPortalCreateOptions { - bridgeType?: string; - channelAvatarUrl?: string; - channelId: string; - channelName?: string; - isDirect?: boolean; - messageRequest?: boolean; - networkAvatarUrl?: string; +export interface MatrixAppserviceBridgeName { + beeperBridgeType?: string; + defaultCommandPrefix?: string; + defaultPort?: number /* int */; + displayName: string; + networkIcon?: string; networkId: string; - networkName: string; networkUrl?: string; - portalKey?: MatrixAppservicePortalKey; - receiver?: 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 }; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index f29ff46..2dd1df5 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -22,7 +22,9 @@ export type { MatrixAppserviceBatchEvent, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, - MatrixAppserviceBeeperPortalCreateOptions, + MatrixAppserviceBridgeName, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index a1c7700..f73e818 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -12,7 +12,9 @@ export type { MatrixAppserviceBatchEvent, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, - MatrixAppserviceBeeperPortalCreateOptions, + MatrixAppserviceBridgeName, + MatrixAppserviceCreateManagementRoomOptions, + MatrixAppserviceCreatePortalRoomOptions, MatrixAppserviceCreateRoomOptions, MatrixAppserviceInfo, MatrixAppserviceInitOptions, From e777234476fb9869f25c08673103f5123e01db6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 22:29:46 +0200 Subject: [PATCH 15/21] Support top-level avatar/name/topic in portal options When creating a portal room, use top-level options as fallbacks for info fields: name and topic now fall back to options.name/options.topic, and avatarUrl falls back to options.avatarUrl. Update the BridgeCreatePortalRoomOptions type to include avatarUrl, name, and topic so callers can provide these values at the top level. --- packages/bridge/src/bridge.ts | 8 +++++--- packages/bridge/src/types.ts | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 1af4e9e..5959b25 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -241,19 +241,21 @@ export class RuntimeBridge implements PickleBridge { 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, + 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: info.name, + name, portalKey: options.portalKey, roomType: options.roomType, - topic: info.topic, + topic, userId: options.userId, })); const portal: Portal = { diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 39a02e0..d01638b 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -609,12 +609,15 @@ export interface QueueRemoteEventResult { } 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; } From 0bca64f5291ab0001a93857c507729accfc9025d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 22:43:36 +0200 Subject: [PATCH 16/21] Bump @types/node; bridge runtime & pickle fixes Update @types/node to ^20.0.0 across packages and adjust code and tests to improve bridge runtime stability and behavior. Notable changes: - Bridge: add try/catch and improved handling in AppserviceWebsocket, guard messageResponse for missing ids, deduplicate network client loads, schedule/drain remote events with a drainPromise, add portal-specific network client filtering, handle portal registration mxid cleanup, and simplify sendMessage type handling. - Store: add management room support and ensure portal mxid mapping is cleaned when changed. - Tests: add missing mocked store methods and update tests to reflect new behavior. - Examples: guard against empty keys when loading env, and respect BEEPER_BASE_DOMAIN in dummy bridge example. - Pickle (TS/Go): make homeserverDomain optional in types and Go init, derive homeserverDomain from URL when omitted, and improve error returns; clarify loginWithPassword error messages. - Documentation/license: add full MPL2 LICENSE and a connector stub to the bridge README. These changes fix race conditions around user login loading and remote event draining, add some missing persistence APIs, and improve robustness of websocket/error handling. --- examples/dummybridge/package.json | 2 +- examples/dummybridge/src/env.ts | 1 + examples/dummybridge/src/index.ts | 2 + package.json | 2 +- packages/ai-sdk/package.json | 2 +- packages/bridge/LICENSE | 372 ++++++++++++++++++ packages/bridge/README.md | 15 + packages/bridge/docs/BRIDGE_TODO.md | 6 +- packages/bridge/package.json | 4 +- .../bridge/src/appservice-websocket.test.ts | 2 +- packages/bridge/src/appservice-websocket.ts | 62 +-- packages/bridge/src/beeper.ts | 3 +- packages/bridge/src/bridge.test.ts | 3 + packages/bridge/src/bridge.ts | 52 ++- packages/bridge/src/events.ts | 6 +- packages/bridge/src/store.ts | 11 +- packages/chat-adapter/package.json | 2 +- packages/cloudflare/package.json | 2 +- .../pickle/native/internal/core/appservice.go | 35 +- packages/pickle/package.json | 2 +- packages/pickle/src/auth.ts | 4 +- .../pickle/src/generated-runtime-types.ts | 2 +- packages/state-file/package.json | 2 +- packages/state-indexeddb/package.json | 2 +- packages/state-memory/package.json | 2 +- packages/state-simple/package.json | 2 +- packages/state-sqlite/package.json | 2 +- pnpm-lock.yaml | 141 +++++-- 28 files changed, 633 insertions(+), 110 deletions(-) diff --git a/examples/dummybridge/package.json b/examples/dummybridge/package.json index 0f9d6d7..30615ad 100644 --- a/examples/dummybridge/package.json +++ b/examples/dummybridge/package.json @@ -14,7 +14,7 @@ "@beeper/pickle-state-file": "workspace:*" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "tsdown": "^0.21.10", "typescript": "^5.7.2" }, diff --git a/examples/dummybridge/src/env.ts b/examples/dummybridge/src/env.ts index 4d74f33..a83f168 100644 --- a/examples/dummybridge/src/env.ts +++ b/examples/dummybridge/src/env.ts @@ -10,6 +10,7 @@ export async function loadEnv(path = ".env"): Promise { 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); diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index f782c12..0b2fb19 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -23,6 +23,8 @@ const bridgeOptions: CreateNodeBeeperBridgeOptions = { 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); 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 index 289de81..d0a1fa1 100644 --- a/packages/bridge/LICENSE +++ b/packages/bridge/LICENSE @@ -1 +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 index 2112e7d..973052d 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -7,12 +7,27 @@ bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts import { loginWithPassword } from "@beeper/pickle/auth"; import { createBeeperBridge, createRemoteMessage } 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", diff --git a/packages/bridge/docs/BRIDGE_TODO.md b/packages/bridge/docs/BRIDGE_TODO.md index a8cbdc0..556c18e 100644 --- a/packages/bridge/docs/BRIDGE_TODO.md +++ b/packages/bridge/docs/BRIDGE_TODO.md @@ -141,7 +141,7 @@ to match bridgev2 concepts while using TypeScript idioms. ## Tests - [ ] Type conformance tests for golden bridge patterns. -- [ ] Runtime start/stop tests. +- [x] Runtime start/stop tests in `bridge.test.ts`. - [ ] WASM option forwarding tests. -- [ ] Remote event queue tests. -- [ ] Matrix sync dispatch 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 index a6697ed..06d988d 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -48,7 +48,7 @@ "scripts": { "build": "tsdown", "clean": "rm -rf dist", - "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs && pnpm build", "test": "vitest run --coverage", "typecheck": "tsc --noEmit" }, @@ -58,7 +58,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "@types/node": "^25.3.2", + "@types/node": "^20.0.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index dfac0e1..58df8d3 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { AddressInfo } from "node:net"; +import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; import { AppserviceWebsocket } from "./appservice-websocket"; diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index 4018bda..ae28543 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -205,35 +205,41 @@ export class AppserviceWebsocket { id: message.id, txnId: message.txn_id, }); - 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); + 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; } - 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; + 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 })); } - this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: `unknown websocket command ${message.command}` })); } async #handleHTTPProxy(data: unknown): Promise { @@ -342,7 +348,7 @@ interface RawMatrixEvent { } function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null { - if (!message.id || message.command === "response" || message.command === "error") return null; + if (message.id === undefined || message.id === null || message.command === "response" || message.command === "error") return null; return { command: ok ? "response" : "error", data, diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index 30ea15a..0aa8029 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -135,9 +135,10 @@ export class BeeperBridgeManagerClient { 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, whoami.userInfo.username), + homeserver: options.homeserver ?? hungryHomeserver(this.#baseDomain, username), homeserverDomain: options.homeserverDomain ?? "beeper.local", registration, whoami, diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index f04dfaa..1264518 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -891,6 +891,7 @@ function createFakeBridgeDataStore(logins: UserLogin[] = []): BridgeDataStore & 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 () => {}), @@ -980,10 +981,12 @@ function createFakeDataStore() { 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 () => {}), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 5959b25..ac450ac 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -130,6 +130,7 @@ export class RuntimeBridge implements PickleBridge { 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; @@ -189,6 +190,7 @@ export class RuntimeBridge implements PickleBridge { this.#started = true; await this.setBridgeState("running"); this.#sendCurrentBridgeStatus(); + this.#scheduleDrain(); } async stop(): Promise { @@ -336,15 +338,27 @@ export class RuntimeBridge implements PickleBridge { 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 client.connect({ ...this.#requestContext(), login }); await this.#setLoginBridgeState(login, "CONNECTED"); defaultLogger("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); this.#sendCurrentBridgeStatus(); @@ -492,7 +506,12 @@ export class RuntimeBridge implements PickleBridge { } registerPortal(portal: Portal): void { - this.#portalsByKey.set(portalKeyString(portal.portalKey), portal); + 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); } @@ -510,7 +529,8 @@ export class RuntimeBridge implements PickleBridge { } async flushRemoteEvents(): Promise { - await this.#drainRemoteEvents(); + this.#scheduleDrain(); + await this.#drainPromise; } remoteEventBacklog(): readonly { event: RemoteEvent; login: UserLogin }[] { @@ -723,7 +743,7 @@ export class RuntimeBridge implements PickleBridge { }; let handlers = 0; try { - for (const client of this.#networkClients.values()) { + 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 }); @@ -926,7 +946,7 @@ export class RuntimeBridge implements PickleBridge { targetMessage: { id: event.relatesTo }, }; let handlers = 0; - for (const client of this.#networkClients.values()) { + for (const client of this.#networkClientsForPortal(portal)) { if (!hasMethod(client, "handleMatrixReaction")) continue; handlers += 1; await client.handleMatrixReaction(this.#requestContext(), msg); @@ -946,7 +966,7 @@ export class RuntimeBridge implements PickleBridge { portal: this.#portalForRoom(roomId), }; let handlers = 0; - for (const client of this.#networkClients.values()) { + for (const client of this.#networkClientsForPortal(msg.portal)) { if (!hasMethod(client, "handleMatrixRedaction")) continue; handlers += 1; await client.handleMatrixRedaction(this.#requestContext(), msg); @@ -966,12 +986,13 @@ export class RuntimeBridge implements PickleBridge { let handlers = 0; for (const userId of userIds) { if (userId === this.#ownUserId) continue; + const portal = this.#portalForRoom(roomId); const msg: MatrixTyping = { - portal: this.#portalForRoom(roomId), + portal, typing: true, userId, }; - for (const client of this.#networkClients.values()) { + for (const client of this.#networkClientsForPortal(portal)) { if (!hasMethod(client, "handleMatrixTyping")) continue; handlers += 1; await client.handleMatrixTyping(this.#requestContext(), msg); @@ -997,21 +1018,30 @@ export class RuntimeBridge implements PickleBridge { } #scheduleDrain(): void { + if (!this.#context) return; this.#drainPromise ??= this.#drainRemoteEvents().finally(() => { this.#drainPromise = null; - if (this.#remoteEvents.length > 0) this.#scheduleDrain(); + 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.shift(); + 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") { @@ -1110,7 +1140,7 @@ export class RuntimeBridge implements PickleBridge { return { client: this.#matrixClient, sendMessage: async (roomId, content) => { - const type = typeof content.msgtype === "string" ? "m.room.message" : "m.room.message"; + 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, diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts index a08e804..bf8e55c 100644 --- a/packages/bridge/src/events.ts +++ b/packages/bridge/src/events.ts @@ -11,6 +11,8 @@ import type { } 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)); @@ -25,10 +27,10 @@ export function createRemoteMessage(options: CreateRemoteMessageOptions): return options.sender; }, getStreamOrder() { - return options.streamOrder ?? options.timestamp?.getTime() ?? Date.now(); + return streamOrder; }, getTimestamp() { - return options.timestamp ?? new Date(); + return timestamp; }, getType(): RemoteEventType { return options.type ?? "message"; diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 0fe3990..6dd13d6 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -1,5 +1,5 @@ import type { MatrixStore, MatrixAccount, SentEvent } from "@beeper/pickle"; -import type { BridgeState, BridgeStatus, Ghost, MessageRequest, Portal, UserLogin } from "./types"; +import type { BridgeState, BridgeStatus, Ghost, ManagementRoom, MessageRequest, Portal, UserLogin } from "./types"; export interface BridgeDataStore { deletePortal(portalKey: string): Promise; @@ -21,6 +21,7 @@ export interface BridgeDataStore { 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; } @@ -117,9 +118,17 @@ export class MatrixBridgeDataStore implements BridgeDataStore { 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); } 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 index 68348c8..380d609 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -48,7 +49,7 @@ type MatrixAppserviceRegistration struct { type MatrixAppserviceInitOptions struct { Homeserver string `json:"homeserver"` - HomeserverDomain string `json:"homeserverDomain"` + HomeserverDomain string `json:"homeserverDomain,omitempty"` Registration MatrixAppserviceRegistration `json:"registration"` } @@ -149,18 +150,26 @@ func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - if req.Homeserver == "" || req.HomeserverDomain == "" { - return nil, errors.New("homeserver and homeserverDomain are required") + 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, req.HomeserverDomain), + botUserID: id.NewUserID(req.Registration.SenderLocalpart, homeserverDomain), host: c.host, homeserver: req.Homeserver, - homeserverDomain: req.HomeserverDomain, + homeserverDomain: homeserverDomain, stateStore: mautrix.NewMemoryStateStore(), } c.appservice = as @@ -560,10 +569,10 @@ func (as *matrixAppservice) ensureJoined(ctx context.Context, cli *mautrix.Clien if err != nil { bot, botErr := as.client(as.botUserID) if botErr != nil { - return err + return botErr } if _, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}); inviteErr != nil { - return err + return inviteErr } resp, err = cli.JoinRoomByID(ctx, roomID) if err != nil { @@ -584,6 +593,18 @@ func appserviceLocalpart(userID id.UserID) string { 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 { 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.ts b/packages/pickle/src/auth.ts index edaa0c2..ef4a90a 100644 --- a/packages/pickle/src/auth.ts +++ b/packages/pickle/src/auth.ts @@ -42,8 +42,8 @@ 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 or BEEPER_USERNAME"); - if (!password) throw new Error("loginWithPassword requires password or BEEPER_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, diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 1e07319..f0cd0e8 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -40,7 +40,7 @@ export interface MatrixAppserviceRegistration { } export interface MatrixAppserviceInitOptions { homeserver: string; - homeserverDomain: string; + homeserverDomain?: string; registration: MatrixAppserviceRegistration; } export interface MatrixAppserviceInfo { 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..09c3484 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": "^20.0.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 0ae3ca9..646f3b5 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: @@ -105,8 +105,8 @@ importers: version: link:../../packages/state-file 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) @@ -120,8 +120,8 @@ importers: 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) @@ -130,7 +130,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/bridge: dependencies: @@ -145,8 +145,8 @@ importers: version: 8.18.0 devDependencies: '@types/node': - specifier: ^25.3.2 - version: 25.6.0 + specifier: ^20.0.0 + version: 20.19.39 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -161,7 +161,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/chat-adapter: dependencies: @@ -176,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 @@ -189,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: @@ -198,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) @@ -208,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) @@ -223,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: @@ -232,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) @@ -245,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: @@ -254,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) @@ -276,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) @@ -289,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: @@ -298,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) @@ -311,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: @@ -320,8 +320,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) @@ -333,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@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) packages: @@ -1146,6 +1146,9 @@ 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@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -2086,6 +2089,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==} @@ -2345,7 +2351,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 @@ -2361,7 +2367,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 @@ -2761,12 +2767,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: @@ -2928,6 +2934,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -2952,7 +2962,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: @@ -2963,6 +2973,14 @@ 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@25.6.0)(esbuild@0.27.7))': dependencies: '@vitest/spy': 4.1.5 @@ -4030,6 +4048,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: {} @@ -4083,6 +4103,18 @@ 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@25.6.0)(esbuild@0.27.7): dependencies: lightningcss: 1.32.0 @@ -4095,6 +4127,35 @@ 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@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 From ae17650440b16f20bb3bbc6caea9c6704ce238e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 22:51:24 +0200 Subject: [PATCH 17/21] Bump @types/node to ^22.5.0 Update packages/state-sqlite devDependency @types/node from ^20.0.0 to ^22.5.0 and regenerate pnpm-lock.yaml. The lockfile now includes @types/node@22.19.17 and updates related vitest/vite snapshots and optional dependency references to use the newer typings. No runtime code changes. --- examples/dummybridge/test/smoke.ts | 4 ++ packages/state-sqlite/package.json | 2 +- pnpm-lock.yaml | 62 ++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts index 2daf57b..0dcacd3 100644 --- a/examples/dummybridge/test/smoke.ts +++ b/examples/dummybridge/test/smoke.ts @@ -30,6 +30,10 @@ const matrixClient = { 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: {} }; }, diff --git a/packages/state-sqlite/package.json b/packages/state-sqlite/package.json index 09c3484..0f092da 100644 --- a/packages/state-sqlite/package.json +++ b/packages/state-sqlite/package.json @@ -27,7 +27,7 @@ "@beeper/pickle": "workspace:*" }, "devDependencies": { - "@types/node": "^20.0.0", + "@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 646f3b5..7f1b63d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,8 +320,8 @@ importers: version: link:../pickle devDependencies: '@types/node': - specifier: ^20.0.0 - version: 20.19.39 + specifier: ^22.5.0 + version: 22.19.17 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) @@ -333,7 +333,7 @@ importers: 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)) + 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: @@ -1149,6 +1149,9 @@ packages: '@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==} @@ -2938,6 +2941,10 @@ snapshots: 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 @@ -2981,6 +2988,14 @@ snapshots: 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 @@ -4115,6 +4130,18 @@ snapshots: 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 @@ -4156,6 +4183,35 @@ snapshots: 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 From 1806b861bcaa523f3a3d169306d4473a12741eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 23:04:03 +0200 Subject: [PATCH 18/21] Add queue API and portal helpers to bridge Introduce a higher-level API for queuing remote events and portal handling: add RuntimeBridge.createPortal and backfillPortal, plus queue(login).message/event helpers and queueMessage/queueEvent methods. Add convertedMessageFromOptions and textMessage helpers to convert simple message options into ConvertedMessage parts. Expand types with PortalReference, BridgeCreatePortalOptions, RemoteEventQueue, BridgeRemoteEventOptions and BridgeRemoteMessageOptions to accept portal IDs, keys or Portal objects and simplify sender handling. Update runtime internals to resolve portal/sender references, expose queue helpers in BridgeContext, and wire new methods through the runtime. Update examples (dummybridge) and smoke test to use the new APIs, and update README usage accordingly. --- examples/dummybridge/src/connector.ts | 46 ++++++++------ examples/dummybridge/src/index.ts | 16 ++--- examples/dummybridge/test/smoke.ts | 22 +++---- packages/bridge/README.md | 34 ++++++----- packages/bridge/src/bridge.ts | 88 +++++++++++++++++++++++++++ packages/bridge/src/types.ts | 36 +++++++++++ 6 files changed, 183 insertions(+), 59 deletions(-) diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index fe7aa53..cc41997 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -118,32 +118,34 @@ export class DummyConnector implements CommandHandlingBridgeConnector { const name = command.args.join(" ") || "Pickle DummyBridge"; const portalId = `dummy-room-${++this.#roomCounter}`; const login = { id: LOGIN_ID }; - const portal = await ctx.bridge.createPortalRoom({ + const portal = await ctx.bridge.createPortal(login, { + id: portalId, invite: [command.sender.userId], name, - portalKey: { id: portalId, receiver: login.id }, + sender: "alice", topic: "Created from the DummyBridge management room.", - userId: ctx.bridge.ghostUserId("alice"), }); return reply(`created ${portal.mxid} for ${portalId}`); } case "message": { const text = command.args.join(" ") || "hello from DummyBridge"; - ctx.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage(ctx, { - body: text, + ctx.queue({ id: LOGIN_ID }).message({ id: `dummy-command-${Date.now()}`, - portalId: PORTAL_ID, - })); + 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.queueRemoteEvent({ id: LOGIN_ID }, this.#remoteMessage(ctx, { - body: `dummy message ${index + 1}/${count}`, + ctx.queue({ id: LOGIN_ID }).message({ id: `dummy-command-${Date.now()}-${index}`, - portalId: PORTAL_ID, - })); + portal: PORTAL_ID, + sender: "alice", + text: `dummy message ${index + 1}/${count}`, + }); } return reply(`queued ${count} messages`); } @@ -369,15 +371,7 @@ export class DummyNetworkAPI implements NetworkAPI { 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: () => ({ - parts: [{ - content: { - body: options.body, - msgtype: "m.text", - }, - type: "m.room.message", - }], - }), + convert: () => textMessage(options.body), data: {}, id: options.id, portalKey, @@ -389,6 +383,18 @@ function remoteMessage(options: { body: string; ghostUserId(localId: string): st }); } +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/index.ts b/examples/dummybridge/src/index.ts index 0b2fb19..ed77f13 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -65,18 +65,18 @@ if (existingRoomId) { console.log(`registered existing portal ${existingRoomId}`); } else if (optionalEnv("DUMMYBRIDGE_CREATE_ROOM") === "1") { const inviteUser = optionalEnv("DUMMYBRIDGE_INVITE_USER"); - portal = await bridge.createPortalRoom({ + portal = await bridge.createPortal(login, { + id: PORTAL_ID, invite: inviteUser ? [inviteUser] : [], name: "Pickle DummyBridge", - portalKey: { id: PORTAL_ID, receiver: login.id }, + sender: "alice", topic: "A TypeScript bridge built with Pickle.", - userId: bridge.ghostUserId("alice"), }); console.log(`created portal ${portal.mxid}`); } if (portal?.mxid && optionalEnv("DUMMYBRIDGE_BACKFILL_ON_START") === "1") { - await bridge.backfillMessages(login, { portal }); + await bridge.backfillPortal(login, portal); console.log(`backfilled ${portal.mxid}`); } @@ -84,14 +84,14 @@ 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.createPortalRoom({ + const room = await bridge.createPortal(login, { + id: portalId, invite: [account.userId], name: `Pickle ${titleCase(portalId)}`, - portalKey: { id: portalId, receiver: login.id }, + sender: "alice", topic: "A dummy chat created by the TypeScript Pickle bridge.", - userId: bridge.ghostUserId("alice"), }); - await bridge.backfillMessages(login, { portal: room }); + 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); diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts index 0dcacd3..b207bf2 100644 --- a/examples/dummybridge/test/smoke.ts +++ b/examples/dummybridge/test/smoke.ts @@ -95,26 +95,18 @@ assert.equal(calls.appserviceInit.length, 1); const login = { id: LOGIN_ID }; await bridge.loadUserLogin(login); -const ghost = bridge.ghostUserId("alice"); -const portal = await bridge.createPortalRoom({ +const portal = await bridge.createPortal(login, { + id: PORTAL_ID, name: "Pickle DummyBridge", - portalKey: { id: PORTAL_ID, receiver: login.id }, - userId: ghost, + sender: "alice", }); assert.equal(portal.mxid, "!dummy:example"); -assert.equal(calls.createRoom[0]?.userId, ghost); +assert.equal(calls.createRoom[0]?.userId, bridge.ghostUserId("alice")); -const backfill = await bridge.backfill({ - events: [{ - content: { body: "old dummy message", msgtype: "m.text" }, - sender: ghost, - timestamp: Date.now() - 60_000, - }], - roomId: portal.mxid, -}); +const backfill = await bridge.backfillPortal(login, portal); -assert.deepEqual(backfill.eventIds, ["$backfill-0"]); +assert.deepEqual(backfill.eventIds, ["$backfill-0", "$backfill-1", "$backfill-2", "$backfill-3", "$backfill-4"]); assert.equal(calls.backfill.length, 1); await bridge.dispatchMatrixEvent({ @@ -136,7 +128,7 @@ await bridge.flushRemoteEvents(); assert.equal(calls.sendMessage.length, 1); assert.equal(calls.sendMessage[0]?.roomId, portal.mxid); -assert.equal(calls.sendMessage[0]?.userId, ghost); +assert.equal(calls.sendMessage[0]?.userId, bridge.ghostUserId("alice")); assert.equal(calls.sendMessage[0]?.content.body, "dummy echo: hello bridge"); await bridge.stop(); diff --git a/packages/bridge/README.md b/packages/bridge/README.md index 973052d..5a9de35 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -6,7 +6,7 @@ bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge, createRemoteMessage } from "@beeper/pickle-bridge"; +import { createBeeperBridge } from "@beeper/pickle-bridge"; import type { BridgeConnector } from "@beeper/pickle-bridge/types"; const account = await loginWithPassword({ @@ -38,26 +38,28 @@ await bridge.start(); const login = { id: "example-login" }; await bridge.loadUserLogin(login); -const portal = await bridge.createPortalRoom({ +const portal = await bridge.createPortal(login, { + id: "remote-room-id", info: { name: "Remote room" }, - portalKey: { id: "remote-room-id", receiver: login.id }, - userId: "@example_alice:example.com", + sender: "alice", }); -await bridge.backfillMessages(login, { portal }); +await bridge.backfillPortal(login, portal); +await bridge.backfillPortal(login, "remote-room-id"); -bridge.queueRemoteEvent(login, createRemoteMessage({ - data: { text: "hello" }, +bridge.queue(login).message({ id: "remote-message-id", - portalKey: { id: "remote-room-id", receiver: login.id }, - sender: { isFromMe: false, sender: "@example_alice:example.com" }, - convert: (_ctx, _portal, _intent, data) => ({ - parts: [{ - type: "m.room.message", - content: { msgtype: "m.text", body: data.text }, - }], - }), -})); + 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" }, +}); ``` The bridge package is Node-only and uses the same Pickle WASM mechanism as diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index ac450ac..fb1cf72 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -2,6 +2,7 @@ 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, @@ -10,6 +11,7 @@ import type { CreateBridgeOptions, BridgeBackfillOptions, BridgeCreateManagementRoomOptions, + BridgeCreatePortalOptions, BridgeCreatePortalRoomOptions, BackfillingNetworkAPI, MatrixAppserviceSendMessageOptions, @@ -17,7 +19,10 @@ import type { NetworkAPI, PickleBridge, Portal, + PortalKey, + PortalReference, QueueRemoteEventResult, + RemoteEventQueue, RemoteEvent, UserLogin, BridgeUser, @@ -32,9 +37,11 @@ import type { MatrixReaction, MatrixRedaction, MatrixTyping, + EventSender, MatrixIntent, MatrixCommand, MatrixCommandResponse, + ConvertedMessage, ManagementRoom, MessageRequest, MessageRequestHandlingNetworkAPI, @@ -57,6 +64,8 @@ import type { BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, + BridgeRemoteEventOptions, + BridgeRemoteMessageOptions, BackfillQueueParams, BackfillQueueResult, ChatViewingNetworkAPI, @@ -271,6 +280,15 @@ export class RuntimeBridge implements PickleBridge { 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); @@ -294,6 +312,38 @@ export class RuntimeBridge implements PickleBridge { 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 { + event: (event) => this.queueEvent(login, event), + message: (options) => this.queueMessage(login, options), + }; + } + + queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult { + return this.queueRemoteEvent(login, createRemoteMessage({ + ...options, + convert: options.convert ?? (() => convertedMessageFromOptions(options)), + data: options.data as T, + portalKey: this.#portalKeyReference(login, options.portal), + sender: this.#eventSenderReference(login, options.sender), + })); + } + + 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, + }); + } + async queueBackfill(login: UserLogin, params: BackfillQueueParams): Promise { const client = await this.loadUserLogin(login); if (!hasMethod(client, "fetchMessages")) { @@ -418,6 +468,24 @@ export class RuntimeBridge implements PickleBridge { 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; } @@ -574,6 +642,9 @@ export class RuntimeBridge implements PickleBridge { bridge: this, client: this.#matrixClient, log: defaultLogger, + queue: (login) => this.queue(login), + queueEvent: (login, event) => this.queueEvent(login, event), + queueMessage: (login, options) => this.queueMessage(login, options), queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), }; if (this.#dataStore) context.dataStore = this.#dataStore; @@ -1568,3 +1639,20 @@ function stringMap(value: unknown): Record { 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/types.ts b/packages/bridge/src/types.ts index d01638b..1ab52d0 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -40,6 +40,8 @@ export interface PortalKey { receiver?: UserLoginID; } +export type PortalReference = PortalID | PortalKey | Portal; + export interface BridgeName { beeperBridgeType?: string; defaultCommandPrefix?: string; @@ -502,7 +504,9 @@ export interface PickleBridge { 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; @@ -516,6 +520,9 @@ export interface PickleBridge { getPortalByMXID(mxid: RoomID): Portal | null; getUserInfo(userId: UserID): Promise; loadUserLogin(login: UserLogin): Promise; + queue(login: UserLogin): RemoteEventQueue; + queueEvent(login: UserLogin, event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; + queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; registerGhost(ghost: Ghost): void; registerManagementRoom(room: ManagementRoom): void; @@ -582,6 +589,9 @@ export interface BridgeContext { client: MatrixClient; dataStore?: BridgeDataStore; log: BridgeLogger; + queue(login: UserLogin): RemoteEventQueue; + queueEvent(login: UserLogin, event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; + queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; } @@ -608,6 +618,11 @@ export interface QueueRemoteEventResult { queued: boolean; } +export interface RemoteEventQueue { + event(event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; + message(options: BridgeRemoteMessageOptions): QueueRemoteEventResult; +} + export interface BridgeCreatePortalRoomOptions { avatarUrl?: string; info?: ChatInfo; @@ -621,6 +636,11 @@ export interface BridgeCreatePortalRoomOptions { userId?: string; } +export interface BridgeCreatePortalOptions extends Omit { + id: PortalID; + sender?: GhostID; +} + export interface BridgeBackfillOptions extends MatrixAppserviceBatchSendOptions {} export interface BridgeCreateManagementRoomOptions { @@ -914,6 +934,22 @@ export interface CreateRemoteMessageOptions { 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; From 285dd053385e63b968271c542a17e65140a4ca48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 23:05:17 +0200 Subject: [PATCH 19/21] Support backfill queue; refactor event/message helpers Add explicit backfill support to the bridge API and consolidate remote message creation. Introduce RemoteEventQueue.backfill and new types BridgeRemoteBackfillOptions / BridgeRemoteBackfillMessageOptions. Refactor RuntimeBridge to use private helpers (#queueEvent, #queueMessage, #queueBackfillEvent and #remoteMessageEvent) and import RemoteMessageWithTransactionID; queueMessage/queueEvent were converted to private implementations and removed from the public PickleBridge/BridgeContext surface. These changes centralize message construction and enable batching backfill payloads when queuing remote events. --- packages/bridge/README.md | 8 ++++++ packages/bridge/src/bridge.ts | 48 +++++++++++++++++++++++++---------- packages/bridge/src/types.ts | 19 +++++++++++--- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/bridge/README.md b/packages/bridge/README.md index 5a9de35..af83331 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -60,6 +60,14 @@ bridge.queue(login).message({ 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 diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index fb1cf72..5297ed9 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -46,6 +46,7 @@ import type { MessageRequest, MessageRequestHandlingNetworkAPI, RemoteMessage, + RemoteMessageWithTransactionID, RemoteBackfill, RemoteChatDelete, RemoteChatInfoChange, @@ -64,6 +65,7 @@ import type { BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, + BridgeRemoteBackfillOptions, BridgeRemoteEventOptions, BridgeRemoteMessageOptions, BackfillQueueParams, @@ -318,22 +320,34 @@ export class RuntimeBridge implements PickleBridge { queue(login: UserLogin): RemoteEventQueue { return { - event: (event) => this.queueEvent(login, event), - message: (options) => this.queueMessage(login, options), + 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, createRemoteMessage({ - ...options, - convert: options.convert ?? (() => convertedMessageFromOptions(options)), - data: options.data as T, - portalKey: this.#portalKeyReference(login, options.portal), - sender: this.#eventSenderReference(login, options.sender), - })); + #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); + return this.queueRemoteEvent(login, { + getBackfillData: () => Promise.resolve({ + 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", + }); } - queueEvent(login: UserLogin, input: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult { + #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 ?? "" }); @@ -344,6 +358,16 @@ export class RuntimeBridge implements PickleBridge { }); } + #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")) { @@ -643,8 +667,6 @@ export class RuntimeBridge implements PickleBridge { client: this.#matrixClient, log: defaultLogger, queue: (login) => this.queue(login), - queueEvent: (login, event) => this.queueEvent(login, event), - queueMessage: (login, options) => this.queueMessage(login, options), queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), }; if (this.#dataStore) context.dataStore = this.#dataStore; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 1ab52d0..fa21826 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -521,8 +521,6 @@ export interface PickleBridge { getUserInfo(userId: UserID): Promise; loadUserLogin(login: UserLogin): Promise; queue(login: UserLogin): RemoteEventQueue; - queueEvent(login: UserLogin, event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; - queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; registerGhost(ghost: Ghost): void; registerManagementRoom(room: ManagementRoom): void; @@ -590,8 +588,6 @@ export interface BridgeContext { dataStore?: BridgeDataStore; log: BridgeLogger; queue(login: UserLogin): RemoteEventQueue; - queueEvent(login: UserLogin, event: RemoteEvent | BridgeRemoteEventOptions): QueueRemoteEventResult; - queueMessage(login: UserLogin, options: BridgeRemoteMessageOptions): QueueRemoteEventResult; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; } @@ -619,10 +615,25 @@ export interface QueueRemoteEventResult { } 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; From 040f0af776b8f7de0cbb891da39252a5ad55076e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 7 May 2026 23:06:30 +0200 Subject: [PATCH 20/21] Strip undefined fields in backfill event Refactor #queueBackfillEvent to construct a RemoteBackfill object and pass it to queueRemoteEvent. The backfill payload is now wrapped with stripUndefined to remove undefined properties before being resolved, and message portal values fall back to options.portal. This cleans up the queued backfill payload and makes the code clearer by using an explicit event object. --- packages/bridge/src/bridge.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 5297ed9..40f417e 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -332,19 +332,20 @@ export class RuntimeBridge implements PickleBridge { #queueBackfillEvent(login: UserLogin, options: BridgeRemoteBackfillOptions): QueueRemoteEventResult { const portalKey = this.#portalKeyReference(login, options.portal); - return this.queueRemoteEvent(login, { - getBackfillData: () => Promise.resolve({ + 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 { From 222bdb081c6c2689a7ae7ba79b2735f0c65e4925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 00:37:10 +0200 Subject: [PATCH 21/21] Update node.ts --- packages/bridge/src/node.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index 6a52646..370a7a7 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,7 +1,40 @@ -export { createBeeperBridge, createBridge, RuntimeBridge } from "./index"; +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"); +}