Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -182,6 +185,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir unlock <user ID> - Unlock the account of the specified user\n" +
"!mjolnir ignore <user ID/server name> - 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 <room alias/ID> [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n";

const policyListMenu =
Expand Down
95 changes: 95 additions & 0 deletions src/commands/LockdownCommand.ts
Original file line number Diff line number Diff line change
@@ -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.
}
43 changes: 43 additions & 0 deletions test/integration/commands/lockdownCommandTest.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading