diff --git a/README.md b/README.md index f8879f3..977b064 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ TODO: Describe what all this means. Phase 1: * [ ] Ban users -* [ ] ACL servers -* [ ] Update lists with new bans/ACLs +* [x] ACL servers +* [x] Update lists with new bans/ACLs * [ ] "Ban on sight" mode (rather than proactive) Phase 2: diff --git a/config/default.yaml b/config/default.yaml index 829eab5..6230223 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -15,6 +15,12 @@ autojoin: true # This should be a room alias or room ID - not a matrix.to URL. managementRoom: "#moderators:example.org" +# The room ID or alias where the bot's own personal ban list is kept. This is +# where the commands to manage a ban list end up being routed to. Note that +# this room is NOT automatically added to the banLists list below - you will +# need to add it yourself! +publishedBanListRoom: "#banlist:example.org" + # A list of rooms to protect (matrix.to URLs) protectedRooms: - "https://matrix.to/#/#yourroom:example.org" diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 9121907..6fee5d2 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -23,7 +23,8 @@ import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; export class Mjolnir { constructor( public readonly client: MatrixClient, - private managementRoomId: string, + public readonly managementRoomId: string, + public readonly publishedBanListRoomId: string, public readonly protectedRooms: { [roomId: string]: string }, public readonly banLists: BanList[], ) { @@ -49,10 +50,13 @@ export class Mjolnir { if (!event['state_key']) return; // we also don't do anything with state events that have no state key if (ALL_RULE_TYPES.includes(event['type'])) { + let updated = false; for (const list of this.banLists) { if (list.roomId !== roomId) continue; await list.updateList(); + updated = true; } + if (!updated) return; const errors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this.client); return this.printActionResult(errors); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f8fe788..bce1c52 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-bot-sdk"; import { Mjolnir } from "../Mjolnir"; import { execStatusCommand } from "./StatusCommand"; +import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -26,6 +26,10 @@ export function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) { if (parts.length === 1) { return execStatusCommand(roomId, event, mjolnir); + } else if (parts[1] === 'ban' && parts.length > 3) { + return execBanCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'unban' && parts.length > 3) { + return execUnbanCommand(roomId, event, mjolnir, parts); } else { // TODO: Help menu } diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index 4163bd2..4bfe2ee 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -16,6 +16,7 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; +// !mjolnir export async function execStatusCommand(roomId: string, event: any, mjolnir: Mjolnir) { let html = ""; let text = ""; diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts new file mode 100644 index 0000000..2c66908 --- /dev/null +++ b/src/commands/UnbanBanCommand.ts @@ -0,0 +1,81 @@ +/* +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 { RULE_ROOM, RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/BanList"; +import { RichReply } from "matrix-bot-sdk"; +import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; + +function parseBits(parts: string[]): { entityType: string, ruleType: string, glob: string, reason: string } { + const entityType = parts[2].toLowerCase(); + const glob = parts[3]; + const reason = parts.slice(4).join(' ') || ""; + + let rule = null; + if (entityType === "user") { + rule = RULE_USER; + } else if (entityType === "room") { + rule = RULE_ROOM; + } else if (entityType === "server") { + rule = RULE_SERVER; + } + if (!rule) { + return {entityType, ruleType: null, glob, reason}; + } + rule = ruleTypeToStable(rule); + + return {entityType, ruleType: rule, glob, reason}; +} + +// !mjolnir ban [reason] +export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const bits = parseBits(parts); + if (!bits.ruleType) { + const replyText = "Unknown entity type '" + bits.entityType + "' - try one of user, room, or server"; + const reply = RichReply.createFor(roomId, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + return mjolnir.client.sendMessage(roomId, reply); + } + + const recommendation = recommendationToStable(RECOMMENDATION_BAN); + const ruleContent = { + entity: bits.glob, + recommendation, + reason: bits.reason, + }; + const stateKey = `rule:${bits.glob}`; + + await mjolnir.client.sendStateEvent(mjolnir.publishedBanListRoomId, bits.ruleType, stateKey, ruleContent); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} + + +// !mjolnir unban +export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const bits = parseBits(parts); + if (!bits.ruleType) { + const replyText = "Unknown entity type '" + bits.entityType + "' - try one of user, room, or server"; + const reply = RichReply.createFor(roomId, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + return mjolnir.client.sendMessage(roomId, reply); + } + + const ruleContent = {}; // empty == clear/unban + const stateKey = `rule:${bits.glob}`; + + await mjolnir.client.sendStateEvent(mjolnir.publishedBanListRoomId, bits.ruleType, stateKey, ruleContent); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} diff --git a/src/config.ts b/src/config.ts index 40abe3a..8443955 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ interface IConfig { dataPath: string; autojoin: boolean; managementRoom: string; + publishedBanListRoom: string; protectedRooms: string[]; // matrix.to urls banLists: string[]; // matrix.to urls } diff --git a/src/index.ts b/src/index.ts index 9f5101c..af1237d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,9 +24,7 @@ import { SimpleFsStorageProvider } from "matrix-bot-sdk"; import config from "./config"; -import BanList, { ALL_RULE_TYPES } from "./models/BanList"; -import { applyServerAcls } from "./actions/ApplyAcl"; -import { RoomUpdateError } from "./models/RoomUpdateError"; +import BanList from "./models/BanList"; import { Mjolnir } from "./Mjolnir"; LogService.setLogger(new RichConsoleLogger()); @@ -40,7 +38,7 @@ if (config.autojoin) { (async function () { const banLists: BanList[] = []; - const protectedRooms:{[roomId: string]: string} = {}; + const protectedRooms: { [roomId: string]: string } = {}; // Ensure we're in all the rooms we expect to be in const joinedRooms = await client.getJoinedRooms(); @@ -71,11 +69,17 @@ if (config.autojoin) { protectedRooms[roomId] = roomRef; } + // Ensure we've joined the ban list we're publishing too + let banListRoomId = await client.resolveRoom(config.publishedBanListRoom); + if (!joinedRooms.includes(banListRoomId)) { + banListRoomId = await client.joinRoom(config.publishedBanListRoom); + } + // Ensure we're also in the management room const managementRoomId = await client.joinRoom(config.managementRoom); await client.sendNotice(managementRoomId, "Mjolnir is starting up. Use !mjolnir to query status."); - const bot = new Mjolnir(client, managementRoomId, protectedRooms, banLists); + const bot = new Mjolnir(client, managementRoomId, banListRoomId, protectedRooms, banLists); await bot.start(); // TODO: Check permissions for mjolnir in protected rooms diff --git a/src/models/BanList.ts b/src/models/BanList.ts index c931f29..66d642f 100644 --- a/src/models/BanList.ts +++ b/src/models/BanList.ts @@ -26,6 +26,13 @@ export const ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"]; export const SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"]; export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; +export function ruleTypeToStable(rule: string, unstable = true): string { + if (USER_RULE_TYPES.includes(rule)) return unstable ? USER_RULE_TYPES[USER_RULE_TYPES.length - 1] : RULE_USER; + if (ROOM_RULE_TYPES.includes(rule)) return unstable ? ROOM_RULE_TYPES[ROOM_RULE_TYPES.length - 1] : RULE_ROOM; + if (SERVER_RULE_TYPES.includes(rule)) return unstable ? SERVER_RULE_TYPES[SERVER_RULE_TYPES.length - 1] : RULE_SERVER; + return null; +} + export default class BanList { private rules: ListRule[] = []; diff --git a/src/models/ListRule.ts b/src/models/ListRule.ts index 6a0f018..78de2c6 100644 --- a/src/models/ListRule.ts +++ b/src/models/ListRule.ts @@ -19,6 +19,11 @@ import { MatrixGlob } from "matrix-bot-sdk/lib/MatrixGlob"; export const RECOMMENDATION_BAN = "m.ban"; export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"]; +export function recommendationToStable(recommendation: string, unstable = true): string { + if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN; + return null; +} + export class ListRule { private glob: MatrixGlob;