Support multiple ban lists

This commit is contained in:
Travis Ralston 2019-10-08 17:57:03 +01:00
parent 56bd7c9500
commit aecc6e4882
9 changed files with 126 additions and 35 deletions

View File

@ -20,7 +20,7 @@ Phase 2:
* [ ] Command to import ACLs, etc from rooms * [ ] Command to import ACLs, etc from rooms
* [x] Vet rooms on startup option * [x] Vet rooms on startup option
* [ ] Command to actually unban users (instead of leaving them stuck) * [ ] Command to actually unban users (instead of leaving them stuck)
* [ ] Support multiple lists * [x] Support multiple lists
Phase 3: Phase 3:
* [ ] Synapse antispam module * [ ] Synapse antispam module

View File

@ -42,12 +42,6 @@ syncOnStartup: true
# resets, etc) before Mjolnir is needed. # resets, etc) before Mjolnir is needed.
verifyPermissionsOnStartup: true 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) # A list of rooms to protect (matrix.to URLs)
protectedRooms: protectedRooms:
- "https://matrix.to/#/#yourroom:example.org" - "https://matrix.to/#/#yourroom:example.org"

View File

@ -36,7 +36,6 @@ export class Mjolnir {
constructor( constructor(
public readonly client: MatrixClient, public readonly client: MatrixClient,
public readonly managementRoomId: string, public readonly managementRoomId: string,
public readonly publishedBanListRoomId: string,
public readonly protectedRooms: { [roomId: string]: string }, public readonly protectedRooms: { [roomId: string]: string },
public readonly banLists: BanList[], public readonly banLists: BanList[],
) { ) {

View File

@ -22,6 +22,7 @@ import { LogService, RichReply } from "matrix-bot-sdk";
import * as htmlEscape from "escape-html"; import * as htmlEscape from "escape-html";
import { execSyncCommand } from "./SyncCommand"; import { execSyncCommand } from "./SyncCommand";
import { execPermissionCheckCommand } from "./PermissionCheckCommand"; import { execPermissionCheckCommand } from "./PermissionCheckCommand";
import { execCreateListCommand } from "./CreateBanListCommand";
export const COMMAND_PREFIX = "!mjolnir"; export const COMMAND_PREFIX = "!mjolnir";
@ -32,9 +33,9 @@ export function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) {
try { try {
if (parts.length === 1 || parts[1] === 'status') { if (parts.length === 1 || parts[1] === 'status') {
return execStatusCommand(roomId, event, mjolnir); 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); 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); return execUnbanCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rules') { } else if (parts[1] === 'rules') {
return execDumpRulesCommand(roomId, event, mjolnir); return execDumpRulesCommand(roomId, event, mjolnir);
@ -42,17 +43,20 @@ export function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) {
return execSyncCommand(roomId, event, mjolnir); return execSyncCommand(roomId, event, mjolnir);
} else if (parts[1] === 'verify') { } else if (parts[1] === 'verify') {
return execPermissionCheckCommand(roomId, event, mjolnir); return execPermissionCheckCommand(roomId, event, mjolnir);
} else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') {
return execCreateListCommand(roomId, event, mjolnir, parts);
} else { } else {
// Help menu // Help menu
const menu = "" + const menu = "" +
"!mjolnir - Print status information\n" + "!mjolnir - Print status information\n" +
"!mjolnir status - Print status information\n" + "!mjolnir status - Print status information\n" +
"!mjolnir ban <user|room|server> <glob> [reason] - Adds an entity to the ban list\n" + "!mjolnir ban <list_shortcode> <user|room|server> <glob> [reason] - Adds an entity to the ban list\n" +
"!mjolnir unban <user|room|server> <glob> - Removes an entity from the ban list\n" + "!mjolnir unban <list_shortcode> <user|room|server> <glob> - Removes an entity from the ban list\n" +
"!mjolnir rules - Lists the rules currently in use by Mjolnir\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 sync - Force updates of all lists and re-apply rules\n" +
"!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" +
"!mjolnir help - This menu\n"; "!mjolnir list create <shortcode> <alias_localpart> - Creates a new ban list with the given shortcode and alias\n" +
"!mjolnir help - This menu\n";
const html = `<b>Mjolnir help:</b><br><pre><code>${htmlEscape(menu)}</code></pre>`; const html = `<b>Mjolnir help:</b><br><pre><code>${htmlEscape(menu)}</code></pre>`;
const text = `Mjolnir help:\n${menu}`; const text = `Mjolnir help:\n${menu}`;
const reply = RichReply.createFor(roomId, event, text, html); const reply = RichReply.createFor(roomId, event, text, html);

View File

@ -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 <shortcode> <alias localpart>
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 (<a href="${roomRef}">${listRoomId}</a>). 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);
}

View File

@ -19,10 +19,11 @@ import { RULE_ROOM, RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/B
import { RichReply } from "matrix-bot-sdk"; import { RichReply } from "matrix-bot-sdk";
import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule";
function parseBits(parts: string[]): { entityType: string, ruleType: string, glob: string, reason: string } { function parseBits(parts: string[]): { listShortcode: string, entityType: string, ruleType: string, glob: string, reason: string } {
const entityType = parts[2].toLowerCase(); const shortcode = parts[2].toLowerCase();
const glob = parts[3]; const entityType = parts[3].toLowerCase();
const reason = parts.slice(4).join(' ') || "<no reason>"; const glob = parts[4];
const reason = parts.slice(5).join(' ') || "<no reason>";
let rule = null; let rule = null;
if (entityType === "user") { if (entityType === "user") {
@ -33,11 +34,11 @@ function parseBits(parts: string[]): { entityType: string, ruleType: string, glo
rule = RULE_SERVER; rule = RULE_SERVER;
} }
if (!rule) { if (!rule) {
return {entityType, ruleType: null, glob, reason}; return {listShortcode: shortcode, entityType, ruleType: null, glob, reason};
} }
rule = ruleTypeToStable(rule); rule = ruleTypeToStable(rule);
return {entityType, ruleType: rule, glob, reason}; return {listShortcode: shortcode, entityType, ruleType: rule, glob, reason};
} }
// !mjolnir ban <user|server|room> <glob> [reason] // !mjolnir ban <user|server|room> <glob> [reason]
@ -58,7 +59,15 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni
}; };
const stateKey = `rule:${bits.glob}`; 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'], '✅'); 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 ruleContent = {}; // empty == clear/unban
const stateKey = `rule:${bits.glob}`; 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'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
} }

