From 74d8caa7e7fa5bba50077c565ba482960299a6d5 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 10 May 2022 17:19:16 +0200 Subject: [PATCH] Since command: adding the ability to mute (#272) --- src/commands/CommandHandler.ts | 2 +- src/commands/SinceCommand.ts | 98 ++++++++---- test/integration/roomMembersTest.ts | 224 ++++++++++++++++++++-------- 3 files changed, 232 insertions(+), 92 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 677294f..79c7b83 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -157,7 +157,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir alias add - Adds to \n" + "!mjolnir alias remove - Deletes the room alias from whatever room it is attached to\n" + "!mjolnir resolve - Resolves a room alias to a room ID\n" + - "!mjolnir since / [rooms...] [reason] - Apply an action (kick, ban or just show) to all users who joined a room since a given date (up to users)\n" + + "!mjolnir since / [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since / (up to users)\n" + "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" + "!mjolnir powerlevel [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" + "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 1e64757..879a42e 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -19,6 +19,7 @@ import { LogLevel, LogService, RichReply } from "matrix-bot-sdk"; import { htmlEscape, parseDuration } from "../utils"; import { ParseEntry } from "shell-quote"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; +import { Join } from "../RoomMembers"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); @@ -26,11 +27,16 @@ const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); enum Action { Kick = "kick", Ban = "ban", + Mute = "mute", + Unmute = "unmute", Show = "show" } type Result = {ok: T} | {error: string}; +type userId = string; +type Summary = { succeeded: userId[], failed: userId[] }; + /** * Attempt to parse a `ParseEntry`, as provided by the shell-style parser, using a parsing function. * @@ -104,6 +110,15 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj } } +function formatResult(action: string, targetRoomId: string, recentJoins: Join[], summary: Summary): {html: string, text: string} { + const html = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.
Succeeded ${summary.succeeded.length}:
    ${summary.succeeded.map(x => `
  • ${htmlEscape(x)}
  • `).join("\n")}
.
Failed ${summary.failed.length}:
    ${summary.succeeded.map(x => `
  • ${htmlEscape(x)}
  • `).join("\n")}
`; + const text = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.\nSucceeded ${summary.succeeded.length}: ${summary.succeeded.map(x => `*${htmlEscape(x)}`).join("\n")}\n Failed ${summary.failed.length}:\n${summary.succeeded.map(x => ` * ${htmlEscape(x)}`).join("\n")}`; + return { + html, + text + }; +} + // Implementation of `execSinceCommand`, counts on caller to print errors. // // This method: @@ -209,46 +224,78 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni for (let targetRoomId of rooms) { let {html, text} = await (async () => { + let results: Summary = { succeeded: [], failed: []}; + const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); + switch (action) { case Action.Show: { - return makeJoinStatus(mjolnir, targetRoomId, maxEntries, minDate, maxAgeMS); + return makeJoinStatus(mjolnir, targetRoomId, maxEntries, minDate, maxAgeMS, recentJoins); } case Action.Kick: { - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); - let results = { good: 0, bad: 0}; - for (let join of joins) { + for (let join of recentJoins) { try { await mjolnir.client.kickUser(join.userId, targetRoomId, reason); - results.good += 1; + results.succeeded.push(join.userId); } catch (ex) { LogService.warn("SinceCommand", "Error while attempting to kick user", ex); - results.bad += 1; + results.failed.push(join.userId); } } - const text_ = `Attempted to kick ${joins.length} users from room ${targetRoomId}, ${results.good} kicked, ${results.bad} failures`; - return { - html: text_, - text: text_, - } + + return formatResult("kick", targetRoomId, recentJoins, results); } case Action.Ban: { - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); - - let results = { good: 0, bad: 0}; - for (let join of joins) { + for (let join of recentJoins) { try { await mjolnir.client.banUser(join.userId, targetRoomId, reason); - results.good += 1; + results.succeeded.push(join.userId); } catch (ex) { LogService.warn("SinceCommand", "Error while attempting to ban user", ex); - results.bad += 1; + results.failed.push(join.userId); } } - const text_ = `Attempted to ban ${joins.length} users from room ${targetRoomId}, ${results.good} kicked, ${results.bad} failures`; - return { - html: text_, - text: text_ + + return formatResult("ban", targetRoomId, recentJoins, results); + } + case Action.Mute: { + const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record}; + + for (let join of recentJoins) { + powerLevels.users[join.userId] = -1; } + try { + await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels); + for (let join of recentJoins) { + results.succeeded.push(join.userId); + } + } catch (ex) { + LogService.warn("SinceCommand", "Error while attempting to mute users", ex); + for (let join of recentJoins) { + results.failed.push(join.userId); + } + } + + return formatResult("mute", targetRoomId, recentJoins, results); + } + case Action.Unmute: { + const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record, users_default?: number}; + for (let join of recentJoins) { + // Restore default powerlevel. + delete powerLevels.users[join.userId]; + } + try { + await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels); + for (let join of recentJoins) { + results.succeeded.push(join.userId); + } + } catch (ex) { + LogService.warn("SinceCommand", "Error while attempting to unmute users", ex); + for (let join of recentJoins) { + results.failed.push(join.userId); + } + } + + return formatResult("unmute", targetRoomId, recentJoins, results); } } })(); @@ -262,7 +309,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni return {ok: undefined}; } -function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number): {html: string, text: string} { +function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number, recentJoins: Join[]): {html: string, text: string} { const HUMANIZER_OPTIONS = { // Reduce "1 day" => "1day" to simplify working with CSV. spacer: "", @@ -270,16 +317,15 @@ function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: numb largest: 1, }; const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS); - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); const htmlFragments = []; const textFragments = []; - for (let join of joins) { + for (let join of recentJoins) { const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS); htmlFragments.push(`
  • ${htmlEscape(join.userId)}: ${durationHumanReadable}
  • `); textFragments.push(`- ${join.userId}: ${durationHumanReadable}`); } return { - html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):
      ${htmlFragments.join()}
    `, - text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` + html: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):
      ${htmlFragments.join()}
    `, + text: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` } } diff --git a/test/integration/roomMembersTest.ts b/test/integration/roomMembersTest.ts index 617b9b8..792c911 100644 --- a/test/integration/roomMembersTest.ts +++ b/test/integration/roomMembersTest.ts @@ -403,7 +403,7 @@ describe("Test: Testing RoomMemberManager", function() { // Create and protect rooms. // - room 0 remains unprotected, as witness; // - room 1 is protected but won't be targeted directly, also as witness. - const NUMBER_OF_ROOMS = 14; + const NUMBER_OF_ROOMS = 18; const roomIds: string[] = []; const roomAliases: string[] = []; const mjolnirUserId = await this.mjolnir.client.getUserId(); @@ -460,121 +460,195 @@ describe("Test: Testing RoomMemberManager", function() { enum Method { kick, - ban + ban, + mute, + unmute, } const WITNESS_UNPROTECTED_ROOM_ID = roomIds[0]; const WITNESS_ROOM_ID = roomIds[1]; - const EXPERIMENTS = [ + class Experiment { + // A human-readable name for the command. + readonly name: string; + // If `true`, this command should affect room `WITNESS_ROOM_ID`. + // Defaults to `false`. + readonly shouldAffectWitnessRoom: boolean; + // The actual command-line. + readonly command: (roomId: string, roomAlias: string) => string; + // The number of responses we expect to this command. + // Defaults to `1`. + readonly n: number; + // How affected users should leave the room. + readonly method: Method; + + // If `true`, should this experiment look at the same room as the previous one. + // Defaults to `false`. + readonly isSameRoomAsPrevious: boolean; + + roomIndex: number | undefined; + + constructor({name, shouldAffectWitnessRoom, command, n, method, sameRoom}: {name: string, command: (roomId: string, roomAlias: string) => string, shouldAffectWitnessRoom?: boolean, n?: number, method: Method, sameRoom?: boolean}) { + this.name = name; + this.shouldAffectWitnessRoom = typeof shouldAffectWitnessRoom === "undefined" ? false : shouldAffectWitnessRoom; + this.command = command; + this.n = typeof n === "undefined" ? 1 : n; + this.method = method; + this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom; + } + + addTo(experiments: Experiment[]) { + if (this.isSameRoomAsPrevious) { + this.roomIndex = experiments[experiments.length - 1].roomIndex; + } else if (experiments.length === 0) { + this.roomIndex = 0; + } else { + this.roomIndex = experiments[experiments.length - 1].roomIndex! + 1; + } + experiments.push(this); + } + } + const EXPERIMENTS: Experiment[] = []; + for (let experiment of [ // Kick bad users in one room, using duration syntax, no reason. - { - // A human-readable name for the command. + new Experiment({ name: "kick with duration", - // The actual command-line. command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId}`, - // If `true`, this command should affect room `WITNESS_ROOM_ID`. - shouldAffectWitnessRoom: false, - // The number of responses we expect to this command. - n: 1, - // How affected users should leave the room. method: Method.kick, - }, + }), // Ban bad users in one room, using duration syntax, no reason. - { + new Experiment({ name: "ban with duration", command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId}`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.ban, - }, + }), + // Mute bad users in one room, using duration syntax, no reason. + new Experiment({ + name: "mute with duration", + command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId}`, + method: Method.mute, + }), + new Experiment({ + name: "unmute with duration", + command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId}`, + method: Method.unmute, + sameRoom: true, + }), // Kick bad users in one room, using date syntax, no reason. - { + new Experiment({ name: "kick with date", command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId}`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.kick, - }, + }), // Ban bad users in one room, using date syntax, no reason. - { + new Experiment({ name: "ban with date", command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId}`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.ban, - }, + }), + // Mute bad users in one room, using date syntax, no reason. + new Experiment({ + name: "mute with date", + command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId}`, + method: Method.mute, + }), + new Experiment({ + name: "unmute with date", + command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId}`, + method: Method.unmute, + sameRoom: true, + }), // Kick bad users in one room, using duration syntax, with reason. - { + new Experiment({ name: "kick with duration and reason", command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId} bad, bad user`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.kick, - }, + }), // Ban bad users in one room, using duration syntax, with reason. - { + new Experiment({ name: "ban with duration and reason", command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId} bad, bad user`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.ban, - }, + }), + // Mute bad users in one room, using duration syntax, with reason. + new Experiment({ + name: "mute with duration and reason", + command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId} bad, bad user`, + method: Method.mute, + }), + new Experiment({ + name: "unmute with duration and reason", + command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId} bad, bad user`, + method: Method.unmute, + sameRoom: true, + }), + // Kick bad users in one room, using date syntax, with reason. - { + new Experiment({ name: "kick with date and reason", command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`, shouldAffectWitnessRoom: false, n: 1, method: Method.kick, - }, + }), // Ban bad users in one room, using date syntax, with reason. - { + new Experiment({ name: "ban with date and reason", command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId} bad, bad user`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.ban, - }, + }), + // Mute bad users in one room, using date syntax, with reason. + new Experiment({ + name: "mute with date and reason", + command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId} bad, bad user`, + method: Method.mute, + }), + new Experiment({ + name: "unmute with date and reason", + command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId} bad, bad user`, + method: Method.unmute, + sameRoom: true, + }), // Kick bad users in one room, using duration syntax, without reason, using alias. - { + new Experiment({ name: "kick with duration, no reason, alias", command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias}`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.kick, - }, - + }), // Kick bad users in one room, using duration syntax, with reason, using alias. - { + new Experiment({ name: "kick with duration, reason and alias", command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias} for some reason`, - shouldAffectWitnessRoom: false, - n: 1, method: Method.kick, - }, + }), // Kick bad users everywhere, no reason - { + new Experiment({ name: "kick with date everywhere", command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`, shouldAffectWitnessRoom: true, n: NUMBER_OF_ROOMS - 1, method: Method.kick, - } - ]; + }), + ]) { + experiment.addTo(EXPERIMENTS); + } for (let i = 0; i < EXPERIMENTS.length; ++i) { const experiment = EXPERIMENTS[i]; - const roomId = roomIds[i + 2]; - const roomAlias = roomAliases[i + 2]; + const index = experiment.roomIndex! + 1; + const roomId = roomIds[index]; + const roomAlias = roomAliases[index]; const joined = this.mjolnir.roomJoins.getUsersInRoom(roomId, start, 100); - assert.ok(joined.length >= 2 * SAMPLE_SIZE, `We should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`); + assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`); + + // Run experiment. await getNthReply(this.mjolnir.client, this.mjolnir.managementRoomId, experiment.n, async () => { const command = experiment.command(roomId, roomAlias); let result = await this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); return result; }); + // Check post-conditions. const usersInRoom = await this.mjolnir.client.getJoinedRoomMembers(roomId); const usersInUnprotectedWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_UNPROTECTED_ROOM_ID); const usersInWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_ROOM_ID); @@ -583,18 +657,38 @@ describe("Test: Testing RoomMemberManager", function() { assert.ok(usersInWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in witness room`); assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected witness room`); } - for (let userId of badUserIds) { - assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`); - assert.equal(usersInWitnessRoom.includes(userId), !experiment.shouldAffectWitnessRoom, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectWitnessRoom ? "NOT" : "still"} be in witness room`); - assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected witness room`); - const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId); - switch (experiment.method) { - case Method.kick: - assert.equal(leaveEvent.membership, "leave"); - break; - case Method.ban: - assert.equal(leaveEvent.membership, "ban"); - break; + if (experiment.method === Method.mute) { + for (let userId of goodUserIds) { + let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); + assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`); + } + for (let userId of badUserIds) { + let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); + assert.ok(!canSpeak, `After a ${experiment.name}, bad user ${userId} should NOT be allowed to speak in the room`); + } + } else if (experiment.method === Method.unmute) { + for (let userId of goodUserIds) { + let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); + assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`); + } + for (let userId of badUserIds) { + let canSpeak = await this.mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); + assert.ok(canSpeak, `After a ${experiment.name}, bad user ${userId} should AGAIN be allowed to speak in the room`); + } + } else { + for (let userId of badUserIds) { + assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`); + assert.equal(usersInWitnessRoom.includes(userId), !experiment.shouldAffectWitnessRoom, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectWitnessRoom ? "NOT" : "still"} be in witness room`); + assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected witness room`); + const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId); + switch (experiment.method) { + case Method.kick: + assert.equal(leaveEvent.membership, "leave"); + break; + case Method.ban: + assert.equal(leaveEvent.membership, "ban"); + break; + } } } }