diff --git a/changelog.d/1683.feature b/changelog.d/1683.feature new file mode 100644 index 000000000..d2c29d2e3 --- /dev/null +++ b/changelog.d/1683.feature @@ -0,0 +1 @@ +* Implement MSC2346 (bridge info state event) for PMs diff --git a/changelog.d/1684.feature b/changelog.d/1684.feature new file mode 100644 index 000000000..29248bd6c --- /dev/null +++ b/changelog.d/1684.feature @@ -0,0 +1 @@ +Add support for MSC3968 ('poorer features') diff --git a/spec/integ/pm.spec.js b/spec/integ/pm.spec.js index 48aff4f46..c9dc3419c 100644 --- a/spec/integ/pm.spec.js +++ b/spec/integ/pm.spec.js @@ -1,6 +1,8 @@ /* * Contains integration tests for private messages. */ +import { defaultEventFeatures } from "../../src/EventFeatures"; + const envBundle = require("../util/env-bundle"); describe("Matrix-to-IRC PMing", () => { @@ -24,7 +26,7 @@ describe("Matrix-to-IRC PMing", () => { afterEach(async () => test.afterEach(env)); - it("should join 1:1 rooms invited from matrix", async () => { + async function testJoinPmRoom(enableBrigeInfoState) { // get the ball rolling const requestPromise = env.mockAppService._trigger("type:m.room.member", { content: { @@ -64,6 +66,43 @@ describe("Matrix-to-IRC PMing", () => { await joinRoomPromise; await requestPromise; + + if (enableBrigeInfoState) { + console.log(intent.underlyingClient.sendStateEvent.calls.all()); + expect(intent.underlyingClient.sendStateEvent.calls.all().map((call) => call.args)).toEqual([ + [ + roomMapping.roomId, + "uk.half-shot.bridge", + "org.matrix.appservice-irc:/irc.example/someone", + { + bridgebot: '@monkeybot:some.home.server', + protocol: { id: 'irc', displayname: 'IRC' }, + channel: { id: 'someone' }, + network: { id: 'irc.example', displayname: '', avatar_url: undefined }, + } + ], + [ + roomMapping.roomId, + "org.matrix.msc3968.room.event_features", + "org.matrix.appservice-irc:/irc.example/someone", + defaultEventFeatures, + ] + ]); + } + else { + expect(intent.underlyingClient.sendStateEvent).not.toHaveBeenCalled(); + } + } + + it("should join 1:1 rooms invited from matrix (without bridge info)", async () => { + await testJoinPmRoom(false); + }); + + it("should join 1:1 rooms invited from matrix (with bridge info)", async () => { + config.ircService.bridgeInfoState = {enabled: true, initial: false}; + await env.ircBridge.onConfigChanged(config); + + await testJoinPmRoom(true); }); it("should join group chat rooms invited from matrix then leave them", async () => { @@ -262,33 +301,54 @@ describe("IRC-to-Matrix PMing", () => { afterEach(async () => test.afterEach(env)); - it("should create a 1:1 matrix room and invite the real matrix user when " + - "it receives a PM directed at a virtual user from a real IRC user", async () => { + async function testPmRoomCreation(enableBrigeInfoState) { // mock create room impl + const expectedInitialState = [{ + type: "m.room.power_levels", + state_key: "", + content: { + users: { + "@alice:anotherhomeserver": 10, + "@irc.example_bob:some.home.server": 100 + }, + events: { + "m.room.avatar": 10, + "m.room.name": 10, + "m.room.canonical_alias": 100, + "m.room.history_visibility": 100, + "m.room.power_levels": 100, + "m.room.encryption": 100 + }, + invite: 100 + }, + }]; + if (enableBrigeInfoState) { + expectedInitialState.push({ + type: "uk.half-shot.bridge", + state_key: "org.matrix.appservice-irc:/irc.example/bob", + content: { + bridgebot: '@monkeybot:some.home.server', + protocol: { id: 'irc', displayname: 'IRC' }, + channel: { id: 'bob' }, + network: { + id: 'irc.example', + displayname: '', + avatar_url: undefined, + } + } + }); + expectedInitialState.push({ + type: "org.matrix.msc3968.room.event_features", + state_key: "org.matrix.appservice-irc:/irc.example/bob", + content: defaultEventFeatures, + }); + } const createRoomPromise = new Promise((resolve) => { sdk.createRoom.and.callFake((opts) => { expect(opts.visibility).toEqual("private"); expect(opts.creation_content["m.federate"]).toEqual(true); expect(opts.preset).not.toBeDefined(); - expect(opts.initial_state).toEqual([{ - type: "m.room.power_levels", - state_key: "", - content: { - users: { - "@alice:anotherhomeserver": 10, - "@irc.example_bob:some.home.server": 100 - }, - events: { - "m.room.avatar": 10, - "m.room.name": 10, - "m.room.canonical_alias": 100, - "m.room.history_visibility": 100, - "m.room.power_levels": 100, - "m.room.encryption": 100 - }, - invite: 100 - }, - }]); + expect(opts.initial_state).toEqual(expectedInitialState); resolve(); return tCreatedRoomId; }); @@ -318,6 +378,19 @@ describe("IRC-to-Matrix PMing", () => { await createRoomPromise; await sentMessagePromise; + } + + it("should create a 1:1 matrix room and invite the real matrix user when " + + "it receives a PM directed at a virtual user from a real IRC user (without bridge info)", async () => { + await testPmRoomCreation(false) + }); + + it("should create a 1:1 matrix room and invite the real matrix user when " + + "it receives a PM directed at a virtual user from a real IRC user (with bridge info)", async () => { + config.ircService.bridgeInfoState = {enabled: true, initial: false}; + await env.ircBridge.onConfigChanged(config); + + await testPmRoomCreation(true) }); it("should not create multiple matrix rooms when several PMs are received in quick succession", async () => { diff --git a/src/EventFeatures.ts b/src/EventFeatures.ts new file mode 100644 index 000000000..ed10b92a5 --- /dev/null +++ b/src/EventFeatures.ts @@ -0,0 +1,48 @@ +import { MSC3968Content } from "matrix-appservice-bridge"; + +export const defaultEventFeatures: MSC3968Content = { + keys: { + "m.in_reply_to": -50, // replies + "m.new_content": -100, // edits + "m.relates_to": -1, // Other relations are unlikely to be bridged gracefully + // encrypted files: + "file": -100, + "thumbnail_file": -100 + }, + // discourage HTML elements with no counterpart on IRC: + html_elements_default: -1, + html_elements: { + "a": 0, + "b": 0, + "code": 0, + "div": 0, + "font": 0, + "p": 0, + "pre": 0, + "i": 0, + "u": 0, + "span": 0, + "strong": 0, + "em": 0, + "strike": 0 + }, + // forbid text messages which are neither text nor HTML (eg. `m.location`), + // and encourage text messages over media (which IRC users may prefer not to display inline): + mimetypes_default: -100, + mimetypes: { + "text/plain": 0, + "text/html": 0 + }, + msgtypes_default: -100, + msgtypes: { + "m.audio": 0, + "m.emote": 100, + "m.file": 0, + "m.image": 0, + "m.notice": 100, + "m.text": 100, + "m.video": 0, + "m.server_notice": 0 + }, +} + diff --git a/src/bridge/IrcBridge.ts b/src/bridge/IrcBridge.ts index 12a02608e..f9b312d3e 100644 --- a/src/bridge/IrcBridge.ts +++ b/src/bridge/IrcBridge.ts @@ -14,12 +14,14 @@ import { NeDBDataStore } from "../datastore/NedbDataStore"; import { PgDataStore } from "../datastore/postgres/PgDataStore"; import { getLogger } from "../logging"; import { DebugApi } from "../DebugApi"; +import { defaultEventFeatures } from "../EventFeatures"; import { Provisioner } from "../provisioning/Provisioner"; import { PublicitySyncer } from "./PublicitySyncer"; import { Histogram } from "prom-client"; import { Bridge, + Intent, MatrixUser, MatrixRoom, Logger, @@ -29,6 +31,7 @@ import { AgeCounters, EphemeralEvent, MembershipQueue, + MappingInfo, BridgeInfoStateSyncer, AppServiceRegistration, AppService, @@ -555,7 +558,7 @@ export class IrcBridge { } } - public createInfoMapping(channel: string, networkId: string) { + public createInfoMapping(channel: string, networkId: string): MappingInfo { const network = this.getServer(networkId); return { protocol: { @@ -569,7 +572,8 @@ export class IrcBridge { }, channel: { id: channel, - } + }, + eventFeatures: defaultEventFeatures, } } @@ -820,6 +824,29 @@ export class IrcBridge { this.bridgeState = "running"; } + /* + * Send state events providing information about the state. + * @param intent if given, sends state events from this client instead of the AS bot + */ + public async syncState(ircChannel: string, server: IrcServer, roomId: string, intent?: Intent) { + if (this.stateSyncer) { + intent = intent || this.getAppServiceBridge().getIntent(); + const events = await this.stateSyncer.createInitialState(roomId, { + channel: ircChannel, + networkId: server.getNetworkId(), + }) + for (const event of events) { + // await after each event so they are sent in the right order + await intent.sendStateEvent( + roomId, + event.type, + event.state_key, + event.content as unknown as Record, + ); + } + } + } + private logMetric(req: Request, outcome: string) { if (!this.timers) { return; // metrics are disabled diff --git a/src/bridge/IrcHandler.ts b/src/bridge/IrcHandler.ts index d515b853e..95c52f290 100644 --- a/src/bridge/IrcHandler.ts +++ b/src/bridge/IrcHandler.ts @@ -4,7 +4,7 @@ import { RoomAccessSyncer } from "./RoomAccessSyncer"; import { IrcServer, MembershipSyncKind } from "../irc/IrcServer"; import { BridgeRequest, BridgeRequestErr } from "../models/BridgeRequest"; import { BridgedClient } from "../irc/BridgedClient"; -import { MatrixRoom, MatrixUser, MembershipQueue } from "matrix-appservice-bridge"; +import { MatrixRoom, MatrixUser, MembershipQueue, InitialEvent } from "matrix-appservice-bridge"; import { IrcUser } from "../models/IrcUser"; import { IrcAction } from "../models/IrcAction"; import { IrcRoom } from "../models/IrcRoom"; @@ -169,6 +169,35 @@ export class IrcHandler { ): Promise { let remainingReties = PM_ROOM_CREATION_RETRIES; let response; + const initialState: InitialEvent[] = [{ + content: { + users: { + [toUserId]: PM_POWERLEVEL_MATRIXUSER, + [fromUserId]: PM_POWERLEVEL_IRCUSER, + }, + events: { + "m.room.avatar": 10, + "m.room.name": 10, + "m.room.canonical_alias": 100, + "m.room.history_visibility": 100, + "m.room.power_levels": 100, + "m.room.encryption": 100 + }, + invite: 100, + }, + type: "m.room.power_levels", + state_key: "", + }] + + if (this.ircBridge.stateSyncer) { + initialState.push( + ...await this.ircBridge.stateSyncer.createInitialState("", { + channel: fromUserNick, // TODO: spec this in MSC2346Content + networkId: server.getNetworkId(), + }) + ); + } + do { try { response = await this.ircBridge.getAppServiceBridge().getIntent( @@ -184,25 +213,7 @@ export class IrcHandler { "m.federate": server.shouldFederatePMs() }, is_direct: true, - initial_state: [{ - content: { - users: { - [toUserId]: PM_POWERLEVEL_MATRIXUSER, - [fromUserId]: PM_POWERLEVEL_IRCUSER, - }, - events: { - "m.room.avatar": 10, - "m.room.name": 10, - "m.room.canonical_alias": 100, - "m.room.history_visibility": 100, - "m.room.power_levels": 100, - "m.room.encryption": 100 - }, - invite: 100, - }, - type: "m.room.power_levels", - state_key: "", - }], + initial_state: initialState, } }); } diff --git a/src/bridge/MatrixHandler.ts b/src/bridge/MatrixHandler.ts index 7660ae971..350695eba 100644 --- a/src/bridge/MatrixHandler.ts +++ b/src/bridge/MatrixHandler.ts @@ -247,6 +247,12 @@ export class MatrixHandler { await this.ircBridge.getStore().setPmRoom( ircRoom, mxRoom, event.sender, event.state_key ); + + // note: it is possible (though unlikely) that the user does not have + // the required power_level to send state events. + // TODO: try with the appservice client if it doesn't? + await this.ircBridge.syncState(invited.nick, invited.server, event.room_id, intent); + return; } req.log.warn(`Room ${event.room_id} is not a 1:1 chat`); diff --git a/src/bridge/RoomCreation.ts b/src/bridge/RoomCreation.ts index df0051e2d..f2f24fd5c 100644 --- a/src/bridge/RoomCreation.ts +++ b/src/bridge/RoomCreation.ts @@ -52,7 +52,7 @@ export async function trackChannelAndCreateRoom(ircBridge: IrcBridge, req: Bridg if (ircBridge.stateSyncer) { initialState.push( // RoomId isn't used by this bridge - await ircBridge.stateSyncer.createInitialState("", { + ...await ircBridge.stateSyncer.createInitialState("", { channel: ircChannel, networkId: server.getNetworkId() }), ) diff --git a/src/provisioning/Provisioner.ts b/src/provisioning/Provisioner.ts index 3a49d5747..7e237d8f5 100644 --- a/src/provisioning/Provisioner.ts +++ b/src/provisioning/Provisioner.ts @@ -569,19 +569,7 @@ export class Provisioner extends ProvisioningApi { } await this.updateBridgingState(roomId, userId, 'success', skey); // Send bridge info state event - if (this.ircBridge.stateSyncer) { - const intent = this.ircBridge.getAppServiceBridge().getIntent(); - const infoMapping = await this.ircBridge.stateSyncer.createInitialState(roomId, { - channel: ircChannel, - networkId: server.getNetworkId(), - }) - await intent.sendStateEvent( - roomId, - infoMapping.type, - infoMapping.state_key, - infoMapping.content as unknown as Record, - ); - } + await this.ircBridge.syncState(ircChannel, server, roomId); } private removeRequest (server: IrcServer, opNick: string) {