diff --git a/changelog.d/941.feature b/changelog.d/941.feature new file mode 100644 index 000000000..956a620ce --- /dev/null +++ b/changelog.d/941.feature @@ -0,0 +1 @@ +Add support for MSC2346; adding information about the bridged channel into room state. \ No newline at end of file diff --git a/config.sample.yaml b/config.sample.yaml index b78ef2511..73d3bace4 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -354,7 +354,11 @@ ircService: # through the bridge e.g. caller ID as there is no way to /ACCEPT. # Default: "" (no user modes) # userModes: "R" - + # Set information about the bridged channel in the room state, so that client's may + # present relevant UI to the user. MSC2346 + bridgeInfoState: + enabled: false + initial: false # Configuration for an ident server. If you are running a public bridge it is # advised you setup an ident server so IRC mods can ban specific matrix users # rather than the application service itself. diff --git a/config.schema.yml b/config.schema.yml index 17d62e8ad..c9766c7ed 100644 --- a/config.schema.yml +++ b/config.schema.yml @@ -120,6 +120,13 @@ properties: mapIrcMentionsToMatrix: type: "string" enum: ["on", "off", "force-off"] + bridgeInfoState: + type: "object" + properties: + enabled: + type: "boolean" + initial: + type: "boolean" servers: type: "object" # all properties must follow the following diff --git a/src/bridge/AdminRoomHandler.ts b/src/bridge/AdminRoomHandler.ts index e4a5e0f7d..a26c09bc5 100644 --- a/src/bridge/AdminRoomHandler.ts +++ b/src/bridge/AdminRoomHandler.ts @@ -227,6 +227,14 @@ export class AdminRoomHandler { } }); } + if (this.ircBridge.stateSyncer) { + initialState.push( + this.ircBridge.stateSyncer.createInitialState( + server, + ircChannel, + ) + ) + } const ircRoom = await this.ircBridge.trackChannel(server, ircChannel, key); const response = await this.ircBridge.getAppServiceBridge().getIntent( sender, diff --git a/src/bridge/BridgeStateSyncer.ts b/src/bridge/BridgeStateSyncer.ts new file mode 100644 index 000000000..dc8a03a8e --- /dev/null +++ b/src/bridge/BridgeStateSyncer.ts @@ -0,0 +1,147 @@ +import { DataStore } from "../datastore/DataStore"; +import { QueuePool } from "../util/QueuePool"; +import { Bridge } from "matrix-appservice-bridge"; +import logging from "../logging"; +import { IrcBridge } from "./IrcBridge"; +import { IrcServer } from "../irc/IrcServer"; + +const log = logging("BridgeStateSyncer"); + +const SYNC_CONCURRENCY = 3; + +interface QueueItem { + roomId: string; + mappings: Array<{networkId: string; channel: string}>; +} + +/** + * This class will set bridge room state according to [MSC2346](https://github.com/matrix-org/matrix-doc/pull/2346) + */ +export class BridgeStateSyncer { + public static readonly EventType = "uk.half-shot.bridge"; + private syncQueue: QueuePool; + constructor(private datastore: DataStore, private bridge: Bridge, private ircBridge: IrcBridge) { + this.syncQueue = new QueuePool(SYNC_CONCURRENCY, this.syncRoom.bind(this)); + } + + public async beginSync() { + log.info("Beginning sync of bridge state events"); + const allMappings = await this.datastore.getAllChannelMappings(); + Object.entries(allMappings).forEach(([roomId, mappings]) => { + this.syncQueue.enqueue(roomId, {roomId, mappings}); + }); + } + + private async syncRoom(item: QueueItem) { + log.info(`Syncing ${item.roomId}`); + const intent = this.bridge.getIntent(); + for (const mapping of item.mappings) { + const key = BridgeStateSyncer.createStateKey(mapping.networkId, mapping.channel); + try { + const eventData = await this.getStateEvent(item.roomId, BridgeStateSyncer.EventType, key); + if (eventData !== null) { // If found, validate. + const expectedContent = this.createBridgeInfoContent( + mapping.networkId, mapping.channel + ); + + const isValid = expectedContent.channel.id === eventData.channel.id && + expectedContent.network.id === eventData.network.id && + expectedContent.network.displayname === eventData.network.displayname && + expectedContent.protocol.id === eventData.protocol.id && + expectedContent.protocol.displayname === eventData.protocol.displayname; + + if (isValid) { + log.debug(`${key} is valid`); + continue; + } + log.info(`${key} is invalid`); + } + } + catch (ex) { + log.warn(`Encountered error when trying to sync ${item.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(mapping.networkId, mapping.channel); + const owner = await this.determineProvisionedOwner(item.roomId, mapping.networkId, mapping.channel); + eventContent.creator = owner || intent.client.credentials.userId; + try { + await intent.sendStateEvent(item.roomId, BridgeStateSyncer.EventType, key, eventContent); + } + catch (ex) { + log.error(`Failed to update room with new state content: ${ex.message}`); + } + } + } + + public createInitialState(server: IrcServer, channel: string, owner?: string) { + return { + type: BridgeStateSyncer.EventType, + content: this.createBridgeInfoContent(server, channel, owner), + state_key: BridgeStateSyncer.createStateKey(server.domain, channel) + }; + } + + public static createStateKey(networkId: string, channel: string) { + networkId = networkId.replace(/\//g, "%2F"); + channel = channel.replace(/\//g, "%2F"); + return `org.matrix.appservice-irc://irc/${networkId}/${channel}` + } + + public createBridgeInfoContent(networkIdOrServer: string|IrcServer, channel: string, creator?: string) { + const server = typeof(networkIdOrServer) === "string" ? + this.ircBridge.getServer(networkIdOrServer) : networkIdOrServer; + if (!server) { + throw Error("Server not known"); + } + const serverName = server.getReadableName(); + return { + creator: creator || "", // Is this known? + protocol: { + id: "irc", + displayname: "IRC", + }, + network: { + id: server.domain, + displayname: serverName, + }, + channel: { + id: channel, + external_url: `irc://${server.domain}/${channel}` + } + } + } + + private async determineProvisionedOwner(roomId: string, networkId: string, channel: string): Promise { + const room = await this.datastore.getRoom(roomId, networkId, channel); + if (!room || room.data.origin !== "provision") { + return null; + } + // Find out who dun it + try { + const ev = await this.getStateEvent(roomId, "m.room.bridging", `irc://${networkId}/${channel}`); + if (ev?.status === "success") { + return ev.user_id; + } + // Event not found or invalid, leave blank. + } + catch (ex) { + log.warn(`Failed to get m.room.bridging information for room: ${ex.message}`); + } + return null; + } + + private async getStateEvent(roomId: string, eventType: string, key: string) { + const intent = this.bridge.getIntent(); + try { + return await intent.getStateEvent(roomId, eventType, key); + } + catch (ex) { + if (ex.errcode !== "M_NOT_FOUND") { + throw ex; + } + } + return null; + } +} diff --git a/src/bridge/IrcBridge.ts b/src/bridge/IrcBridge.ts index 46d074cd9..fe324bd90 100644 --- a/src/bridge/IrcBridge.ts +++ b/src/bridge/IrcBridge.ts @@ -35,7 +35,7 @@ import { DataStore } from "../datastore/DataStore"; import { MatrixAction } from "../models/MatrixAction"; import { BridgeConfig } from "../config/BridgeConfig"; import { MembershipQueue } from "../util/MembershipQueue"; - +import { BridgeStateSyncer } from "./BridgeStateSyncer"; const log = getLogger("IrcBridge"); const DEFAULT_PORT = 8090; @@ -67,6 +67,8 @@ export class IrcBridge { }|null = null; private membershipCache: MembershipCache; private readonly membershipQueue: MembershipQueue; + private bridgeStateSyncer!: BridgeStateSyncer; + constructor(public readonly config: BridgeConfig, private registration: AppServiceRegistration) { // TODO: Don't log this to stdout Logging.configure({console: config.ircService.logging.level}); @@ -313,6 +315,10 @@ export class IrcBridge { return this.config.homeserver.domain; } + public get stateSyncer() { + return this.bridgeStateSyncer; + } + public async run(port: number|null) { const dbConfig = this.config.database; // cli port, then config port, then default port @@ -414,6 +420,17 @@ export class IrcBridge { this.membershipCache.setMemberEntry(roomId, this.appServiceUserId, "join"); } + if (this.config.ircService.bridgeInfoState?.enabled) { + this.bridgeStateSyncer = new BridgeStateSyncer(this.dataStore, this.bridge, this); + if (this.config.ircService.bridgeInfoState.initial) { + this.bridgeStateSyncer.beginSync().then(() => { + log.info("Bridge state syncing completed"); + }).catch((err) => { + log.error("Bridge state syncing resulted in an error:", err); + }); + } + } + log.info("Joining mapped Matrix rooms..."); await this.joinMappedMatrixRooms(); log.info("Syncing relevant membership lists..."); @@ -1010,12 +1027,12 @@ export class IrcBridge { } }); const bridgingEvent = stateEvents.find((ev: {type: string}) => ev.type === "m.room.bridging"); + const bridgeInfoEvent = stateEvents.find((ev: {type: string}) => ev.type === BridgeStateSyncer.EventType); if (bridgingEvent) { - // The room had a bridge state event, so try to stick it in the new one. try { await this.bridge.getIntent().sendStateEvent( newRoomId, - "m.room.bridging", + bridgingEvent.type, bridgingEvent.state_key, bridgingEvent.content ); @@ -1026,6 +1043,21 @@ export class IrcBridge { log.warn("Could not send m.room.bridging event to new room:", ex); } } + if (bridgeInfoEvent) { + try { + await this.bridge.getIntent().sendStateEvent( + newRoomId, + bridgeInfoEvent.type, + bridgingEvent.state_key, + bridgingEvent.content + ); + log.info("Bridge info event copied to new room"); + } + catch (ex) { + // We may not have permissions to do so, which means we are basically stuffed. + log.warn("Could not send bridge info event to new room:", ex); + } + } await Bluebird.all(rooms.map((room) => { return this.getBotClient(room.getServer()).then((bot) => { // This will invoke NAMES and make members join the new room, diff --git a/src/bridge/IrcHandler.ts b/src/bridge/IrcHandler.ts index a20e5e984..ee75fcf58 100644 --- a/src/bridge/IrcHandler.ts +++ b/src/bridge/IrcHandler.ts @@ -350,6 +350,15 @@ export class IrcHandler { } }); } + + if (this.ircBridge.stateSyncer) { + initialState.push( + this.ircBridge.stateSyncer.createInitialState( + server, + channel, + ) + ) + } const ircRoom = await this.ircBridge.trackChannel(server, channel); const response = await this.ircBridge.getAppServiceBridge().getIntent( virtualMatrixUser.getId() diff --git a/src/bridge/MatrixHandler.ts b/src/bridge/MatrixHandler.ts index e27f82e4c..f8c4530d3 100644 --- a/src/bridge/MatrixHandler.ts +++ b/src/bridge/MatrixHandler.ts @@ -1029,6 +1029,14 @@ export class MatrixHandler { } }); } + if (this.ircBridge.stateSyncer) { + options.initial_state.push( + this.ircBridge.stateSyncer.createInitialState( + channelInfo.server, + channelInfo.channel, + ) + ) + } if (channelInfo.server.forceRoomVersion()) { options.room_version = channelInfo.server.forceRoomVersion(); } diff --git a/src/config/BridgeConfig.ts b/src/config/BridgeConfig.ts index 2e8ad1607..cd9a1cd18 100644 --- a/src/config/BridgeConfig.ts +++ b/src/config/BridgeConfig.ts @@ -45,6 +45,10 @@ export interface BridgeConfig { address: string; port: number; }; + bridgeInfoState?: { + enabled: boolean; + initial: boolean; + }; }; sentry?: { enabled: boolean; diff --git a/src/provisioning/Provisioner.ts b/src/provisioning/Provisioner.ts index d468e90a3..a6bdb75de 100644 --- a/src/provisioning/Provisioner.ts +++ b/src/provisioning/Provisioner.ts @@ -13,6 +13,7 @@ import * as express from "express"; import { IrcServer } from "../irc/IrcServer"; import { IrcUser } from "../models/IrcUser"; import { BridgedClient, GetNicksResponseOperators } from "../irc/BridgedClient"; +import { BridgeStateSyncer } from "../bridge/BridgeStateSyncer"; const log = logging("Provisioner"); @@ -566,6 +567,16 @@ export class Provisioner { return; } await this.updateBridgingState(roomId, userId, 'success', skey); + // Send bridge info state event + if (this.ircBridge.stateSyncer) { + const intent = this.ircBridge.getAppServiceBridge().getIntent(); + await intent.sendStateEvent( + roomId, + BridgeStateSyncer.EventType, + BridgeStateSyncer.createStateKey(server.domain, ircChannel), + this.ircBridge.stateSyncer.createBridgeInfoContent(server, ircChannel, userId) + ); + } } private removeRequest (server: IrcServer, opNick: string) {