mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Add a protection system with a "first message is an image" demo
This commit is contained in:
parent
4f8b55c45f
commit
d5f260b982
@ -23,6 +23,8 @@ import { applyUserBans } from "./actions/ApplyBan";
|
||||
import config from "./config";
|
||||
import { logMessage } from "./LogProxy";
|
||||
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
|
||||
import { IProtection } from "./protections/IProtection";
|
||||
import { PROTECTIONS } from "./protections/protections";
|
||||
|
||||
export const STATE_NOT_STARTED = "not_started";
|
||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||
@ -30,12 +32,14 @@ export const STATE_SYNCING = "syncing";
|
||||
export const STATE_RUNNING = "running";
|
||||
|
||||
const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
|
||||
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
|
||||
|
||||
export class Mjolnir {
|
||||
|
||||
private displayName: string;
|
||||
private localpart: string;
|
||||
private currentState: string = STATE_NOT_STARTED;
|
||||
private protections: IProtection[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly client: MatrixClient,
|
||||
@ -81,6 +85,10 @@ export class Mjolnir {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
public get enabledProtections(): IProtection[] {
|
||||
return this.protections;
|
||||
}
|
||||
|
||||
public start() {
|
||||
return this.client.start().then(async () => {
|
||||
this.currentState = STATE_CHECKING_PERMISSIONS;
|
||||
@ -94,6 +102,7 @@ export class Mjolnir {
|
||||
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.buildWatchedBanLists();
|
||||
await this.syncLists(config.verboseLogging);
|
||||
await this.enableProtections();
|
||||
}
|
||||
}).then(async () => {
|
||||
this.currentState = STATE_RUNNING;
|
||||
@ -101,6 +110,53 @@ export class Mjolnir {
|
||||
});
|
||||
}
|
||||
|
||||
private async getEnabledProtections() {
|
||||
let enabled: string[] = [];
|
||||
try {
|
||||
const protections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
|
||||
if (protections && protections['enabled']) {
|
||||
for (const protection of protections['enabled']) {
|
||||
enabled.push(protection);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", e);
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private async enableProtections() {
|
||||
for (const protection of await this.getEnabledProtections()) {
|
||||
try {
|
||||
this.enableProtection(protection, false);
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async enableProtection(protectionName: string, persist = true): Promise<any> {
|
||||
const definition = PROTECTIONS[protectionName];
|
||||
if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
|
||||
|
||||
const protection = definition.factory();
|
||||
this.protections.push(protection);
|
||||
|
||||
if (persist) {
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
|
||||
}
|
||||
}
|
||||
|
||||
public async disableProtection(protectionName: string): Promise<any> {
|
||||
const idx = this.protections.findIndex(p => p.name === protectionName);
|
||||
if (idx >= 0) this.protections.splice(idx, 1);
|
||||
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
|
||||
}
|
||||
|
||||
public async watchList(roomRef: string): Promise<BanList> {
|
||||
const joinedRooms = await this.client.getJoinedRooms();
|
||||
const permalink = Permalinks.parseUrl(roomRef);
|
||||
@ -325,6 +381,18 @@ export class Mjolnir {
|
||||
|
||||
if (Object.keys(this.protectedRooms).includes(roomId)) {
|
||||
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves
|
||||
|
||||
// Iterate all the protections
|
||||
for (const protection of this.protections) {
|
||||
try {
|
||||
await protection.handleEvent(this, roomId, event);
|
||||
} catch (e) {
|
||||
LogService.error("Mjolnir", "Error handling protection: " + protection.name);
|
||||
LogService.error("Mjolnir", e);
|
||||
await this.client.sendNotice(config.managementRoom, "There was an error processing an event through a protection - see log for details.");
|
||||
}
|
||||
}
|
||||
|
||||
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
|
||||
// power levels were updated - recheck permissions
|
||||
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
|
||||
|
@ -28,6 +28,7 @@ import { execRedactCommand } from "./RedactCommand";
|
||||
import { execImportCommand } from "./ImportCommand";
|
||||
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
|
||||
import { execDeactivateCommand } from "./DeactivateCommand";
|
||||
import { execDisableProtection, execEnableProtection, execListProtections } from "./ProtectionsCommands";
|
||||
|
||||
export const COMMAND_PREFIX = "!mjolnir";
|
||||
|
||||
@ -62,6 +63,12 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
|
||||
return await execSetDefaultListCommand(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'deactivate' && parts.length > 2) {
|
||||
return await execDeactivateCommand(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'protections') {
|
||||
return await execListProtections(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'enable' && parts.length > 1) {
|
||||
return await execEnableProtection(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'disable' && parts.length > 1) {
|
||||
return await execDisableProtection(roomId, event, mjolnir, parts);
|
||||
} else {
|
||||
// Help menu
|
||||
const menu = "" +
|
||||
@ -79,6 +86,9 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
|
||||
"!mjolnir import <room alias/ID> <list shortcode> - Imports bans and ACLs into the given list\n" +
|
||||
"!mjolnir default <shortcode> - Sets the default list for commands\n" +
|
||||
"!mjolnir deactivate <user ID> - Deactivates a user ID\n" +
|
||||
"!mjolnir protections - List all available protections\n" +
|
||||
"!mjolnir enable <protection> - Enables a particular protection\n" +
|
||||
"!mjolnir disable <protection> - Disables a particular protection\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}`;
|
||||
|
61
src/commands/ProtectionsCommands.ts
Normal file
61
src/commands/ProtectionsCommands.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { LogService, RichReply } from "matrix-bot-sdk";
|
||||
import { PROTECTIONS } from "../protections/protections";
|
||||
|
||||
// !mjolnir enable <protection>
|
||||
export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
try {
|
||||
await mjolnir.enableProtection(parts[2]);
|
||||
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
|
||||
} catch (e) {
|
||||
LogService.error("ProtectionsCommands", e);
|
||||
|
||||
const message = `Error enabling protection '${parts[0]}' - check the name and try again.`;
|
||||
const reply = RichReply.createFor(roomId, event, message, message);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
||||
}
|
||||
|
||||
// !mjolnir disable <protection>
|
||||
export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
await mjolnir.disableProtection(parts[2]);
|
||||
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
|
||||
}
|
||||
|
||||
// !mjolnir protections
|
||||
export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
const possibleProtections = Object.keys(PROTECTIONS);
|
||||
const enabledProtections = mjolnir.enabledProtections.map(p => p.name);
|
||||
|
||||
let html = "Available protections:<ul>";
|
||||
let text = "Available protections:\n";
|
||||
|
||||
for (const protection of possibleProtections) {
|
||||
const emoji = enabledProtections.includes(protection) ? '🟢' : '🔴'; // green vs red circles
|
||||
html += `<li>${emoji} <code>${protection}</code> - ${PROTECTIONS[protection].description}</li>`;
|
||||
text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`;
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
|
||||
const reply = RichReply.createFor(roomId, event, text, html);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
70
src/protections/FirstMessageIsImage.ts
Normal file
70
src/protections/FirstMessageIsImage.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
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 { IProtection } from "./IProtection";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
|
||||
export class FirstMessageIsImage implements IProtection {
|
||||
|
||||
public justJoined: { [roomId: string]: string[] } = {};
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'FirstMessageIsImageProtection';
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
if (!this.justJoined[roomId]) this.justJoined[roomId] = [];
|
||||
|
||||
if (event['type'] === 'm.room.member') {
|
||||
const membership = event['content']['membership'] || 'join';
|
||||
let prevMembership = "leave";
|
||||
if (event['unsigned'] && event['unsigned']['prev_content']) {
|
||||
prevMembership = event['unsigned']['prev_content']['membership'] || 'leave';
|
||||
}
|
||||
|
||||
// We look at the previous membership to filter out profile changes
|
||||
if (membership === 'join' && prevMembership !== "join") {
|
||||
this.justJoined[roomId].push(event['state_key']);
|
||||
LogService.info("FirstMessageIsImage", `Tracking ${event['state_key']} in ${roomId} as just joined`);
|
||||
}
|
||||
|
||||
return; // stop processing (membership event spam is another problem)
|
||||
}
|
||||
|
||||
if (event['type'] === 'm.room.message') {
|
||||
const content = event['content'] || {};
|
||||
const msgtype = content['msgtype'] || 'm.text';
|
||||
const formattedBody = content['formatted_body'] || '';
|
||||
const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img');
|
||||
if (isMedia && this.justJoined[roomId].includes(event['sender'])) {
|
||||
await logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining.`);
|
||||
await mjolnir.client.banUser(event['sender'], roomId, "spam");
|
||||
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
|
||||
}
|
||||
}
|
||||
|
||||
const idx = this.justJoined[roomId].indexOf(event['sender']);
|
||||
if (idx >= 0) {
|
||||
LogService.info("FirstMessageIsImage", `${event['sender']} is no longer considered suspect`);
|
||||
this.justJoined[roomId].splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
26
src/protections/IProtection.ts
Normal file
26
src/protections/IProtection.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
/**
|
||||
* Represents a protection mechanism of sorts. Protections are intended to be
|
||||
* event-based (ie: X messages in a period of time, or posting X events)
|
||||
*/
|
||||
export interface IProtection {
|
||||
readonly name: string;
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
|
||||
}
|
33
src/protections/protections.ts
Normal file
33
src/protections/protections.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 { FirstMessageIsImage } from "./FirstMessageIsImage";
|
||||
import { IProtection } from "./IProtection";
|
||||
|
||||
export const PROTECTIONS: PossibleProtections = {
|
||||
[new FirstMessageIsImage().name]: {
|
||||
description: "If the first thing a user does after joining is to post an image or video, " +
|
||||
"they'll be banned for spam. This does not publish the ban to any of your ban lists.",
|
||||
factory: () => new FirstMessageIsImage(),
|
||||
},
|
||||
};
|
||||
|
||||
export interface PossibleProtections {
|
||||
[name: string]: {
|
||||
description: string;
|
||||
factory: () => IProtection;
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user