|
| 1 | +// Copyright 2024 - 2025 Gnuxie <[email protected]> |
| 2 | +// Copyright 2024 The Matrix.org Foundation C.I.C. |
| 3 | +// |
| 4 | +// SPDX-License-Identifier: Apache-2.0 |
| 5 | + |
| 6 | +import { |
| 7 | + AbstractProtection, |
| 8 | + ActionResult, |
| 9 | + EDStatic, |
| 10 | + EventConsequences, |
| 11 | + Logger, |
| 12 | + Ok, |
| 13 | + ProtectedRoomsSet, |
| 14 | + Protection, |
| 15 | + ProtectionDescription, |
| 16 | + RoomEvent, |
| 17 | + RoomMessageSender, |
| 18 | + Task, |
| 19 | + UserConsequences, |
| 20 | + Value, |
| 21 | + describeProtection, |
| 22 | + isError, |
| 23 | +} from "matrix-protection-suite"; |
| 24 | +import { Draupnir } from "../Draupnir"; |
| 25 | +import { |
| 26 | + MatrixRoomID, |
| 27 | + StringUserID, |
| 28 | +} from "@the-draupnir-project/matrix-basic-types"; |
| 29 | +import { Type } from "@sinclair/typebox"; |
| 30 | +import { LazyLeakyBucket } from "../queues/LeakyBucket"; |
| 31 | +import { sendMatrixEventsFromDeadDocument } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; |
| 32 | +import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; |
| 33 | +import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; |
| 34 | + |
| 35 | +const log = new Logger("MentionLimitProtection"); |
| 36 | + |
| 37 | +const MentionsContentSchema = Type.Object({ |
| 38 | + "m.mentions": Type.Object({ |
| 39 | + user_ids: Type.Array(Type.String()), |
| 40 | + }), |
| 41 | +}); |
| 42 | + |
| 43 | +const NewContentMentionsSchema = Type.Object({ |
| 44 | + "m.new_content": MentionsContentSchema, |
| 45 | +}); |
| 46 | + |
| 47 | +const WeakTextContentSchema = Type.Object({ |
| 48 | + body: Type.Optional(Type.String()), |
| 49 | + formatted_body: Type.Optional(Type.String()), |
| 50 | +}); |
| 51 | + |
| 52 | +export function isContainingMentionsOverLimit( |
| 53 | + event: RoomEvent, |
| 54 | + maxMentions: number, |
| 55 | + checkBody: boolean |
| 56 | +): boolean { |
| 57 | + const isOverLimit = (user_ids: string[]): boolean => |
| 58 | + user_ids.length > maxMentions; |
| 59 | + if ( |
| 60 | + Value.Check(NewContentMentionsSchema, event.content) && |
| 61 | + isOverLimit(event.content["m.new_content"]["m.mentions"].user_ids) |
| 62 | + ) { |
| 63 | + return true; |
| 64 | + } |
| 65 | + if ( |
| 66 | + Value.Check(MentionsContentSchema, event.content) && |
| 67 | + isOverLimit(event.content["m.mentions"].user_ids) |
| 68 | + ) { |
| 69 | + return true; |
| 70 | + } |
| 71 | + if (checkBody && Value.Check(WeakTextContentSchema, event.content)) { |
| 72 | + if ( |
| 73 | + event.content.body !== undefined && |
| 74 | + event.content.body.split("@").length - 1 > maxMentions |
| 75 | + ) { |
| 76 | + return true; |
| 77 | + } |
| 78 | + } |
| 79 | + return false; |
| 80 | +} |
| 81 | + |
| 82 | +const MentionLimitProtectionSettings = Type.Object( |
| 83 | + { |
| 84 | + maxMentions: Type.Integer({ |
| 85 | + description: "The maximum number of mentions permitted.", |
| 86 | + default: 3, |
| 87 | + }), |
| 88 | + warningText: Type.String({ |
| 89 | + description: |
| 90 | + "The reason to use to notify the user after redacting their infringing message.", |
| 91 | + default: |
| 92 | + "You have mentioned too many users in this message, so we have had to redact it.", |
| 93 | + }), |
| 94 | + includeLegacyMentions: Type.Boolean({ |
| 95 | + description: |
| 96 | + "Whether to scrape the body for legacy mentions, can lead to more false positives.", |
| 97 | + default: false, |
| 98 | + }), |
| 99 | + }, |
| 100 | + { |
| 101 | + title: "MentionLimitProtectionSettings", |
| 102 | + } |
| 103 | +); |
| 104 | + |
| 105 | +type MentionLimitProtectionSettings = EDStatic< |
| 106 | + typeof MentionLimitProtectionSettings |
| 107 | +>; |
| 108 | + |
| 109 | +export type MentionLimitProtectionDescription = ProtectionDescription< |
| 110 | + unknown, |
| 111 | + typeof MentionLimitProtectionSettings, |
| 112 | + MentionLimitProtectionCapabilities |
| 113 | +>; |
| 114 | + |
| 115 | +export class MentionLimitProtection |
| 116 | + extends AbstractProtection<MentionLimitProtectionDescription> |
| 117 | + implements Protection<MentionLimitProtectionDescription> |
| 118 | +{ |
| 119 | + private readonly eventConsequences: EventConsequences; |
| 120 | + private readonly userConsequences: UserConsequences; |
| 121 | + private readonly warningText: string; |
| 122 | + private readonly maxMentions: number; |
| 123 | + private readonly includeLegacymentions: boolean; |
| 124 | + private readonly consequenceBucket = new LazyLeakyBucket<StringUserID>( |
| 125 | + 1, |
| 126 | + 30 * 60_000 // half an hour will do |
| 127 | + ); |
| 128 | + constructor( |
| 129 | + description: MentionLimitProtectionDescription, |
| 130 | + capabilities: MentionLimitProtectionCapabilities, |
| 131 | + private readonly roomMessageSender: RoomMessageSender, |
| 132 | + protectedRoomsSet: ProtectedRoomsSet, |
| 133 | + settings: MentionLimitProtectionSettings |
| 134 | + ) { |
| 135 | + super(description, capabilities, protectedRoomsSet, {}); |
| 136 | + this.eventConsequences = capabilities.eventConsequences; |
| 137 | + this.userConsequences = capabilities.userConsequences; |
| 138 | + this.maxMentions = settings.maxMentions; |
| 139 | + this.warningText = settings.warningText; |
| 140 | + this.includeLegacymentions = settings.includeLegacyMentions; |
| 141 | + } |
| 142 | + public async handleTimelineEvent( |
| 143 | + _room: MatrixRoomID, |
| 144 | + event: RoomEvent |
| 145 | + ): Promise<ActionResult<void>> { |
| 146 | + if (event.sender === this.protectedRoomsSet.userID) { |
| 147 | + return Ok(undefined); |
| 148 | + } |
| 149 | + if ( |
| 150 | + isContainingMentionsOverLimit( |
| 151 | + event, |
| 152 | + this.maxMentions, |
| 153 | + this.includeLegacymentions |
| 154 | + ) |
| 155 | + ) { |
| 156 | + const infractions = this.consequenceBucket.getTokenCount(event.sender); |
| 157 | + if (infractions > 0) { |
| 158 | + const userResult = await this.userConsequences.consequenceForUserInRoom( |
| 159 | + event.room_id, |
| 160 | + event.sender, |
| 161 | + this.warningText |
| 162 | + ); |
| 163 | + if (isError(userResult)) { |
| 164 | + log.error("Failed to ban the user", event.sender, userResult.error); |
| 165 | + } |
| 166 | + // fall through to the event consequence on purpose so we redact the event too. |
| 167 | + } else { |
| 168 | + // if they're not being banned we need to tell them why their message got redacted. |
| 169 | + void Task( |
| 170 | + sendMatrixEventsFromDeadDocument( |
| 171 | + this.roomMessageSender, |
| 172 | + event.room_id, |
| 173 | + <root> |
| 174 | + {renderMentionPill(event.sender, event.sender)} {this.warningText} |
| 175 | + </root>, |
| 176 | + { replyToEvent: event } |
| 177 | + ), |
| 178 | + { |
| 179 | + log, |
| 180 | + } |
| 181 | + ); |
| 182 | + } |
| 183 | + this.consequenceBucket.addToken(event.sender); |
| 184 | + return await this.eventConsequences.consequenceForEvent( |
| 185 | + event.room_id, |
| 186 | + event.event_id, |
| 187 | + this.warningText |
| 188 | + ); |
| 189 | + } else { |
| 190 | + return Ok(undefined); |
| 191 | + } |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +export type MentionLimitProtectionCapabilities = { |
| 196 | + eventConsequences: EventConsequences; |
| 197 | + userConsequences: UserConsequences; |
| 198 | +}; |
| 199 | + |
| 200 | +describeProtection< |
| 201 | + MentionLimitProtectionCapabilities, |
| 202 | + Draupnir, |
| 203 | + typeof MentionLimitProtectionSettings |
| 204 | +>({ |
| 205 | + name: "MentionLimitProtection", |
| 206 | + description: `A potection that will remove any messages with |
| 207 | + a number of mentions over a preconfigured limit. |
| 208 | + Please read the documentation https://the-draupnir-project.github.io/draupnir-documentation/protections/mention-limit-protection.`, |
| 209 | + capabilityInterfaces: { |
| 210 | + eventConsequences: "EventConsequences", |
| 211 | + userConsequences: "UserConsequences", |
| 212 | + }, |
| 213 | + defaultCapabilities: { |
| 214 | + eventConsequences: "StandardEventConsequences", |
| 215 | + userConsequences: "StandardUserConsequences", |
| 216 | + }, |
| 217 | + configSchema: MentionLimitProtectionSettings, |
| 218 | + factory: (decription, protectedRoomsSet, draupnir, capabilitySet, settings) => |
| 219 | + Ok( |
| 220 | + new MentionLimitProtection( |
| 221 | + decription, |
| 222 | + capabilitySet, |
| 223 | + draupnir.clientPlatform.toRoomMessageSender(), |
| 224 | + protectedRoomsSet, |
| 225 | + settings |
| 226 | + ) |
| 227 | + ), |
| 228 | +}); |
0 commit comments