Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 58 additions & 140 deletions packages/bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, Mat
import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket";
import { createBeeperAppServiceInit } from "./beeper";
import { createRemoteMessage } from "./events";
import { getOrCreateAppserviceDeviceId } from "./store";
import { handleProvisioningHTTPProxy } from "./provisioning";
import type {
BridgeContext,
BridgeLogger,
Expand Down Expand Up @@ -60,11 +62,11 @@ import type {
LoginProcessDisplayAndWait,
LoginProcessUserInput,
LoginProcessWithOverride,
LoginStep,
LoginUserInput,
BridgeStateEvent,
BridgeStatePayload,
BridgeBeeperOptions,
BridgeMatrixConfig,
BridgeRemoteBackfillOptions,
BridgeRemoteEventOptions,
BridgeRemoteMessageOptions,
Expand All @@ -74,6 +76,8 @@ import type {
MessageCheckpoint,
MessageCheckpointStatus,
MessageCheckpointStep,
HTTPProxyHandlingBridgeConnector,
LoginStep,
} from "./types";

type GenericMatrixEvent = Extract<MatrixClientEvent, { content: Record<string, unknown>; kind: string }>;
Expand All @@ -84,27 +88,30 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge {

export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise<PickleBridge> {
if (!options.store) throw new Error("createBeeperBridge requires store outside the Node entrypoint");
const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({
address: options.address,
baseDomain: options.baseDomain,
bridge: options.bridge,
bridgeType: options.bridgeType,
getOnly: options.getOnly,
homeserverDomain: options.homeserverDomain,
token: options.account.accessToken,
}));
const matrix = {
...options.matrix,
account: options.account,
homeserver: options.matrix?.homeserver ?? options.account.homeserver,
appservice: options.matrix?.appservice ?? appservice,
deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(options.store, options.bridge),
homeserver: options.matrix?.homeserver ?? appservice.homeserver,
store: options.store,
token: options.matrix?.token ?? options.account.accessToken,
token: options.matrix?.token ?? appservice.registration.asToken,
};
return createBeeperBridgeWithClient({ ...options, matrix }, createMatrixClient(matrix));
return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), createMatrixClient(matrix));
}

export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise<PickleBridge> {
const store = options.store ?? options.matrix?.store;
if (!store) throw new Error("createBeeperBridgeWithClient requires store");
const matrix = {
...options.matrix,
account: options.account,
homeserver: options.matrix?.homeserver ?? options.account.homeserver,
store,
token: options.matrix?.token ?? options.account.accessToken,
};
const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({
const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({
address: options.address,
baseDomain: options.baseDomain,
bridge: options.bridge,
Expand All @@ -113,6 +120,18 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp
homeserverDomain: options.homeserverDomain,
token: options.account.accessToken,
}));
const matrix = {
...options.matrix,
appservice: options.matrix?.appservice ?? appservice,
deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge),
homeserver: options.matrix?.homeserver ?? appservice.homeserver,
store,
token: options.matrix?.token ?? appservice.registration.asToken,
};
return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), client);
}

