Since command: adding the ability to mute (#272)

This commit is contained in:
David Teller 2022-05-10 17:19:16 +02:00 committed by GitHub
parent a88fc64a07
commit 74d8caa7e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 232 additions and 92 deletions

View File

@ -157,7 +157,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir alias add <room alias> <target room alias/ID> - Adds <room alias> to <target room>\n" +
"!mjolnir alias remove <room alias> - Deletes the room alias from whatever room it is attached to\n" +
"!mjolnir resolve <room alias> - Resolves a room alias to a room ID\n" +
"!mjolnir since <date>/<duration> <action> <limit> [rooms...] [reason] - Apply an action (kick, ban or just show) to all users who joined a room since a given date (up to <limit> users)\n" +
"!mjolnir since <date>/<duration> <action> <limit> [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since <date>/<duration> (up to <limit> users)\n" +
"!mjolnir shutdown room <room alias/ID> [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" +
"!mjolnir powerlevel <user ID> <power level> [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" +
"!mjolnir make admin <room alias> [user alias/ID] - Make the specified user or the bot itself admin of the room\n" +

View File

@ -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<T> = {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}.<br/>Succeeded ${summary.succeeded.length}: <ul>${summary.succeeded.map(x => `<li>${htmlEscape(x)}</li>`).join("\n")}</ul>.<br/> Failed ${summary.failed.length}: <ul>${summary.succeeded.map(x => `<li>${htmlEscape(x)}</li>`).join("\n")}</ul>`;
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</* userId */ string, number>};
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</* userId */ string, number>, 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(`<li>${htmlEscape(join.userId)}: ${durationHumanReadable}</li>`);
textFragments.push(`- ${join.userId}: ${durationHumanReadable}`);
}
return {
html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries): <ul> ${htmlFragments.join()} </ul>`,
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): <ul> ${htmlFragments.join()} </ul>`,
text: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}`
}
}

View File

@ -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;
}
}
}
}