diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index a7a901f..efd919a 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -59,7 +59,7 @@ export class Mjolnir { private displayName: string; private localpart: string; private currentState: string = STATE_NOT_STARTED; - private protections: IProtection[] = []; + public protections = new Map(); /** * This is for users who are not listed on a watchlist, * but have been flagged by the automatic spam detection as suispicous @@ -237,7 +237,7 @@ export class Mjolnir { } public get enabledProtections(): IProtection[] { - return this.protections; + return [...this.protections.values()].filter(p => p.enabled); } /** @@ -293,7 +293,7 @@ export class Mjolnir { if (config.syncOnStartup) { await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.syncLists(config.verboseLogging); - await this.enableProtections(); + await this.registerProtections(); } this.currentState = STATE_RUNNING; @@ -375,32 +375,52 @@ export class Mjolnir { } } - private async getEnabledProtections() { - let enabled: string[] = []; - try { - const protections: { enabled: string[] } | null = 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", extractRequestError(e)); - } - - return enabled; - } - - private async enableProtections() { - for (const protection of await this.getEnabledProtections()) { + /* + * Take all the builtin protections, register them to set their enabled (or not) state and + * update their settings with any saved non-default values + */ + private async registerProtections() { + for (const protection of PROTECTIONS) { try { - this.enableProtection(protection, false); + await this.registerProtection(protection); } catch (e) { LogService.warn("Mjolnir", extractRequestError(e)); } } } + /* + * Make a list of the names of enabled protections and save them in a state event + */ + private async saveEnabledProtections() { + const protections = this.enabledProtections.map(p => p.name); + await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); + } + /* + * Enable a protection by name and persist its enable state in to a state event + * + * @param name The name of the protection whose settings we're enabling + */ + public async enableProtection(name: string) { + const protection = this.protections.get(name); + if (protection !== undefined) { + protection.enabled = true; + await this.saveEnabledProtections(); + } + } + /* + * Disable a protection by name and remove it from the persistent list of enabled protections + * + * @param name The name of the protection whose settings we're disabling + */ + public async disableProtection(name: string) { + const protection = this.protections.get(name); + if (protection !== undefined) { + protection.enabled = false; + await this.saveEnabledProtections(); + } + } + /* * Read org.matrix.mjolnir.setting state event, find any saved settings for * the requested protectionName, then iterate and validate against their parser @@ -417,10 +437,10 @@ export class Mjolnir { ); } catch { // setting does not exist, return empty object - return savedSettings; + return {}; } - const settingDefinitions = PROTECTIONS[protectionName].factory().settings; + const settingDefinitions = this.protections.get(protectionName)?.settings ?? {}; const validatedSettings: { [setting: string]: any } = {} for (let [key, value] of Object.entries(savedSettings)) { if ( @@ -452,17 +472,21 @@ export class Mjolnir { * @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 protection = this.protections.get(protectionName); + if (protection === undefined) { + return; + } + const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); for (let [key, value] of Object.entries(changedSettings)) { - if (!(key in settingDefinitions)) { + if (!(key in protection.settings)) { throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); } - if (typeof(settingDefinitions[key].value) !== typeof(value)) { + if (typeof(protection.settings[key].value) !== typeof(value)) { throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`); } - if (!settingDefinitions[key].validate(value)) { + if (!protection.settings[key].validate(value)) { throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); } validatedSettings[key] = value; @@ -473,31 +497,40 @@ export class Mjolnir { ); } - public async enableProtection(protectionName: string, persist = true): Promise { - const definition = PROTECTIONS[protectionName]; - if (!definition) throw new Error("Failed to find protection by name: " + protectionName); + /* + * Given a protection object; add it to our list of protections, set whether it is enabled + * and update its settings with any saved non-default values. + * + * @param protection The protection object we want to register + */ + public async registerProtection(protection: IProtection): Promise { + this.protections.set(protection.name, protection) - const protection = definition.factory(); - this.protections.push(protection); + let enabledProtections: { enabled: string[] } | null = null; + try { + enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); + } catch { + // this setting either doesn't exist, or we failed to read it (bad network?) + // TODO: retry on certain failures? + } + protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; - const savedSettings = await this.getProtectionSettings(protectionName); + const savedSettings = await this.getProtectionSettings(protection.name); 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 }); - } } - - public async disableProtection(protectionName: string): Promise { - 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 }); + /* + * Given a protection object; remove it from our list of protections. + * + * @param protection The protection object we want to unregister + */ + public unregisterProtection(protectionName: string) { + if (!(protectionName in this.protections)) { + throw new Error("Failed to find protection by name: " + protectionName); + } + this.protections.delete(protectionName); } public async watchList(roomRef: string): Promise { @@ -779,8 +812,8 @@ 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) { + // Iterate all the enabled protections + for (const protection of this.enabledProtections) { try { await protection.handleEvent(this, roomId, event); } catch (e) { diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts index 8320758..99b3dc4 100644 --- a/src/commands/ProtectionsCommands.ts +++ b/src/commands/ProtectionsCommands.ts @@ -17,7 +17,6 @@ limitations under the License. import { htmlEscape } from "../utils"; import { Mjolnir } from "../Mjolnir"; import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; -import { PROTECTIONS } from "../protections/protections"; import { isListSetting } from "../protections/ProtectionSettings"; // !mjolnir enable @@ -51,12 +50,12 @@ enum ConfigAction { */ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise { const [protectionName, ...settingParts] = parts[0].split("."); - const protection = PROTECTIONS[protectionName]; + const protection = mjolnir.protections.get(protectionName); if (protection === undefined) { return `Unknown protection ${protectionName}`; } - const defaultSettings = protection.factory().settings + const defaultSettings = protection.settings const settingName = settingParts[0]; const stringValue = parts[1]; @@ -83,22 +82,16 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac } } - // 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); - } + const oldValue = protection.settings[settingName].value; + protection.settings[settingName].setValue(value); - return `Changed ${protectionName}.${settingName} to ${value} (was ${oldSettings[settingName]})`; + return `Changed ${protectionName}.${settingName} to ${value} (was ${oldValue})`; } /* @@ -146,7 +139,7 @@ export async function execConfigRemoveProtection(roomId: string, event: any, mjo * !mjolnir get [protection name] */ export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let pickProtections = Object.keys(PROTECTIONS); + let pickProtections = Object.keys(mjolnir.protections); if (parts.length < 3) { // no specific protectionName provided, show all of them. @@ -170,24 +163,19 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni 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); + const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {}; - if (Object.keys(availableSettings).length === 0) continue; + if (Object.keys(protectionSettings).length === 0) { + continue; + } - const settingNames = Object.keys(PROTECTIONS[protectionName].factory().settings); + const settingNames = Object.keys(protectionSettings); // 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] - + let value = protectionSettings[settingName].value text += `* ${protectionName}.${settingName}: ${value}`; // `protectionName` and `settingName` are user-provided but // validated against the names of existing protections and their @@ -214,16 +202,15 @@ export async function execDisableProtection(roomId: string, event: any, mjolnir: // !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:
    "; let text = "Available protections:\n"; - for (const protection of possibleProtections) { - const emoji = enabledProtections.includes(protection) ? '🟢 (enabled)' : '🔴 (disabled)'; - html += `
  • ${emoji} ${protection} - ${PROTECTIONS[protection].description}
  • `; - text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`; + for (const [protectionName, protection] of mjolnir.protections) { + const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)'; + html += `
  • ${emoji} ${protectionName} - ${protection.description}
  • `; + text += `* ${emoji} ${protectionName} - ${protection.description}\n`; } html += "
"; diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 81fbf07..5af0299 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtection } from "./IProtection"; +import { Protection } from "./IProtection"; import { NumberProtectionSetting } from "./ProtectionSettings"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; @@ -25,7 +25,7 @@ import config from "../config"; export const DEFAULT_MAX_PER_MINUTE = 10; const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase -export class BasicFlooding implements IProtection { +export class BasicFlooding extends Protection { private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {}; private recentlyBanned: string[] = []; @@ -34,11 +34,13 @@ export class BasicFlooding implements IProtection { maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE) }; - constructor() { } - public get name(): string { return 'BasicFloodingProtection'; } + public get description(): string { + return "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."; + } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (!this.lastEvents[roomId]) this.lastEvents[roomId] = {}; diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 6c526d9..1258e42 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtection } from "./IProtection"; +import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; import { logMessage } from "../LogProxy"; import config from "../config"; import { isTrueJoinEvent } from "../utils"; -export class FirstMessageIsImage implements IProtection { +export class FirstMessageIsImage extends Protection { private justJoined: { [roomId: string]: string[] } = {}; private recentlyBanned: string[] = []; @@ -29,11 +29,16 @@ export class FirstMessageIsImage implements IProtection { settings = {}; constructor() { + super(); } public get name(): string { return 'FirstMessageIsImageProtection'; } + public get description(): string { + return "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."; + } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (!this.justJoined[roomId]) this.justJoined[roomId] = []; diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 61a9a05..2a7d14c 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -25,6 +25,15 @@ import { AbstractProtectionSetting } from "./ProtectionSettings"; */ export interface IProtection { readonly name: string; + readonly description: string; + enabled: boolean; settings: { [setting: string]: AbstractProtectionSetting }; handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; } +export abstract class Protection implements IProtection { + abstract readonly name: string + abstract readonly description: string; + enabled = false; + abstract settings: { [setting: string]: AbstractProtectionSetting }; + abstract handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; +} diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 7979445..16b5dd5 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -14,22 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtection } from "./IProtection"; +import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { logMessage } from "../LogProxy"; import config from "../config"; -export class MessageIsMedia implements IProtection { +export class MessageIsMedia extends Protection { settings = {}; constructor() { + super(); } public get name(): string { return 'MessageIsMediaProtection'; } + public get description(): string { + return "If a user posts an image or video, that message will be redacted. No bans are issued."; + } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (event['type'] === 'm.room.message') { diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index 867d84f..c922153 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -14,22 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtection } from "./IProtection"; +import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { logMessage } from "../LogProxy"; import config from "../config"; -export class MessageIsVoice implements IProtection { +export class MessageIsVoice extends Protection { settings = {}; constructor() { + super(); } public get name(): string { return 'MessageIsVoiceProtection'; } + public get description(): string { + return "If a user posts a voice message, that message will be redacted. No bans are issued."; + } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (event['type'] === 'm.room.message' && event['content']) { diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index f3caad6..d16cfcd 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IProtection } from "./IProtection"; +import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; import { logMessage } from "../LogProxy"; import config from "../config"; import { isTrueJoinEvent } from "../utils"; -export class WordList implements IProtection { +export class WordList extends Protection { settings = {}; @@ -29,6 +29,7 @@ export class WordList implements IProtection { private badWords: RegExp; constructor() { + super(); // Create a mega-regex from all the tiny baby regexs this.badWords = new RegExp( "(" + config.protections.wordlist.words.join(")|(") + ")", @@ -39,6 +40,10 @@ export class WordList implements IProtection { public get name(): string { return 'WordList'; } + public get description(): string { + return "If a user posts a monitored word a set amount of time after joining, they " + + "will be banned from that room. This will not publish the ban to a ban list."; + } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { diff --git a/src/protections/protections.ts b/src/protections/protections.ts index 356bc9e..b212a24 100644 --- a/src/protections/protections.ts +++ b/src/protections/protections.ts @@ -16,40 +16,15 @@ limitations under the License. import { FirstMessageIsImage } from "./FirstMessageIsImage"; import { IProtection } from "./IProtection"; -import { BasicFlooding, DEFAULT_MAX_PER_MINUTE } from "./BasicFlooding"; +import { BasicFlooding } from "./BasicFlooding"; import { WordList } from "./WordList"; import { MessageIsVoice } from "./MessageIsVoice"; import { MessageIsMedia } from "./MessageIsMedia"; -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(), - }, - [new BasicFlooding().name]: { - 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(), - }, - [new WordList().name]: { - description: "If a user posts a monitored word a set amount of time after joining, they " + - "will be banned from that room. This will not publish the ban to a ban list.", - factory: () => new WordList(), - }, - [new MessageIsVoice().name]: { - description: "If a user posts a voice message, that message will be redacted. No bans are issued.", - factory: () => new MessageIsVoice(), - }, - [new MessageIsMedia().name]: { - description: "If a user posts an image or video, that message will be redacted. No bans are issued.", - factory: () => new MessageIsMedia(), - } -}; - -export interface PossibleProtections { - [name: string]: { - description: string; - factory: () => IProtection; - }; -} +export const PROTECTIONS: IProtection[] = [ + new FirstMessageIsImage(), + new BasicFlooding(), + new WordList(), + new MessageIsVoice(), + new MessageIsMedia() +]; diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts index c9db7af..2942cd6 100644 --- a/test/integration/protectionSettingsTest.ts +++ b/test/integration/protectionSettingsTest.ts @@ -28,14 +28,11 @@ describe("Test: Protection settings", function() { 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.registerProtection(new class implements IProtection { + name = "05OVMS"; + description = "A test protection"; + settings = { test: new NumberProtectionSetting(3) }; + }); await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 }); assert.equal( @@ -46,17 +43,13 @@ describe("Test: Protection settings", function() { 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.registerProtection(new class implements IProtection { + name = "HPUjKN"; + settings = { + test1: new NumberProtectionSetting(3), + test2: new NumberProtectionSetting(4) + }; + }); await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 }); await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 }); @@ -68,14 +61,11 @@ describe("Test: Protection settings", 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() }; - } - }; + await this.mjolnir.registerProtection(new class implements IProtection { + name = "JY2TPN"; + description = "A test protection"; + settings = { test: new StringProtectionSetting() }; + }); let reply = new Promise((resolve, reject) => { @@ -96,14 +86,11 @@ describe("Test: Protection settings", 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() }; - } - }; + await this.mjolnir.registerProtection(new class implements IProtection { + name = "r33XyT"; + description = "A test protection"; + settings = { test: new StringListProtectionSetting() }; + }); let reply = new Promise((resolve, reject) => { @@ -123,15 +110,11 @@ describe("Test: Protection settings", 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() }; - } - }; - + await this.mjolnir.registerProtection(new class implements IProtection { + name = "oXzT0E"; + description = "A test protection"; + settings = { test: new StringListProtectionSetting() }; + }); let reply = () => new Promise((resolve, reject) => { client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { @@ -148,5 +131,33 @@ describe("Test: Protection settings", function() { assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] }); }); + it("Mjolnir will change a protection setting in-place", async function() { + this.timeout(20000); + await client.joinRoom(config.managementRoom); + + await this.mjolnir.registerProtection(new class implements IProtection { + name = "d0sNrt"; + description = "A test protection"; + settings = { test: new StringProtectionSetting() }; + }); + + let replyPromise = new Promise((resolve, reject) => { + let i = 0; + client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Changed d0sNrt.test ")) { + if (++i == 2) { + resolve(event); + } + } + })) + }); + + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd1"}) + await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd2"}) + assert.equal( + (await replyPromise).content.body.split("\n", 3)[2], + "Changed d0sNrt.test to asd2 (was asd1)" + ) + }); });