function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservice: NonNullable<CreateBridgeOptions["appservice"]>, matrix: BridgeMatrixConfig): CreateBridgeOptions {
const runtimeOptions: CreateBridgeOptions = {
appservice,
beeper: {
Expand All @@ -124,7 +143,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp
matrix,
};
if (options.dataStore) runtimeOptions.dataStore = options.dataStore;
return new RuntimeBridge(runtimeOptions, client);
return runtimeOptions;
}

export class RuntimeBridge implements PickleBridge {
Expand Down Expand Up @@ -170,6 +189,10 @@ export class RuntimeBridge implements PickleBridge {
return this.#context;
}

getOwnUserId(): string | null {
return this.#ownUserId;
}

async start(): Promise<void> {
if (this.#started) return;
await this.#loadPersistedStatus();
Expand Down Expand Up @@ -261,6 +284,7 @@ export class RuntimeBridge implements PickleBridge {
avatarUrl: info.avatar?.mxc ?? options.avatarUrl,
bridge: this.connector.getName(),
bridgeName: this.#beeperOptions?.bridge,
initialState: options.initialState,
initialMembers: this.#beeperOptions ? invite : undefined,
invite,
isDirect: options.roomType === "dm",
Expand Down Expand Up @@ -758,55 +782,19 @@ export class RuntimeBridge implements PickleBridge {
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));
if (hasMethod(this.connector, "handleHTTPProxy")) {
const handled = await (this.connector as HTTPProxyHandlingBridgeConnector).handleHTTPProxy(this.#requestContext(), request);
if (handled) return normalizeHTTPProxyResponse(handled);
}
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;
return handleProvisioningHTTPProxy({
capabilities: () => this.connector.getCapabilities(),
createLogin: (flowId) => this.createLogin({ id: this.#ownerUserId ?? this.#ownUserId ?? "" }, flowId),
listLogins: () => Array.from(this.#userLogins.values()),
loginFlows: () => this.connector.getLoginFlows(),
loadLogin: (login) => this.loadUserLogin(login).then(() => undefined),
requestContext: () => this.#requestContext(),
resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }),
}, { logins: this.#provisioningLogins }, request);
}

async #dispatchMatrixMessage(event: MatrixMessageEvent): Promise<MatrixDispatchResult> {
Expand Down Expand Up @@ -1418,20 +1406,6 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function provisioningCapabilities(capabilities: { provisioning?: { groupCreation?: unknown; resolveIdentifier?: unknown } }): unknown {
const provisioning = capabilities.provisioning;
if (provisioning) {
return {
group_creation: provisioning.groupCreation ?? {},
resolve_identifier: provisioning.resolveIdentifier ?? {},
};
}
return {
group_creation: {},
resolve_identifier: {},
};
}

function hasPushURL(url: string | undefined): boolean {
return Boolean(url && url !== "websocket");
}
Expand Down Expand Up @@ -1585,22 +1559,15 @@ function beeperAppServiceOptions(input: {
return output;
}

function jsonHTTPResponse(status: number, body: unknown): HTTPProxyResponse {
return {
body,
headers: { "content-type": ["application/json"] },
status,
};
}

function matrixError(errcode: string, error: string): Record<string, string> {
return { errcode, error };
}

function loginStepResponse(loginId: string, step: LoginStep): Record<string, unknown> {
function normalizeHTTPProxyResponse(response: { body?: unknown; headers?: Record<string, string | string[]>; status: number }): HTTPProxyResponse {
const headers: Record<string, string[]> = {};
for (const [key, value] of Object.entries(response.headers ?? {})) {
headers[key] = Array.isArray(value) ? value : [value];
}
return {
login_id: loginId,
...loginStepJSON(step),
body: response.body,
headers,
status: response.status,
};
}

Expand All @@ -1610,55 +1577,6 @@ function loginStepText(step: LoginStep): string {
return lines.join("\n");
}

function loginStepJSON(step: LoginStep): Record<string, unknown> {
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<string, string> {
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)}`;
}
Expand Down
31 changes: 24 additions & 7 deletions packages/bridge/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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 { createBeeperAppServiceInit } from "./beeper";
import { RuntimeBridge } from "./bridge";
import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store";
import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types";

export { createBridgeDataStore, MatrixBridgeDataStore } from "./store";
Expand All @@ -19,19 +20,35 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge {

export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise<PickleBridge> {
const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options));
const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({
bridge: options.bridge,
token: options.account.accessToken,
...(options.address ? { address: options.address } : {}),
...(options.baseDomain ? { baseDomain: options.baseDomain } : {}),
...(options.bridgeType ? { bridgeType: options.bridgeType } : {}),
...(options.getOnly !== undefined ? { getOnly: options.getOnly } : {}),
...(options.homeserverDomain ? { homeserverDomain: options.homeserverDomain } : {}),
});
const matrix = {
...options.matrix,
appservice,
deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge),
homeserver: options.matrix?.homeserver ?? appservice.homeserver,
store,
token: options.matrix?.token ?? appservice.registration.asToken,
};
return createBeeperBridgeWithClient({
...options,
return new RuntimeBridge({
appservice,
beeper: {
bridge: options.bridge,
ownerUserId: options.account.userId,
...(options.bridgeType ? { bridgeType: options.bridgeType } : {}),
},
connector: options.connector,
dataStore: options.dataStore ?? createBridgeDataStore(store),
matrix,
}, createMatrixClient({
...matrix,
account: options.account,
homeserver: matrix.homeserver ?? options.account.homeserver,
token: matrix.token ?? options.account.accessToken,
}));
}

Expand Down
Loading
Loading