Skip to content

Commit

Permalink
A first implementation of report-to-moderator
Browse files Browse the repository at this point in the history
Traditionally, when a user clicks "report" in a Matrix client, this goes
to the homeserver administrator, who often is the wrong person for the job.

MSC3215 introduces a mechanism to let clients cooperate with a bot to send
the report to the moderator instead. Client support has landed in Element Web
(behind a Labs flag) in in 2021. This allows Mjölnir to serve as the partner
bot.
  • Loading branch information
Yoric authored and David Teller committed Jan 10, 2023
1 parent 1451ac9 commit fa5fbee
Show file tree
Hide file tree
Showing 14 changed files with 564 additions and 28 deletions.
16 changes: 16 additions & 0 deletions mx-tester.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,19 @@ homeserver:
remote:
per_second: 10000
burst_count: 10000

# Creating a few users simplifies testing.
users:
- localname: admin
admin: true
rooms:
- public: true
name: "List of users"
alias: access-control-list
members:
- admin
- user_in_mjolnir_for_all
# This user can use Mjölnir-for-all
- localname: user_in_mjolnir_for_all
# This user cannot
- localname: user_regular
4 changes: 2 additions & 2 deletions src/Mjolnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class Mjolnir {
if (options.autojoinOnlyIfManager) {
const managers = await client.getJoinedRoomMembers(mjolnir.managementRoomId);
if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
} else {
} else if (options.acceptInvitesFromSpace) {
const spaceId = await client.resolveRoom(options.acceptInvitesFromSpace);
const spaceUserIds = await client.getJoinedRoomMembers(spaceId)
.catch(async e => {
Expand All @@ -141,7 +141,7 @@ export class Mjolnir {
*/
static async setupMjolnirFromConfig(client: MatrixSendClient, matrixEmitter: MatrixEmitter, config: IConfig): Promise<Mjolnir> {
if (!config.autojoinOnlyIfManager && config.acceptInvitesFromSpace === getDefaultConfig().acceptInvitesFromSpace) {
throw new TypeError("`autojoinOnlyIfManager` has been disabled, yet no space has been provided for `acceptInvitesFromSpace`.");
throw new TypeError("`autojoinOnlyIfManager` has been disabled but you have not set `acceptInvitesFromSpace`. Please make it empty to accept invites from everywhere or give it a namespace alias or room id.");
}
const joinedRooms = await client.getJoinedRooms();

Expand Down
4 changes: 2 additions & 2 deletions src/appservice/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ export class Api {

// TODO: provisionNewMjolnir will throw if it fails...
// https://github.com/matrix-org/mjolnir/issues/408
const [mjolnirId, managementRoom] = await this.mjolnirManager.provisionNewMjolnir(userId);
const mjolnir = await this.mjolnirManager.provisionNewMjolnir(userId);

response.status(200).json({ mxid: mjolnirId, roomId: managementRoom });
response.status(200).json({ mxid: await mjolnir.getUserId(), roomId: mjolnir.managementRoomId });
}

/**
Expand Down
25 changes: 22 additions & 3 deletions src/appservice/AppService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class MjolnirAppService {
await bridge.initialise();
const accessControlListId = await bridge.getBot().getClient().resolveRoom(config.accessControlList);
const accessControl = await AccessControl.setupAccessControl(accessControlListId, bridge);
const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl);
const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl, config);
const appService = new MjolnirAppService(
config,
bridge,
Expand Down Expand Up @@ -120,14 +120,32 @@ export class MjolnirAppService {
if ('m.room.member' === mxEvent.type) {
if ('invite' === mxEvent.content['membership'] && mxEvent.state_key === this.bridge.botUserId) {
log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir`);
let mjolnir = null;
try {
await this.mjolnirManager.provisionNewMjolnir(mxEvent.sender)
mjolnir = await this.mjolnirManager.getOrProvisionMjolnir(mxEvent.sender);
mjolnir.start();
} catch (e: any) {
log.error(`Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e);
// continue, we still want to reject this invitation.
}

if (mjolnir) {
// Let's try to invite the provisioned Mjölnir instead of the appservice bot.
try {
const mjolnirUserId = await mjolnir.getUserId();
await this.bridge.getBot().getClient().joinRoom(mxEvent.room_id);
await this.bridge.getBot().getClient().sendNotice(mxEvent.room_id, `Setting up moderation in this room.`);
await this.bridge.getBot().getClient().inviteUser(mjolnirUserId, mxEvent.room_id);
await mjolnir.joinRoom(mxEvent.room_id);
log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, substituted ${mjolnirUserId}`);
await this.bridge.getBot().getClient().sendNotice(mxEvent.room_id, `You should now give ${mjolnirUserId} admin privileges.`);
} catch (e: any) {
log.error(`Failed to invite provisioned Mjölnir ${await mjolnir.getUserId()} for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e);
}
}

try {
// reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir.
// reject the invite to keep the room clean and make sure the inviter doesn't get confused and think this is their mjolnir.
await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id);
} catch (e: any) {
log.warn("Unable to reject an invite to a room", e);
Expand All @@ -144,6 +162,7 @@ export class MjolnirAppService {
*/
private async start(port: number) {
log.info("Starting MjolnirAppService, Matrix-side to listen on port", port);
log.info("Bridge user id is", this.bridge.botUserId);
this.api.start(this.config.webAPI.port);
await this.bridge.listen(port);
log.info("MjolnirAppService started successfully");
Expand Down
42 changes: 31 additions & 11 deletions src/appservice/MjolnirManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Mjolnir } from "../Mjolnir";
import { Request, WeakEvent, BridgeContext, Bridge, Intent, Logger } from "matrix-appservice-bridge";
import { getProvisionedMjolnirConfig } from "../config";
import { IConfig as IAppserviceConfig } from "./config/config";
import PolicyList from "../models/PolicyList";
import { Permalinks, MatrixClient } from "matrix-bot-sdk";
import { DataStore } from "./datastore";
Expand All @@ -19,12 +20,14 @@ const log = new Logger('MjolnirManager');
* * Informing mjolnirs about new events.
*/
export class MjolnirManager {
private readonly mjolnirs: Map</*the user id of the mjolnir*/string, ManagedMjolnir> = new Map();
private readonly perMjolnirId: Map</*the user id of the mjolnir*/string, ManagedMjolnir> = new Map();
private readonly perOwnerId: Map</*the user id of the owner*/string, ManagedMjolnir> = new Map();

private constructor(
private readonly dataStore: DataStore,
private readonly bridge: Bridge,
private readonly accessControl: AccessControl
private readonly accessControl: AccessControl,
private readonly config: IAppserviceConfig,
) {

}
Expand All @@ -36,8 +39,8 @@ export class MjolnirManager {
* @param accessControl Who has access to the bridge.
* @returns A new mjolnir manager.
*/
public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl): Promise<MjolnirManager> {
const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl);
public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, config: IAppserviceConfig): Promise<MjolnirManager> {
const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl, config);
await mjolnirManager.createMjolnirsFromDataStore();
return mjolnirManager;
}
Expand All @@ -50,7 +53,8 @@ export class MjolnirManager {
* @returns A new managed mjolnir.
*/
public async makeInstance(requestingUserId: string, managementRoomId: string, client: MatrixClient): Promise<ManagedMjolnir> {
const intentListener = new MatrixIntentListener(await client.getUserId());
let mjolnirUserId = await client.getUserId();
const intentListener = new MatrixIntentListener(mjolnirUserId);
const managedMjolnir = new ManagedMjolnir(
requestingUserId,
await Mjolnir.setupMjolnirFromConfig(
Expand All @@ -61,7 +65,11 @@ export class MjolnirManager {
intentListener,
);
await managedMjolnir.start();
this.mjolnirs.set(await client.getUserId(), managedMjolnir);
if (this.config.displayName) {
await client.setDisplayName(this.config.displayName);
}
this.perMjolnirId.set(mjolnirUserId, managedMjolnir);
this.perOwnerId.set(requestingUserId, managedMjolnir);
return managedMjolnir;
}

Expand All @@ -72,7 +80,7 @@ export class MjolnirManager {
* @returns The matching managed mjolnir instance.
*/
public getMjolnir(mjolnirId: string, ownerId: string): ManagedMjolnir|undefined {
const mjolnir = this.mjolnirs.get(mjolnirId);
const mjolnir = this.perMjolnirId.get(mjolnirId);
if (mjolnir) {
if (mjolnir.ownerId !== ownerId) {
throw new Error(`${mjolnirId} is owned by a different user to ${ownerId}`);
Expand All @@ -93,7 +101,7 @@ export class MjolnirManager {
// TODO we need to use the database for this but also provide the utility
// for going from a MjolnirRecord to a ManagedMjolnir.
// https://github.com/matrix-org/mjolnir/issues/409
return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerId !== ownerId);
return [...this.perMjolnirId.values()].filter(mjolnir => mjolnir.ownerId !== ownerId);
}

/**
Expand All @@ -102,15 +110,23 @@ export class MjolnirManager {
public onEvent(request: Request<WeakEvent>, context: BridgeContext) {
// TODO We need a way to map a room id (that the event is from) to a set of managed mjolnirs that should be informed.
// https://github.com/matrix-org/mjolnir/issues/412
[...this.mjolnirs.values()].forEach((mj: ManagedMjolnir) => mj.onEvent(request));
[...this.perMjolnirId.values()].forEach((mj: ManagedMjolnir) => mj.onEvent(request));
}

public async getOrProvisionMjolnir(requestingUserId: string): Promise<ManagedMjolnir> {
const existingMjolnir = this.perOwnerId.get(requestingUserId);
if (existingMjolnir) {
return existingMjolnir;
}
return this.provisionNewMjolnir(requestingUserId);
}

/**
* provision a new mjolnir for a matrix user.
* @param requestingUserId The mxid of the user we are creating a mjolnir for.
* @returns The matrix id of the new mjolnir and its management room.
*/
public async provisionNewMjolnir(requestingUserId: string): Promise<[string, string]> {
public async provisionNewMjolnir(requestingUserId: string): Promise<ManagedMjolnir> {
const access = this.accessControl.getUserAccess(requestingUserId);
if (access.outcome !== Access.Allowed) {
throw new Error(`${requestingUserId} tried to provision a mjolnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`);
Expand All @@ -135,7 +151,7 @@ export class MjolnirManager {
management_room: managementRoomId,
});

return [mjIntent.userId, managementRoomId];
return mjolnir;
} else {
throw new Error(`User: ${requestingUserId} has already provisioned ${provisionedMjolnirs.length} mjolnirs.`);
}
Expand Down Expand Up @@ -220,6 +236,10 @@ export class ManagedMjolnir {
public async start(): Promise<void> {
await this.mjolnir.start();
}

public async getUserId(): Promise<string> {
return await this.mjolnir.client.getUserId();
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/appservice/config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ accessControlList: "#access-control-list:localhost:9999"
# This is a web api that the widget connects to in order to interact with the appservice.
webAPI:
port: 9001

bot:
# The display name of the bot
displayName: Moderation bot
3 changes: 3 additions & 0 deletions src/appservice/config/config.harness.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ accessControlList: "#access-control-list:localhost:9999"

webAPI:
port: 9001

bot:
displayName: Moderation bot
7 changes: 6 additions & 1 deletion src/appservice/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,17 @@ export interface IConfig {
*/
address: string;
}
}
},
/** a display name */
displayName?: string,
}

export function read(configPath: string): IConfig {
const content = fs.readFileSync(configPath, "utf8");
const parsed = load(content);
const config = (parsed as object) as IConfig;
if (!config.displayName) {
config.displayName = "Moderation Bot";
}
return config;
}
4 changes: 4 additions & 0 deletions src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { execKickCommand } from "./KickCommand";
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand";
import { parse as tokenize } from "shell-quote";
import { execSinceCommand } from "./SinceCommand";
import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand";


export const COMMAND_PREFIX = "!mjolnir";
Expand Down Expand Up @@ -101,6 +102,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st
return await execAddProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') {
return await execRemoveProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'setup') {
return await execSetupProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length === 2) {
return await execListProtectedRooms(roomId, event, mjolnir);
} else if (parts[1] === 'move' && parts.length > 3) {
Expand Down Expand Up @@ -156,6 +159,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir rooms - Lists all the protected rooms\n" +
"!mjolnir rooms add <room alias/ID> - Adds a protected room (may cause high server load)\n" +
"!mjolnir rooms remove <room alias/ID> - Removes a protected room\n" +
"!mjolnir rooms setup <room alias/ID> reporting - Setup decentralized reporting in a room\n" +
"!mjolnir move <room alias> <room alias/ID> - Moves a <room alias> to a new <room ID>\n" +
"!mjolnir directory add <room alias/ID> - Publishes a room in the server's room directory\n" +
"!mjolnir directory remove <room alias/ID> - Removes a room from the server's room directory\n" +
Expand Down
61 changes: 61 additions & 0 deletions src/commands/SetupDecentralizedReportingCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk";

const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by";
const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of";

// !mjolnir rooms setup <room alias/ID> reporting
export async function execSetupProtectedRoom(commandRoomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
// For the moment, we only accept a subcommand `reporting`.
if (parts[4] !== 'reporting') {
await mjolnir.client.sendNotice(commandRoomId, "Invalid subcommand for `rooms setup <room alias/ID> subcommand`, expected one of \"reporting\"");
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '❌');
return;
}
const protectedRoomId = await mjolnir.client.joinRoom(parts[3]);

try {
const userId = await mjolnir.client.getUserId();

// A backup of the previous state in case we need to rollback.
let previousState: /* previous content */ any | /* there was no previous content */ null;
try {
previousState = await mjolnir.client.getRoomStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY);
} catch (ex) {
previousState = null;
}

// Setup protected room -> moderation room link.
// We do this before the other one to be able to fail early if we do not have a sufficient
// powerlevel.
let eventId = await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, {
room_id: commandRoomId,
user_id: userId,
});

try {
// Setup moderation room -> protected room.
await mjolnir.client.sendStateEvent(commandRoomId, EVENT_MODERATOR_OF, protectedRoomId, {
user_id: userId,
});
} catch (ex) {
// If the second `sendStateEvent` fails, we could end up with a room half setup, which
// is bad. Attempt to rollback.
try {
if (previousState) {
await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, previousState);
} else {
await mjolnir.client.redactEvent(protectedRoomId, eventId, "Rolling back incomplete MSC3215 setup");
}
} finally {
// Ignore second exception
throw ex;
}
}

} catch (ex) {
mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "execSetupProtectedRoom", ex.message);
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '❌');
}
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,5 +322,9 @@ export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig {

config.managementRoom = managementRoomId;
config.protectedRooms = [];

// Configure Mjölnir to accept invites automatically (necessary for requesting moderation)
config.autojoinOnlyIfManager = false;
config.acceptInvitesFromSpace = "";
return config;
}
Loading

0 comments on commit fa5fbee

Please sign in to comment.