matrix-spam-ml

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

commit 99d2d7b015a8c7b47b56524c007058ef6cf98e0b
parent 5ec4ab3fa9a81d73e323c552601288861d5c01c9
Author: MTRNord <mtrnord1@gmail.com>
Date:   Fri, 23 Dec 2022 23:48:46 +0100

Issue bans and basic banlist support

Diffstat:
Mbot/package-lock.json | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mbot/package.json | 9+++------
Abot/src/banlist/banlist.ts | 499+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abot/src/commands/commandHandler.ts | 6++++++
Mbot/src/index.ts | 45++++++++++++++++++---------------------------
Abot/src/reactionHandlers/reactionHandler.ts | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 830 insertions(+), 70 deletions(-)

diff --git a/bot/package-lock.json b/bot/package-lock.json @@ -10,9 +10,11 @@ "license": "Apache-2.0", "dependencies": { "@tensorflow/tfjs-node": "^4.1.0", + "@types/node": "^18.11.17", "html-to-text": "^9.0.3", "js-yaml": "^4.1.0", - "matrix-bot-sdk": "^0.6.2" + "matrix-bot-sdk": "^0.6.3", + "short-uuid": "^4.2.2" }, "devDependencies": { "@types/html-to-text": "^8.1.1", @@ -287,6 +289,18 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/@matrix-org/matrix-sdk-crypto-nodejs": { + "version": "0.1.0-beta.3", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.3.tgz", + "integrity": "sha512-jHFn6xBeNqfsY5gX60akbss7iFBHZwXycJWMw58Mjz08OwOi7AbTxeS9I2Pa4jX9/M2iinskmGZbzpqOT2fM3A==", + "hasInstallScript": true, + "dependencies": { + "node-downloader-helper": "^2.1.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -963,6 +977,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -2641,11 +2660,11 @@ } }, "node_modules/matrix-bot-sdk": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/matrix-bot-sdk/-/matrix-bot-sdk-0.6.2.tgz", - "integrity": "sha512-+kXlXkQBQgWC6oUwYEosJlXjceaj7jQUnPlALFhGeAabgVm8tmuvFNVKqClwvrrjj+0Gzsmt+rcJHmkvqymFXA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/matrix-bot-sdk/-/matrix-bot-sdk-0.6.3.tgz", + "integrity": "sha512-YXVML3VaRWa6KUtx2z2WQ6819e20ssruw7ytNZAB+c2X7GWvZgMV41PoZ+jNFJj5H2OPIuwVxJF5aYBy49Xm6A==", "dependencies": { - "@matrix-org/matrix-sdk-crypto-nodejs": "^0.1.0-beta.1", + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.1.0-beta.3", "@types/express": "^4.17.13", "another-json": "^0.2.0", "async-lock": "^1.3.2", @@ -2661,24 +2680,12 @@ "morgan": "^1.10.0", "request": "^2.88.2", "request-promise": "^4.2.6", - "sanitize-html": "^2.7.0" + "sanitize-html": "^2.8.0" }, "engines": { "node": ">=16.0.0" } }, - "node_modules/matrix-bot-sdk/node_modules/@matrix-org/matrix-sdk-crypto-nodejs": { - "version": "0.1.0-beta.1", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.1.tgz", - "integrity": "sha512-jCSKrmNh6kaqnOwS/Pqgqkeb+CAvwGuS0oNEW3LaWKrJWFAfUrt+lXBCs7kAP79Qo5ZKBU06BekbZuwYhWbhkQ==", - "hasInstallScript": true, - "dependencies": { - "node-downloader-helper": "^2.1.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/matrix-bot-sdk/node_modules/@selderee/plugin-htmlparser2": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz", @@ -3026,9 +3033,9 @@ } }, "node_modules/node-downloader-helper": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.5.tgz", - "integrity": "sha512-sLedzfv8C4VMAvTdDQcjLFAl3gydNeBXh2bLcCzvZRmd4EK0rkoTxJ8tkxnriUSJO/n13skJzH7l6CzCdBwYGg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.6.tgz", + "integrity": "sha512-VkOvAXIopI3xMuM/MC5UL7NqqnizQ/9QXZt28jR8FPZ6fHLQm4xe4+YXJ9FqsWwLho5BLXrF51nfOQ0QcohRkQ==", "bin": { "ndh": "bin/ndh" }, @@ -3778,6 +3785,26 @@ "node": ">=8" } }, + "node_modules/short-uuid": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/short-uuid/-/short-uuid-4.2.2.tgz", + "integrity": "sha512-IE7hDSGV2U/VZoCsjctKX6l5t5ak2jE0+aeGJi3KtvjIUNuZVmHVYUjNBhmo369FIWGDtaieRaO8A83Lvwfpqw==", + "dependencies": { + "any-base": "^1.1.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/short-uuid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -4454,6 +4481,14 @@ } } }, + "@matrix-org/matrix-sdk-crypto-nodejs": { + "version": "0.1.0-beta.3", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.3.tgz", + "integrity": "sha512-jHFn6xBeNqfsY5gX60akbss7iFBHZwXycJWMw58Mjz08OwOi7AbTxeS9I2Pa4jX9/M2iinskmGZbzpqOT2fM3A==", + "requires": { + "node-downloader-helper": "^2.1.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4947,6 +4982,11 @@ "color-convert": "^2.0.1" } }, + "any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, "aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -6264,11 +6304,11 @@ } }, "matrix-bot-sdk": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/matrix-bot-sdk/-/matrix-bot-sdk-0.6.2.tgz", - "integrity": "sha512-+kXlXkQBQgWC6oUwYEosJlXjceaj7jQUnPlALFhGeAabgVm8tmuvFNVKqClwvrrjj+0Gzsmt+rcJHmkvqymFXA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/matrix-bot-sdk/-/matrix-bot-sdk-0.6.3.tgz", + "integrity": "sha512-YXVML3VaRWa6KUtx2z2WQ6819e20ssruw7ytNZAB+c2X7GWvZgMV41PoZ+jNFJj5H2OPIuwVxJF5aYBy49Xm6A==", "requires": { - "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.1", + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.1.0-beta.3", "@types/express": "^4.17.13", "another-json": "^0.2.0", "async-lock": "^1.3.2", @@ -6284,17 +6324,9 @@ "morgan": "^1.10.0", "request": "^2.88.2", "request-promise": "^4.2.6", - "sanitize-html": "^2.7.0" + "sanitize-html": "^2.8.0" }, "dependencies": { - "@matrix-org/matrix-sdk-crypto-nodejs": { - "version": "0.1.0-beta.1", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.1.tgz", - "integrity": "sha512-jCSKrmNh6kaqnOwS/Pqgqkeb+CAvwGuS0oNEW3LaWKrJWFAfUrt+lXBCs7kAP79Qo5ZKBU06BekbZuwYhWbhkQ==", - "requires": { - "node-downloader-helper": "^2.1.1" - } - }, "@selderee/plugin-htmlparser2": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz", @@ -6548,9 +6580,9 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, "node-downloader-helper": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.5.tgz", - "integrity": "sha512-sLedzfv8C4VMAvTdDQcjLFAl3gydNeBXh2bLcCzvZRmd4EK0rkoTxJ8tkxnriUSJO/n13skJzH7l6CzCdBwYGg==" + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.6.tgz", + "integrity": "sha512-VkOvAXIopI3xMuM/MC5UL7NqqnizQ/9QXZt28jR8FPZ6fHLQm4xe4+YXJ9FqsWwLho5BLXrF51nfOQ0QcohRkQ==" }, "node-fetch": { "version": "2.6.7", @@ -7068,6 +7100,22 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "short-uuid": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/short-uuid/-/short-uuid-4.2.2.tgz", + "integrity": "sha512-IE7hDSGV2U/VZoCsjctKX6l5t5ak2jE0+aeGJi3KtvjIUNuZVmHVYUjNBhmo369FIWGDtaieRaO8A83Lvwfpqw==", + "requires": { + "any-base": "^1.1.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", diff --git a/bot/package.json b/bot/package.json @@ -21,13 +21,10 @@ }, "dependencies": { "@tensorflow/tfjs-node": "^4.1.0", + "@types/node": "^18.11.17", "html-to-text": "^9.0.3", "js-yaml": "^4.1.0", - "matrix-bot-sdk": "^0.6.2" - }, - "overrides": { - "matrix-bot-sdk": { - "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.1" - } + "matrix-bot-sdk": "^0.6.3", + "short-uuid": "^4.2.2" } } diff --git a/bot/src/banlist/banlist.ts b/bot/src/banlist/banlist.ts @@ -0,0 +1,499 @@ +import { MatrixClient, MatrixGlob, RoomCreateOptions } from "matrix-bot-sdk"; +import { Config } from "../index"; +import short from "short-uuid"; + +export enum EntityType { + /// `entity` is to be parsed as a glob of users IDs + RULE_USER = "m.policy.rule.user", + + /// `entity` is to be parsed as a glob of room IDs/aliases + RULE_ROOM = "m.policy.rule.room", + + /// `entity` is to be parsed as a glob of server names + RULE_SERVER = "m.policy.rule.server", +} + +export const RULE_USER = EntityType.RULE_USER; +export const RULE_ROOM = EntityType.RULE_ROOM; +export const RULE_SERVER = EntityType.RULE_SERVER; + +// README! The order here matters for determining whether a type is obsolete, most recent should be first. +// These are the current and historical types for each type of rule which were used while MSC2313 was being developed +// and were left as an artifact for some time afterwards. +// Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years. +export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"]; +export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"]; +export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"]; +export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; + +export enum Recommendation { + /// The rule recommends a "ban". + /// + /// The actual semantics for this "ban" may vary, e.g. room ban, + /// server ban, ignore user, etc. To determine the semantics for + /// this "ban", clients need to take into account the context for + /// the list, e.g. how the rule was imported. + Ban = "m.ban", + + /// The rule specifies an "opinion", as a number in [-100, +100], + /// where -100 represents a user who is considered absolutely toxic + /// by whoever issued this ListRule and +100 represents a user who + /// is considered absolutely absolutely perfect by whoever issued + /// this ListRule. + Opinion = "org.matrix.msc3845.opinion", + + /** + * This is a rule that recommends allowing a user to participate. + * Used for the construction of allow lists. + */ + Allow = "org.matrix.mjolnir.allow", +} + +interface PolicyStateEvent { + type: string, + content: { + entity: string, + reason: string, + recommendation: Recommendation, + opinion?: number, + }, + event_id: string, + state_key: string, +} + +/** + * All variants of recommendation `m.ban` + */ +const RECOMMENDATION_BAN_VARIANTS = [ + // Stable + Recommendation.Ban, + // Unstable prefix, for compatibility. + "org.matrix.mjolnir.ban" +]; + +/** + * All variants of recommendation `m.opinion` + */ +const RECOMMENDATION_OPINION_VARIANTS: string[] = [ + // Unstable + Recommendation.Opinion +]; + +const RECOMMENDATION_ALLOW_VARIANTS: string[] = [ + // Unstable + Recommendation.Allow +] + +export const OPINION_MIN = -100; +export const OPINION_MAX = +100; + +/** + * Representation of a rule within a Policy List. + */ +export abstract class ListRule { + /** + * A glob for `entity`. + */ + private glob: MatrixGlob; + constructor( + /** + * The event source for the rule. + */ + public readonly sourceEvent: PolicyStateEvent, + /** + * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. + */ + public readonly entity: string, + /** + * A human-readable reason for this rule, for audit purposes. + */ + public readonly reason: string, + /** + * The type of entity for this rule, e.g. user, server domain, etc. + */ + public readonly kind: EntityType, + /** + * The recommendation for this rule, e.g. "ban" or "opinion", or `null` + * if the recommendation is one that Mjölnir doesn't understand. + */ + public readonly recommendation: Recommendation | null) { + this.glob = new MatrixGlob(entity); + } + + /** + * Determine whether this rule should apply to a given entity. + */ + public isMatch(entity: string): boolean { + return this.glob.test(entity); + } + + /** + * @returns Whether the entity in he rule represents a Matrix glob (and not a literal). + */ + public isGlob(): boolean { + return /[*?]/.test(this.entity); + } + + /** + * Validate and parse an event into a ListRule. + * + * @param event An *untrusted* event. + * @returns null if the ListRule is invalid or not recognized by Mjölnir. + */ + public static parse(event: PolicyStateEvent): ListRule | null { + // Parse common fields. + // If a field is ill-formed, discard the rule. + const content = event['content']; + if (!content || typeof content !== "object") { + return null; + } + const entity = content['entity']; + if (!entity || typeof entity !== "string") { + return null; + } + const recommendation = content['recommendation']; + if (!recommendation || typeof recommendation !== "string") { + return null; + } + + const reason = content['reason'] || '<no reason>'; + if (typeof reason !== "string") { + return null; + } + + const type = event['type']; + let kind; + if (USER_RULE_TYPES.includes(type)) { + kind = EntityType.RULE_USER; + } else if (ROOM_RULE_TYPES.includes(type)) { + kind = EntityType.RULE_ROOM; + } else if (SERVER_RULE_TYPES.includes(type)) { + kind = EntityType.RULE_SERVER; + } else { + return null; + } + + // From this point, we may need specific fields. + if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) { + return new ListRuleBan(event, entity, reason, kind); + } else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) { + const opinion = content['opinion']; + if (!Number.isInteger(opinion)) { + return null; + } + return new ListRuleOpinion(event, entity, reason, kind, opinion); + } else if (RECOMMENDATION_ALLOW_VARIANTS.includes(recommendation)) { + return new ListRuleAllow(event, entity, reason, kind); + } else { + // As long as the `recommendation` is defined, we assume + // that the rule is correct, just unknown. + return new ListRuleUnknown(event, entity, reason, kind, content); + } + } +} + +/** + * A rule representing a "ban". + */ +export class ListRuleBan extends ListRule { + constructor( + /** + * The event source for the rule. + */ + sourceEvent: PolicyStateEvent, + /** + * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. + */ + entity: string, + /** + * A human-readable reason for this rule, for audit purposes. + */ + reason: string, + /** + * The type of entity for this rule, e.g. user, server domain, etc. + */ + kind: EntityType, + ) { + super(sourceEvent, entity, reason, kind, Recommendation.Ban) + } +} + +/** + * A rule representing an "allow". + */ +export class ListRuleAllow extends ListRule { + constructor( + /** + * The event source for the rule. + */ + sourceEvent: PolicyStateEvent, + /** + * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. + */ + entity: string, + /** + * A human-readable reason for this rule, for audit purposes. + */ + reason: string, + /** + * The type of entity for this rule, e.g. user, server domain, etc. + */ + kind: EntityType, + ) { + super(sourceEvent, entity, reason, kind, Recommendation.Allow) + } +} + +/** + * A rule representing an "opinion" + */ +export class ListRuleOpinion extends ListRule { + constructor( + /** + * The event source for the rule. + */ + sourceEvent: PolicyStateEvent, + /** + * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. + */ + entity: string, + /** + * A human-readable reason for this rule, for audit purposes. + */ + reason: string, + /** + * The type of entity for this rule, e.g. user, server domain, etc. + */ + kind: EntityType, + /** + * A number in [-100, +100] where -100 represents the worst possible opinion + * on the entity (e.g. toxic user or community) and +100 represents the best + * possible opinion on the entity (e.g. pillar of the community). + */ + public readonly opinion: number | undefined + ) { + super(sourceEvent, entity, reason, kind, Recommendation.Opinion); + if (!Number.isInteger(opinion)) { + throw new TypeError(`The opinion must be an integer, got ${opinion ?? 'undefined'}`); + } + if ((opinion ?? 0) < OPINION_MIN || (opinion ?? 0) > OPINION_MAX) { + throw new TypeError(`The opinion must be within [-100, +100], got ${opinion ?? 'undefined'}`); + } + } +} + +/** + * Any list rule that we do not understand. + */ +export class ListRuleUnknown extends ListRule { + constructor( + /** + * The event source for the rule. + */ + sourceEvent: PolicyStateEvent, + /** + * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. + */ + entity: string, + /** + * A human-readable reason for this rule, for audit purposes. + */ + reason: string, + /** + * The type of entity for this rule, e.g. user, server domain, etc. + */ + kind: EntityType, + /** + * The event used to create the rule. + */ + public readonly content: unknown, + ) { + super(sourceEvent, entity, reason, kind, null); + } +} + +/* Soom of this code is taken from. (Not copied but based upon. This is needed for compat reasons.) */ +export default class BanListHandler { + private readonly uuidGen = short(short.constants.cookieBase90); + constructor(private readonly client: MatrixClient, private readonly config: Config) { } + + /** + * This is used to annotate state events we store with the rule they are associated with. + * If we refactor this, it is important to also refactor any listeners to 'PolicyList.update' + * which may assume `ListRule`s that are removed will be identital (Object.is) to when they were added. + * If you are adding new listeners, you should check the source event_id of the rule. + */ + private static readonly EVENT_RULE_ANNOTATION_KEY = 'org.matrix.mjolnir.annotation.rule'; + + public static async createPolicyRoom(client: MatrixClient): Promise<string> { + const powerLevels: { [key: string]: number | object } = { + "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": { + [await client.getUserId()]: 100, + }, + "users_default": 0, + }; + // Support for MSC3784. + const roomOptions: RoomCreateOptions = { + creation_content: { + type: "support.feline.policy.lists.msc.v1" + }, + preset: "public_chat", + + power_level_content_override: powerLevels, + } + const listRoomId = await client.createRoom(roomOptions); + return listRoomId + } + + // Adds a rule to the ban list and bans the user. + public async banUser(userId: string, reason: string): Promise<void> { + await this.addRule(userId, reason, RULE_USER, Recommendation.Ban); + + // Get all rooms the user is in + const rooms = await this.client.getJoinedRooms(); + for (const room of rooms) { + // Get all members in the room + const members = await this.client.getJoinedRoomMembers(room); + for (const member of members) { + // If the member is the user we want to ban + if (member == userId) { + // Ban the user + // TODO: allow setting a reason + await this.client.banUser(room, member, reason); + + // Redact all messages the user sent + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`); + // Load more if we have more than 100 messages + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (messages.chunk.length == 100) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions + const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + messages.chunk = messages.chunk.concat(moreMessages.chunk); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + for (const message of messages.chunk) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + await this.client.redactEvent(room, message.event_id); + } + } + } + } + } + + // This kicks the user. It doesn't actually add a rule to the ban list. + public async kickUser(userId: string, reason: string): Promise<void> { + // Get all rooms the user is in + const rooms = await this.client.getJoinedRooms(); + for (const room of rooms) { + // Get all members in the room + const members = await this.client.getJoinedRoomMembers(room); + for (const member of members) { + // If the member is the user we want to ban + if (member == userId) { + // Ban the user + // TODO: allow setting a reason + await this.client.kickUser(room, member, reason); + + // Redact all messages the user sent + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`); + // Load more if we have more than 100 messages + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (messages.chunk.length == 100) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions + const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + messages.chunk = messages.chunk.concat(moreMessages.chunk); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + for (const message of messages.chunk) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + await this.client.redactEvent(room, message.event_id); + } + } + } + } + } + + private async addRule(userId: string, reason: string, kind: string, recommendation: Recommendation): Promise<void> { + await this.client.sendStateEvent(this.config.banlistRoom, kind, this.uuidGen.new(), { + entity: userId, + reason: reason, + recommendation: recommendation, + }); + } + + /** + * Return all the active rules of a given kind. + * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the PolicyList. + * @param recommendation A specific recommendation to filter for e.g. `m.ban`. Please remember recommendation varients are normalized. + * @returns The active ListRules for the ban list of that kind. + */ + public async rulesOfKind(roomId: string, kind: string, recommendation?: Recommendation): Promise<ListRule[]> { + const rules: ListRule[] = [] + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const stateKeyMap = await this.client.getRoomStateEvent(roomId, kind, ""); + if (stateKeyMap) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + for (const event of stateKeyMap.values()) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const rule = event[BanListHandler.EVENT_RULE_ANNOTATION_KEY]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (rule && rule.kind === kind) { + if (recommendation === undefined) { + rules.push(rule as ListRuleUnknown); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (rule.recommendation === recommendation) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + switch (rule.recommendation) { + case Recommendation.Ban: + rules.push(rule as ListRuleBan); + break; + case Recommendation.Allow: + rules.push(rule as ListRuleAllow); + break; + case Recommendation.Opinion: + rules.push(rule as ListRuleOpinion); + break; + default: + rules.push(rule as ListRuleUnknown); + break; + } + } + } + } + } + return rules; + } + + public async serverRules(roomId: string): Promise<ListRule[]> { + return this.rulesOfKind(roomId, RULE_SERVER); + } + + public async userRules(roomId: string): Promise<ListRule[]> { + return this.rulesOfKind(roomId, RULE_USER); + } + + public async roomRules(roomId: string): Promise<ListRule[]> { + return this.rulesOfKind(roomId, RULE_ROOM); + } + + public async allRules(roomId: string): Promise<ListRule[]> { + return [...await this.serverRules(roomId), ...await this.userRules(roomId), ...await this.roomRules(roomId)]; + } +} diff --git a/bot/src/commands/commandHandler.ts b/bot/src/commands/commandHandler.ts @@ -0,0 +1,6 @@ +//import { MatrixClient } from "matrix-bot-sdk"; + +// TODO: Add needed functions and then implement +// abstract class CommandHandler { +// constructor(protected readonly client: MatrixClient) { } +// } diff --git a/bot/src/index.ts b/bot/src/index.ts @@ -18,8 +18,10 @@ import { Rank, Tensor } from "@tensorflow/tfjs-node"; import { TFSavedModel } from "@tensorflow/tfjs-node/dist/saved_model"; import { ReactionEvent } from "./events/ReactionEvent"; import { htmlToText } from "html-to-text"; +import { BAN_REACTION, FALSE_POSITIVE_REACTION, getReactionHandler, KICK_REACTION } from "./reactionHandlers/reactionHandler"; +import BanListHandler from "./banlist/banlist"; -type Config = { +export type Config = { homeserver: string; accessToken: string; modelPath: string; @@ -27,14 +29,15 @@ type Config = { adminRoom: string; // A possibly public room where warnings land which also is used to issue actions from as an admin warningsRoom: string; + // TODO: Dont set this via config but via a setup in the room on first launch or via the cli or something. + banlistRoom: string; }; const THRESHOLD = 0.8; -const BAN_REACTION = "🚨 Ban User"; -const KICK_REACTION = "⚠️ Kick User"; -const FALSE_POSITIVE_REACTION = "✅ False positive"; +const startUpMessage = "Bot is starting up..."; class Bot { + private readonly policyRoomHandler = new BanListHandler(this.client, this.config); public static async createBot() { LogService.setLogger(new RichConsoleLogger()); LogService.muteModule("Metrics"); @@ -105,6 +108,7 @@ class Bot { await client.joinRoom(config.adminRoom); await client.joinRoom(config.warningsRoom); + return new Bot(config, client, model); } @@ -116,7 +120,12 @@ class Bot { 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(() => LogService.info("index", "Bot started!")).catch(console.error); + client.start().then(async () => { + LogService.info("index", "Bot started!"); + // Send notice that bot is starting into both rooms + await client.sendNotice(config.adminRoom, startUpMessage); + await client.sendNotice(config.warningsRoom, startUpMessage); + }).catch(console.error); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -126,31 +135,13 @@ class Bot { if (event.isRedacted) return; // Ignore redacted events if (event.sender === await this.client.getUserId()) return; // Ignore ourselves - let reactedToEvent: { content: { "space.midnightthoughts.sending_user": string } } | undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - reactedToEvent = await this.client.getEvent(roomId, event.targetEventId ?? ""); + try { + await getReactionHandler(roomId, event, this.client, this.config, this.policyRoomHandler).handleReaction(); } catch (e) { - LogService.error("index", "Could not get event", e); - return; - } - - // Making typescript happy - if (reactedToEvent == undefined) { - LogService.error("index", "Could not get event"); return; - } - - // TODO: parse actions taken and handle them - if (event.reaction === BAN_REACTION) { - LogService.info("index", `Admin selected ban for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${event.targetEventId ?? "unknown"}`); - } - if (event.reaction === KICK_REACTION) { - LogService.info("index", `Admin selected kick for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${event.targetEventId ?? "unknown"}`); - } - if (event.reaction === FALSE_POSITIVE_REACTION) { - LogService.info("index", `Admin selected false positive for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${event.targetEventId ?? "unknown"}`); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + //LogService.error("index", `Error handling reaction: ${e}`); } } diff --git a/bot/src/reactionHandlers/reactionHandler.ts b/bot/src/reactionHandlers/reactionHandler.ts @@ -0,0 +1,219 @@ +import { LogService, MatrixClient } from "matrix-bot-sdk"; +import BanListHandler from "../banlist/banlist"; +import { ReactionEvent, ReactionEventContent } from "../events/ReactionEvent"; +import { Config } from "../index"; + +export const BAN_REACTION = "🚨 Ban User"; +export const KICK_REACTION = "⚠️ Kick User"; +export const FALSE_POSITIVE_REACTION = "✅ False positive"; + + +export function getReactionHandler<T extends ReactionEventContent>(roomId: string, event: ReactionEvent<T>, client: MatrixClient, config: Config, policyRoomHandler: BanListHandler): ReactionHandler<T> { + switch (event.reaction) { + case BAN_REACTION: + return new BanReactionHandler(roomId, event, client, config, policyRoomHandler); + case KICK_REACTION: + return new KickReactionHandler(roomId, event, client, config, policyRoomHandler); + case FALSE_POSITIVE_REACTION: + return new FalsePositiveReactionHandler(roomId, event, client, config, policyRoomHandler); + default: + throw new Error("Reaction not supported"); + } +} + +export abstract class ReactionHandler<T extends ReactionEventContent> { + constructor(protected readonly roomId: string, protected readonly event: ReactionEvent<T>, protected readonly client: MatrixClient, protected readonly config: Config, protected readonly policyRoomHandler: BanListHandler) { } + + public abstract handleReaction(): Promise<void>; +} + + +class BanReactionHandler<T extends ReactionEventContent> extends ReactionHandler<T> { + public async handleReaction(): Promise<void> { + // Check if reaction was issued by an admin that can do this action + // Get users in the admin room + const users = await this.client.getJoinedRoomMembers(this.config.adminRoom); + + // Get power levels in the admin room + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const powerLevels = await this.client.getRoomStateEvent(this.config.adminRoom, "m.room.power_levels", ""); + + // Map users to their power level + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const admins = users.filter(u => powerLevels.content.users[u] >= powerLevels.content.ban); + + // Check if sender is an admin + if (!admins.includes(this.event.sender)) { + return; + } + + + + let reactedToEvent: { content: { "space.midnightthoughts.sending_user": string; "space.midnightthoughts.previous_action": string; } } | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + reactedToEvent = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + + } catch (e) { + LogService.error("index", "Could not get event", e); + return; + } + + // Making typescript happy + if (reactedToEvent == undefined) { + LogService.error("index", "Could not get event"); + return; + } + + if (process.env["DEBUG"] == "true") { + LogService.info("BanReactionHandler", `Admin selected ban for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${this.event.targetEventId ?? "unknown"}`); + return; + } + + // We dont need to issue this twice for the same user + // TODO: check if the user is already banned + if (reactedToEvent.content["space.midnightthoughts.previous_action"] !== "ban") { + await this.policyRoomHandler.banUser(reactedToEvent.content["space.midnightthoughts.sending_user"], "Banned by admin"); + + // Get the message we sent in the warning room to update it + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const warningMessage = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + // Update the event to reflect the action + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["space.midnightthoughts.previous_action"] = "ban"; + // Add relates_to to the event + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["m.relates_to"] = { + "rel_type": "m.replace", + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + "event_id": this.event.targetEventId ?? "" + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["m.new_content"] = { + "msgtype": "m.text", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "body": warningMessage.content.body, + "format": "org.matrix.custom.html", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "formatted_body": warningMessage.content.formatted_body + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await this.client.sendEvent(this.roomId, "m.room.message", warningMessage.content) + } + } +} + +class KickReactionHandler<T extends ReactionEventContent> extends ReactionHandler<T> { + public async handleReaction(): Promise<void> { + // Check if reaction was issued by an admin that can do this action + // Get users in the admin room + const users = await this.client.getJoinedRoomMembers(this.config.adminRoom); + + // Get power levels in the admin room + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const powerLevels = await this.client.getRoomStateEvent(this.config.adminRoom, "m.room.power_levels", ""); + + // Map users to their power level + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const admins = users.filter(u => powerLevels.content.users[u] >= powerLevels.content.kick); + + // Check if sender is an admin + if (!admins.includes(this.event.sender)) { + return; + } + + let reactedToEvent: { content: { "space.midnightthoughts.sending_user": string; "space.midnightthoughts.previous_action": string; } } | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + reactedToEvent = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + + } catch (e) { + LogService.error("index", "Could not get event", e); + return; + } + + // Making typescript happy + if (reactedToEvent == undefined) { + LogService.error("index", "Could not get event"); + return; + } + + if (process.env["DEBUG"]?.toLowerCase() == "true" || process.env["DEBUG"] == "1") { + LogService.info("BanReactionHandler", `Admin selected kick for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${this.event.targetEventId ?? "unknown"}`); + return; + } + + await this.policyRoomHandler.kickUser(reactedToEvent.content["space.midnightthoughts.sending_user"], "Kicked by admin"); + // Get the message we sent in the warning room to update it + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const warningMessage = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + // Update the event to reflect the action + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["space.midnightthoughts.previous_action"] = "kick"; + // Add relates_to to the event + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["m.relates_to"] = { + "rel_type": "m.replace", + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + "event_id": this.event.targetEventId ?? "" + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["m.new_content"] = { + "msgtype": "m.text", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "body": warningMessage.content.body, + "format": "org.matrix.custom.html", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "formatted_body": warningMessage.content.formatted_body + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await this.client.sendEvent(this.roomId, "m.room.message", warningMessage.content) + } +} + +class FalsePositiveReactionHandler<T extends ReactionEventContent> extends ReactionHandler<T> { + public async handleReaction(): Promise<void> { + let reactedToEvent: { content: { "space.midnightthoughts.sending_user": string; "space.midnightthoughts.previous_action": string; } } | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + reactedToEvent = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + + } catch (e) { + LogService.error("index", "Could not get event", e); + return; + } + + // Making typescript happy + if (reactedToEvent == undefined) { + LogService.error("index", "Could not get event"); + return; + } + + LogService.info("BanReactionHandler", `Admin selected false positive for ${reactedToEvent.content["space.midnightthoughts.sending_user"]} on ${this.event.targetEventId ?? "unknown"}`); + + // Get the message we sent in the warning room to update it + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const warningMessage = await this.client.getEvent(this.roomId, this.event.targetEventId ?? ""); + // Update the event to reflect the action + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["space.midnightthoughts.previous_action"] = "false_positive"; + // Add relates_to to the event + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + warningMessage.content["m.relates_to"] = { + "rel_type": "m.replace", + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + "event_id": this.event.targetEventId ?? "" + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + warningMessage.content["m.new_content"] = { + "msgtype": "m.text", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "body": warningMessage.content.body, + "format": "org.matrix.custom.html", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + "formatted_body": warningMessage.content.formatted_body + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await this.client.sendEvent(this.roomId, "m.room.message", warningMessage.content) + } +}