View File

@ -30,7 +30,6 @@ interface IConfig {
verboseLogging: boolean; verboseLogging: boolean;
syncOnStartup: boolean; syncOnStartup: boolean;
verifyPermissionsOnStartup: boolean; verifyPermissionsOnStartup: boolean;
publishedBanListRoom: string;
protectedRooms: string[]; // matrix.to urls protectedRooms: string[]; // matrix.to urls
banLists: string[]; // matrix.to urls banLists: string[]; // matrix.to urls
} }

View File

@ -77,17 +77,11 @@ LogService.setLogger(new RichConsoleLogger());
protectedRooms[roomId] = roomRef; 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 // Ensure we're also in the management room
const managementRoomId = await client.joinRoom(config.managementRoom); const managementRoomId = await client.joinRoom(config.managementRoom);
await client.sendNotice(managementRoomId, "Mjolnir is starting up. Use !mjolnir to query status."); 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(); await bot.start();
LogService.info("index", "Bot started!") LogService.info("index", "Bot started!")

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient } from "matrix-bot-sdk"; import { LogService, MatrixClient } from "matrix-bot-sdk";
import { ListRule } from "./ListRule"; import { ListRule } from "./ListRule";
export const RULE_USER = "m.room.rule.user"; 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 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 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 { 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 (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 (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 { export default class BanList {
private rules: ListRule[] = []; private rules: ListRule[] = [];
private shortcode: string = null;
constructor(public readonly roomId: string, public readonly roomRef, private client: MatrixClient) { 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[] { public get serverRules(): ListRule[] {
return this.rules.filter(r => r.kind === RULE_SERVER); 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); const state = await this.client.getRoomState(this.roomId);
for (const event of state) { 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'])) { if (event['state_key'] === '' || !ALL_RULE_TYPES.includes(event['type'])) {
continue; continue;
} }