/[sudobot]/branches/6.x/src/automod/MessageRuleService.ts
ViewVC logotype

Contents of /branches/6.x/src/automod/MessageRuleService.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 18448 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 /**
2 * This file is part of SudoBot.
3 *
4 * Copyright (C) 2021-2023 OSN Developers.
5 *
6 * SudoBot is free software; you can redistribute it and/or modify it
7 * under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * SudoBot is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
15 *
16 * You should have received a copy of the GNU Affero General Public License
17 * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 // TODO: Add an allow/disallow mode to all roles
21
22 import {
23 Message,
24 PermissionFlagsBits,
25 Snowflake,
26 TextChannel,
27 escapeCodeBlock,
28 escapeInlineCode,
29 escapeMarkdown
30 } from "discord.js";
31 import Service from "../core/Service";
32 import { CreateLogEmbedOptions } from "../services/LoggerService";
33 import { HasEventListeners } from "../types/HasEventListeners";
34 import { MessageRuleType } from "../types/MessageRuleSchema";
35 import { log, logError, logWarn } from "../utils/logger";
36 import { escapeRegex, getEmoji } from "../utils/utils";
37
38 export const name = "messageRuleService";
39
40 type RuleHandlerMethod = Extract<keyof MessageRuleService, `rule${string}`>;
41
42 type RuleInfo =
43 | RuleHandlerMethod
44 | {
45 method: RuleHandlerMethod;
46 autoHandleModes?: boolean;
47 };
48
49 const handlers: Record<MessageRuleType["type"], RuleInfo> = {
50 domain: {
51 method: "ruleDomain",
52 autoHandleModes: false
53 },
54 blocked_file_extension: "ruleBlockedFileExtension",
55 blocked_mime_type: "ruleBlockedMimeType",
56 anti_invite: "ruleAntiInvite",
57 regex_filter: "ruleRegexFilter",
58 block_repeated_text: "ruleRepeatedText",
59 block_mass_mention: "ruleBlockMassMention",
60 regex_must_match: "ruleRegexMustMatch"
61 };
62
63 type MessageRuleAction = MessageRuleType["actions"][number];
64
65 export default class MessageRuleService extends Service implements HasEventListeners {
66 private config(guildId: Snowflake) {
67 return this.client.configManager.config[guildId]?.message_rules;
68 }
69
70 async onMessageCreate(message: Message<boolean>) {
71 if (message.author.bot) {
72 return false;
73 }
74
75 const config = this.config(message.guildId!);
76
77 if (
78 !config?.enabled ||
79 config?.global_disabled_channels?.includes(message.channelId!) ||
80 (await this.client.permissionManager.isImmuneToAutoMod(message.member!, PermissionFlagsBits.ManageGuild))
81 ) {
82 return false;
83 }
84
85 return this.processMessageRules(message, config.rules);
86 }
87
88 private async processMessageRules(message: Message, rules: Array<MessageRuleType>) {
89 for (const rule of rules) {
90 if (rule.actions.length === 0) {
91 log("No action found in this rule! Considering it as disabled.");
92 continue;
93 }
94
95 if (rule.actions.length === 1 && rule.actions.includes("delete") && !message.deletable) {
96 log("Missing permissions to delete messages, but the rule actions include `delete`. Skipping.");
97 continue;
98 }
99
100 if (rule.actions.includes("mute") && rule.actions.includes("warn")) {
101 logWarn("You cannot include mute and warn together as message rule actions! Skipping.");
102 continue;
103 }
104
105 if (rule.disabled_channels.includes(message.channelId!)) {
106 logWarn("This rule is disabled in this channel.");
107 continue;
108 }
109
110 if (rule.immune_users.includes(message.author.id)) {
111 logWarn("This user is immune to this rule.");
112 continue;
113 }
114
115 if (message.member?.roles.cache.hasAny(...rule.immune_roles)) {
116 logWarn("This user is immune to this rule, due to having some whitelisted roles.");
117 continue;
118 }
119
120 const handlerFunctionInfo = handlers[rule.type];
121 const handlerFunctionName: RuleHandlerMethod | undefined =
122 typeof handlerFunctionInfo === "string" ? handlerFunctionInfo : handlerFunctionInfo?.method;
123 const handlerFunctionMetaInfo = (typeof handlerFunctionInfo === "string" ? null : handlerFunctionInfo) ?? {
124 method: handlerFunctionName,
125 autoHandleModes: true
126 };
127
128 if (!handlerFunctionInfo || !handlerFunctionName?.startsWith("rule")) {
129 continue;
130 }
131
132 const handler = this[handlerFunctionName] as (
133 ...args: any[]
134 ) => Promise<boolean | null | undefined | CreateLogEmbedOptions>;
135
136 if (typeof handler !== "function") {
137 continue;
138 }
139
140 try {
141 const result = await handler.call(this, message, rule);
142 const inverse = rule.mode === "inverse";
143 const { autoHandleModes } = handlerFunctionMetaInfo;
144
145 if ((result && !inverse) || (inverse && ((autoHandleModes && !result) || (!autoHandleModes && result)))) {
146 try {
147 for (const action of rule.actions) {
148 log("Taking action: ", action);
149 await this.takeAction(message, rule, action);
150 }
151
152 const embedOptions =
153 result && typeof result === "object" && !inverse
154 ? result
155 : inverse
156 ? {
157 options: {
158 description: `${getEmoji(this.client, "info")} This rule was __inversed__.`,
159 ...(result && typeof result === "object" && "options" in result ? result.options : {})
160 },
161 ...(result && typeof result === "object" ? result : {})
162 }
163 : undefined;
164
165 log(embedOptions);
166
167 await this.client.logger
168 .logMessageRuleAction({
169 message,
170 actions: rule.actions,
171 rule: rule.type,
172 embedOptions
173 })
174 .catch(logError);
175 } catch (e) {
176 logError(e);
177 }
178
179 return true;
180 }
181 } catch (e) {
182 logError(e);
183 continue;
184 }
185 }
186
187 return false;
188 }
189
190 private getReason(rule: MessageRuleType, key: Extract<keyof MessageRuleType, `${string}_reason`>) {
191 return rule[key] ?? rule.common_reason ?? "Your message violated the server rules. Please be careful next time.";
192 }
193
194 private async takeAction(message: Message, rule: MessageRuleType, action: MessageRuleAction) {
195 switch (action) {
196 case "delete":
197 if (message.deletable) {
198 await message.delete().catch(logError);
199 }
200
201 break;
202
203 case "verbal_warn":
204 {
205 const content = this.getReason(rule, "verbal_warning_reason");
206
207 await message.reply({
208 content
209 });
210 }
211
212 break;
213
214 case "warn":
215 {
216 const reason = this.getReason(rule, "warning_reason");
217
218 await this.client.infractionManager.createMemberWarn(message.member!, {
219 guild: message.guild!,
220 moderator: this.client.user!,
221 notifyUser: true,
222 reason,
223 sendLog: true
224 });
225 }
226
227 break;
228
229 case "mute":
230 {
231 const reason = this.getReason(rule, "mute_reason");
232
233 await this.client.infractionManager.createMemberMute(message.member!, {
234 guild: message.guild!,
235 moderator: this.client.user!,
236 notifyUser: true,
237 reason,
238 sendLog: true,
239 autoRemoveQueue: true,
240 duration: rule.mute_duration === -1 ? 60_000 : rule.mute_duration
241 });
242 }
243
244 break;
245
246 case "clear":
247 {
248 await this.client.infractionManager.bulkDeleteMessages({
249 guild: message.guild!,
250 moderator: this.client.user!,
251 count: 50,
252 sendLog: true,
253 messageChannel: message.channel! as TextChannel,
254 user: message.member!.user,
255 reason: "Message rule triggered"
256 });
257 }
258
259 break;
260 }
261 }
262
263 async ruleDomain(message: Message, rule: Extract<MessageRuleType, { type: "domain" }>) {
264 if (message.content.trim() === "") {
265 return null;
266 }
267
268 const { domains, scan_links_only, mode } = rule;
269
270 const prefix = scan_links_only ? `(https?://)` : `(https?://)?`;
271 let specificRegex = `${prefix}(`;
272 const genericRegex = `${prefix}([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})`;
273
274 let index = 0;
275 for (const domain of domains) {
276 const escapedDomain = escapeRegex(domain).replace(/\\\*/g, "[A-Za-z0-9-]+");
277 specificRegex += `${escapedDomain}`;
278
279 if (index < domains.length - 1) {
280 specificRegex += "|";
281 }
282
283 index++;
284 }
285
286 specificRegex += ")\\S*";
287 log(specificRegex);
288
289 const specificMatches = [...(new RegExp(specificRegex, "i").exec(message.content) ?? [])];
290 log(specificMatches);
291
292 const genericMatches = [...(new RegExp(genericRegex, "i").exec(message.content) ?? [])];
293 log(genericMatches);
294
295 if (specificMatches.length > 0) {
296 const cleanedDomain = (specificMatches[2] ?? specificMatches[1] ?? specificMatches[0]).replace(/https?:\/\//, "");
297 if (mode === "normal") {
298 return {
299 title: "Blocked domain(s) detected",
300 fields: [
301 {
302 name: "Domain",
303 value: `\`${escapeMarkdown(cleanedDomain)}\``
304 }
305 ]
306 } satisfies CreateLogEmbedOptions;
307 } else if (mode === "inverse") {
308 return false;
309 }
310 } else if (genericMatches.length > 0 && mode === "inverse" && !scan_links_only) {
311 const cleanedDomain = (genericMatches[2] ?? genericMatches[1] ?? genericMatches[0]).replace(/https?:\/\//, "");
312 return {
313 title: "Blocked domain(s) detected",
314 fields: [
315 {
316 name: "Domain",
317 value: `\`${escapeMarkdown(cleanedDomain)}\``
318 }
319 ]
320 } satisfies CreateLogEmbedOptions;
321 }
322
323 return false;
324 }
325
326 async ruleBlockedFileExtension(message: Message, rule: Extract<MessageRuleType, { type: "blocked_file_extension" }>) {
327 for (const attachment of message.attachments.values()) {
328 for (const extension of rule.data) {
329 if (attachment.proxyURL.endsWith(`.${extension}`)) {
330 return {
331 title: "File(s) with blocked extensions found",
332 fields: [
333 {
334 name: "File",
335 value: `[${attachment.name}](${attachment.url}): \`.${escapeMarkdown(extension)}\``
336 }
337 ]
338 };
339 }
340 }
341 }
342
343 return null;
344 }
345
346 async ruleBlockedMimeType(message: Message, rule: Extract<MessageRuleType, { type: "blocked_mime_type" }>) {
347 for (const attachment of message.attachments.values()) {
348 if (rule.data.includes(attachment.contentType ?? "unknown")) {
349 return {
350 title: "File(s) with blocked MIME-type found",
351 fields: [
352 {
353 name: "File",
354 value: `[${attachment.name}](${attachment.url}): \`${attachment.contentType}\``
355 }
356 ]
357 };
358 }
359 }
360
361 return null;
362 }
363
364 async ruleAntiInvite(message: Message, rule: Extract<MessageRuleType, { type: "anti_invite" }>) {
365 if (message.content.trim() === "") {
366 return null;
367 }
368
369 const allowedInviteCodes = rule.allowed_invite_codes;
370 const regex = /(https?:\/\/)?discord.(gg|com\/invite)\/([A-Za-z0-9_]+)/gi;
371 const matches = message.content.matchAll(regex);
372
373 for (const match of matches) {
374 if (match[3] && !allowedInviteCodes.includes(match[3])) {
375 if (rule.allow_internal_invites && this.client.inviteTracker.invites.has(`${message.guildId!}_${match[3]}`)) {
376 continue;
377 }
378
379 return {
380 title: "Posted Invite(s)",
381 fields: [
382 {
383 name: "Invite URL",
384 value: `\`https://discord.gg/${match[3]}\``
385 }
386 ]
387 };
388 }
389 }
390
391 return null;
392 }
393
394 async ruleRegexMustMatch(message: Message, rule: Extract<MessageRuleType, { type: "regex_must_match" }>) {
395 if (message.content.trim() === "") {
396 return null;
397 }
398
399 const { patterns } = rule;
400
401 for (const pattern of patterns) {
402 const regex = new RegExp(
403 typeof pattern === "string" ? pattern : pattern[0],
404 typeof pattern === "string" ? "gi" : pattern[1]
405 );
406
407 if (regex.test(message.content)) {
408 return null;
409 }
410 }
411
412 return {
413 title: "Message did not match with the specified regex patterns"
414 };
415 }
416
417 async ruleRegexFilter(message: Message, rule: Extract<MessageRuleType, { type: "regex_filter" }>) {
418 if (message.content.trim() === "") {
419 return null;
420 }
421
422 const { patterns } = rule;
423
424 for (const pattern of patterns) {
425 const regex = new RegExp(
426 typeof pattern === "string" ? pattern : pattern[0],
427 typeof pattern === "string" ? "gi" : pattern[1]
428 );
429
430 if (regex.test(message.content)) {
431 return {
432 title: "Message matched with a blocked regex pattern",
433 fields: [
434 {
435 name: "Pattern Info",
436 value: `Pattern: \`${escapeInlineCode(
437 escapeCodeBlock(typeof pattern === "string" ? pattern : pattern[0])
438 )}\`\nFlags: \`${escapeInlineCode(
439 escapeCodeBlock(typeof pattern === "string" ? "gi" : pattern[1])
440 )}\``
441 }
442 ]
443 };
444 }
445 }
446
447 return null;
448 }
449
450 async ruleRepeatedText(message: Message, rule: Extract<MessageRuleType, { type: "block_repeated_text" }>) {
451 if (message.content.trim() === "") {
452 return null;
453 }
454
455 if (new RegExp("(.+)\\1{" + rule.max_repeated_chars + ",}", "gm").test(message.content)) {
456 return {
457 title: "Repeated text detected",
458 fields: [
459 {
460 name: "Description",
461 value: `Too many repetitive characters were found`
462 }
463 ]
464 };
465 } else if (new RegExp("^(.+)(?: +\\1){" + rule.max_repeated_words + "}", "gm").test(message.content)) {
466 return {
467 title: "Repeated text detected",
468 fields: [
469 {
470 name: "Description",
471 value: `Too many repetitive words were found`
472 }
473 ]
474 };
475 }
476
477 return null;
478 }
479
480 async ruleBlockMassMention(message: Message, rule: Extract<MessageRuleType, { type: "block_mass_mention" }>) {
481 if (message.content.trim() === "") {
482 return null;
483 }
484
485 let data = [...message.content.matchAll(new RegExp(`\<\@[0-9]+\>`, "gm"))];
486
487 console.log("users", data);
488
489 if (data.length >= rule.max_mentions || (rule.max_user_mentions > 0 && data.length >= rule.max_user_mentions)) {
490 return {
491 title: "Mass mentions detected",
492 fields: [
493 {
494 name: "Description",
495 value: `Too many users were mentioned`
496 }
497 ]
498 };
499 }
500
501 data = [...message.content.matchAll(new RegExp(`\<\@\&[0-9]+\>`, "gm"))];
502
503 if (data.length >= rule.max_mentions || (rule.max_role_mentions > 0 && data.length >= rule.max_role_mentions)) {
504 return {
505 title: "Repeated text detected",
506 fields: [
507 {
508 name: "Description",
509 value: `Too many roles were mentioned`
510 }
511 ]
512 };
513 }
514
515 return null;
516 }
517 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26