mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
trusted reporters (#183)
* Trusted Reporters protection * redact/ban reasons * some documentation
This commit is contained in:
parent
ff9a7db159
commit
f74cf8a6e5
@ -225,7 +225,9 @@ export class Mjolnir {
|
||||
|
||||
// Setup Web APIs
|
||||
console.log("Creating Web APIs");
|
||||
this.webapis = new WebAPIs(new ReportManager(this), this.ruleServer);
|
||||
const reportManager = new ReportManager(this);
|
||||
reportManager.on("report.new", this.handleReport);
|
||||
this.webapis = new WebAPIs(reportManager, this.ruleServer);
|
||||
}
|
||||
|
||||
public get lists(): BanList[] {
|
||||
@ -977,4 +979,10 @@ export class Mjolnir {
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this.client, roomId);
|
||||
}
|
||||
|
||||
private async handleReport(roomId: string, reporterId: string, event: any, reason?: string) {
|
||||
for (const protection of this.enabledProtections) {
|
||||
await protection.handleReport(this, roomId, reporterId, event, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,12 +28,26 @@ export interface IProtection {
|
||||
readonly description: string;
|
||||
enabled: boolean;
|
||||
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
||||
/*
|
||||
* Handle a single event from a protected room, to decide if we need to
|
||||
* respond to it
|
||||
*/
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
|
||||
/*
|
||||
* Handle a single reported event from a protecte room, to decide if we
|
||||
* need to respond to it
|
||||
*/
|
||||
handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, reason: string, event: any): Promise<any>;
|
||||
}
|
||||
export abstract class Protection implements IProtection {
|
||||
abstract readonly name: string
|
||||
abstract readonly description: string;
|
||||
enabled = false;
|
||||
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
||||
abstract handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,10 @@ limitations under the License.
|
||||
|
||||
export class ProtectionSettingValidationError extends Error {};
|
||||
|
||||
/*
|
||||
* @param TChange Type for individual pieces of data (e.g. `string`)
|
||||
* @param TValue Type for overall value of this setting (e.g. `string[]`)
|
||||
*/
|
||||
export class AbstractProtectionSetting<TChange, TValue> {
|
||||
// the current value of this setting
|
||||
value: TValue
|
||||
@ -91,6 +95,12 @@ export class StringListProtectionSetting extends AbstractProtectionListSetting<s
|
||||
}
|
||||
}
|
||||
|
||||
// A list of strings that match the glob pattern @*:*
|
||||
export class MXIDListProtectionSetting extends StringListProtectionSetting {
|
||||
// validate an individual piece of data for this setting - namely a single mxid
|
||||
validate = (data: string) => /^@\S+:\S+$/.test(data);
|
||||
}
|
||||
|
||||
export class NumberProtectionSetting extends AbstractProtectionSetting<number, number> {
|
||||
min: number|undefined;
|
||||
max: number|undefined;
|
||||
|
93
src/protections/TrustedReporters.ts
Normal file
93
src/protections/TrustedReporters.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import config from "../config";
|
||||
import { Protection } from "./IProtection";
|
||||
import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
|
||||
const MAX_REPORTED_EVENT_BACKLOG = 20;
|
||||
|
||||
/*
|
||||
* Hold a list of users trusted to make reports, and enact consequences on
|
||||
* events that surpass configured report count thresholds
|
||||
*/
|
||||
export class TrustedReporters extends Protection {
|
||||
private recentReported = new Map<string /* eventId */, Set<string /* reporterId */>>();
|
||||
|
||||
settings = {
|
||||
mxids: new MXIDListProtectionSetting(),
|
||||
alertThreshold: new NumberProtectionSetting(3),
|
||||
// -1 means 'disabled'
|
||||
redactThreshold: new NumberProtectionSetting(-1),
|
||||
banThreshold: new NumberProtectionSetting(-1)
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'TrustedReporters';
|
||||
}
|
||||
public get description(): string {
|
||||
return "Count reports from trusted reporters and take a configured action";
|
||||
}
|
||||
|
||||
public async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
|
||||
if (!this.settings.mxids.value.includes(reporterId)) {
|
||||
// not a trusted user, we're not interested
|
||||
return
|
||||
}
|
||||
|
||||
let reporters = this.recentReported.get(event.id);
|
||||
if (reporters === undefined) {
|
||||
// first report we've seen recently for this event
|
||||
reporters = new Set<string>();
|
||||
this.recentReported.set(event.id, reporters);
|
||||
if (this.recentReported.size > MAX_REPORTED_EVENT_BACKLOG) {
|
||||
// queue too big. push the oldest reported event off the queue
|
||||
const oldest = Array.from(this.recentReported.keys())[0];
|
||||
this.recentReported.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
reporters.add(reporterId);
|
||||
|
||||
let met: string[] = [];
|
||||
if (reporters.size === this.settings.alertThreshold.value) {
|
||||
met.push("alert");
|
||||
// do nothing. let the `sendMessage` call further down be the alert
|
||||
}
|
||||
if (reporters.size === this.settings.redactThreshold.value) {
|
||||
met.push("redact");
|
||||
await mjolnir.client.redactEvent(roomId, event.id, "abuse detected");
|
||||
}
|
||||
if (reporters.size === this.settings.banThreshold.value) {
|
||||
met.push("ban");
|
||||
await mjolnir.client.banUser(event.userId, roomId, "abuse detected");
|
||||
}
|
||||
|
||||
|
||||
if (met.length > 0) {
|
||||
await mjolnir.client.sendMessage(config.managementRoom, {
|
||||
msgtype: "m.notice",
|
||||
body: `message ${event.id} reported by ${[...reporters].join(', ')}. `
|
||||
+ `actions: ${met.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -20,11 +20,13 @@ import { BasicFlooding } from "./BasicFlooding";
|
||||
import { WordList } from "./WordList";
|
||||
import { MessageIsVoice } from "./MessageIsVoice";
|
||||
import { MessageIsMedia } from "./MessageIsMedia";
|
||||
import { TrustedReporters } from "./TrustedReporters";
|
||||
|
||||
export const PROTECTIONS: IProtection[] = [
|
||||
new FirstMessageIsImage(),
|
||||
new BasicFlooding(),
|
||||
new WordList(),
|
||||
new MessageIsVoice(),
|
||||
new MessageIsMedia()
|
||||
new MessageIsMedia(),
|
||||
new TrustedReporters()
|
||||
];
|
||||
|
@ -19,6 +19,7 @@ import { LogService, UserID } from "matrix-bot-sdk";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import { htmlEscape } from "../utils";
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
|
||||
@ -74,9 +75,10 @@ enum Kind {
|
||||
/**
|
||||
* A class designed to respond to abuse reports.
|
||||
*/
|
||||
export class ReportManager {
|
||||
export class ReportManager extends EventEmitter {
|
||||
private displayManager: DisplayManager;
|
||||
constructor(public mjolnir: Mjolnir) {
|
||||
super();
|
||||
// Configure bot interactions.
|
||||
mjolnir.client.on("room.event", async (roomId, event) => {
|
||||
try {
|
||||
@ -101,17 +103,17 @@ export class ReportManager {
|
||||
* The following MUST hold true:
|
||||
* - the reporter's id is `reporterId`;
|
||||
* - the reporter is a member of `roomId`;
|
||||
* - `eventId` did take place in room `roomId`;
|
||||
* - the reporter could witness event `eventId` in room `roomId`;
|
||||
* - `event` did take place in room `roomId`;
|
||||
* - the reporter could witness event `event` in room `roomId`;
|
||||
* - the event being reported is `event`;
|
||||
*
|
||||
* @param roomId The room in which the abuse took place.
|
||||
* @param eventId The ID of the event reported as abuse.
|
||||
* @param reporterId The user who reported the event.
|
||||
* @param event The event being reported.
|
||||
* @param reason A reason provided by the reporter.
|
||||
*/
|
||||
public async handleServerAbuseReport({ reporterId, event, reason }: { roomId: string, eventId: string, reporterId: string, event: any, reason?: string }) {
|
||||
public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
|
||||
this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason });
|
||||
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId });
|
||||
}
|
||||
|
||||
|
@ -175,7 +175,7 @@ export class WebAPIs {
|
||||
}
|
||||
|
||||
let reason = request.body["reason"];
|
||||
await this.reportManager.handleServerAbuseReport({ roomId, eventId, reporterId, event, reason });
|
||||
await this.reportManager.handleServerAbuseReport({ roomId, reporterId, event, reason });
|
||||
|
||||
// Match the spec behavior of `/report`: return 200 and an empty JSON.
|
||||
response.status(200).json({});
|
||||
|
Loading…
Reference in New Issue
Block a user