From 97df4d5f617637cd0cd2dd0705d628b8c2f5f205 Mon Sep 17 00:00:00 2001 From: Marco Cirillo Date: Mon, 7 Mar 2022 10:14:06 +0100 Subject: [PATCH] Add command to elevate a user (or the bot) as room administrator (#219) --- .gitignore | 3 + config/default.yaml | 10 ++ config/harness.yaml | 10 ++ src/Mjolnir.ts | 17 +++ src/commands/CommandHandler.ts | 4 + src/commands/MakeRoomAdminCommand.ts | 43 +++++++ src/config.ts | 3 + .../commands/makedminCommandTest.ts | 107 ++++++++++++++++++ 8 files changed, 197 insertions(+) create mode 100644 src/commands/MakeRoomAdminCommand.ts create mode 100644 test/integration/commands/makedminCommandTest.ts diff --git a/.gitignore b/.gitignore index 2d50e9d..68e477c 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ typings/ # Python packing directories. mjolnir.egg-info/ + +# VS +.vs diff --git a/config/default.yaml b/config/default.yaml index 3400099..f6ac602 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -95,6 +95,16 @@ protectedRooms: # Manually add these rooms to the protected rooms list if you want them protected. protectAllJoinedRooms: false +# Server administration commands, these commands will only work if Mjolnir is +# a global server administrator +admin: + # The `make admin` upgrades the powerlevel of a specified user (or the bot itself) + # of a room to make them admin of the room (powerlevel 100). + # + # This only works if the room has at least one admin on the local homeserver + # (the homeserver specified in `homeserverUrl` in this file). + enableMakeRoomAdminCommand: false + # Misc options for command handling and commands commands: # If true, Mjolnir will respond to commands like !help and !ban instead of diff --git a/config/harness.yaml b/config/harness.yaml index 434c4b8..e3c3a9d 100644 --- a/config/harness.yaml +++ b/config/harness.yaml @@ -96,6 +96,16 @@ protectedRooms: [] # Manually add these rooms to the protected rooms list if you want them protected. protectAllJoinedRooms: false +# Server administration commands, these commands will only work if Mjolnir is +# a global server administrator +admin: + # The `make admin` upgrades the powerlevel of a specified user (or the bot itself) + # of a room to make them admin of the room (powerlevel 100). + # + # This only works if the room has at least one admin on the local homeserver + # (the homeserver specified in `homeserverUrl` in this file). + enableMakeRoomAdminCommand: true + # Misc options for command handling and commands commands: # If true, Mjolnir will respond to commands like !help and !ban instead of diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 1e13539..539df7b 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -1011,6 +1011,23 @@ export class Mjolnir { }); } + /** + * Make a user administrator via the Synapse Admin API + * @param roomId the room where the user (or the bot) shall be made administrator. + * @param userId optionally specify the user mxID to be made administrator, if not specified the bot mxID will be used. + * @returns The list of errors encountered, for reporting to the management room. + */ + public async makeUserRoomAdmin(roomId: string, userId?: string): Promise { + try { + const endpoint = `/_synapse/admin/v1/rooms/${roomId}/make_room_admin`; + return await this.client.doRequest("POST", endpoint, null, { + user_id: userId || await this.client.getUserId(), /* if not specified make the bot administrator */ + }); + } catch (e) { + return extractRequestError(e); + } + } + public queueRedactUserMessagesIn(userId: string, roomId: string) { this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); } diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 65ea340..b89a670 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -37,6 +37,7 @@ import { execSetPowerLevelCommand } from "./SetPowerLevelCommand"; import { execShutdownRoomCommand } from "./ShutdownRoomCommand"; import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands"; import { execKickCommand } from "./KickCommand"; +import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -110,6 +111,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execShutdownRoomCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'kick' && parts.length > 2) { 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 { // Help menu const menu = "" + @@ -148,6 +151,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir resolve - Resolves a room alias to a room ID\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" + "!mjolnir help - This menu\n"; const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; diff --git a/src/commands/MakeRoomAdminCommand.ts b/src/commands/MakeRoomAdminCommand.ts new file mode 100644 index 0000000..5165dd3 --- /dev/null +++ b/src/commands/MakeRoomAdminCommand.ts @@ -0,0 +1,43 @@ +/* +Copyright 2021, 2022 Marco Cirillo + +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 config from "../config"; +import { Mjolnir } from "../Mjolnir"; +import { RichReply } from "matrix-bot-sdk"; + +// !mjolnir make admin [] +export async function execMakeRoomAdminCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!config.admin?.enableMakeRoomAdminCommand || !isAdmin) { + const message = "Either the command is disabled or I am not running as homeserver administrator."; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } + + let err = await mjolnir.makeUserRoomAdmin(await mjolnir.client.resolveRoom(parts[3]), parts[4]); + if (err instanceof Error || typeof (err) === "string") { + const errMsg = "Failed to process command:"; + const message = typeof (err) === "string" ? `${errMsg}: ${err}` : `${errMsg}: ${err.message}`; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } else { + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + } +} diff --git a/src/config.ts b/src/config.ts index d6eaa9c..9a60560 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,9 @@ interface IConfig { fasterMembershipChecks: boolean; automaticallyRedactForReasons: string[]; // case-insensitive globs protectAllJoinedRooms: boolean; + admin?: { + enableMakeRoomAdminCommand?: boolean; + } commands: { allowNoPrefix: boolean; additionalPrefixes: string[]; diff --git a/test/integration/commands/makedminCommandTest.ts b/test/integration/commands/makedminCommandTest.ts new file mode 100644 index 0000000..87e1152 --- /dev/null +++ b/test/integration/commands/makedminCommandTest.ts @@ -0,0 +1,107 @@ +import { strict as assert } from "assert"; + +import config from "../../../src/config"; +import { newTestUser } from "../clientHelper"; +import { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction"; +import { LogService } from "matrix-bot-sdk"; +import { getFirstReaction } from "./commandUtils"; + +describe("Test: The make admin command", function () { + afterEach(function () { + this.moderator?.stop(); + this.userA?.stop(); + this.userB?.stop(); + this.userC?.stop(); + }); + + it('Mjölnir make the bot self room administrator', async function () { + this.timeout(90000); + if (!config.admin?.enableMakeRoomAdminCommand) { + done(); + } + const mjolnir = config.RUNTIME.client!; + const mjolnirUserId = await mjolnir.getUserId(); + const moderator = await newTestUser({ name: { contains: "moderator" } }); + const userA = await newTestUser({ name: { contains: "a" } }); + const userAId = await userA.getUserId(); + this.moderator = moderator; + this.userA = userA; + let powerLevels: any; + + await moderator.joinRoom(config.managementRoom); + LogService.debug("makeadminTest", `Joining managementRoom: ${config.managementRoom}`); + let targetRoom = await moderator.createRoom({ invite: [mjolnirUserId] }); + LogService.debug("makeadminTest", `moderator creating targetRoom: ${targetRoom}; and inviting ${mjolnirUserId}`); + await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}` }); + LogService.debug("makeadminTest", `Adding targetRoom: ${targetRoom}`); + try { + await moderator.start(); + await userA.start(); + await userA.joinRoom(targetRoom); + powerLevels = await mjolnir.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); + assert.notEqual(powerLevels["users"][mjolnirUserId], 100, `Bot should not yet be an admin of ${targetRoom}`); + await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => { + LogService.debug("makeadminTest", `Sending: !mjolnir make admin ${targetRoom}`); + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir make admin ${targetRoom}` }); + }); + } finally { + await moderator.stop(); + await userA.stop(); + } + LogService.debug("makeadminTest", `Making self admin`); + + powerLevels = await mjolnir.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); + assert.equal(powerLevels["users"][mjolnirUserId], 100, "Bot should be a room admin."); + assert.equal(powerLevels["users"][userAId], (0 || undefined), "User A is not supposed to be a room admin."); + }); + + it('Mjölnir make the tester room administrator', async function () { + this.timeout(90000); + if (!config.admin?.enableMakeRoomAdminCommand) { + done(); + } + const mjolnir = config.RUNTIME.client!; + const moderator = await newTestUser({ name: { contains: "moderator" } }); + const userA = await newTestUser({ name: { contains: "a" } }); + const userB = await newTestUser({ name: { contains: "b" } }); + const userC = await newTestUser({ name: { contains: "c" } }); + const userBId = await userB.getUserId(); + const userCId = await userC.getUserId(); + this.moderator = moderator; + this.userA = userA; + this.userB = userB; + this.userC = userC; + let powerLevels: any; + + await moderator.joinRoom(this.mjolnir.managementRoomId); + LogService.debug("makeadminTest", `Joining managementRoom: ${this.mjolnir.managementRoomId}`); + let targetRoom = await userA.createRoom({ invite: [userBId, userCId] }); + LogService.debug("makeadminTest", `User A creating targetRoom: ${targetRoom}; and inviting ${userBId} and ${userCId}`); + try { + await userB.start(); + await userC.start(); + await userB.joinRoom(targetRoom); + await userC.joinRoom(targetRoom); + } finally { + LogService.debug("makeadminTest", `${userBId} and ${userCId} joining targetRoom: ${targetRoom}`); + await userB.stop(); + await userC.stop(); + } + try { + await moderator.start(); + powerLevels = await userA.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); + assert.notEqual(powerLevels["users"][userBId], 100, `User B should not yet be an admin of ${targetRoom}`); + await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => { + LogService.debug("makeadminTest", `Sending: !mjolnir make admin ${targetRoom} ${userBId}`); + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir make admin ${targetRoom} ${userBId}` }); + }); + } finally { + await moderator.stop(); + } + LogService.debug("makeadminTest", `Making User B admin`); + + powerLevels = await userA.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); + assert.equal(powerLevels["users"][userBId], 100, "User B should be a room admin."); + assert.equal(powerLevels["users"][userCId], (0 || undefined), "User C is not supposed to be a room admin."); + }); +});