Fix report poller (#662)

Fixes https://github.com/the-draupnir-project/Draupnir/issues/258
Fixes https://github.com/the-draupnir-project/Draupnir/issues/408
Fixes https://github.com/the-draupnir-project/Draupnir/issues/409

* Create a way to only forward reports in WebAPIs.

Honestly, I'm going to revert this because I think I have found a
better way of testing the report poller.

* Begin improving and fixing the report poller.

We need to change the ReportManager so that we can interface it out
for testing. The reason being that the report poller is inactive
in the harness and so we can't use that with a protection handle
to test. Instead I want to instantiate a report poller with
a mocked report manager.

* Update integration test nginx to mirror reports to synapse.

We need this so that we can test the report poller without needing to
do gymnastics to selectively forward reports.

* Interface out ReportManager.

Needed so we can test the report poller without doing gymnastics with
setting up fake protections.

* Fix report poller from paginating over the same reports.

https://github.com/the-draupnir-project/planning/issues/38.

* Revert "Create a way to only forward reports in WebAPIs."

This reverts commit 59b335f658.
We don't need this anymore.

* Update for MPS v2.4.0

Gives us the synapse admin client, updates schema, and gives us the fix for https://github.com/the-draupnir-project/Draupnir/issues/560
This commit is contained in:
Gnuxie
2025-01-10 17:06:54 +00:00
committed by GitHub
parent 2655572cfc
commit fa5ce9ad9c
8 changed files with 173 additions and 163 deletions
+2 -2
View File
@@ -70,8 +70,8 @@
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^10.3.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.3.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.3.2",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.4.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0",
"parse-duration": "^1.0.2",
"pg": "^8.8.0",
"shell-quote": "^1.7.3",
+3 -3
View File
@@ -32,7 +32,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import ManagementRoomOutput from "./managementroom/ManagementRoomOutput";
import { ReportPoller } from "./report/ReportPoller";
import { ReportManager } from "./report/ReportManager";
import { StandardReportManager } from "./report/ReportManager";
import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler";
import {
MatrixSendClient,
@@ -106,7 +106,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
* Handle user reports from the homeserver.
* FIXME: ReportManager should be a protection.
*/
public readonly reportManager: ReportManager;
public readonly reportManager: StandardReportManager;
public readonly reactionHandler: MatrixReactionHandler;
@@ -157,7 +157,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
clientUserID,
clientPlatform
);
this.reportManager = new ReportManager(this);
this.reportManager = new StandardReportManager(this);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, this.reportManager);
}
+71 -34
View File
@@ -102,10 +102,32 @@ enum Kind {
ESCALATED_REPORT,
}
export interface ReportManager {
handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void;
handleServerAbuseReport({
roomID,
reporterId,
event,
reason,
}: {
roomID: StringRoomID;
reporterId: string;
event: RoomEvent;
reason?: string;
}): Promise<void>;
handleReaction({
roomID,
event,
}: {
roomID: StringRoomID;
event: RoomEvent;
}): Promise<void>;
}
/**
* A class designed to respond to abuse reports.
*/
export class ReportManager {
export class StandardReportManager {
private displayManager: DisplayManager;
constructor(public draupnir: Draupnir) {
this.displayManager = new DisplayManager(this);
@@ -580,7 +602,7 @@ interface IUIAction {
* @param report Details on the abuse report.
*/
canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
moderationroomID: string
): Promise<boolean>;
@@ -590,20 +612,20 @@ interface IUIAction {
*
* @param report Details on the abuse report.
*/
title(manager: ReportManager, report: IReport): Promise<string>;
title(manager: StandardReportManager, report: IReport): Promise<string>;
/**
* A human-readable help message to display for the end-user.
*
* @param report Details on the abuse report.
*/
help(manager: ReportManager, report: IReport): Promise<string>;
help(manager: StandardReportManager, report: IReport): Promise<string>;
/**
* Attempt to execute the action.
*/
execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
moderationroomID: string,
displayManager: DisplayManager
@@ -618,25 +640,25 @@ class IgnoreBadReport implements IUIAction {
public emoji = "🚯";
public needsConfirmation = true;
public async canExecute(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<boolean> {
return true;
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Ignore";
}
public async help(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Ignore bad report";
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReportWithAction
): Promise<string | undefined> {
await manager.draupnir.client.sendEvent(
@@ -667,7 +689,7 @@ class RedactMessage implements IUIAction {
public emoji = "🗍";
public needsConfirmation = true;
public async canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<boolean> {
try {
@@ -681,16 +703,19 @@ class RedactMessage implements IUIAction {
}
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Redact";
}
public async help(_manager: ReportManager, report: IReport): Promise<string> {
public async help(
_manager: StandardReportManager,
report: IReport
): Promise<string> {
return `Redact event ${report.event_id}`;
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
_moderationroomID: string
): Promise<string | undefined> {
@@ -707,7 +732,7 @@ class KickAccused implements IUIAction {
public emoji = "⚽";
public needsConfirmation = true;
public async canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<boolean> {
try {
@@ -721,16 +746,19 @@ class KickAccused implements IUIAction {
}
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Kick";
}
public async help(_manager: ReportManager, report: IReport): Promise<string> {
public async help(
_manager: StandardReportManager,
report: IReport
): Promise<string> {
return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`;
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<string | undefined> {
await manager.draupnir.client.kickUser(report.accused_id, report.room_id);
@@ -746,7 +774,7 @@ class MuteAccused implements IUIAction {
public emoji = "🤐";
public needsConfirmation = true;
public async canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<boolean> {
try {
@@ -761,16 +789,19 @@ class MuteAccused implements IUIAction {
}
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Mute";
}
public async help(_manager: ReportManager, report: IReport): Promise<string> {
public async help(
_manager: StandardReportManager,
report: IReport
): Promise<string> {
return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`;
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<string | undefined> {
await manager.draupnir.client.setUserPowerLevel(
@@ -790,7 +821,7 @@ class BanAccused implements IUIAction {
public emoji = "🚫";
public needsConfirmation = true;
public async canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<boolean> {
try {
@@ -804,16 +835,19 @@ class BanAccused implements IUIAction {
}
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Ban";
}
public async help(_manager: ReportManager, report: IReport): Promise<string> {
public async help(
_manager: StandardReportManager,
report: IReport
): Promise<string> {
return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`;
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport
): Promise<string | undefined> {
await manager.draupnir.client.banUser(report.accused_id, report.room_id);
@@ -829,25 +863,25 @@ class Help implements IUIAction {
public emoji = "❓";
public needsConfirmation = false;
public async canExecute(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<boolean> {
return true;
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Help";
}
public async help(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "This help";
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
moderationroomID: string
): Promise<string | undefined> {
@@ -884,7 +918,7 @@ class EscalateToServerModerationRoom implements IUIAction {
public emoji = "⏫";
public needsConfirmation = true;
public async canExecute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
moderationroomID: string
): Promise<boolean> {
@@ -901,16 +935,19 @@ class EscalateToServerModerationRoom implements IUIAction {
return true;
}
public async title(
_manager: ReportManager,
_manager: StandardReportManager,
_report: IReport
): Promise<string> {
return "Escalate";
}
public async help(manager: ReportManager, _report: IReport): Promise<string> {
public async help(
manager: StandardReportManager,
_report: IReport
): Promise<string> {
return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`;
}
public async execute(
manager: ReportManager,
manager: StandardReportManager,
report: IReport,
_moderationroomID: string,
displayManager: DisplayManager
@@ -939,7 +976,7 @@ class EscalateToServerModerationRoom implements IUIAction {
}
class DisplayManager {
constructor(private owner: ReportManager) {}
constructor(private owner: StandardReportManager) {}
/**
* Display the report and any UI button.
+35 -46
View File
@@ -8,18 +8,19 @@
// https://github.com/matrix-org/mjolnir
// </text>
import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
import {
MatrixSendClient,
SynapseAdminClient,
} from "matrix-protection-suite-for-matrix-bot-sdk";
import { ReportManager } from "./ReportManager";
import { LogLevel, LogService } from "matrix-bot-sdk";
import { LogLevel } from "matrix-bot-sdk";
import ManagementRoomOutput from "../managementroom/ManagementRoomOutput";
import { Draupnir } from "../Draupnir";
import {
ActionException,
ActionExceptionKind,
Ok,
SynapseReport,
Task,
Value,
isError,
} from "matrix-protection-suite";
@@ -50,10 +51,23 @@ export class ReportPoller {
*/
private timeout: ReturnType<typeof setTimeout> | null = null;
private readonly synapseAdminClient: SynapseAdminClient;
private readonly pollPeriod: number;
constructor(
private draupnir: Draupnir,
private manager: ReportManager
) {}
private manager: ReportManager,
options: { pollPeriod?: number } = {}
) {
if (draupnir.synapseAdminClient === undefined) {
throw new TypeError(
`Unable to find synapse admin client for report poller`
);
}
this.synapseAdminClient = draupnir.synapseAdminClient;
this.pollPeriod = options.pollPeriod ?? 30_000; // a minute in milliseconds
}
private schedulePoll() {
if (this.timeout === null) {
@@ -65,7 +79,7 @@ export class ReportPoller {
*/
this.timeout = setTimeout(
this.tryGetAbuseReports.bind(this),
30_000 // a minute in milliseconds
this.pollPeriod
);
} else {
throw new InvalidStateError("poll already scheduled");
@@ -73,46 +87,19 @@ export class ReportPoller {
}
private async getAbuseReports() {
let response:
| {
event_reports: unknown[];
next_token: number | undefined;
}
| undefined;
try {
response = await this.draupnir.client.doRequest(
"GET",
"/_synapse/admin/v1/event_reports",
{
// short for direction: forward; i.e. show newest last
dir: "f",
from: this.from.toString(),
}
);
} catch (ex) {
const response = await this.synapseAdminClient.getAbuseReports({
direction: "f",
from: this.from,
});
if (isError(response)) {
await this.draupnir.managementRoomOutput.logMessage(
LogLevel.ERROR,
"getAbuseReports",
`failed to poll events: ${ex}`
`failed to poll events: ${response.error.toReadableString()}`
);
return;
}
if (response === undefined) {
throw new TypeError(
`we should have got a response from /event_reports/, code is wrong.`
);
}
for (const rawReport of response.event_reports) {
const reportResult = Value.Decode(SynapseReport, rawReport);
if (isError(reportResult)) {
LogService.error(
"ReportPoller",
`Failed to decode a synapse report ${reportResult.error.uuid}`,
rawReport
);
continue;
}
const report = reportResult.ok;
for (const report of response.ok.event_reports) {
// FIXME: shouldn't we have a SafeMatrixSendClient in the BotSDKMPS that gives us ActionResult's with
// Decoded events.
// Problem is that our current event model isn't going to match up with extensible events.
@@ -143,7 +130,7 @@ export class ReportPoller {
await this.manager.handleServerAbuseReport({
roomID: report.room_id,
reporterId: report.sender,
reporterId: report.user_id,
event: event,
...(report.reason ? { reason: report.reason } : {}),
});
@@ -152,13 +139,15 @@ export class ReportPoller {
/*
* This API endpoint returns an opaque `next_token` number that we
* need to give back to subsequent requests for pagination, so here we
* save it in account data
* save it in account data. Except it's not opaque, and there's no way
* to use this API as a poll without cheating and using the total. Brill.
*/
if (response.next_token !== undefined) {
this.from = response.next_token;
const nextToken = response.ok.next_token ?? response.ok.total ?? 0;
if (nextToken !== this.from) {
this.from = nextToken;
try {
await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, {
from: response.next_token,
from: nextToken,
});
} catch (ex) {
await this.draupnir.managementRoomOutput.logMessage(
+2 -3
View File
@@ -11,7 +11,7 @@
import { Server } from "http";
import express from "express";
import { MatrixClient } from "matrix-bot-sdk";
import { ReportManager } from "../report/ReportManager";
import { StandardReportManager } from "../report/ReportManager";
import { IConfig } from "../config";
import {
StringRoomID,
@@ -35,7 +35,7 @@ export class WebAPIs {
private httpServer?: Server | undefined;
constructor(
private reportManager: ReportManager,
private reportManager: StandardReportManager,
private readonly config: IConfig
) {
// Setup JSON parsing.
@@ -257,7 +257,6 @@ export class WebAPIs {
// with all Matrix homeservers, rather than just Synapse.
event = await reporterClient.getEvent(roomID, eventID);
}
const reason = request.body["reason"];
await this.reportManager.handleServerAbuseReport({
roomID,
+46 -66
View File
@@ -11,31 +11,30 @@
import { MatrixClient } from "matrix-bot-sdk";
import { newTestUser } from "./clientHelper";
import { DraupnirTestContext } from "./mjolnirSetupUtils";
import {
ActionResult,
Ok,
Protection,
ProtectionDescription,
Task,
describeConfig,
} from "matrix-protection-suite";
import {
MatrixRoomReference,
StringRoomID,
} from "@the-draupnir-project/matrix-basic-types";
import { Type } from "@sinclair/typebox";
import { randomUUID } from "crypto";
import expect from "expect";
import { createMock } from "ts-auto-mock";
import { ReportManager } from "../../src/report/ReportManager";
import { ReportPoller } from "../../src/report/ReportPoller";
describe("Test: Report polling", function () {
let client: MatrixClient;
let reportPoller: ReportPoller | undefined;
this.beforeEach(async function () {
client = await newTestUser(this.config.homeserverUrl, {
name: { contains: "protection-settings" },
});
});
this.afterEach(function () {
reportPoller?.stop();
});
it("Draupnir correctly retrieves a report from synapse", async function (
this: DraupnirTestContext
) {
this.timeout(40000);
const draupnir = this.draupnir;
if (draupnir === undefined) {
throw new TypeError(`Test didn't setup properly`);
@@ -47,63 +46,44 @@ describe("Test: Report polling", function () {
await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(
MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID)
);
const eventId = await client.sendMessage(protectedRoomId, {
msgtype: "m.text",
body: "uwNd3q",
const testReportReason = randomUUID();
const reportsFound = new Set<string>();
const duplicateReports = new Set<string>();
const reportManager = createMock<ReportManager>({
handleServerAbuseReport({ event, reason }) {
if (reason === testReportReason) {
if (reportsFound.has(event.event_id)) {
duplicateReports.add(event.event_id);
}
reportsFound.add(event.event_id);
}
return Promise.resolve(undefined);
},
});
await new Promise((resolve) => {
const testProtectionDescription: ProtectionDescription = {
name: "jYvufI",
description: "A test protection",
capabilities: {},
defaultCapabilities: {},
factory: function (
_description,
_protectedRoomsSet,
_context,
_capabilities,
_settings
): ActionResult<Protection<ProtectionDescription>> {
return Ok({
handleEventReport(report) {
if (report.reason === "x5h1Je") {
resolve(null);
}
return Promise.resolve(Ok(undefined));
},
description: testProtectionDescription,
requiredEventPermissions: [],
requiredPermissions: [],
requiredStatePermissions: [],
});
},
protectionSettings: describeConfig({ schema: Type.Object({}) }),
};
void Task(
(async () => {
await draupnir.protectedRoomsSet.protections.addProtection(
testProtectionDescription,
draupnir.protectedRoomsSet,
draupnir
);
await client.doRequest(
"POST",
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`,
"",
{
reason: "x5h1Je",
}
);
})()
reportPoller = new ReportPoller(draupnir, reportManager, {
pollPeriod: 500,
});
const reportEvent = async () => {
const eventId = await client.sendMessage(protectedRoomId, {
msgtype: "m.text",
body: "uwNd3q",
});
await client.doRequest(
"POST",
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`,
"",
{
reason: testReportReason,
}
);
});
// So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26)
// because the promise above gets resolved before we finish awaiting the report sending request on L#31,
// then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond.
// Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling.
// Ok, well apparently that needs a big refactor to change, but if you change the config before running this test,
// then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326.
await new Promise((resolve) => setTimeout(resolve, 1000));
};
reportPoller.start({ from: 1 });
for (let i = 0; i < 20; i++) {
await reportEvent();
}
// wait for them to come down the poll.
await new Promise((resolve) => setTimeout(resolve, 3000));
expect(reportsFound.size).toBe(20);
expect(duplicateReports.size).toBe(0);
} as unknown as Mocha.AsyncFunc);
});
+6 -1
View File
@@ -11,6 +11,7 @@ http {
listen [::]:8081 ipv6only=off;
location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ {
mirror /report_mirror;
# Abuse reports should be sent to Mjölnir.
# The r0 endpoint is deprecated but still used by many clients.
# As of this writing, the v3 endpoint is the up-to-date version.
@@ -31,6 +32,10 @@ http {
location / {
# Everything else should be sent to Synapse.
proxy_pass http://127.0.0.1:9999;
}
}
location /report_mirror {
internal;
proxy_pass http://127.0.0.1:9999$request_uri;
}
}
}
+8 -8
View File
@@ -2677,17 +2677,17 @@ matrix-appservice@^2.0.0:
request-promise "^4.2.6"
sanitize-html "^2.11.0"
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.3.2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.3.2.tgz#c16f72a0c05980ad54fab7fa53bfd5c5be54edc2"
integrity sha512-WbEJ2erZcZ8KgF5BBcIvC9EjahDYXz+De8B5o4+a8zOK7jXt2ky1rp9KEj9eAE7JisgbLNQnprarpMpX380prw==
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.4.0.tgz#5598bbd28ca7f94174049daf12d52c0a3388b3aa"
integrity sha512-p5G86TBW2TY0boiQhWPXchJBDpsWi70iYM2nJ8BthnXBx6Ck8ON2Km36EEctXks68ylXnFitV2FGvAnN4+mNvA==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.3.0.tgz#1a7346f1ed26f7e6459001ac0ff025ca11ab66d9"
integrity sha512-YpBrQxRiFg0DVd/ru16W7pcqHH7sZv7r+JFUn5ZIL8Ya0Vt+MpWo22ZFkU0WGf5+I1KfgYLzV/C2nBisS4ZskA==
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.4.0.tgz#60659023fd6e5aec4e1282f7726da3baab074ee5"
integrity sha512-oEA0Vi/VJsHWXNQ0RMiLUID+YpgTH05+OGnHGVyeSS/8HP5Ziij3ZGMJvemZ2pij8Uq6az0hVV/NZUOefCoI1A==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"