From 78b73153b7c112c333053a68e8de183c25883581 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Oct 2019 15:53:37 +0100 Subject: [PATCH] Add a redact command --- README.md | 2 +- src/commands/CommandHandler.ts | 4 ++ src/commands/RedactCommand.ts | 47 +++++++++++++++++++ src/utils.ts | 86 ++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/commands/RedactCommand.ts diff --git a/README.md b/README.md index 7794821..e8a7cfc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Phase 1: Phase 2: * [x] Pantalaimon support * [x] No-op mode (for verifying behaviour) -* [ ] Redact messages on ban (optionally) +* [x] Redact messages on ban (optionally) * [x] More useful spam in management room * [ ] Command to import ACLs, etc from rooms * [x] Vet rooms on startup option diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f3a9d52..d3fc620 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -24,6 +24,7 @@ import { execSyncCommand } from "./SyncCommand"; import { execPermissionCheckCommand } from "./PermissionCheckCommand"; import { execCreateListCommand } from "./CreateBanListCommand"; import { execUnwatchCommand, execWatchCommand } from "./WatchUnwatchCommand"; +import { execRedactCommand } from "./RedactCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -50,6 +51,8 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir return await execWatchCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'unwatch' && parts.length > 1) { return await execUnwatchCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'redact' && parts.length > 1) { + return await execRedactCommand(roomId, event, mjolnir, parts); } else { // Help menu const menu = "" + @@ -57,6 +60,7 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir "!mjolnir status - Print status information\n" + "!mjolnir ban [reason] - Adds an entity to the ban list\n" + "!mjolnir unban - Removes an entity from the ban list\n" + + "!mjolnir redact [room alias/ID] - Redacts messages by the sender in the target room (or all rooms)\n" + "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + "!mjolnir sync - Force updates of all lists and re-apply rules\n" + "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts new file mode 100644 index 0000000..5d2805e --- /dev/null +++ b/src/commands/RedactCommand.ts @@ -0,0 +1,47 @@ +/* +Copyright 2019 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 { Mjolnir } from "../Mjolnir"; +import { getMessagesByUserSinceLastJoin } from "../utils"; +import config from "../config"; + +// !mjolnir redact [room alias] +export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const userId = parts[2]; + let roomAlias = null; + if (parts.length > 3) { + roomAlias = await mjolnir.client.resolveRoom(parts[3]); + } + + const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); + for (const targetRoomId of targetRoomIds) { + if (config.verboseLogging) { + await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Fetching sent messages for ${userId} in ${targetRoomId} to redact...`); + } + + const eventsToRedact = await getMessagesByUserSinceLastJoin(mjolnir.client, userId, targetRoomId); + for (const event of eventsToRedact) { + if (config.verboseLogging) { + await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Redacting ${event['event_id']} in ${targetRoomId}`); + } + if (!config.noop) { + await mjolnir.client.redactEvent(targetRoomId, event['event_id']); + } + } + } + + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} diff --git a/src/utils.ts b/src/utils.ts index 6693920..0ccf7a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient } from "matrix-bot-sdk"; + export function setToArray(set: Set): T[] { const arr: T[] = []; for (const v of set) { @@ -21,3 +23,87 @@ export function setToArray(set: Set): T[] { } return arr; } + +export async function getMessagesByUserSinceLastJoin(client: MatrixClient, sender: string, roomId: string): Promise { + const filter = { + room: { + rooms: [roomId], + state: { + types: ["m.room.member"], + rooms: [roomId], + }, + timeline: { + senders: [sender], + rooms: [roomId], + }, + ephemeral: { + limit: 0, + types: [], + }, + account_data: { + limit: 0, + types: [], + }, + }, + presence: { + limit: 0, + types: [], + }, + account_data: { + limit: 0, + types: [], + }, + }; + + function initialSync() { + const qs = { + filter: JSON.stringify(filter), + }; + return client.doRequest("GET", "/_matrix/client/r0/sync", qs); + } + + function backfill(from: string) { + const qs = { + filter: JSON.stringify(filter), + from: from, + dir: "b", + }; + return client.doRequest("GET", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages`, qs); + } + + // Do an initial sync first to get the batch token + const response = await initialSync(); + if (!response) return []; + + let token = response['next_batch']; + + const messages = []; + + const timeline = (((response['rooms'] || {})['join'] || {})[roomId] || {})['timeline'] || {}; + const syncedMessages = timeline['events'] || []; + token = timeline['prev_batch'] || token; + for (const event of syncedMessages) { + if (event['sender'] === sender) messages.push(event); + if (event['type'] === 'm.room.member' && event['state_key'] === sender) { + if (event['content'] && event['content']['membership'] === 'join') { + return messages; // we're done! + } + } + } + + while (token) { + const bfMessages = await backfill(token); + token = bfMessages['end']; + + for (const event of (bfMessages['chunk'] || [])) { + if (event['sender'] === sender) messages.push(event); + if (event['type'] === 'm.room.member' && event['state_key'] === sender) { + if (event['content'] && event['content']['membership'] === 'join') { + return messages; // we're done! + } + } + } + } + + return messages; +}