diff --git a/README.md b/README.md index a4ba1e8..a3ad18f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Phase 2: * [ ] Command to import ACLs, etc from rooms * [x] Vet rooms on startup option * [ ] Command to actually unban users (instead of leaving them stuck) -* [ ] Support multiple lists +* [x] Support multiple lists Phase 3: * [ ] Synapse antispam module diff --git a/config/default.yaml b/config/default.yaml index de0fbbd..0e3b43d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -42,12 +42,6 @@ syncOnStartup: true # resets, etc) before Mjolnir is needed. verifyPermissionsOnStartup: true -# 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 8546ff8..365356e 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -36,7 +36,6 @@ export class Mjolnir { constructor( public readonly client: MatrixClient, public readonly managementRoomId: string, - public readonly publishedBanListRoomId: string, public readonly protectedRooms: { [roomId: string]: string }, public readonly banLists: BanList[], ) { diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 95de555..d9ea80a 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -22,6 +22,7 @@ import { LogService, RichReply } from "matrix-bot-sdk"; import * as htmlEscape from "escape-html"; import { execSyncCommand } from "./SyncCommand"; import { execPermissionCheckCommand } from "./PermissionCheckCommand"; +import { execCreateListCommand } from "./CreateBanListCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -32,9 +33,9 @@ export function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) { try { if (parts.length === 1 || parts[1] === 'status') { return execStatusCommand(roomId, event, mjolnir); - } else if (parts[1] === 'ban' && parts.length > 3) { + } else if (parts[1] === 'ban' && parts.length > 4) { return execBanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unban' && parts.length > 3) { + } else if (parts[1] === 'unban' && parts.length > 4) { return execUnbanCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'rules') { return execDumpRulesCommand(roomId, event, mjolnir); @@ -42,17 +43,20 @@ export function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) { return execSyncCommand(roomId, event, mjolnir); } else if (parts[1] === 'verify') { return execPermissionCheckCommand(roomId, event, mjolnir); + } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { + return execCreateListCommand(roomId, event, mjolnir, parts); } else { // Help menu const menu = "" + - "!mjolnir - Print status information\n" + - "!mjolnir status - Print status information\n" + - "!mjolnir ban [reason] - Adds an entity to the ban list\n" + - "!mjolnir unban - Removes an entity from the ban list\n" + - "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + - "!mjolnir sync - Force updates of all lists and re-apply rules\n" + - "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + - "!mjolnir help - This menu\n"; + "!mjolnir - Print status information\n" + + "!mjolnir status - Print status information\n" + + "!mjolnir ban [reason] - Adds an entity to the ban list\n" + + "!mjolnir unban - Removes an entity from the ban list\n" + + "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + + "!mjolnir sync - Force updates of all lists and re-apply rules\n" + + "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + + "!mjolnir list create - Creates a new ban list with the given shortcode and alias\n" + + "!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/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts new file mode 100644 index 0000000..cc969dc --- /dev/null +++ b/src/commands/CreateBanListCommand.ts @@ -0,0 +1,63 @@ +/* +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 { SHORTCODE_EVENT_TYPE } from "../models/BanList"; +import { Permalinks, RichReply } from "matrix-bot-sdk"; + +// !mjolnir list create +export async function execCreateListCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const shortcode = parts[3]; + const aliasLocalpart = parts[4]; + + const powerLevels = { + "ban": 50, + "events": { + "m.room.name": 100, + "m.room.power_levels": 100, + }, + "events_default": 50, // non-default + "invite": 0, + "kick": 50, + "notifications": { + "room": 20, + }, + "redact": 50, + "state_default": 50, + "users": { + // populated in a moment + }, + "users_default": 0, + }; + powerLevels['users'][await mjolnir.client.getUserId()] = 100; + powerLevels['users'][event['sender']] = 50; + + const listRoomId = await mjolnir.client.createRoom({ + preset: "public_chat", + room_alias_name: aliasLocalpart, + invite: [event['sender']], + initial_state: [{type: SHORTCODE_EVENT_TYPE, state_key: "", content: {shortcode: shortcode}}], + power_level_content_override: powerLevels, + }); + + const roomRef = Permalinks.forRoom(listRoomId); + + const html = `Created new list (${listRoomId}). It is not tracked - you will have to add this to the Mjolnir config to use it.`; + const text = `Created new list (${roomRef}). It is not tracked - you will have to add this to the Mjolnir config to use it.`; + const reply = RichReply.createFor(roomId, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index ea50157..68eb704 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -19,10 +19,11 @@ import { RULE_ROOM, RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/B 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(' ') || ""; +function parseBits(parts: string[]): { listShortcode: string, entityType: string, ruleType: string, glob: string, reason: string } { + const shortcode = parts[2].toLowerCase(); + const entityType = parts[3].toLowerCase(); + const glob = parts[4]; + const reason = parts.slice(5).join(' ') || ""; let rule = null; if (entityType === "user") { @@ -33,11 +34,11 @@ function parseBits(parts: string[]): { entityType: string, ruleType: string, glo rule = RULE_SERVER; } if (!rule) { - return {entityType, ruleType: null, glob, reason}; + return {listShortcode: shortcode, entityType, ruleType: null, glob, reason}; } rule = ruleTypeToStable(rule); - return {entityType, ruleType: rule, glob, reason}; + return {listShortcode: shortcode, entityType, ruleType: rule, glob, reason}; } // !mjolnir ban [reason] @@ -58,7 +59,15 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni }; const stateKey = `rule:${bits.glob}`; - await mjolnir.client.sendStateEvent(mjolnir.publishedBanListRoomId, bits.ruleType, stateKey, ruleContent); + const list = mjolnir.banLists.find(b => b.listShortcode === bits.listShortcode); + if (!list) { + const replyText = "No ban list with that shortcode was found."; + const reply = RichReply.createFor(roomId, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + return mjolnir.client.sendMessage(roomId, reply); + } + + await mjolnir.client.sendStateEvent(list.roomId, bits.ruleType, stateKey, ruleContent); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } @@ -75,6 +84,14 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol const ruleContent = {}; // empty == clear/unban const stateKey = `rule:${bits.glob}`; - await mjolnir.client.sendStateEvent(mjolnir.publishedBanListRoomId, bits.ruleType, stateKey, ruleContent); + const list = mjolnir.banLists.find(b => b.listShortcode === bits.listShortcode); + if (!list) { + const replyText = "No ban list with that shortcode was found."; + const reply = RichReply.createFor(roomId, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + return mjolnir.client.sendMessage(roomId, reply); + } + + await mjolnir.client.sendStateEvent(list.roomId, bits.ruleType, stateKey, ruleContent); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/config.ts b/src/config.ts index 4d11170..c38b19f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,7 +30,6 @@ interface IConfig { verboseLogging: boolean; syncOnStartup: boolean; verifyPermissionsOnStartup: boolean; - publishedBanListRoom: string; protectedRooms: string[]; // matrix.to urls banLists: string[]; // matrix.to urls } diff --git a/src/index.ts b/src/index.ts index 438dca2..36697bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,17 +77,11 @@ LogService.setLogger(new RichConsoleLogger()); 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, banListRoomId, protectedRooms, banLists); + const bot = new Mjolnir(client, managementRoomId, protectedRooms, banLists); await bot.start(); LogService.info("index", "Bot started!") diff --git a/src/models/BanList.ts b/src/models/BanList.ts index b34bafc..cc72ea6 100644 --- a/src/models/BanList.ts +++ b/src/models/BanList.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-bot-sdk"; +import { LogService, MatrixClient } from "matrix-bot-sdk"; import { ListRule } from "./ListRule"; export const RULE_USER = "m.room.rule.user"; @@ -26,6 +26,8 @@ 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 const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode"; + 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; @@ -35,10 +37,24 @@ export function ruleTypeToStable(rule: string, unstable = true): string { export default class BanList { private rules: ListRule[] = []; + private shortcode: string = null; constructor(public readonly roomId: string, public readonly roomRef, private client: MatrixClient) { } + public get listShortcode(): string { + return this.shortcode; + } + + public set listShortcode(newShortcode: string) { + const currentShortcode = this.shortcode; + this.shortcode = newShortcode; + this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', {shortcode: this.shortcode}).catch(err => { + LogService.error("BanList", err); + if (this.shortcode === newShortcode) this.shortcode = currentShortcode; + }); + } + public get serverRules(): ListRule[] { return this.rules.filter(r => r.kind === RULE_SERVER); } @@ -56,6 +72,11 @@ export default class BanList { const state = await this.client.getRoomState(this.roomId); for (const event of state) { + if (event['state_key'] === '' && event['type'] === SHORTCODE_EVENT_TYPE) { + this.shortcode = (event['content'] || {})['shortcode'] || null; + continue; + } + if (event['state_key'] === '' || !ALL_RULE_TYPES.includes(event['type'])) { continue; }