mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-08-23 18:55:26 +00:00
![David Teller](/assets/img/avatar_default.png)
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.
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
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");
|
|
});
|
|
}); |