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
* [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

View File

@ -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"

View File

@ -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[],
) {

View File

@ -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,16 +43,19 @@ 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 <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 ban <list_shortcode> <user|room|server> <glob> [reason] - Adds an entity to 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 sync - Force updates of all lists and re-apply rules\n" +
"!mjolnir verify - Ensures Mjolnir can moderate all your rooms\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 text = `Mjolnir help:\n${menu}`;

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 { 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(' ') || "<no reason>";
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(' ') || "<no reason>";
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 <user|server|room> <glob> [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'], '✅');
}

View File

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

View File

@ -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!")

View File

@ -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;
}