Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changelog.d/1683.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Implement MSC2346 (bridge info state event) for PMs
1 change: 1 addition & 0 deletions changelog.d/1684.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for MSC3968 ('poorer features')
117 changes: 95 additions & 22 deletions spec/integ/pm.spec.js
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 () => {
Expand Down
48 changes: 48 additions & 0 deletions src/EventFeatures.ts
Original file line number Diff line number Diff line change
@@ -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
},
}

31 changes: 29 additions & 2 deletions src/bridge/IrcBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +31,7 @@ import {
AgeCounters,
EphemeralEvent,
MembershipQueue,
MappingInfo,
BridgeInfoStateSyncer,
AppServiceRegistration,
AppService,
Expand Down Expand Up @@ -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: {
Expand All @@ -569,7 +572,8 @@ export class IrcBridge {
},
channel: {
id: channel,
}
},
eventFeatures: defaultEventFeatures,
}
}

Expand Down Expand Up @@ -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<string, unknown>,
);
}
}
}

private logMetric(req: Request<BridgeRequestData>, outcome: string) {
if (!this.timers) {
return; // metrics are disabled
Expand Down
51 changes: 31 additions & 20 deletions src/bridge/IrcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -169,6 +169,35 @@ export class IrcHandler {
): Promise<MatrixRoom> {
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(
Expand All @@ -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,
}
});
}
Expand Down
6 changes: 6 additions & 0 deletions src/bridge/MatrixHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/RoomCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}),
)
Expand Down
14 changes: 1 addition & 13 deletions src/provisioning/Provisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
);
}
await this.ircBridge.syncState(ircChannel, server, roomId);
}

private removeRequest (server: IrcServer, opNick: string) {
Expand Down