diff --git a/changelog.d/312.feature b/changelog.d/312.feature new file mode 100644 index 00000000..745433a8 --- /dev/null +++ b/changelog.d/312.feature @@ -0,0 +1 @@ +Add the `BridgeInfoStateSyncer` helper component to sync MSC2346 format state events to rooms. \ No newline at end of file diff --git a/src/components/bridge-info-state.ts b/src/components/bridge-info-state.ts new file mode 100644 index 00000000..3384b163 --- /dev/null +++ b/src/components/bridge-info-state.ts @@ -0,0 +1,141 @@ +import { Bridge } from "../bridge"; +import logger from "./logging"; +import PQueue from "p-queue"; + +const log = logger.get("BridgeStateSyncer"); +export interface MappingInfo { + creator?: string; + protocol: { + id: string; + displayname?: string; + // eslint-disable-next-line camelcase + avatar_url?: `mxc://${string}`; + // eslint-disable-next-line camelcase + external_url?: string; + }; + network?: { + id: string; + displayname?: string; + // eslint-disable-next-line camelcase + avatar_url?: `mxc://${string}`; + // eslint-disable-next-line camelcase + external_url?: string; + }; + channel: { + id: string; + displayname?: string; + // eslint-disable-next-line camelcase + avatar_url?: `mxc://${string}`; + // eslint-disable-next-line camelcase + external_url?: string; + }; +} + +export interface MSC2364Content extends MappingInfo { + bridgebot: string; +} + +interface Opts { + /** + * The name of the bridge implementation, ideally in Java package naming format: + * @example org.matrix.matrix-appservice-irc + */ + bridgeName: string; + /** + * This should return some standard information about a given + * mapping. + */ + getMapping: (roomId: string, info: BridgeMappingInfo) => Promise; +} + +/** + * This class ensures that rooms contain a valid bridge info + * event ([MSC2346](https://github.com/matrix-org/matrix-doc/pull/2346)) + * which displays the connected protocol, network and room. + */ +export class BridgeInfoStateSyncer { + public static readonly EventType = "uk.half-shot.bridge"; + constructor(private bridge: Bridge, private opts: Opts) { + } + + /** + * Check all rooms and ensure they have correct state. + * @param allMappings All bridged room mappings + * @param concurrency How many rooms to handle at a time, defaults to 3. + */ + public async initialSync(allMappings: Record, concurrency = 3) { + log.info("Beginning sync of bridge state events"); + const syncQueue = new PQueue({ concurrency }); + Object.entries(allMappings).forEach(([roomId, mappings]) => { + syncQueue.add(() => this.syncRoom(roomId, mappings)); + }); + return syncQueue.onIdle(); + } + + private async syncRoom(roomId: string, mappings: BridgeMappingInfo[]) { + log.info(`Syncing ${roomId}`); + const intent = this.bridge.getIntent(); + for (const mappingInfo of mappings) { + const realMapping = await this.opts.getMapping(roomId, mappingInfo); + const key = this.createStateKey(realMapping); + const content = this.createBridgeInfoContent(realMapping); + try { + const eventData: MSC2364Content|null = await intent.getStateEvent( + roomId, BridgeInfoStateSyncer.EventType, key, true + ); + if (eventData !== null) { // If found, validate. + if (JSON.stringify(eventData) === JSON.stringify(content)) { + continue; + } + log.debug(`${key} for ${roomId} is invalid, updating`); + } + } + catch (ex) { + log.warn(`Encountered error when trying to sync ${roomId}`); + break; // To be on the safe side, do not retry this room. + } + + // Event wasn't found or was invalid, let's try setting one. + const eventContent = this.createBridgeInfoContent(realMapping); + try { + await intent.sendStateEvent( + roomId, BridgeInfoStateSyncer.EventType, key, eventContent as unknown as Record + ); + } + catch (ex) { + log.error(`Failed to update room with new state content: ${ex.message}`); + } + } + } + + public async createInitialState(roomId: string, bridgeMappingInfo: BridgeMappingInfo) { + const mapping = await this.opts.getMapping(roomId, bridgeMappingInfo); + return { + type: BridgeInfoStateSyncer.EventType, + content: this.createBridgeInfoContent(mapping), + state_key: this.createStateKey(mapping), + }; + } + + public createStateKey(mapping: MappingInfo) { + const networkId = mapping.network ? mapping.network?.id.replace(/\//g, "%2F") + "/" : ""; + const channel = mapping.channel.id.replace(/\//g, "%2F"); + return `${this.opts.bridgeName}:/${networkId}${channel}`; + } + + public createBridgeInfoContent(mapping: MappingInfo) + : MSC2364Content { + const content: MSC2364Content = { + bridgebot: this.bridge.botUserId, + protocol: mapping.protocol, + channel: mapping.channel, + }; + if (mapping.creator) { + content.creator = mapping.creator; + } + if (mapping.network) { + content.network = mapping.network; + } + return content; + } +} diff --git a/src/components/intent.ts b/src/components/intent.ts index 4dce2bd9..feeb66ec 100644 --- a/src/components/intent.ts +++ b/src/components/intent.ts @@ -720,11 +720,20 @@ export class Intent { * state if they are not already joined. * @param roomId The room to get the state from. * @param eventType The event type to fetch. - * @param [stateKey=""] The state key of the event to fetch. + * @param stateKey The state key of the event to fetch. + * @param returnNull Return null on not found, rather than throwing */ - public async getStateEvent(roomId: string, eventType: string, stateKey = "") { + public async getStateEvent(roomId: string, eventType: string, stateKey = "", returnNull = false) { await this._ensureJoined(roomId); - return this.client.getStateEvent(roomId, eventType, stateKey); + try { + return await this.client.getStateEvent(roomId, eventType, stateKey); + } + catch (ex) { + if (ex.errcode !== "M_NOT_FOUND" || !returnNull) { + throw ex; + } + } + return null; } /** diff --git a/src/index.ts b/src/index.ts index cf0637aa..2fb3ba63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export * from "./components/membership-queue"; export * as Logging from "./components/logging"; export { unstable } from "./errors"; export * from "./components/event-types"; +export * from "./components/bridge-info-state"; /* eslint-disable @typescript-eslint/no-var-requires */