diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 82b3e3b..3015b70 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -36,6 +36,7 @@ import { logMessage } from "./LogProxy"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import { IProtection } from "./protections/IProtection"; import { PROTECTIONS } from "./protections/protections"; +import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { Healthz } from "./health/healthz"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; @@ -401,6 +402,78 @@ export class Mjolnir { } } + /* + * Read org.matrix.mjolnir.setting state event, find any saved settings for + * the requested protectionName, then iterate and validate against their parser + * counterparts in IProtection.settings and return those which validate + * + * @param protectionName The name of the protection whose settings we're reading + * @returns Every saved setting for this protectionName that has a valid value + */ + public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { + let savedSettings: { [setting: string]: any } = {} + try { + savedSettings = await this.client.getRoomStateEvent( + this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName + ); + } catch { + // setting does not exist, return empty object + return savedSettings; + } + + const settingDefinitions = PROTECTIONS[protectionName].factory().settings; + const validatedSettings: { [setting: string]: any } = {} + for (let [key, value] of Object.entries(savedSettings)) { + if ( + // is this a setting name with a known parser? + key in settingDefinitions + // is the datatype of this setting's value what we expect? + && typeof(settingDefinitions[key].value) === typeof(value) + // is this setting's value valid for the setting? + && settingDefinitions[key].validate(value) + ) { + validatedSettings[key] = value; + } else { + await logMessage( + LogLevel.WARN, + "getProtectionSetting", + `Tried to read ${protectionName}.${key} and got invalid value ${value}` + ); + } + } + return validatedSettings; + } + + /* + * Takes an object of settings we want to change and what their values should be, + * check that their values are valid, combine them with current saved settings, + * then save the amalgamation to a state event + * + * @param protectionName Which protection these settings belong to + * @param changedSettings The settings to change and their values + */ + public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { + const settingDefinitions = PROTECTIONS[protectionName].factory().settings; + const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); + + for (let [key, value] of Object.entries(changedSettings)) { + if (!(key in settingDefinitions)) { + throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); + } + if (typeof(settingDefinitions[key].value) !== typeof(value)) { + throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`); + } + if (!settingDefinitions[key].validate(value)) { + throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); + } + validatedSettings[key] = value; + } + + await this.client.sendStateEvent( + this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings + ); + } + public async enableProtection(protectionName: string, persist = true): Promise { const definition = PROTECTIONS[protectionName]; if (!definition) throw new Error("Failed to find protection by name: " + protectionName); @@ -408,6 +481,12 @@ export class Mjolnir { const protection = definition.factory(); this.protections.push(protection); + const savedSettings = await this.getProtectionSettings(protectionName); + for (let [key, value] of Object.entries(savedSettings)) { + // this.getProtectionSettings() validates this data for us, so we don't need to + protection.settings[key].setValue(value); + } + if (persist) { const existing = this.protections.map(p => p.name); await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing }); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index d26005f..97bde07 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -28,7 +28,8 @@ import { execRedactCommand } from "./RedactCommand"; import { execImportCommand } from "./ImportCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execDeactivateCommand } from "./DeactivateCommand"; -import { execDisableProtection, execEnableProtection, execListProtections } from "./ProtectionsCommands"; +import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, + execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands"; import { execListProtectedRooms } from "./ListProtectedRoomsCommand"; import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand"; import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand"; @@ -76,6 +77,14 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir return await execEnableProtection(roomId, event, mjolnir, parts); } else if (parts[1] === 'disable' && parts.length > 1) { return await execDisableProtection(roomId, event, mjolnir, parts); + } else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) { + return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)) + } else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) { + return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)) + } else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) { + return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)) + } else if (parts[1] === 'config' && parts[2] === 'get') { + return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') { return await execAddProtectedRoom(roomId, event, mjolnir, parts); } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') { @@ -122,6 +131,10 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir "!mjolnir protections - List all available protections\n" + "!mjolnir enable - Enables a particular protection\n" + "!mjolnir disable - Disables a particular protection\n" + + "!mjolnir config set . [value] - Change a projection setting\n" + + "!mjolnir config add . [value] - Add a value to a list protection setting\n" + + "!mjolnir config remove . [value] - Remove a value from a list protection setting\n" + + "!mjolnir config get [protection] - List protection settings\n" + "!mjolnir rooms - Lists all the protected rooms\n" + "!mjolnir rooms add - Adds a protected room (may cause high server load)\n" + "!mjolnir rooms remove - Removes a protected room\n" + diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts index 15f2f58..601f2fb 100644 --- a/src/commands/ProtectionsCommands.ts +++ b/src/commands/ProtectionsCommands.ts @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +import * as htmlEscape from "escape-html"; import { Mjolnir } from "../Mjolnir"; import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; import { PROTECTIONS } from "../protections/protections"; +import { isListSetting } from "../protections/ProtectionSettings"; // !mjolnir enable export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { @@ -33,6 +35,177 @@ export async function execEnableProtection(roomId: string, event: any, mjolnir: } } +enum ConfigAction { + Set, + Add, + Remove +} + +/* + * Process a given ConfigAction against a given protection setting + * + * @param mjolnir Current Mjolnir instance + * @param parts Arguments given to the command being processed + * @param action Which ConfigAction to do to the provided protection setting + * @returns Command success or failure message + */ +async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise { + const [protectionName, ...settingParts] = parts[0].split("."); + const protection = PROTECTIONS[protectionName]; + if (protection === undefined) { + return `Unknown protection ${protectionName}`; + } + + const defaultSettings = protection.factory().settings + const settingName = settingParts[0]; + const stringValue = parts[1]; + + if (!(settingName in defaultSettings)) { + return `Unknown setting ${settingName}`; + } + + const parser = defaultSettings[settingName]; + // we don't need to validate `value`, because mjolnir.setProtectionSettings does + // it for us (and raises an exception if there's a problem) + let value = parser.fromString(stringValue); + + if (action === ConfigAction.Add) { + if (!isListSetting(parser)) { + return `Setting ${settingName} isn't a list`; + } else { + value = parser.addValue(value); + } + } else if (action === ConfigAction.Remove) { + if (!isListSetting(parser)) { + return `Setting ${settingName} isn't a list`; + } else { + value = parser.removeValue(value); + } + } + + // we need this to show what the value used to be + const oldSettings = await mjolnir.getProtectionSettings(protectionName); + + try { + await mjolnir.setProtectionSettings(protectionName, { [settingName]: value }); + } catch (e) { + return `Failed to set setting: ${e.message}`; + } + + const enabledProtections = Object.fromEntries(mjolnir.enabledProtections.map(p => [p.name, p])); + if (protectionName in enabledProtections) { + // protection is currently loaded, so change the live setting value + enabledProtections[protectionName].settings[settingName].setValue(value); + } + + return `Changed ${protectionName}.${settingName} to ${value} (was ${oldSettings[settingName]})`; +} + +/* + * Change a protection setting + * + * !mjolnir set . + */ +export async function execConfigSetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Set); + + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} + +/* + * Add a value to a protection list setting + * + * !mjolnir add . + */ +export async function execConfigAddProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Add); + + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} + +/* + * Remove a value from a protection list setting + * + * !mjolnir remove . + */ +export async function execConfigRemoveProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Remove); + + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} + +/* + * Get all protection settings or get all settings for a given protection + * + * !mjolnir get [protection name] + */ +export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + let pickProtections = Object.keys(PROTECTIONS); + + if (parts.length < 3) { + // no specific protectionName provided, show all of them. + + // sort output by protection name + pickProtections.sort(); + } else { + if (!pickProtections.includes(parts[0])) { + const errMsg = `Unknown protection: ${parts[0]}`; + const errReply = RichReply.createFor(roomId, event, errMsg, errMsg); + errReply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, errReply); + return; + } + pickProtections = [parts[0]]; + } + + let text = "Protection settings\n"; + let html = "Protection settings
    "; + + let anySettings = false; + + for (const protectionName of pickProtections) { + // get all available settings, their default values, and their parsers + const availableSettings = PROTECTIONS[protectionName].factory().settings; + // get all saved non-default values + const savedSettings = await mjolnir.getProtectionSettings(protectionName); + + if (Object.keys(availableSettings).length === 0) continue; + + const settingNames = Object.keys(PROTECTIONS[protectionName].factory().settings); + // this means, within each protection name, setting names are sorted + settingNames.sort(); + for (const settingName of settingNames) { + anySettings = true; + + let value = availableSettings[settingName].value + if (settingName in savedSettings) + // we have a non-default value for this setting, use it + value = savedSettings[settingName] + + text += `* ${protectionName}.${settingName}: ${value}`; + // `protectionName` and `settingName` are user-provided but + // validated against the names of existing protections and their + // settings, so XSS is avoided for these already + html += `
  • ${protectionName}.${settingName}: ${htmlEscape(value)}
  • ` + } + } + + html += "
"; + + if (!anySettings) + html = text = "No settings found"; + + const reply = RichReply.createFor(roomId, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} + // !mjolnir disable export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { await mjolnir.disableProtection(parts[2]); diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 017b568..3f9cb3f 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -15,12 +15,14 @@ limitations under the License. */ import { IProtection } from "./IProtection"; +import { NumberProtectionSetting } from "./ProtectionSettings"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; import { logMessage } from "../LogProxy"; import config from "../config"; -export const MAX_PER_MINUTE = 10; // if this is exceeded, we'll ban the user for spam and redact their messages +// if this is exceeded, we'll ban the user for spam and redact their messages +export const DEFAULT_MAX_PER_MINUTE = 10; const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase export class BasicFlooding implements IProtection { @@ -28,7 +30,11 @@ export class BasicFlooding implements IProtection { private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {}; private recentlyBanned: string[] = []; + maxPerMinute = new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE); + settings = {}; + constructor() { + this.settings['maxPerMinute'] = this.maxPerMinute; } public get name(): string { @@ -56,7 +62,7 @@ export class BasicFlooding implements IProtection { messageCount++; } - if (messageCount >= MAX_PER_MINUTE) { + if (messageCount >= this.maxPerMinute.value) { await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); if (!config.noop) { await mjolnir.client.banUser(event['sender'], roomId, "spam"); @@ -82,8 +88,8 @@ export class BasicFlooding implements IProtection { } // Trim the oldest messages off the user's history if it's getting large - if (forUser.length > MAX_PER_MINUTE * 2) { - forUser.splice(0, forUser.length - (MAX_PER_MINUTE * 2) - 1); + if (forUser.length > this.maxPerMinute.value * 2) { + forUser.splice(0, forUser.length - (this.maxPerMinute.value * 2) - 1); } } } diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 89a7865..6c526d9 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -26,6 +26,8 @@ export class FirstMessageIsImage implements IProtection { private justJoined: { [roomId: string]: string[] } = {}; private recentlyBanned: string[] = []; + settings = {}; + constructor() { } diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 4d7d261..61a9a05 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; +import { AbstractProtectionSetting } from "./ProtectionSettings"; /** * Represents a protection mechanism of sorts. Protections are intended to be @@ -24,5 +25,6 @@ import { Mjolnir } from "../Mjolnir"; */ export interface IProtection { readonly name: string; + settings: { [setting: string]: AbstractProtectionSetting }; handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; } diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index a26289e..7979445 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -22,6 +22,8 @@ import config from "../config"; export class MessageIsMedia implements IProtection { + settings = {}; + constructor() { } diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index 2bf6f81..867d84f 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -22,6 +22,8 @@ import config from "../config"; export class MessageIsVoice implements IProtection { + settings = {}; + constructor() { } diff --git a/src/protections/ProtectionSettings.ts b/src/protections/ProtectionSettings.ts new file mode 100644 index 0000000..3acde9f --- /dev/null +++ b/src/protections/ProtectionSettings.ts @@ -0,0 +1,120 @@ +/* +Copyright 2022 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. +*/ + +export class ProtectionSettingValidationError extends Error {}; + +export class AbstractProtectionSetting { + // the current value of this setting + value: TValue + + /* + * Deserialise a value for this setting type from a string + * + * @param data Serialised value + * @returns Deserialised value or undefined if deserialisation failed + */ + fromString(data: string): TChange | undefined { + throw new Error("not Implemented"); + } + + /* + * Check whether a given value is valid for this setting + * + * @param data Setting value + * @returns Validity of provided value + */ + validate(data: TChange): boolean { + throw new Error("not Implemented"); + } + + /* + * Store a value in this setting, only to be used after `validate()` + * @param data Validated setting value + */ + setValue(data: TValue) { + this.value = data; + } +} +export class AbstractProtectionListSetting extends AbstractProtectionSetting { + /* + * Add `data` to the current setting value, and return that new object + * + * @param data Value to add to the current setting value + * @returns The potential new value of this setting object + */ + addValue(data: TChange): TValue { + throw new Error("not Implemented"); + } + + /* + * Remove `data` from the current setting value, and return that new object + * + * @param data Value to remove from the current setting value + * @returns The potential new value of this setting object + */ + removeValue(data: TChange): TValue { + throw new Error("not Implemented"); + } +} +export function isListSetting(object: any): object is AbstractProtectionListSetting { + return object instanceof AbstractProtectionListSetting; +} + + +export class StringProtectionSetting extends AbstractProtectionSetting { + value = ""; + fromString = (data) => data; + validate = (data) => true; +} +export class StringListProtectionSetting extends AbstractProtectionListSetting { + value: string[] = []; + fromString = (data) => data; + validate = (data) => true; + addValue(data: string): string[] { + return [...this.value, data]; + } + removeValue(data: string): string[] { + const index = this.value.indexOf(data); + return this.value.splice(index, index + 1); + } +} + +export class NumberProtectionSetting extends AbstractProtectionSetting { + min: number|undefined; + max: number|undefined; + + constructor( + defaultValue: number, + min: number|undefined = undefined, + max: number|undefined = undefined + ) { + super(); + this.setValue(defaultValue); + this.min = min; + this.max = max; + } + + fromString(data) { + let number = Number(data); + return isNaN(number) ? undefined : number; + } + validate(data) { + return (!isNaN(data) + && (this.min === undefined || this.min <= data) + && (this.max === undefined || data <= this.max)) + } + +} diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 88015eb..f3caad6 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -23,6 +23,8 @@ import { isTrueJoinEvent } from "../utils"; export class WordList implements IProtection { + settings = {}; + private justJoined: { [roomId: string]: { [username: string]: Date} } = {}; private badWords: RegExp; diff --git a/src/protections/protections.ts b/src/protections/protections.ts index cbd7ee8..356bc9e 100644 --- a/src/protections/protections.ts +++ b/src/protections/protections.ts @@ -16,7 +16,7 @@ limitations under the License. import { FirstMessageIsImage } from "./FirstMessageIsImage"; import { IProtection } from "./IProtection"; -import { BasicFlooding, MAX_PER_MINUTE } from "./BasicFlooding"; +import { BasicFlooding, DEFAULT_MAX_PER_MINUTE } from "./BasicFlooding"; import { WordList } from "./WordList"; import { MessageIsVoice } from "./MessageIsVoice"; import { MessageIsMedia } from "./MessageIsMedia"; @@ -28,7 +28,7 @@ export const PROTECTIONS: PossibleProtections = { factory: () => new FirstMessageIsImage(), }, [new BasicFlooding().name]: { - description: "If a user posts more than " + MAX_PER_MINUTE + " messages in 60s they'll be " + + description: "If a user posts more than " + DEFAULT_MAX_PER_MINUTE + " messages in 60s they'll be " + "banned for spam. This does not publish the ban to any of your ban lists.", factory: () => new BasicFlooding(), }, diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts new file mode 100644 index 0000000..3b84511 --- /dev/null +++ b/test/integration/protectionSettingsTest.ts @@ -0,0 +1,152 @@ +import { strict as assert } from "assert"; + +import config from "../../src/config"; +import { PROTECTIONS } from "../../src/protections/protections"; +import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; +import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; +import { newTestUser, noticeListener } from "./clientHelper"; +import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; + +describe("Test: Protection settings", function() { + let client; + this.beforeEach(async function () { + client = await newTestUser(true); + await client.start(); + }) + this.afterEach(async function () { + await client.stop(); + }) + it("Mjolnir refuses to save invalid protection setting values", async function() { + this.timeout(20000); + await assert.rejects( + async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}), + ProtectionSettingValidationError + ); + }); + it("Mjolnir successfully saves valid protection setting values", async function() { + this.timeout(20000); + + PROTECTIONS["05OVMS"] = { + description: "A test protection", + factory: () => new class implements IProtection { + name = "05OVMS"; + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {}; + settings = { test: new NumberProtectionSetting(3) }; + } + }; + + await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 }); + assert.equal( + (await this.mjolnir.getProtectionSettings("05OVMS"))["test"], + 123 + ); + }); + it("Mjolnir should accumulate changed settings", async function() { + this.timeout(20000); + + PROTECTIONS["HPUjKN"] = { + description: "A test protection", + factory: () => new class implements IProtection { + name = "HPUjKN"; + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {}; + settings = { + test1: new NumberProtectionSetting(3), + test2: new NumberProtectionSetting(4) + }; + } + }; + + await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 }); + await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 }); + const settings = await this.mjolnir.getProtectionSettings("HPUjKN"); + assert.equal(settings["test1"], 1); + assert.equal(settings["test2"], 2); + }); + it("Mjolnir responds to !set correctly", async function() { + this.timeout(20000); + await client.joinRoom(config.managementRoom); + + PROTECTIONS["JY2TPN"] = { + description: "A test protection", + factory: () => new class implements IProtection { + name = "JY2TPN"; + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {}; + settings = { test: new StringProtectionSetting() }; + } + }; + + + let reply = new Promise((resolve, reject) => { + client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Changed JY2TPN.test ")) { + resolve(event); + } + })) + }); + + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"}) + await reply + + const settings = await this.mjolnir.getProtectionSettings("JY2TPN"); + assert.equal(settings["test"], "asd"); + }); + it("Mjolnir adds a value to a list setting", async function() { + this.timeout(20000); + await client.joinRoom(config.managementRoom); + + PROTECTIONS["r33XyT"] = { + description: "A test protection", + factory: () => new class implements IProtection { + name = "r33XyT"; + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {}; + settings = { test: new StringListProtectionSetting() }; + } + }; + + + let reply = new Promise((resolve, reject) => { + client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Changed r33XyT.test ")) { + resolve(event); + } + })) + }); + + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"}) + await reply + + assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] }); + }); + it("Mjolnir removes a value from a list setting", async function() { + this.timeout(20000); + await client.joinRoom(config.managementRoom); + + PROTECTIONS["oXzT0E"] = { + description: "A test protection", + factory: () => new class implements IProtection { + name = "oXzT0E"; + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {}; + settings = { test: new StringListProtectionSetting() }; + } + }; + + + let reply = new Promise((resolve, reject) => { + let i = 0; + client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Changed oXzT0E.test ")) { + if (++i == 2) { + resolve(event); + } + } + })) + }); + + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add oXzT0E.test asd"}) + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"}) + await reply + + assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] }); + }); +}); +