diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index c306480b..0b778747 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -53,6 +53,7 @@ import { execUnsuspendCommand } from "./UnsuspendCommand"; import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand"; import { execLockCommand } from "./LockCommand"; import { execUnlockCommand } from "./UnlockCommand"; +import { execLockdownCommand } from "./LockdownCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -152,6 +153,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execLockCommand(roomId, event, mjolnir, parts); } else if (parts[1] === "unlock") { return await execUnlockCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === "lockdown") { + return await execLockdownCommand(roomId, event, mjolnir, parts); } else if (parts[1] === "help") { // Help menu const protectionMenu = @@ -182,6 +185,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir unlock - Unlock the account of the specified user\n" + "!mjolnir ignore - Add user to list of users/servers that cannot be banned/ACL'd. Note that this does not survive restart.\n" + "!mjolnir ignored - List currently ignored entities.\n" + + "!mjolnir lockdown (lock|unlock) [room alias/ID] - Locks a room to invite-only. If not specified, this applies to all protected rooms.\n" + "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n"; const policyListMenu = diff --git a/src/commands/LockdownCommand.ts b/src/commands/LockdownCommand.ts new file mode 100644 index 00000000..3d2ff209 --- /dev/null +++ b/src/commands/LockdownCommand.ts @@ -0,0 +1,95 @@ +/* +Copyright 2025 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 { MatrixSendClient } from "../MatrixEmitter"; +import { Mjolnir } from "../Mjolnir"; +import { JoinRulesEventContent, LogLevel, LogService } from "@vector-im/matrix-bot-sdk"; + +export const LOCKDOWN_EVENT_TYPE = "org.matrix.mjolnir.lockdown"; + +// !mjolnir lockdown [roomId] +export async function execLockdownCommand(managementRoomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const lockOrUnlock = parts[2]?.toLocaleLowerCase(); + const target = parts[3]; + + if (!["lock", "unlock"].includes(lockOrUnlock)) { + throw Error("Command must be lock or unlock"); + } + + let targetRooms: string[]; + if (target) { + const targetRoomId = await mjolnir.client.resolveRoom(target); + targetRooms = [targetRoomId]; + } else if (mjolnir.config.protectAllJoinedRooms) { + targetRooms = await mjolnir.client.getJoinedRooms(); + } else { + targetRooms = mjolnir.protectedRoomsConfig.getExplicitlyProtectedRooms(); + } + + if (!targetRooms.length) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "LockdownCommand", "No protected rooms found"); + return; + } + await mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "LockdownCommand", + target ? `Locking down room` : "Locking down ALL protected rooms", + ); + await mjolnir.client.unstableApis.addReactionToEvent(managementRoomId, event["event_id"], "⏳"); + let didError = false; + for (const roomId of targetRooms) { + try { + await ensureLockdownState(mjolnir.client, roomId, lockOrUnlock === "lock"); + } catch (ex) { + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "Lock Command", + `There was an error locking ${target}, please check the logs for more information.`, + ); + LogService.error("LockdownCommand", `Error changing lockdown state of ${roomId}:`, ex); + didError = true; + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "❌"); + } + } + + if (!didError) { + await mjolnir.client.unstableApis.addReactionToEvent(managementRoomId, event["event_id"], "✅"); + } +} + +async function ensureLockdownState(client: MatrixSendClient, roomId: string, lockdown: boolean) { + const currentState = await client.getSafeRoomAccountData< + { locked: false } | { locked: true; previousState: JoinRulesEventContent } + >(LOCKDOWN_EVENT_TYPE, roomId, { locked: false }); + const currentJoinRule = (await client.getRoomStateEvent(roomId, "m.room.join_rules", "")) as JoinRulesEventContent; + if (!currentState.locked && lockdown) { + const newState = { + locked: true, + previousState: currentJoinRule, + }; + await client.sendStateEvent(roomId, "m.room.join_rules", "", { + join_rule: "invite", + }); + await client.setRoomAccountData(LOCKDOWN_EVENT_TYPE, roomId, newState); + } else if (currentState.locked && !lockdown) { + const newState = { + locked: false, + }; + await client.sendStateEvent(roomId, "m.room.join_rules", "", currentState.previousState); + await client.setRoomAccountData(LOCKDOWN_EVENT_TYPE, roomId, newState); + } + // Else, nothing to do. +} diff --git a/test/integration/commands/lockdownCommandTest.ts b/test/integration/commands/lockdownCommandTest.ts new file mode 100644 index 00000000..a2815d55 --- /dev/null +++ b/test/integration/commands/lockdownCommandTest.ts @@ -0,0 +1,43 @@ +import { strict as assert } from "assert"; + +import { newTestUser } from "../clientHelper"; +import { getFirstReaction } from "./commandUtils"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +describe("Test: lockdown command", function () { + let client: MatrixClient; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "lockdown-command" } }); + await client.start(); + }); + this.afterEach(async function () { + await client.stop(); + }); + it("should lockdown a room", async function () { + this.timeout(20000); + const badRoom = await client.createRoom({ preset: "public_chat" }); + await client.joinRoom(this.mjolnir.managementRoomId); + + const reply1 = new Promise(async (resolve, reject) => { + const msgid = await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir lockdown lock ${badRoom}`, + }); + client.on("room.event", (roomId, event) => { + if ( + roomId === this.mjolnir.managementRoomId && + event?.type === "m.reaction" && + event.sender === this.mjolnir.client.userId && + event.content?.["m.relates_to"]?.event_id === msgid + ) { + resolve(event); + } + }); + }); + + await reply1; + + const newJoinRules = await client.getRoomStateEvent(badRoom, "m.room.join_rules", ""); + console.log(newJoinRules); + }); +});