matrix-spam-ml

git clone git://archive.git.mtrnord.blog/MTRNord/matrix-spam-ml.git
Log | Files | Refs | Submodules | README | LICENSE

commit 924a2bf858011a7cea9569d7169edf713db6ae28
parent 7f97efbe719b5fa08d0c6f104414dd0296f88e77
Author: MTRNord <mtrnord1@gmail.com>
Date:   Tue, 20 Dec 2022 19:10:45 +0100

Do some cleanup on the bot and prepare handling reactions

Diffstat:
Mbot/package.json | 3++-
Abot/src/events/ReactionEvent.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mbot/src/index.ts | 72++++++++++++++++++++++++++++++++++++++++--------------------------------
3 files changed, 87 insertions(+), 33 deletions(-)

diff --git a/bot/package.json b/bot/package.json @@ -6,7 +6,8 @@ "type": "module", "scripts": { "build": "tsc", - "lint": "eslint src/**/*.ts" + "lint": "eslint src/**/*.ts", + "run": "node --experimental-specifier-resolution=node ./dist/index.js" }, "author": "MTRNord <mtrnord@nordgedanken.dev>", "license": "Apache-2.0", diff --git a/bot/src/events/ReactionEvent.ts b/bot/src/events/ReactionEvent.ts @@ -0,0 +1,45 @@ +import { RoomEvent } from "matrix-bot-sdk"; + +/** + * The defintion for the relation + * @category Matrix event contents + * @see ReactionEventContent + */ +export interface RelatesTo { + rel_type: "m.annotation"; + event_id: string; + key: string; +} + +/** + * The content definition for m.reaction events + * @category Matrix event contents + * @see ReactionEvent + */ +export interface ReactionEventContent { + "m.relates_to": RelatesTo | undefined; +} + +/** + * Represents an m.reaction room event + * @category Matrix events + */ +export class ReactionEvent<T extends ReactionEventContent> extends RoomEvent<T> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(event: any) { + super(event); + } + + /** + * Whether or not the event is redacted (or looked redacted). + */ + public get isRedacted(): boolean { + // Presume the event redacted if we're missing content + return this.content["m.relates_to"] === undefined; + } + + public get relatesTo(): RelatesTo { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-extra-non-null-assertion + return this.content["m.relates_to"]!!; + } +} diff --git a/bot/src/index.ts b/bot/src/index.ts @@ -2,15 +2,18 @@ import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin, - // MessageEvent, - // TextualMessageEventContent, RustSdkCryptoStorageProvider, + LogService, + RichConsoleLogger, + MessageEvent, + MessageEventContent, } from "matrix-bot-sdk"; import { readFile } from "fs/promises"; import { load } from "js-yaml"; import * as tf from "@tensorflow/tfjs-node"; import { Rank, Tensor } from "@tensorflow/tfjs-node"; import { TFSavedModel } from "@tensorflow/tfjs-node/dist/saved_model"; +import { ReactionEvent } from "./events/ReactionEvent"; type Config = { homeserver: string; @@ -22,51 +25,45 @@ type Config = { warningsRoom: string; }; -type HelperMessageEvent = { - content: { - msgtype: string | undefined, - body: string | undefined - } | undefined, - sender: string -}; - class Bot { public static async createBot() { + LogService.setLogger(new RichConsoleLogger()); + LogService.muteModule("Metrics"); const config = load(await readFile("./config.yaml", "utf8")) as Config; // Add some divider for clarity after tensorflow loaded up const line = '-'.repeat(process.stdout.columns); - console.log(line); + LogService.info("index", line); // Check if required fields are set if (config.homeserver == undefined && config.accessToken == undefined) { - console.error("Missing homeserver and accessToken config values"); + LogService.error("index", "Missing homeserver and accessToken config values"); process.exit(1); } else if (config.homeserver == undefined) { - console.error("Missing homeserver config value"); + LogService.error("index", "Missing homeserver config value"); process.exit(1); } else if (config.accessToken == undefined) { - console.error("Missing accessToken config value"); + LogService.error("index", "Missing accessToken config value"); process.exit(1); } if (config.adminRoom == undefined && config.warningsRoom == undefined) { - console.error("Missing adminRoom and warningsRoom config values"); + LogService.error("index", "Missing adminRoom and warningsRoom config values"); process.exit(1); } else if (config.adminRoom == undefined) { - console.error("Missing adminRoom config value"); + LogService.error("index", "Missing adminRoom config value"); process.exit(1); } else if (config.warningsRoom == undefined) { - console.error("Missing warningsRoom config value"); + LogService.error("index", "Missing warningsRoom config value"); process.exit(1); } if (config.adminRoom.startsWith("#")) { - console.error("adminRoom config value needs to be a roomid starting with a \"!\""); + LogService.error("index", "adminRoom config value needs to be a roomid starting with a \"!\""); process.exit(1); } if (config.warningsRoom.startsWith("#")) { - console.error("warningsRoom config value needs to be a roomid starting with a \"!\""); + LogService.error("index", "warningsRoom config value needs to be a roomid starting with a \"!\""); process.exit(1); } @@ -92,7 +89,7 @@ class Bot { // they can be easily added to any room. const client = new MatrixClient(homeserverUrl, accessToken, storage, cryptoProvider); - // TODO replace with manual handling to need admin approval + // TODO: replace with manual handling to need admin approval AutojoinRoomsMixin.setupOnClient(client); // Join rooms as needed but crash if missing @@ -106,17 +103,29 @@ class Bot { // Before we start the bot, register our command handler // eslint-disable-next-line @typescript-eslint/no-misused-promises client.on("room.message", this.handleMessage.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-misused-promises + client.on("room.event", this.handleEvents.bind(this)); // Now that everything is set up, start the bot. This will start the sync loop and run until killed. - client.start().then(() => console.log("Bot started!")).catch(console.error); + client.start().then(() => LogService.info("index", "Bot started!")).catch(console.error); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async handleEvents(_roomId: string, ev: any): Promise<void> { + // For now only handle reactions + const event = new ReactionEvent(ev); + if (event.sender === await this.client.getUserId()) return; // Ignore ourselves + + // TODO: parse actions taken and handle them } // This is the command handler we registered a few lines up // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async handleMessage(roomId: string, event: HelperMessageEvent): Promise<void> { - // Don't handle unhelpful events (ones that aren't text messages, are redacted, or sent by us) - if (event.content?.msgtype !== 'm.text') return; - if (event.sender === await this.client.getUserId()) return; + private async handleMessage(roomId: string, ev: any): Promise<void> { + const event = new MessageEvent(ev); + if (event.isRedacted) return; // Ignore redacted events that come through + if (event.sender === await this.client.getUserId()) return; // Ignore ourselves + if (event.messageType !== "m.text") return; // Ignore non-text messages if (roomId !== this.config.adminRoom && roomId !== this.config.warningsRoom) { await this.checkSpam(event, event.content?.body ?? "", roomId); @@ -124,22 +133,22 @@ class Bot { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async checkSpam(event: HelperMessageEvent, body: string, roomId: string) { + private async checkSpam(event: MessageEvent<MessageEventContent>, body: string, roomId: string) { // Check if spam const data = tf.tensor([body]) const prediction: Tensor<Rank.R2> = this.model.predict(data) as Tensor<Rank.R2>; const prediction_data: number[][] = await prediction.array(); - console.log(`Checking: "${body}"`); - console.log(`Prediction: ${prediction_data.toString()}`); + LogService.info("index", `Checking: "${body}"`); + LogService.info("index", `Prediction: ${prediction_data.toString()}`); const prediction_value = ((prediction_data[0] ?? [])[0] ?? 0); if (prediction_value > 0.8) { - // TODO reverse resolve alias for the roomID and make pill. + // TODO: reverse resolve alias for the roomID and make pill. const alert_event_id = await this.client.sendHtmlText(this.config.warningsRoom, `<blockquote>\n<p>${body}</p>\n</blockquote>\n<p>Above message was detected as spam. See json file for full event and use reactions to take action or no action.</p>\n<p>It was sent in ${roomId}</p>\n<p>Spam Score is: ${prediction_value.toFixed(3)}</p>\n`); await this.client.unstableApis.addReactionToEvent(this.config.warningsRoom, alert_event_id, "🚨 Ban User"); await this.client.unstableApis.addReactionToEvent(this.config.warningsRoom, alert_event_id, "⚠️ Kick User"); await this.client.unstableApis.addReactionToEvent(this.config.warningsRoom, alert_event_id, "✅ False positive"); - const eventContent = Buffer.from(JSON.stringify(event), 'utf8'); + const eventContent = Buffer.from(JSON.stringify(event.raw), 'utf8'); const media = await this.client.uploadContent(eventContent, "application/json", "event.json"); await this.client.sendMessage(this.config.warningsRoom, { msgtype: "m.file", @@ -152,8 +161,7 @@ class Bot { url: media, }) } else { - // const message = new MessageEvent(event); - // const textEvent = new MessageEvent<TextualMessageEventContent>(message.raw); + // const textEvent = new MessageEvent<TextualMessageEventContent>(event.raw); //await this.client.unstableApis.addReactionToEvent(roomId, textEvent.eventId, "Classified Not Spam") } }