matrix-spam-ml

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

banlist.ts (19859B)


      1 import { MatrixClient, MatrixGlob, RoomCreateOptions } from "matrix-bot-sdk";
      2 import { Config } from "../index";
      3 import short from "short-uuid";
      4 
      5 export enum EntityType {
      6     /// `entity` is to be parsed as a glob of users IDs
      7     RULE_USER = "m.policy.rule.user",
      8 
      9     /// `entity` is to be parsed as a glob of room IDs/aliases
     10     RULE_ROOM = "m.policy.rule.room",
     11 
     12     /// `entity` is to be parsed as a glob of server names
     13     RULE_SERVER = "m.policy.rule.server",
     14 }
     15 
     16 export const RULE_USER = EntityType.RULE_USER;
     17 export const RULE_ROOM = EntityType.RULE_ROOM;
     18 export const RULE_SERVER = EntityType.RULE_SERVER;
     19 
     20 // README! The order here matters for determining whether a type is obsolete, most recent should be first.
     21 // These are the current and historical types for each type of rule which were used while MSC2313 was being developed
     22 // and were left as an artifact for some time afterwards.
     23 // Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years.
     24 export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"];
     25 export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"];
     26 export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"];
     27 export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES];
     28 
     29 export enum Recommendation {
     30     /// The rule recommends a "ban".
     31     ///
     32     /// The actual semantics for this "ban" may vary, e.g. room ban,
     33     /// server ban, ignore user, etc. To determine the semantics for
     34     /// this "ban", clients need to take into account the context for
     35     /// the list, e.g. how the rule was imported.
     36     Ban = "m.ban",
     37 
     38     /// The rule specifies an "opinion", as a number in [-100, +100],
     39     /// where -100 represents a user who is considered absolutely toxic
     40     /// by whoever issued this ListRule and +100 represents a user who
     41     /// is considered absolutely absolutely perfect by whoever issued
     42     /// this ListRule.
     43     Opinion = "org.matrix.msc3845.opinion",
     44 
     45     /**
     46      * This is a rule that recommends allowing a user to participate.
     47      * Used for the construction of allow lists.
     48      */
     49     Allow = "org.matrix.mjolnir.allow",
     50 }
     51 
     52 interface PolicyStateEvent {
     53     type: string,
     54     content: {
     55         entity: string,
     56         reason: string,
     57         recommendation: Recommendation,
     58         opinion?: number,
     59     },
     60     event_id: string,
     61     state_key: string,
     62 }
     63 
     64 /**
     65  * All variants of recommendation `m.ban`
     66  */
     67 const RECOMMENDATION_BAN_VARIANTS = [
     68     // Stable
     69     Recommendation.Ban,
     70     // Unstable prefix, for compatibility.
     71     "org.matrix.mjolnir.ban"
     72 ];
     73 
     74 /**
     75  * All variants of recommendation `m.opinion`
     76  */
     77 const RECOMMENDATION_OPINION_VARIANTS: string[] = [
     78     // Unstable
     79     Recommendation.Opinion
     80 ];
     81 
     82 const RECOMMENDATION_ALLOW_VARIANTS: string[] = [
     83     // Unstable
     84     Recommendation.Allow
     85 ]
     86 
     87 export const OPINION_MIN = -100;
     88 export const OPINION_MAX = +100;
     89 
     90 /**
     91  * Representation of a rule within a Policy List.
     92  */
     93 export abstract class ListRule {
     94     /**
     95      * A glob for `entity`.
     96      */
     97     private glob: MatrixGlob;
     98     constructor(
     99         /**
    100          * The event source for the rule.
    101          */
    102         public readonly sourceEvent: PolicyStateEvent,
    103         /**
    104          * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
    105          */
    106         public readonly entity: string,
    107         /**
    108          * A human-readable reason for this rule, for audit purposes.
    109          */
    110         public readonly reason: string,
    111         /**
    112          * The type of entity for this rule, e.g. user, server domain, etc.
    113          */
    114         public readonly kind: EntityType,
    115         /**
    116          * The recommendation for this rule, e.g. "ban" or "opinion", or `null`
    117          * if the recommendation is one that Mjölnir doesn't understand.
    118          */
    119         public readonly recommendation: Recommendation | null) {
    120         this.glob = new MatrixGlob(entity);
    121     }
    122 
    123     /**
    124      * Determine whether this rule should apply to a given entity.
    125      */
    126     public isMatch(entity: string): boolean {
    127         return this.glob.test(entity);
    128     }
    129 
    130     /**
    131      * @returns Whether the entity in he rule represents a Matrix glob (and not a literal).
    132      */
    133     public isGlob(): boolean {
    134         return /[*?]/.test(this.entity);
    135     }
    136 
    137     /**
    138      * Validate and parse an event into a ListRule.
    139      *
    140      * @param event An *untrusted* event.
    141      * @returns null if the ListRule is invalid or not recognized by Mjölnir.
    142      */
    143     public static parse(event: PolicyStateEvent): ListRule | null {
    144         // Parse common fields.
    145         // If a field is ill-formed, discard the rule.
    146         const content = event['content'];
    147         if (!content || typeof content !== "object") {
    148             return null;
    149         }
    150         const entity = content['entity'];
    151         if (!entity || typeof entity !== "string") {
    152             return null;
    153         }
    154         const recommendation = content['recommendation'];
    155         if (!recommendation || typeof recommendation !== "string") {
    156             return null;
    157         }
    158 
    159         const reason = content['reason'] || '<no reason>';
    160         if (typeof reason !== "string") {
    161             return null;
    162         }
    163 
    164         const type = event['type'];
    165         let kind;
    166         if (USER_RULE_TYPES.includes(type)) {
    167             kind = EntityType.RULE_USER;
    168         } else if (ROOM_RULE_TYPES.includes(type)) {
    169             kind = EntityType.RULE_ROOM;
    170         } else if (SERVER_RULE_TYPES.includes(type)) {
    171             kind = EntityType.RULE_SERVER;
    172         } else {
    173             return null;
    174         }
    175 
    176         // From this point, we may need specific fields.
    177         if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) {
    178             return new ListRuleBan(event, entity, reason, kind);
    179         } else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) {
    180             const opinion = content['opinion'];
    181             if (!Number.isInteger(opinion)) {
    182                 return null;
    183             }
    184             return new ListRuleOpinion(event, entity, reason, kind, opinion);
    185         } else if (RECOMMENDATION_ALLOW_VARIANTS.includes(recommendation)) {
    186             return new ListRuleAllow(event, entity, reason, kind);
    187         } else {
    188             // As long as the `recommendation` is defined, we assume
    189             // that the rule is correct, just unknown.
    190             return new ListRuleUnknown(event, entity, reason, kind, content);
    191         }
    192     }
    193 }
    194 
    195 /**
    196  * A rule representing a "ban".
    197  */
    198 export class ListRuleBan extends ListRule {
    199     constructor(
    200         /**
    201          * The event source for the rule.
    202          */
    203         sourceEvent: PolicyStateEvent,
    204         /**
    205          * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
    206          */
    207         entity: string,
    208         /**
    209          * A human-readable reason for this rule, for audit purposes.
    210          */
    211         reason: string,
    212         /**
    213          * The type of entity for this rule, e.g. user, server domain, etc.
    214          */
    215         kind: EntityType,
    216     ) {
    217         super(sourceEvent, entity, reason, kind, Recommendation.Ban)
    218     }
    219 }
    220 
    221 /**
    222  * A rule representing an "allow".
    223  */
    224 export class ListRuleAllow extends ListRule {
    225     constructor(
    226         /**
    227          * The event source for the rule.
    228          */
    229         sourceEvent: PolicyStateEvent,
    230         /**
    231          * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
    232          */
    233         entity: string,
    234         /**
    235          * A human-readable reason for this rule, for audit purposes.
    236          */
    237         reason: string,
    238         /**
    239          * The type of entity for this rule, e.g. user, server domain, etc.
    240          */
    241         kind: EntityType,
    242     ) {
    243         super(sourceEvent, entity, reason, kind, Recommendation.Allow)
    244     }
    245 }
    246 
    247 /**
    248  * A rule representing an "opinion"
    249  */
    250 export class ListRuleOpinion extends ListRule {
    251     constructor(
    252         /**
    253          * The event source for the rule.
    254          */
    255         sourceEvent: PolicyStateEvent,
    256         /**
    257          * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
    258          */
    259         entity: string,
    260         /**
    261          * A human-readable reason for this rule, for audit purposes.
    262          */
    263         reason: string,
    264         /**
    265          * The type of entity for this rule, e.g. user, server domain, etc.
    266          */
    267         kind: EntityType,
    268         /**
    269          * A number in [-100, +100] where -100 represents the worst possible opinion
    270          * on the entity (e.g. toxic user or community) and +100 represents the best
    271          * possible opinion on the entity (e.g. pillar of the community).
    272          */
    273         public readonly opinion: number | undefined
    274     ) {
    275         super(sourceEvent, entity, reason, kind, Recommendation.Opinion);
    276         if (!Number.isInteger(opinion)) {
    277             throw new TypeError(`The opinion must be an integer, got ${opinion ?? 'undefined'}`);
    278         }
    279         if ((opinion ?? 0) < OPINION_MIN || (opinion ?? 0) > OPINION_MAX) {
    280             throw new TypeError(`The opinion must be within [-100, +100], got ${opinion ?? 'undefined'}`);
    281         }
    282     }
    283 }
    284 
    285 /**
    286  * Any list rule that we do not understand.
    287  */
    288 export class ListRuleUnknown extends ListRule {
    289     constructor(
    290         /**
    291          * The event source for the rule.
    292          */
    293         sourceEvent: PolicyStateEvent,
    294         /**
    295          * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
    296          */
    297         entity: string,
    298         /**
    299          * A human-readable reason for this rule, for audit purposes.
    300          */
    301         reason: string,
    302         /**
    303          * The type of entity for this rule, e.g. user, server domain, etc.
    304          */
    305         kind: EntityType,
    306         /**
    307          * The event used to create the rule.
    308          */
    309         public readonly content: unknown,
    310     ) {
    311         super(sourceEvent, entity, reason, kind, null);
    312     }
    313 }
    314 
    315 /* Soom of this code is taken from. (Not copied but based upon. This is needed for compat reasons.)  */
    316 export default class BanListHandler {
    317     private readonly uuidGen = short(short.constants.cookieBase90);
    318     constructor(private readonly client: MatrixClient, private readonly config: Config) { }
    319 
    320     /**
    321      * This is used to annotate state events we store with the rule they are associated with.
    322      * If we refactor this, it is important to also refactor any listeners to 'PolicyList.update'
    323      * which may assume `ListRule`s that are removed will be identital (Object.is) to when they were added.
    324      * If you are adding new listeners, you should check the source event_id of the rule.
    325      */
    326     private static readonly EVENT_RULE_ANNOTATION_KEY = 'org.matrix.mjolnir.annotation.rule';
    327 
    328     public static async createPolicyRoom(client: MatrixClient): Promise<string> {
    329         const powerLevels: { [key: string]: number | object } = {
    330             "ban": 50,
    331             "events": {
    332                 "m.room.name": 100,
    333                 "m.room.power_levels": 100,
    334             },
    335             "events_default": 50, // non-default
    336             "invite": 0,
    337             "kick": 50,
    338             "notifications": {
    339                 "room": 20,
    340             },
    341             "redact": 50,
    342             "state_default": 50,
    343             "users": {
    344                 [await client.getUserId()]: 100,
    345             },
    346             "users_default": 0,
    347         };
    348         // Support for MSC3784.
    349         const roomOptions: RoomCreateOptions = {
    350             creation_content: {
    351                 type: "support.feline.policy.lists.msc.v1"
    352             },
    353             preset: "public_chat",
    354 
    355             power_level_content_override: powerLevels,
    356         }
    357         const listRoomId = await client.createRoom(roomOptions);
    358         return listRoomId
    359     }
    360 
    361     // Adds a rule to the ban list and bans the user.
    362     public async banUser(userId: string, reason: string): Promise<void> {
    363         await this.addRule(userId, reason, RULE_USER, Recommendation.Ban);
    364 
    365         // Get all rooms the user is in
    366         const rooms = await this.client.getJoinedRooms();
    367         for (const room of rooms) {
    368             // Get all members in the room
    369             const members = await this.client.getJoinedRoomMembers(room);
    370             for (const member of members) {
    371                 // If the member is the user we want to ban
    372                 if (member == userId) {
    373                     // Ban the user
    374                     // TODO: allow setting a reason
    375                     await this.client.banUser(room, member, reason);
    376 
    377                     // Redact all messages the user sent
    378                     // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    379                     const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`);
    380                     // Load more if we have more than 100 messages
    381                     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    382                     if (messages.chunk.length == 100) {
    383                         // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
    384                         const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`);
    385                         // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
    386                         messages.chunk = messages.chunk.concat(moreMessages.chunk);
    387                     }
    388                     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    389                     for (const message of messages.chunk) {
    390                         // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
    391                         await this.client.redactEvent(room, message.event_id);
    392                     }
    393                 }
    394             }
    395         }
    396     }
    397 
    398     // This kicks the user. It doesn't actually add a rule to the ban list.
    399     public async kickUser(userId: string, reason: string): Promise<void> {
    400         // Get all rooms the user is in
    401         const rooms = await this.client.getJoinedRooms();
    402         for (const room of rooms) {
    403             // Get all members in the room
    404             const members = await this.client.getJoinedRoomMembers(room);
    405             for (const member of members) {
    406                 // If the member is the user we want to ban
    407                 if (member == userId) {
    408                     // Ban the user
    409                     // TODO: allow setting a reason
    410                     await this.client.kickUser(room, member, reason);
    411 
    412                     // Redact all messages the user sent
    413                     // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    414                     const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`);
    415                     // Load more if we have more than 100 messages
    416                     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    417                     if (messages.chunk.length == 100) {
    418                         // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
    419                         const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`);
    420                         // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
    421                         messages.chunk = messages.chunk.concat(moreMessages.chunk);
    422                     }
    423                     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    424                     for (const message of messages.chunk) {
    425                         // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
    426                         await this.client.redactEvent(room, message.event_id);
    427                     }
    428                 }
    429             }
    430         }
    431     }
    432 
    433     private async addRule(userId: string, reason: string, kind: string, recommendation: Recommendation): Promise<void> {
    434         await this.client.sendStateEvent(this.config.banlistRoom, kind, this.uuidGen.new(), {
    435             entity: userId,
    436             reason: reason,
    437             recommendation: recommendation,
    438         });
    439     }
    440 
    441     /**
    442      * Return all the active rules of a given kind.
    443      * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the PolicyList.
    444      * @param recommendation A specific recommendation to filter for e.g. `m.ban`. Please remember recommendation varients are normalized.
    445      * @returns The active ListRules for the ban list of that kind.
    446      */
    447     public async rulesOfKind(roomId: string, kind: string, recommendation?: Recommendation): Promise<ListRule[]> {
    448         const rules: ListRule[] = []
    449         // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    450         const stateKeyMap = await this.client.getRoomStateEvent(roomId, kind, "");
    451         if (stateKeyMap) {
    452             // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
    453             for (const event of stateKeyMap.values()) {
    454                 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
    455                 const rule = event[BanListHandler.EVENT_RULE_ANNOTATION_KEY];
    456                 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    457                 if (rule && rule.kind === kind) {
    458                     if (recommendation === undefined) {
    459                         rules.push(rule as ListRuleUnknown);
    460                         // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    461                     } else if (rule.recommendation === recommendation) {
    462                         // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    463                         switch (rule.recommendation) {
    464                             case Recommendation.Ban:
    465                                 rules.push(rule as ListRuleBan);
    466                                 break;
    467                             case Recommendation.Allow:
    468                                 rules.push(rule as ListRuleAllow);
    469                                 break;
    470                             case Recommendation.Opinion:
    471                                 rules.push(rule as ListRuleOpinion);
    472                                 break;
    473                             default:
    474                                 rules.push(rule as ListRuleUnknown);
    475                                 break;
    476                         }
    477                     }
    478                 }
    479             }
    480         }
    481         return rules;
    482     }
    483 
    484     public async serverRules(roomId: string): Promise<ListRule[]> {
    485         return this.rulesOfKind(roomId, RULE_SERVER);
    486     }
    487 
    488     public async userRules(roomId: string): Promise<ListRule[]> {
    489         return this.rulesOfKind(roomId, RULE_USER);
    490     }
    491 
    492     public async roomRules(roomId: string): Promise<ListRule[]> {
    493         return this.rulesOfKind(roomId, RULE_ROOM);
    494     }
    495 
    496     public async allRules(roomId: string): Promise<ListRule[]> {
    497         return [...await this.serverRules(roomId), ...await this.userRules(roomId), ...await this.roomRules(roomId)];
    498     }
    499 }