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:
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)
+ }
+}