From 2f6120eca9954b6b752ab9cfc3814024a671deb7 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 24 Jul 2024 12:16:12 -0700 Subject: [PATCH] Add commands to suspend/unsuspend user (#506) --- mx-tester.yml | 5 + src/Mjolnir.ts | 13 +++ src/commands/CommandHandler.ts | 10 +- src/commands/SuspendCommand.ts | 38 ++++++++ src/commands/UnsuspendCommand.ts | 38 ++++++++ .../commands/suspendCommandTest.ts | 91 +++++++++++++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/commands/SuspendCommand.ts create mode 100644 src/commands/UnsuspendCommand.ts create mode 100644 test/integration/commands/suspendCommandTest.ts diff --git a/mx-tester.yml b/mx-tester.yml index b25b46e..29e3793 100644 --- a/mx-tester.yml +++ b/mx-tester.yml @@ -35,6 +35,7 @@ modules: homeserver: + docker: element-hq/synapse:latest # Basic configuration. server_name: localhost:9999 public_baseurl: http://localhost:9999 @@ -76,6 +77,10 @@ homeserver: per_second: 10000 burst_count: 10000 + experimental_features: + msc3823_account_suspension: true + + # Creating a few users simplifies testing. users: - localname: admin diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 914c2ec..8224147 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -479,6 +479,19 @@ export class Mjolnir { return await this.client.doRequest("POST", endpoint); } + public async suspendSynapseUser(userId: string): Promise { + const endpoint = `/_synapse/admin/v1/suspend/${userId}`; + const body = {"suspend": true} + return await this.client.doRequest("PUT", endpoint, null, body); + } + + public async unsuspendSynapseUser(userId: string): Promise { + const endpoint = `/_synapse/admin/v1/suspend/${userId}`; + const body = {"suspend": false} + return await this.client.doRequest("PUT", endpoint, null, body); + } + + public async shutdownSynapseRoom(roomId: string, message?: string): Promise { const endpoint = `/_synapse/admin/v1/rooms/${roomId}`; return await this.client.doRequest("DELETE", endpoint, null, { diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 0ae520f..420bc09 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -43,6 +43,8 @@ import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; +import {execSuspendCommand} from "./SuspendCommand"; +import {execUnsuspendCommand} from "./UnsuspendCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -128,6 +130,10 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execKickCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) { return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'suspend' && parts.length > 2) { + return await execSuspendCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'unsuspend' && parts.length > 2) { + return await execUnsuspendCommand(roomId, event, mjolnir, parts) } else { // Help menu const menu = "" + @@ -170,7 +176,9 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!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" + - "!mjolnir help - This menu\n"; + "!mjolnir suspend - Suspend the specified user" + + "!mjolnir unsuspend - Unsuspend the specified user" + + "!mjolnir help - This menu\n" const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; const reply = RichReply.createFor(roomId, event, text, html); diff --git a/src/commands/SuspendCommand.ts b/src/commands/SuspendCommand.ts new file mode 100644 index 0000000..db74e32 --- /dev/null +++ b/src/commands/SuspendCommand.ts @@ -0,0 +1,38 @@ +/* +Copyright 2024 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 {RichReply} from "matrix-bot-sdk"; + +export async function execSuspendCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const target = parts[2]; + + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!isAdmin) { + const message = "I am not a Synapse administrator, or the endpoint is blocked"; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); + return; + } + + await mjolnir.suspendSynapseUser(target); + const msg = `User ${target} has been suspended.` + const confirmation = RichReply.createFor(roomId, event, msg, msg); + confirmation['msgtype'] = "m.notice"; + await mjolnir.client.sendMessage(roomId, confirmation) + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} \ No newline at end of file diff --git a/src/commands/UnsuspendCommand.ts b/src/commands/UnsuspendCommand.ts new file mode 100644 index 0000000..fbb06d1 --- /dev/null +++ b/src/commands/UnsuspendCommand.ts @@ -0,0 +1,38 @@ +/* +Copyright 2024 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 {RichReply} from "matrix-bot-sdk"; + +export async function execUnsuspendCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const target = parts[2]; + + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!isAdmin) { + const message = "I am not a Synapse administrator, or the endpoint is blocked"; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } + + await mjolnir.unsuspendSynapseUser(target); + const msg = `User ${target}'s suspension has been reversed.` + const confirmation = RichReply.createFor(roomId, event, msg, msg); + confirmation['msgtype'] = "m.notice"; + mjolnir.client.sendMessage(roomId, confirmation) + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} \ No newline at end of file diff --git a/test/integration/commands/suspendCommandTest.ts b/test/integration/commands/suspendCommandTest.ts new file mode 100644 index 0000000..f9afa2c --- /dev/null +++ b/test/integration/commands/suspendCommandTest.ts @@ -0,0 +1,91 @@ +/* +Copyright 2024 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 {newTestUser} from "../clientHelper"; +import {strict as assert} from "assert"; +import { MatrixClient, RoomCreateOptions } from "matrix-bot-sdk"; +import { read as configRead } from "../../../src/config"; + +describe("Test: suspend/unsuspend command", function () { + let admin: MatrixClient; + let badUser: MatrixClient; + const config = configRead() + this.beforeEach(async () => { + admin = await newTestUser(config.homeserverUrl, { name: { contains: "suspend-command" }}); + await admin.start(); + badUser = await newTestUser(config.homeserverUrl, {name: { contains: "bad-user"}}) + await badUser.start(); + }) + this.afterEach(async function () { + admin.stop(); + badUser.stop(); + }) + + it("Mjolnir asks synapse to suspend and unsuspend a user", async function() { + this.timeout(20000); + await admin.joinRoom(this.mjolnir.managementRoomId); + const roomOption: RoomCreateOptions = {preset: "public_chat"} + const room = await admin.createRoom(roomOption); + await badUser.joinRoom(room) + await admin.joinRoom(room) + const badUserID = await badUser.getUserId() + + let reply = new Promise(async (resolve, reject) => { + await admin.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: `!mjolnir suspend ${badUserID}`}); + admin.on('room.event', (roomId, event) => { + if ( + roomId === this.mjolnir.managementRoomId + && event?.type === "m.room.message" + && event.sender === this.mjolnir.client.userId + && event.content?.body.endsWith(`User ${badUserID} has been suspended.`) + ) { + resolve(event); + } + }); + }); + + await reply + try { + await badUser.sendMessage(room, {msgtype: "m.text", body: `testing`}) + assert.fail("Bad user successfully sent message.") + } + catch (error) { + assert.match(error.message, /ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED/i, ) + } + + let reply2 = new Promise(async (resolve, reject) => { + await admin.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: `!mjolnir unsuspend ${badUserID}`}); + admin.on('room.event', (roomId, event) => { + if ( + roomId === this.mjolnir.managementRoomId + && event?.type === "m.room.message" + && event.sender === this.mjolnir.client.userId + && event.content?.body.endsWith(`User ${badUserID}'s suspension has been reversed.`) + ) { + resolve(event); + } + }); + }); + await reply2 + + try { + await badUser.sendMessage(room, {msgtype: "m.text", body: `testing`}); + } + catch (error) { + assert.fail("Unable to send message, account not successfully unsuspended.") + } + }); +});