mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
A first implementation of report-to-moderator
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.
This commit is contained in:
parent
1451ac9951
commit
fa5fbee229
@ -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
|
||||
|
@ -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 => {
|
||||
@ -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();
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
@ -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 {
|
||||
// reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir.
|
||||
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 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);
|
||||
@ -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");
|
||||
|
@ -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";
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -102,7 +110,15 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -110,7 +126,7 @@ export class MjolnirManager {
|
||||
* @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'}`);
|
||||
@ -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.`);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -11,3 +11,6 @@ accessControlList: "#access-control-list:localhost:9999"
|
||||
|
||||
webAPI:
|
||||
port: 9001
|
||||
|
||||
bot:
|
||||
displayName: Moderation bot
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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";
|
||||
@ -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) {
|
||||
@ -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" +
|
||||
|
61
src/commands/SetupDecentralizedReportingCommand.ts
Normal file
61
src/commands/SetupDecentralizedReportingCommand.ts
Normal file
@ -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'], '✅');
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -43,12 +43,16 @@ export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report";
|
||||
/// reports (see `IReportWithAction` for the content).
|
||||
export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation";
|
||||
|
||||
/// MSC3215-style abuse report sent to the moderators (instead of to the homeserver admin).
|
||||
const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report";
|
||||
const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of";
|
||||
|
||||
const NATURE_DESCRIPTIONS_LIST: [string, string][] = [
|
||||
["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"],
|
||||
["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"],
|
||||
["org.matrix.msc3215.abuse.nature.csam", "child sexual abuse material [likely illegal, consider warning authorities]"],
|
||||
["org.matrix.msc3215.abuse.nature.hate_speech", "spam"],
|
||||
["org.matrix.msc3215.abuse.nature.spam", "impersonation"],
|
||||
["org.matrix.msc3215.abuse.nature.hate_speech", "hate speech"],
|
||||
["org.matrix.msc3215.abuse.nature.spam", "spam"],
|
||||
["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"],
|
||||
["org.matrix.msc3215.abuse.nature.doxxing", "non-consensual sharing of identifiable private information of a third party (doxxing)"],
|
||||
["org.matrix.msc3215.abuse.nature.violence", "threats of violence or death, either to self or others"],
|
||||
@ -57,6 +61,10 @@ const NATURE_DESCRIPTIONS_LIST: [string, string][] = [
|
||||
["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"],
|
||||
["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"],
|
||||
["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"],
|
||||
["org.matrix.msc3215.abuse.nature.illegal", "illegal content [consider warning authorities]"],
|
||||
["org.matrix.msc3215.abuse.nature.toxic", "toxic behavior"],
|
||||
["org.matrix.msc3215.abuse.nature.other", "other"],
|
||||
["org.matrix.msc3215.abuse.nature.test", "just a test, please ignore"],
|
||||
];
|
||||
const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST);
|
||||
|
||||
@ -80,6 +88,10 @@ export class ReportManager extends EventEmitter {
|
||||
super();
|
||||
// Configure bot interactions.
|
||||
mjolnir.matrixEmitter.on("room.event", async (roomId, event) => {
|
||||
// Reactions within the room.
|
||||
if (roomId !== mjolnir.managementRoomId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
switch (event["type"]) {
|
||||
case "m.reaction": {
|
||||
@ -91,6 +103,20 @@ export class ReportManager extends EventEmitter {
|
||||
LogService.error("ReportManager", "Uncaught error while handling an event", ex);
|
||||
}
|
||||
});
|
||||
mjolnir.matrixEmitter.on("room.event", async (roomId, event) => {
|
||||
// Moderation requests in ANY room.
|
||||
try {
|
||||
switch (event["type"]) {
|
||||
case EVENT_MODERATION_REQUEST: {
|
||||
await this.handleUntrustedModerationRequest(roomId, event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
LogService.error("ReportManager", "Uncaught error while handling an event", ex);
|
||||
}
|
||||
});
|
||||
|
||||
this.displayManager = new DisplayManager(this);
|
||||
}
|
||||
|
||||
@ -118,6 +144,52 @@ export class ReportManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
public async handleUntrustedModerationRequest(dmRoomId: string, report: any) {
|
||||
let { event_id: eventId, nature, room_id: roomId, reporter: reporterId, comment } = report["content"] || {};
|
||||
|
||||
// SECURITY: check that we are expecting moderation requests from that room.
|
||||
if (!roomId) {
|
||||
LogService.warn("ReportManager", "Received a moderation request without `room_id`");
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance note: we should cache this event, see https://github.com/matrix-org/mjolnir/pull/379.
|
||||
let eventModeratorOf = await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId);
|
||||
if (!eventModeratorOf) {
|
||||
LogService.warn("ReportManager", "Received a moderation request but we are not moderating that room");
|
||||
return;
|
||||
}
|
||||
if (eventModeratorOf["user_id"] !== await this.mjolnir.client.getUserId()) {
|
||||
LogService.warn("ReportManager", "Received a moderation request but we are not the moderator bot for this room");
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: validate `comment`, `nature`.
|
||||
if (comment && typeof comment !== "string") {
|
||||
LogService.warn("ReportManager", "Received a moderation request with a comment that isn't a string");
|
||||
return;
|
||||
}
|
||||
if (typeof nature !== "string" || !NATURE_DESCRIPTIONS.has(nature)) {
|
||||
LogService.warn("ReportManager", "Received a moderation request with an invalid nature", nature);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the report and act upon it.
|
||||
let event;
|
||||
try {
|
||||
event = await this.mjolnir.client.getEvent(roomId, eventId)
|
||||
} catch (ex) {
|
||||
LogService.warn("ReportManager", "Received a moderation request with an event that we cannot read", roomId, eventId, ex);
|
||||
return;
|
||||
}
|
||||
this.emit("report.new", { roomId, reporterId, event: event, reason: comment });
|
||||
if (this.mjolnir.config.displayReports) {
|
||||
await this.displayManager.displayReportAndUI({ kind: Kind.MODERATION_REQUEST, nature, event, reporterId, reason: comment, moderationRoomId: this.mjolnir.managementRoomId });
|
||||
}
|
||||
|
||||
await this.mjolnir.client.sendNotice(dmRoomId, "Thank you for your report, it has been sent to the moderators");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a reaction to an abuse report.
|
||||
*
|
||||
|
@ -264,13 +264,6 @@ describe("Test: Reporting abuse", async () => {
|
||||
console.log("Test: Reporting abuse - send reports");
|
||||
|
||||
// Time to report.
|
||||
let reportToFind = {
|
||||
reporterId: goodUserId,
|
||||
accusedId: badUserId,
|
||||
eventId: badEventId,
|
||||
text: badText,
|
||||
comment: null,
|
||||
};
|
||||
try {
|
||||
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`);
|
||||
} catch (e) {
|
||||
|
335
test/integration/moderationRequestTest.ts
Normal file
335
test/integration/moderationRequestTest.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import { strict as assert } from "assert";
|
||||
import { ABUSE_REPORT_KEY } from "../../src/report/ReportManager";
|
||||
import { newTestUser } from "./clientHelper";
|
||||
|
||||
const REPORT_NOTICE_REGEXPS = {
|
||||
reporter: /Filed by (?<reporterDisplay>[^ ]*) \((?<reporterId>[^ ]*)\)/,
|
||||
accused: /Against (?<accusedDisplay>[^ ]*) \((?<accusedId>[^ ]*)\)/,
|
||||
room: /Room (?<roomAliasOrId>[^ ]*)/,
|
||||
event: /Event (?<eventId>[^ ]*) Go to event/,
|
||||
content: /Content (?<eventContent>.*)/,
|
||||
comments: /Comments Comments (?<comments>.*)/,
|
||||
nature: /Nature (?<natureDisplay>[^(]*) \((?<natureSource>[^ ]*)\)/,
|
||||
};
|
||||
|
||||
const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by";
|
||||
const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of";
|
||||
const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report";
|
||||
|
||||
describe("Test: Requesting moderation", async () => {
|
||||
it(`Mjölnir propagates moderation requests`, async function() {
|
||||
this.timeout(90000);
|
||||
|
||||
// Listen for any notices that show up.
|
||||
let notices: any[] = [];
|
||||
|
||||
this.mjolnir.client.on("room.event", (roomId, event) => {
|
||||
if (roomId = this.mjolnir.managementRoomId) {
|
||||
notices.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a few users and a room, make sure that Mjölnir is moderator in the room.
|
||||
let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }});
|
||||
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }});
|
||||
let goodUserId = await goodUser.getUserId();
|
||||
let badUserId = await badUser.getUserId();
|
||||
|
||||
let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] });
|
||||
await goodUser.inviteUser(await badUser.getUserId(), roomId);
|
||||
await badUser.joinRoom(roomId);
|
||||
await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100);
|
||||
|
||||
// Setup moderated_by/moderator_of.
|
||||
await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`);
|
||||
|
||||
// Prepare DM room to send moderation requests.
|
||||
let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId() ]});
|
||||
this.mjolnir.client.joinRoom(dmRoomId);
|
||||
|
||||
// Wait until moderated_by/moderator_of are setup
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
try {
|
||||
await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY);
|
||||
} catch (ex) {
|
||||
console.log("moderated_by not setup yet, waiting");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId);
|
||||
} catch (ex) {
|
||||
console.log("moderator_of not setup yet, waiting");
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("Test: Requesting moderation - send messages");
|
||||
// Exchange a few messages.
|
||||
let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported.
|
||||
let badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
||||
let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
||||
let badText3 = `<b>BAD</b>: ${Math.random()}`; // Will be reported as abuse.
|
||||
let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long.
|
||||
let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines.
|
||||
let badEventId = await badUser.sendText(roomId, badText);
|
||||
let badEventId2 = await badUser.sendText(roomId, badText2);
|
||||
let badEventId3 = await badUser.sendText(roomId, badText3);
|
||||
let badEventId4 = await badUser.sendText(roomId, badText4);
|
||||
let badEventId5 = await badUser.sendText(roomId, badText5);
|
||||
let badEvent2Comment = `COMMENT: ${Math.random()}`;
|
||||
|
||||
console.log("Test: Requesting moderation - send reports");
|
||||
let reportsToFind: any[] = []
|
||||
|
||||
let sendReport = async ({eventId, nature, comment, text, textPrefix}: {eventId: string, nature: string, text?: string, textPrefix?: string, comment?: string}) => {
|
||||
await goodUser.sendRawEvent(dmRoomId, EVENT_MODERATION_REQUEST, {
|
||||
event_id: eventId,
|
||||
room_id: roomId,
|
||||
moderated_by_id: await this.mjolnir.client.getUserId(),
|
||||
nature,
|
||||
reporter: goodUserId,
|
||||
comment,
|
||||
});
|
||||
reportsToFind.push({
|
||||
reporterId: goodUserId,
|
||||
accusedId: badUserId,
|
||||
eventId,
|
||||
text,
|
||||
textPrefix,
|
||||
comment: comment || null,
|
||||
nature,
|
||||
});
|
||||
};
|
||||
|
||||
// Without a comment.
|
||||
await sendReport({ eventId: badEventId, nature: "org.matrix.msc3215.abuse.nature.disagreement", text: badText });
|
||||
// With a comment.
|
||||
await sendReport({ eventId: badEventId2, nature: "org.matrix.msc3215.abuse.nature.toxic", text: badText2, comment: badEvent2Comment });
|
||||
// With html in the text.
|
||||
await sendReport({ eventId: badEventId3, nature: "org.matrix.msc3215.abuse.nature.illegal", text: badText3 });
|
||||
// With a long text.
|
||||
await sendReport({ eventId: badEventId4, nature: "org.matrix.msc3215.abuse.nature.spam", textPrefix: badText4.substring(0, 256) });
|
||||
// With a very long text.
|
||||
await sendReport({ eventId: badEventId5, nature: "org.matrix.msc3215.abuse.nature.other", textPrefix: badText5.substring(0, 256).split("\n").join(" ") });
|
||||
|
||||
console.log("Test: Reporting abuse - wait");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
let found: any[] = [];
|
||||
for (let toFind of reportsToFind) {
|
||||
for (let event of notices) {
|
||||
if ("content" in event && "body" in event.content) {
|
||||
if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) {
|
||||
// Not a report or not our report.
|
||||
continue;
|
||||
}
|
||||
let report = event.content[ABUSE_REPORT_KEY];
|
||||
let body = event.content.body as string;
|
||||
let matches: Map<string, RegExpMatchArray> | null = new Map();
|
||||
for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) {
|
||||
let match = body.match(REPORT_NOTICE_REGEXPS[key]);
|
||||
if (match) {
|
||||
console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups);
|
||||
} else {
|
||||
console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]);
|
||||
matches = null;
|
||||
break;
|
||||
}
|
||||
matches.set(key, match);
|
||||
}
|
||||
if (!matches) {
|
||||
// Not a report, skipping.
|
||||
console.debug("Not a report, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
assert(body.length < 3000, `The report shouldn't be too long ${body.length}`);
|
||||
assert(body.split("\n").length < 200, "The report shouldn't have too many newlines.");
|
||||
|
||||
assert.equal(matches.get("event")!.groups!.eventId, toFind.eventId, "The report should specify the correct event id");;
|
||||
|
||||
assert.equal(matches.get("reporter")!.groups!.reporterId, toFind.reporterId, "The report should specify the correct reporter");
|
||||
assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter");
|
||||
assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), "The report should display the correct reporter");
|
||||
|
||||
assert.equal(matches.get("accused")!.groups!.accusedId, toFind.accusedId, "The report should specify the correct accused");
|
||||
assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused");
|
||||
assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), "The report should display the correct reporter");
|
||||
|
||||
if (toFind.text) {
|
||||
assert.equal(matches.get("content")!.groups!.eventContent, toFind.text, "The report should contain the text we inserted in the event");
|
||||
}
|
||||
if (toFind.textPrefix) {
|
||||
assert.ok(matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`);
|
||||
}
|
||||
if (toFind.comment) {
|
||||
assert.equal(matches.get("comments")!.groups!.comments, toFind.comment, "The report should contain the comment we added");
|
||||
}
|
||||
assert.equal(matches.get("room")!.groups!.roomAliasOrId, roomId, "The report should specify the correct room");
|
||||
assert.equal(report.room_id, roomId, "The embedded report should specify the correct room");
|
||||
assert.equal(matches.get("nature")!.groups!.natureSource, toFind.nature, "The report should specify the correct nature");
|
||||
found.push(toFind);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(found, reportsToFind, `Found ${found.length} reports out of ${reportsToFind.length}`);
|
||||
});
|
||||
|
||||
it('The redact action works', async function() {
|
||||
this.timeout(60000);
|
||||
|
||||
// Listen for any notices that show up.
|
||||
let notices: any[] = [];
|
||||
this.mjolnir.client.on("room.event", (roomId, event) => {
|
||||
if (roomId = this.mjolnir.managementRoomId) {
|
||||
notices.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a moderator.
|
||||
let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }});
|
||||
this.mjolnir.client.inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId);
|
||||
await moderatorUser.joinRoom(this.mjolnir.managementRoomId);
|
||||
|
||||
// Create a few users and a room.
|
||||
let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-good-user" }});
|
||||
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-bad-user" }});
|
||||
let goodUserId = await goodUser.getUserId();
|
||||
let badUserId = await badUser.getUserId();
|
||||
|
||||
let roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] });
|
||||
await moderatorUser.inviteUser(await goodUser.getUserId(), roomId);
|
||||
await moderatorUser.inviteUser(await badUser.getUserId(), roomId);
|
||||
await badUser.joinRoom(roomId);
|
||||
await goodUser.joinRoom(roomId);
|
||||
|
||||
// Setup Mjölnir as moderator for our room.
|
||||
await moderatorUser.inviteUser(await this.mjolnir.client.getUserId(), roomId);
|
||||
await moderatorUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100);
|
||||
|
||||
// Setup moderated_by/moderator_of.
|
||||
await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`);
|
||||
|
||||
// Prepare DM room to send moderation requests.
|
||||
let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId() ]});
|
||||
this.mjolnir.client.joinRoom(dmRoomId);
|
||||
|
||||
// Wait until moderated_by/moderator_of are setup
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
try {
|
||||
await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY);
|
||||
} catch (ex) {
|
||||
console.log("moderated_by not setup yet, waiting");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId);
|
||||
} catch (ex) {
|
||||
console.log("moderator_of not setup yet, waiting");
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("Test: Reporting abuse - send messages");
|
||||
// Exchange a few messages.
|
||||
let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported.
|
||||
let badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
||||
let goodEventId = await goodUser.sendText(roomId, goodText);
|
||||
let badEventId = await badUser.sendText(roomId, badText);
|
||||
let goodEventId2 = await goodUser.sendText(roomId, goodText);
|
||||
|
||||
console.log("Test: Reporting abuse - send reports");
|
||||
|
||||
// Time to report.
|
||||
await goodUser.sendRawEvent(dmRoomId, EVENT_MODERATION_REQUEST, {
|
||||
event_id: badEventId,
|
||||
room_id: roomId,
|
||||
moderated_by_id: await this.mjolnir.client.getUserId(),
|
||||
nature: "org.matrix.msc3215.abuse.nature.test",
|
||||
reporter: goodUserId,
|
||||
});
|
||||
|
||||
|
||||
console.log("Test: Reporting abuse - wait");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
let mjolnirRooms = new Set(await this.mjolnir.client.getJoinedRooms());
|
||||
assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room");
|
||||
|
||||
// Find the notice
|
||||
let noticeId;
|
||||
for (let event of notices) {
|
||||
if ("content" in event && ABUSE_REPORT_KEY in event.content) {
|
||||
if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != badEventId) {
|
||||
// Not a report or not our report.
|
||||
continue;
|
||||
}
|
||||
noticeId = event.event_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(noticeId, "We should have found our notice");
|
||||
|
||||
// Find the buttons.
|
||||
let buttons: any[] = [];
|
||||
for (let event of notices) {
|
||||
if (event["type"] != "m.reaction") {
|
||||
continue;
|
||||
}
|
||||
if (event["content"]["m.relates_to"]["rel_type"] != "m.annotation") {
|
||||
continue;
|
||||
}
|
||||
if (event["content"]["m.relates_to"]["event_id"] != noticeId) {
|
||||
continue;
|
||||
}
|
||||
buttons.push(event);
|
||||
}
|
||||
|
||||
// Find the redact button... and click it.
|
||||
let redactButtonId = null;
|
||||
for (let button of buttons) {
|
||||
if (button["content"]["m.relates_to"]["key"].includes("[redact-message]")) {
|
||||
redactButtonId = button["event_id"];
|
||||
await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", button["content"]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(redactButtonId, "We should have found the redact button");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// This should have triggered a confirmation request, with more buttons!
|
||||
let confirmEventId = null;
|
||||
for (let event of notices) {
|
||||
console.debug("Is this the confirm button?", event);
|
||||
if (!event["content"]["m.relates_to"]) {
|
||||
console.debug("Not a reaction");
|
||||
continue;
|
||||
}
|
||||
if (!event["content"]["m.relates_to"]["key"].includes("[confirm]")) {
|
||||
console.debug("Not confirm");
|
||||
continue;
|
||||
}
|
||||
if (!event["content"]["m.relates_to"]["event_id"] == redactButtonId) {
|
||||
console.debug("Not reaction to redact button");
|
||||
continue;
|
||||
}
|
||||
|
||||
// It's the confirm button, click it!
|
||||
confirmEventId = event["event_id"];
|
||||
await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", event["content"]);
|
||||
break;
|
||||
}
|
||||
assert.ok(confirmEventId, "We should have found the confirm button");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// This should have redacted the message.
|
||||
let newBadEvent = await this.mjolnir.client.getEvent(roomId, badEventId);
|
||||
assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event");
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user