/[sudobot]/branches/8.x/src/services/InfractionManager.ts
ViewVC logotype

Annotation of /branches/8.x/src/services/InfractionManager.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (hide annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 50537 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 rakinar2 577 /**
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     import { Infraction, InfractionType } from "@prisma/client";
21     import { AlignmentEnum, AsciiTable3 } from "ascii-table3";
22     import { formatDistanceToNowStrict } from "date-fns";
23     import {
24     APIEmbed,
25     APIEmbedField,
26     APIUser,
27     ChannelType,
28     ColorResolvable,
29     DiscordAPIError,
30     EmbedBuilder,
31     EmbedField,
32     Guild,
33     GuildMember,
34     Message,
35     MessageResolvable,
36     OverwriteData,
37     PermissionFlagsBits,
38     PrivateThreadChannel,
39     Snowflake,
40     TextBasedChannel,
41     TextChannel,
42     ThreadAutoArchiveDuration,
43     User,
44     escapeCodeBlock,
45     escapeMarkdown,
46     time
47     } from "discord.js";
48     import path from "path";
49     import { CommandAbortedError } from "../core/Command";
50     import Service from "../core/Service";
51     import { log, logError } from "../utils/Logger";
52     import QueueEntry from "../utils/QueueEntry";
53     import { safeChannelFetch } from "../utils/fetch";
54     import { getEmoji, wait } from "../utils/utils";
55    
56     export const name = "infractionManager";
57    
58     export default class InfractionManager extends Service {
59     protected readonly multiplier: Record<InfractionType, number> = {
60     BAN: 30,
61     MASSBAN: 30,
62     TEMPBAN: 25,
63     KICK: 20,
64     MASSKICK: 20,
65     BEAN: 0,
66     BULK_DELETE_MESSAGE: 1,
67     MUTE: 10,
68     NOTE: 0,
69     SOFTBAN: 20,
70     TIMEOUT: 10,
71     TIMEOUT_REMOVE: 0,
72     UNBAN: 0,
73     UNMUTE: 0,
74     WARNING: 5
75     };
76    
77     calculatePoints(infractionCounts: Record<InfractionType, number>) {
78     let points = 0;
79    
80     for (const type in infractionCounts) {
81     points +=
82     (infractionCounts[type as InfractionType] ?? 0) *
83     this.multiplier[type as InfractionType];
84     }
85    
86     return points;
87     }
88    
89     recommendAction(infractions: Array<{ _count: number; type: InfractionType }>) {
90     const infractionCounts = {} as Record<InfractionType, number>;
91    
92     for (const { type, _count: count } of infractions) {
93     infractionCounts[type] = count;
94     }
95    
96     for (const key in InfractionType) {
97     infractionCounts[key as InfractionType] ??= 0;
98     }
99    
100     const points = this.calculatePoints(infractionCounts);
101    
102     if ((infractionCounts.BAN ?? 0) > 0 || (infractionCounts.MASSBAN ?? 0) > 0) {
103     return { action: "Permanent Ban", points };
104     }
105    
106     if ((infractionCounts.MASSKICK ?? 0) > 0) {
107     return { action: "Kick", points };
108     }
109    
110     if ((infractionCounts.TEMPBAN ?? 0) > 0 && (infractionCounts.TEMPBAN ?? 0) < 3) {
111     return { action: `Temporary Ban for **${infractionCounts.TEMPBAN}** days`, points };
112     } else if ((infractionCounts.TEMPBAN ?? 0) >= 3) {
113     return { action: "Permanent Ban", points };
114     }
115    
116     if (points >= 60) {
117     return { action: "Permanent Ban", points };
118     } else if (points >= 50 && points < 60) {
119     return { action: `Temporary Ban for **${points - 50 + 1}** days`, points };
120     } else if (points >= 45 && points < 50) {
121     return { action: "Softban", points };
122     } else if (points >= 40 && points < 45) {
123     return { action: "Kick", points };
124     } else if (points >= 20 && points < 40) {
125     return {
126     action: `Mute for ${
127     points < 30
128     ? `**${points - 20 + 1}** hour${points === 20 ? "" : "s"}`
129     : `**${points - 30 + 1}** days${points === 30 ? "" : "s"}`
130     }`,
131     points
132     };
133     } else if (points >= 10 && points < 20) {
134     return {
135     action: "Manual Warning",
136     points
137     };
138     } else if (points > 0) {
139     return {
140     action: "Verbal Warning",
141     points
142     };
143     }
144    
145     return { action: "None", points };
146     }
147    
148     summarizeInfractionsGroup(infractions: Array<{ _count: number; type: InfractionType }>) {
149     let string = "";
150     let totalCount = 0;
151    
152     for (const { type, _count: count } of infractions) {
153     string += `**${count}** ${type[0]}${type.substring(1).toLowerCase()}${
154     count === 1 ? "" : "s"
155     }, `;
156     totalCount += count;
157     }
158    
159     return string === "" ? "No infractions yet" : `${string}**${totalCount}** total`;
160     }
161    
162     getInfractionCountsInGroup(userId: string, guildId: string) {
163     return this.client.prisma.infraction.groupBy({
164     by: "type",
165     where: {
166     userId,
167     guildId
168     },
169     _count: true
170     });
171     }
172    
173     async getUserStats(userId: string, guildId: string) {
174     const infractions = await this.getInfractionCountsInGroup(userId, guildId);
175     return {
176     summary: this.summarizeInfractionsGroup(infractions),
177     ...this.recommendAction(infractions)
178     };
179     }
180    
181     public typeToString(type: InfractionType) {
182     return type === InfractionType.BULK_DELETE_MESSAGE
183     ? "Bulk message delete"
184     : type[0] + type.substring(1).toLowerCase();
185     }
186    
187     generateInfractionDetailsEmbed(user: User | null, infraction: Infraction) {
188     let metadataString = "";
189    
190     if (infraction.metadata && typeof infraction.metadata === "object") {
191     const entries = Object.entries(infraction.metadata as Record<string, string>);
192    
193     if (entries.length > 0) {
194     metadataString += "```\n";
195    
196     for (const [key, value] of entries) {
197     log(key, value);
198     metadataString += `${key}: ${escapeCodeBlock(`${value}`)}\n`;
199     }
200    
201     metadataString += "\n```";
202     }
203     }
204    
205     return new EmbedBuilder({
206     author: {
207     name: user?.username ?? "Unknown User",
208     iconURL: user?.displayAvatarURL() ?? undefined
209     },
210     color: 0x007bff,
211     fields: [
212     {
213     name: "ID",
214     value: infraction.id.toString(),
215     inline: true
216     },
217     {
218     name: "Action Type",
219     value: this.typeToString(infraction.type),
220     inline: true
221     },
222     ...(infraction.queueId
223     ? [
224     {
225     name: "Associated Queue ID",
226     value: infraction.queueId.toString(),
227     inline: true
228     }
229     ]
230     : []),
231     {
232     name: "User",
233     value: `${user?.username ?? `<@${infraction.userId}>`} (${infraction.userId})`
234     },
235     {
236     name: "Responsible Moderator",
237     value: `<@${infraction.moderatorId}> (${infraction.moderatorId})`,
238     inline: true
239     },
240     {
241     name: "Metadata",
242     value: metadataString === "" ? "*No metadata available*" : metadataString
243     },
244     {
245     name: "Reason",
246     value: infraction.reason ?? "*No reason provided*"
247     },
248    
249     {
250     name: "Created At",
251     value: `${time(infraction.createdAt, "F")} (${time(
252     infraction.createdAt,
253     "R"
254     )})`,
255     inline: true
256     },
257     {
258     name: "Updated At",
259     value: `${time(infraction.updatedAt, "F")} (${time(
260     infraction.updatedAt,
261     "R"
262     )})`,
263     inline: true
264     },
265     ...(infraction.expiresAt
266     ? [
267     {
268     name: `Expire${
269     infraction.expiresAt.getTime() <= Date.now() ? "d" : "s"
270     } At`,
271     value: `${time(infraction.expiresAt, "F")} (${time(
272     infraction.expiresAt,
273     "R"
274     )})`,
275     inline: true
276     }
277     ]
278     : [])
279     ]
280     }).setTimestamp();
281     }
282    
283     public processInfractionReason(
284     guildId: Snowflake,
285     reason?: string | null,
286     abortOnNotFound = false
287     ) {
288     if (!reason?.length) {
289     return null;
290     }
291    
292     let finalReason = reason;
293     const templates =
294     this.client.configManager.config[guildId]?.infractions?.reason_templates ?? {};
295     const templateWrapper =
296     this.client.configManager.config[guildId]?.infractions
297     ?.reason_template_placeholder_wrapper ?? "{{%name%}}";
298    
299     for (const key in templates) {
300     const placeholder = templateWrapper.replace("%name%", `( *)${key}( *)`);
301     finalReason = finalReason.replace(new RegExp(placeholder, "gi"), templates[key]);
302     }
303    
304     if (abortOnNotFound) {
305     const matches = [...finalReason.matchAll(/\{\{[A-Za-z0-9_-]+\}\}/gi)];
306    
307     if (matches.length > 0) {
308     const abortReason = `${getEmoji(
309     this.client,
310     "error"
311     )} The following placeholders were not found in the reason: \`${matches
312     .map(m => m[0])
313     .join("`, `")}\`
314     `;
315     throw new CommandAbortedError(abortReason);
316     }
317     }
318    
319     return finalReason;
320     }
321    
322     private async sendDMBuildEmbed(guild: Guild, options: SendDMOptions) {
323     const { fields, description, actionDoneName, id, reason, color, title } = options;
324    
325     const internalFields: EmbedField[] = [
326     ...(this.client.configManager.config[guild.id]!.infractions?.send_ids_to_user
327     ? [
328     {
329     name: "Infraction ID",
330     value: `${id}`,
331     inline: false
332     }
333     ]
334     : [])
335     ];
336    
337     return new EmbedBuilder({
338     author: {
339     name: actionDoneName
340     ? `You have been ${actionDoneName} in ${guild.name}`
341     : title ?? "",
342     iconURL: guild.iconURL() ?? undefined
343     },
344     description,
345     fields: [
346     {
347     name: "Reason",
348     value: reason ?? "*No reason provided*"
349     },
350     ...(fields
351     ? typeof fields === "function"
352     ? await fields(internalFields)
353     : fields
354     : []),
355     ...(typeof fields === "function" ? [] : internalFields ?? [])
356     ]
357     })
358     .setTimestamp()
359     .setColor(color ?? 0x0f14a60);
360     }
361    
362     private async sendDM(user: User, guild: Guild, options: SendDMOptions) {
363     const embed = await this.sendDMBuildEmbed(guild, options);
364     const { fallback = false, infraction } = options;
365    
366     try {
367     await user.send({
368     embeds: [embed]
369     });
370    
371     return true;
372     } catch (e) {
373     logError(e);
374    
375     if (!fallback || !infraction) {
376     return false;
377     }
378    
379     try {
380     return await this.notifyUserFallback({
381     infraction,
382     user,
383     guild,
384     sendDMOptions: options,
385     embed
386     });
387     } catch (e) {
388     logError(e);
389     return false;
390     }
391     }
392     }
393    
394     async createUserSoftban(
395     user: User,
396     {
397     guild,
398     moderator,
399     reason,
400     deleteMessageSeconds,
401     notifyUser,
402     sendLog,
403     abortOnTemplateNotFound
404     }: CreateUserBanOptions & { deleteMessageSeconds: number }
405     ) {
406     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
407    
408     const infraction = await this.client.prisma.infraction.create({
409     data: {
410     type: InfractionType.SOFTBAN,
411     userId: user.id,
412     guildId: guild.id,
413     reason,
414     moderatorId: moderator.id,
415     metadata: {
416     deleteMessageSeconds
417     }
418     }
419     });
420     const { id } = infraction;
421    
422     if (sendLog)
423     this.client.loggerService.logUserSoftBan({
424     moderator,
425     guild,
426     id: `${id}`,
427     user,
428     deleteMessageSeconds,
429     reason
430     });
431    
432     if (notifyUser) {
433     await this.sendDM(user, guild, {
434     id,
435     actionDoneName: "softbanned",
436     reason
437     });
438     }
439    
440     try {
441     await guild.bans.create(user, {
442     reason,
443     deleteMessageSeconds
444     });
445    
446     log("Seconds: " + deleteMessageSeconds);
447    
448     await wait(1500);
449     await guild.bans.remove(user, `Softban remove: ${reason}`);
450     return infraction;
451     } catch (e) {
452     logError(e);
453     return null;
454     }
455     }
456    
457     async createUserBan(
458     user: User,
459     {
460     guild,
461     moderator,
462     reason,
463     deleteMessageSeconds,
464     notifyUser,
465     duration,
466     sendLog,
467     autoRemoveQueue,
468     abortOnTemplateNotFound
469     }: CreateUserBanOptions
470     ) {
471     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
472    
473     try {
474     await guild.bans.create(user, {
475     reason,
476     deleteMessageSeconds
477     });
478     } catch (e) {
479     logError(e);
480     await this.autoRemoveUnbanQueue(guild, user).catch(logError);
481     return null;
482     }
483    
484     const infraction = await this.client.prisma.infraction.create({
485     data: {
486     type: duration ? InfractionType.TEMPBAN : InfractionType.BAN,
487     userId: user.id,
488     guildId: guild.id,
489     reason,
490     moderatorId: moderator.id,
491     metadata: {
492     deleteMessageSeconds,
493     duration
494     }
495     }
496     });
497    
498     if (autoRemoveQueue) {
499     await this.autoRemoveUnbanQueue(guild, user).catch(logError);
500     }
501    
502     if (sendLog)
503     this.client.loggerService.logUserBan({
504     moderator,
505     guild,
506     id: `${infraction.id}`,
507     user,
508     deleteMessageSeconds,
509     reason,
510     duration
511     });
512    
513     if (notifyUser) {
514     await this.sendDM(user, guild, {
515     id: infraction.id,
516     actionDoneName: "banned",
517     reason,
518     fields: duration
519     ? [
520     {
521     name: "Duration",
522     value: formatDistanceToNowStrict(new Date(Date.now() - duration))
523     }
524     ]
525     : undefined
526     }).catch(logError);
527     }
528    
529     log("Seconds: " + deleteMessageSeconds);
530    
531     if (duration) {
532     log("Added unban queue");
533    
534     await this.client.queueManager.add(
535     new QueueEntry({
536     args: [user.id],
537     client: this.client,
538     filePath: path.resolve(__dirname, "../queues/UnbanQueue"),
539     guild,
540     name: "UnbanQueue",
541     createdAt: new Date(),
542     userId: user.id,
543     willRunAt: new Date(Date.now() + duration)
544     })
545     );
546     }
547    
548     return infraction;
549     }
550    
551     async createUserFakeBan(
552     user: User,
553     { guild, reason, notifyUser, duration, abortOnTemplateNotFound }: CreateUserBanOptions
554     ) {
555     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
556     const id = Math.round(Math.random() * 1000);
557    
558     if (notifyUser) {
559     await this.sendDM(user, guild, {
560     id,
561     actionDoneName: "banned",
562     reason,
563     fields: duration
564     ? [
565     {
566     name: "Duration",
567     value: formatDistanceToNowStrict(new Date(Date.now() - duration))
568     }
569     ]
570     : undefined
571     });
572     }
573    
574     return { id, reason };
575     }
576    
577     async createUserShot(
578     user: User,
579     {
580     guild,
581     reason,
582     moderator,
583     abortOnTemplateNotFound
584     }: Omit<CommonOptions, "notifyUser" | "sendLog">
585     ) {
586     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
587     const id = Math.round(Math.random() * 1000);
588    
589     await this.sendDM(user, guild, {
590     title: `You have gotten a shot in ${guild.name}`,
591     id,
592     reason,
593     fields: [
594     {
595     name: "💉 Doctor",
596     value: `${moderator.username}`
597     }
598     ],
599     color: 0x007bff
600     });
601    
602     return { id, reason };
603     }
604    
605     async createUserBean(
606     user: User,
607     {
608     guild,
609     moderator,
610     reason,
611     abortOnTemplateNotFound
612     }: Pick<CreateUserBanOptions, "guild" | "moderator" | "reason" | "abortOnTemplateNotFound">
613     ) {
614     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
615     const infraction = await this.client.prisma.infraction.create({
616     data: {
617     type: InfractionType.BEAN,
618     userId: user.id,
619     guildId: guild.id,
620     reason,
621     moderatorId: moderator.id
622     }
623     });
624    
625     await this.sendDM(user, guild, {
626     id: infraction.id,
627     actionDoneName: "beaned",
628     reason,
629     color: 0x007bff
630     });
631    
632     return infraction;
633     }
634    
635     private async autoRemoveUnbanQueue(guild: Guild, user: User) {
636     for (const queue of this.client.queueManager.queues.values()) {
637     if (
638     queue.options.name === "UnbanQueue" &&
639     queue.options.guild.id === guild.id &&
640     queue.options.args[0] === user.id
641     ) {
642     await this.client.queueManager.remove(queue);
643     }
644     }
645     }
646    
647     async removeUserBan(
648     user: User,
649     {
650     guild,
651     moderator,
652     reason,
653     autoRemoveQueue = true,
654     sendLog,
655     abortOnTemplateNotFound
656     }: CommonOptions & { autoRemoveQueue?: boolean }
657     ) {
658     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
659    
660     try {
661     await guild.bans.remove(user, reason);
662     } catch (error) {
663     logError(error);
664     return {
665     error,
666     noSuchBan:
667     error instanceof DiscordAPIError &&
668     error.code === 10026 &&
669     error.status === 404,
670     infraction: null
671     };
672     }
673    
674     if (autoRemoveQueue) await this.autoRemoveUnbanQueue(guild, user);
675    
676     const infraction = await this.client.prisma.infraction.create({
677     data: {
678     type: InfractionType.UNBAN,
679     userId: user.id,
680     guildId: guild.id,
681     reason,
682     moderatorId: moderator.id
683     }
684     });
685    
686     if (sendLog)
687     this.client.loggerService.logUserUnban({
688     moderator,
689     guild,
690     id: `${infraction.id}`,
691     user,
692     reason
693     });
694    
695     return { infraction };
696     }
697    
698     async createMemberKick(
699     member: GuildMember,
700     { guild, moderator, reason, notifyUser, abortOnTemplateNotFound }: CommonOptions
701     ) {
702     if (!member.kickable) return null;
703    
704     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
705    
706     try {
707     await member.kick(reason);
708     } catch (e) {
709     logError(e);
710     return null;
711     }
712    
713     const infraction = await this.client.prisma.infraction.create({
714     data: {
715     type: InfractionType.KICK,
716     userId: member.user.id,
717     guildId: guild.id,
718     reason,
719     moderatorId: moderator.id
720     }
721     });
722    
723     this.client.loggerService.logMemberKick({
724     moderator,
725     guild,
726     id: `${infraction.id}`,
727     member,
728     reason
729     });
730    
731     if (notifyUser) {
732     await this.sendDM(member.user, guild, {
733     id: infraction.id,
734     actionDoneName: "kicked",
735     reason
736     });
737     }
738    
739     return infraction;
740     }
741    
742     async createMemberWarn(
743     member: GuildMember,
744     { guild, moderator, reason, notifyUser, abortOnTemplateNotFound }: CommonOptions
745     ) {
746     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
747    
748     const infraction = await this.client.prisma.infraction.create({
749     data: {
750     type: InfractionType.WARNING,
751     userId: member.user.id,
752     guildId: guild.id,
753     reason,
754     moderatorId: moderator.id
755     }
756     });
757    
758     this.client.loggerService.logMemberWarning({
759     moderator,
760     member,
761     guild,
762     id: `${infraction.id}`,
763     reason
764     });
765    
766     let result: boolean | null = false;
767    
768     if (notifyUser) {
769     result = await this.sendDM(member.user, guild, {
770     id: infraction.id,
771     actionDoneName: "warned",
772     reason,
773     fallback: true,
774     infraction
775     });
776     }
777    
778     return { id: infraction.id, result, reason, infraction };
779     }
780    
781     bulkDeleteMessagesApplyFilters(message: Message, filters: BulkDeleteFilter[]) {
782     for (const filter of filters) {
783     if (!filter(message)) {
784     return false;
785     }
786     }
787    
788     return true;
789     }
790    
791     async bulkDeleteMessages({
792     user,
793     messagesToDelete,
794     messageChannel,
795     guild,
796     moderator,
797     reason,
798     sendLog,
799     count: messageCount,
800     logOnly = false,
801     filters = [],
802     offset = 0,
803     abortOnTemplateNotFound
804     }: BulkDeleteMessagesOptions) {
805     if (messageChannel && !(messagesToDelete && messagesToDelete.length === 0)) {
806     reason =
807     this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
808    
809     let messages: MessageResolvable[] | null = messagesToDelete ?? null;
810    
811     if (!logOnly && messages === null) {
812     log("The messagesToDelete was option not provided. Fetching messages manually.");
813    
814     try {
815     const allMessages = await messageChannel.messages.fetch({ limit: 100 });
816     messages = [];
817    
818     let i = 0;
819    
820     for (const [, m] of allMessages) {
821     if (i < offset) {
822     i++;
823     continue;
824     }
825    
826     if (messageCount && i >= messageCount) {
827     break;
828     }
829    
830     if (
831     (user ? m.author.id === user.id : true) &&
832     Date.now() - m.createdAt.getTime() <= 1000 * 60 * 60 * 24 * 7 * 2 &&
833     this.bulkDeleteMessagesApplyFilters(m, filters)
834     ) {
835     messages.push(m);
836     i++;
837     }
838     }
839     } catch (e) {
840     logError(e);
841     messages = null;
842     }
843     }
844    
845     const count = messages ? messages.length : 0;
846    
847     const infraction = user
848     ? await this.client.prisma.infraction.create({
849     data: {
850     type: InfractionType.BULK_DELETE_MESSAGE,
851     guildId: guild.id,
852     moderatorId: moderator.id,
853     userId: user.id,
854     metadata: {
855     count
856     },
857     reason
858     }
859     })
860     : null;
861    
862     if (
863     sendLog &&
864     this.client.configManager.config[messageChannel.guildId!]?.logging?.enabled &&
865     this.client.configManager.config[messageChannel.guildId!]?.logging?.events
866     .message_bulk_delete
867     ) {
868     this.client.loggerService
869     .logBulkDeleteMessages({
870     channel: messageChannel,
871     count,
872     guild,
873     id: !infraction ? undefined : `${infraction.id}`,
874     user,
875     moderator,
876     reason,
877     messages: messages ?? []
878     })
879     .catch(logError);
880     }
881    
882     if (!logOnly && messages) {
883     try {
884     await messageChannel.bulkDelete(messages);
885     const reply = await messageChannel.send(
886     `${getEmoji(this.client, "check")} Deleted ${messages.length} messages${
887     user ? ` from user **@${escapeMarkdown(user.username)}**` : ""
888     }`
889     );
890    
891     setTimeout(() => reply.delete().catch(logError), 5000);
892     return true;
893     } catch (e) {
894     logError(e);
895     }
896     }
897    
898     return false;
899     }
900     }
901    
902     async createMemberMute(
903     member: GuildMember,
904     {
905     guild,
906     moderator,
907     reason,
908     notifyUser,
909     duration,
910     messagesToDelete,
911     messageChannel,
912     bulkDeleteReason,
913     sendLog,
914     autoRemoveQueue,
915     abortOnTemplateNotFound
916     }: CreateMemberMuteOptions
917     ) {
918     const mutedRole = this.client.configManager.config[guild.id]?.muting?.role;
919    
920     if (mutedRole || duration) {
921     reason =
922     this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
923     }
924    
925     if (!mutedRole) {
926     if (!duration) {
927     return {
928     error: "Muted role is not configured and duration wasn't provided, please set the muted role to perform this operation."
929     };
930     }
931    
932     try {
933     await member.disableCommunicationUntil(new Date(Date.now() + duration), reason);
934     } catch (e) {
935     logError(e);
936     return { error: "Failed to timeout user, make sure I have enough permissions!" };
937     }
938     } else {
939     try {
940     await member.roles.add(mutedRole, reason);
941     } catch (e) {
942     logError(e);
943     return {
944     error: "Failed to assign the muted role to this user. Make sure that I have enough permissions to do it."
945     };
946     }
947     }
948    
949     if (autoRemoveQueue) {
950     for (const queue of this.client.queueManager.queues.values()) {
951     if (
952     queue.options.name === "UnmuteQueue" &&
953     queue.options.guild.id === member.guild.id &&
954     queue.options.args[0] === member.user.id
955     ) {
956     log("Called");
957     await this.client.queueManager.remove(queue);
958     }
959     }
960     }
961    
962     log("Removal succeeded");
963    
964     let queueId: number | undefined;
965    
966     if (duration) {
967     log("Adding queue");
968     queueId = await this.client.queueManager.add(
969     new QueueEntry({
970     args: [member.user.id],
971     guild,
972     client: this.client,
973     createdAt: new Date(),
974     filePath: path.resolve(__dirname, "../queues/UnmuteQueue"),
975     name: "UnmuteQueue",
976     userId: moderator.id,
977     willRunAt: new Date(Date.now() + duration)
978     })
979     );
980     log("Succeeded");
981     }
982    
983     const infraction = await this.client.prisma.infraction.create({
984     data: {
985     type: InfractionType.MUTE,
986     userId: member.user.id,
987     guildId: guild.id,
988     reason,
989     moderatorId: moderator.id,
990     expiresAt: duration ? new Date(Date.now() + duration) : undefined,
991     queueId,
992     metadata: duration ? { duration } : undefined
993     }
994     });
995    
996     const { id } = infraction;
997    
998     if (sendLog) {
999     this.client.loggerService.logMemberMute({
1000     moderator,
1001     member,
1002     guild,
1003     id: `${id}`,
1004     duration,
1005     reason
1006     });
1007     }
1008    
1009     if (messageChannel && !(messagesToDelete && messagesToDelete.length === 0)) {
1010     this.bulkDeleteMessages({
1011     user: member.user,
1012     guild,
1013     moderator,
1014     messagesToDelete,
1015     messageChannel,
1016     sendLog: true,
1017     notifyUser: false,
1018     reason:
1019     bulkDeleteReason ??
1020     `This user was muted with delete messages option specified. The mute reason was: ${
1021     reason ?? "*No reason provided*"
1022     }`
1023     }).catch(logError);
1024     }
1025    
1026     let result: boolean | null = !notifyUser;
1027    
1028     if (notifyUser) {
1029     result = await this.sendDM(member.user, guild, {
1030     id,
1031     actionDoneName: "muted",
1032     reason,
1033     fields: [
1034     {
1035     name: "Duration",
1036     value: `${
1037     duration
1038     ? formatDistanceToNowStrict(new Date(Date.now() - duration))
1039     : "*No duration set*"
1040     }`
1041     }
1042     ],
1043     fallback: true,
1044     infraction
1045     });
1046     }
1047    
1048     return { id, result, reason, infraction };
1049     }
1050    
1051     async removeMemberMute(
1052     member: GuildMember,
1053     {
1054     guild,
1055     moderator,
1056     reason,
1057     notifyUser,
1058     sendLog,
1059     autoRemoveQueue = true,
1060     abortOnTemplateNotFound
1061     }: CommonOptions & { autoRemoveQueue?: boolean }
1062     ): Promise<{
1063     error?: string;
1064     result?: boolean | null;
1065     id?: number;
1066     reason?: string | null;
1067     infraction?: Infraction | null;
1068     }> {
1069     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
1070    
1071     const mutedRole = this.client.configManager.config[guild.id]?.muting?.role;
1072    
1073     if (!mutedRole) {
1074     if (!member.isCommunicationDisabled()) {
1075     return { error: "This user is not muted" };
1076     }
1077    
1078     try {
1079     await member.disableCommunicationUntil(null, reason);
1080     } catch (e) {
1081     logError(e);
1082     return {
1083     error: "Failed to remove timeout from user, make sure I have enough permissions!"
1084     };
1085     }
1086     } else {
1087     try {
1088     await member.roles.remove(mutedRole);
1089     } catch (e) {
1090     logError(e);
1091     return {
1092     error: "Failed to remove the muted role to this user. Make sure that I have enough permissions to do it."
1093     };
1094     }
1095     }
1096    
1097     const infraction = await this.client.prisma.infraction.create({
1098     data: {
1099     type: InfractionType.UNMUTE,
1100     userId: member.user.id,
1101     guildId: guild.id,
1102     reason,
1103     moderatorId: moderator.id
1104     }
1105     });
1106    
1107     if (sendLog) {
1108     this.client.loggerService.logMemberUnmute({
1109     moderator,
1110     member,
1111     guild,
1112     id: `${infraction.id}`,
1113     reason
1114     });
1115     }
1116    
1117     let result: boolean | null = !notifyUser;
1118    
1119     if (notifyUser) {
1120     result = await this.sendDM(member.user, guild, {
1121     id: infraction.id,
1122     actionDoneName: "unmuted",
1123     reason,
1124     color: "Green"
1125     });
1126     }
1127    
1128     if (autoRemoveQueue) {
1129     for (const queue of this.client.queueManager.queues.values()) {
1130     if (
1131     queue.options.name === "UnmuteQueue" &&
1132     queue.options.guild.id === member.guild.id &&
1133     queue.options.args[0] === member.user.id
1134     ) {
1135     log("Called");
1136     await this.client.queueManager.remove(queue);
1137     }
1138     }
1139     }
1140    
1141     return { id: infraction.id, result, reason, infraction };
1142     }
1143    
1144     async createUserMassBan({
1145     users,
1146     sendLog,
1147     reason,
1148     deleteMessageSeconds,
1149     moderator,
1150     guild,
1151     callAfterEach,
1152     abortOnTemplateNotFound,
1153     callback
1154     }: CreateUserMassBanOptions) {
1155     if (users.length > 20) {
1156     return { error: "Cannot perform this operation on more than 20 users" };
1157     }
1158    
1159     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
1160     const startTime = Date.now();
1161    
1162     const createInfractionData = [];
1163     const skippedUsers: string[] = [];
1164     const completedUsers: string[] = [];
1165     let count = 0;
1166     let calledJustNow = false;
1167    
1168     if (callback) {
1169     await callback({
1170     count,
1171     users,
1172     completedUsers,
1173     skippedUsers,
1174     reason
1175     })?.catch(logError);
1176     }
1177    
1178     for (const user of users) {
1179     if (callAfterEach && callback && count !== 0 && count % callAfterEach === 0) {
1180     await callback({
1181     count,
1182     users,
1183     completedUsers,
1184     skippedUsers,
1185     reason
1186     })?.catch(logError);
1187    
1188     calledJustNow = true;
1189     } else calledJustNow = false;
1190    
1191     try {
1192     await guild.bans.create(user, {
1193     reason,
1194     deleteMessageSeconds
1195     });
1196    
1197     completedUsers.push(user);
1198    
1199     createInfractionData.push({
1200     type: InfractionType.MASSBAN,
1201     userId: user,
1202     reason,
1203     moderatorId: moderator.id,
1204     guildId: guild.id,
1205     metadata: {
1206     deleteMessageSeconds
1207     }
1208     });
1209     } catch (e) {
1210     logError(e);
1211     skippedUsers.push(user);
1212     }
1213    
1214     count++;
1215     }
1216    
1217     if (!calledJustNow && callback) {
1218     await callback({
1219     count,
1220     users,
1221     completedUsers,
1222     skippedUsers,
1223     completedIn: Math.round((Date.now() - startTime) / 1000),
1224     reason
1225     })?.catch(logError);
1226     }
1227    
1228     await this.client.prisma.infraction.createMany({
1229     data: createInfractionData
1230     });
1231    
1232     if (sendLog)
1233     await this.client.loggerService.logUserMassBan({
1234     users: completedUsers,
1235     reason,
1236     guild,
1237     moderator,
1238     deleteMessageSeconds
1239     });
1240    
1241     return { success: true };
1242     }
1243    
1244     async createMemberMassKick({
1245     users,
1246     sendLog,
1247     reason,
1248     moderator,
1249     guild,
1250     callAfterEach,
1251     callback,
1252     abortOnTemplateNotFound
1253     }: Omit<CreateUserMassBanOptions, "deleteMessageSeconds">) {
1254     if (users.length > 10) {
1255     return { error: "Cannot perform this operation on more than 10 users" };
1256     }
1257    
1258     reason = this.processInfractionReason(guild.id, reason, abortOnTemplateNotFound) ?? reason;
1259     const startTime = Date.now();
1260    
1261     const createInfractionData = [];
1262     const skippedUsers: string[] = [];
1263     const completedUsers: string[] = [];
1264     let count = 0;
1265     let calledJustNow = false;
1266    
1267     if (callback) {
1268     await callback({
1269     count,
1270     users,
1271     completedUsers,
1272     skippedUsers,
1273     reason
1274     })?.catch(logError);
1275     }
1276    
1277     for (const user of users) {
1278     if (callAfterEach && callback && count !== 0 && count % callAfterEach === 0) {
1279     await callback({
1280     count,
1281     users,
1282     completedUsers,
1283     skippedUsers,
1284     reason
1285     })?.catch(logError);
1286    
1287     calledJustNow = true;
1288     } else calledJustNow = false;
1289    
1290     try {
1291     const member = guild.members.cache.get(user) ?? (await guild.members.fetch(user));
1292     await member.kick(reason);
1293    
1294     completedUsers.push(user);
1295    
1296     createInfractionData.push({
1297     type: InfractionType.MASSKICK,
1298     userId: user,
1299     reason,
1300     moderatorId: moderator.id,
1301     guildId: guild.id
1302     });
1303     } catch (e) {
1304     logError(e);
1305     skippedUsers.push(user);
1306     }
1307    
1308     count++;
1309     }
1310    
1311     if (!calledJustNow && callback) {
1312     await callback({
1313     count,
1314     users,
1315     completedUsers,
1316     skippedUsers,
1317     completedIn: Math.round((Date.now() - startTime) / 1000),
1318     reason
1319     })?.catch(logError);
1320     }
1321    
1322     await this.client.prisma.infraction.createMany({
1323     data: createInfractionData
1324     });
1325    
1326     if (sendLog)
1327     await this.client.loggerService.logUserMassBan({
1328     users: completedUsers,
1329     reason,
1330     guild,
1331     moderator
1332     });
1333    
1334     return { success: true };
1335     }
1336    
1337     async createInfractionHistoryBuffer(user: User | APIUser, guild: Guild) {
1338     const infractions = await this.client.prisma.infraction.findMany({
1339     where: {
1340     userId: user.id,
1341     guildId: guild.id
1342     }
1343     });
1344    
1345     if (infractions.length === 0) {
1346     return { buffer: null, count: 0 };
1347     }
1348    
1349     let fileContents = `*** Infraction History ***\nUser: @${user.username} (${user.id})\nServer: ${guild.name} (${guild.id})\n`;
1350    
1351     fileContents += `Generated By: SudoBot/${this.client.metadata.data.version}\n`;
1352     fileContents += `Total Records: ${infractions.length}\n\n`;
1353    
1354     const table = new AsciiTable3("Infractions");
1355     const fields = ["Type", "Reason", "Date", "Duration"];
1356    
1357     if (this.client.configManager.config[guild.id]?.infractions?.send_ids_to_user) {
1358     fields.unshift("ID");
1359     }
1360    
1361     table.setHeading(...fields);
1362     table.setAlign(3, AlignmentEnum.CENTER);
1363     table.addRowMatrix(
1364     infractions.map(infraction => {
1365     const row: unknown[] = [
1366     infraction.type,
1367     infraction.reason ?? "*No reason provided*",
1368     `${infraction.createdAt.toUTCString()} (${formatDistanceToNowStrict(
1369     infraction.createdAt,
1370     {
1371     addSuffix: true
1372     }
1373     )}) `,
1374     (infraction.metadata as Record<string, number>)?.duration
1375     ? formatDistanceToNowStrict(
1376     new Date(
1377     Date.now() -
1378     (infraction.metadata as Record<string, number>)?.duration
1379     )
1380     )
1381     : "*None*"
1382     ];
1383    
1384     if (this.client.configManager.config[guild.id]?.infractions?.send_ids_to_user) {
1385     row.unshift(infraction.id);
1386     }
1387    
1388     return row;
1389     })
1390     );
1391    
1392     fileContents += table.toString();
1393     fileContents += "\n\n";
1394     fileContents += `Seeing something unexpected? Contact the staff of ${guild.name}.\n`;
1395    
1396     return { buffer: Buffer.from(fileContents), count: infractions.length };
1397     }
1398    
1399     private async notifyUserFallback({
1400     infraction,
1401     user,
1402     guild,
1403     sendDMOptions,
1404     embed
1405     }: NotifyUserFallbackOptions) {
1406     const channelId =
1407     this.client.configManager.config[guild.id]?.infractions?.dm_fallback_parent_channel;
1408     const fallbackMode =
1409     this.client.configManager.config[guild.id]?.infractions?.dm_fallback ?? "none";
1410    
1411     if (!channelId || fallbackMode === "none") {
1412     return false;
1413     }
1414    
1415     const channel = await safeChannelFetch(guild, channelId);
1416    
1417     if (
1418     !channel ||
1419     (fallbackMode === "create_channel" && channel.type !== ChannelType.GuildCategory) ||
1420     (fallbackMode === "create_thread" && (!channel.isTextBased() || channel.isThread()))
1421     ) {
1422     return false;
1423     }
1424    
1425     if (fallbackMode === "create_channel") {
1426     return this.notifyUserInPrivateChannel({
1427     infraction,
1428     user,
1429     parentChannel: channel as TextChannel,
1430     sendDMOptions,
1431     embed
1432     });
1433     } else if (fallbackMode === "create_thread") {
1434     return this.notifyUserInPrivateThread({
1435     infraction,
1436     user,
1437     channel: channel as TextChannel,
1438     sendDMOptions,
1439     embed
1440     });
1441     }
1442    
1443     return true;
1444     }
1445    
1446     private async sendDMEmbedToChannel(
1447     channel: TextBasedChannel | PrivateThreadChannel,
1448     embed: EmbedBuilder,
1449     actionDoneName: ActionDoneName | undefined,
1450     user: User
1451     ) {
1452     const apiEmbed = embed.toJSON();
1453    
1454     const finalEmbed = {
1455     ...apiEmbed,
1456     author: {
1457     ...apiEmbed.author,
1458     name: `You have been ${actionDoneName ?? "given an infraction"}`
1459     }
1460     } satisfies APIEmbed;
1461    
1462     return await channel.send({
1463     content: user.toString(),
1464     embeds: [finalEmbed]
1465     });
1466     }
1467    
1468     private async notifyUserInPrivateChannel({
1469     infraction,
1470     parentChannel,
1471     user,
1472     sendDMOptions: { actionDoneName },
1473     embed
1474     }: Omit<NotifyUserFallbackOptions, "guild"> & { parentChannel: TextChannel }) {
1475     try {
1476     const expiresIn =
1477     this.client.configManager.config[parentChannel.guild.id]?.infractions
1478     ?.dm_fallback_channel_expires_in;
1479    
1480     const channel = await parentChannel.guild.channels.create({
1481     name: `infraction-${infraction.id}`,
1482     type: ChannelType.GuildText,
1483     parent: parentChannel.id,
1484     reason: "Creating fallback channel to notify the user about their infraction",
1485     permissionOverwrites: [
1486     ...parentChannel.permissionOverwrites.cache.values(),
1487     {
1488     id: user.id,
1489     allow: [
1490     PermissionFlagsBits.ViewChannel,
1491     PermissionFlagsBits.ReadMessageHistory
1492     ]
1493     }
1494     ] satisfies OverwriteData[]
1495     });
1496    
1497     await this.sendDMEmbedToChannel(channel, embed, actionDoneName, user);
1498    
1499     if (expiresIn) {
1500     await this.client.queueManager.add(
1501     new QueueEntry({
1502     args: [channel.id],
1503     client: this.client,
1504     createdAt: new Date(),
1505     filePath: path.resolve(__dirname, "../queues/ChannelDeleteQueue"),
1506     guild: parentChannel.guild,
1507     name: "ChannelDeleteQueue",
1508     userId: this.client.user!.id,
1509     willRunAt: new Date(Date.now() + expiresIn)
1510     })
1511     );
1512     }
1513     } catch (e) {
1514     logError(e);
1515     return false;
1516     }
1517    
1518     return true;
1519     }
1520    
1521     private async notifyUserInPrivateThread({
1522     infraction,
1523     channel,
1524     user,
1525     sendDMOptions: { actionDoneName },
1526     embed
1527     }: Omit<NotifyUserFallbackOptions, "guild"> & { channel: TextChannel }) {
1528     try {
1529     const thread = (await channel.threads.create({
1530     name: `Infraction #${infraction.id}`,
1531     autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
1532     type: ChannelType.PrivateThread,
1533     reason: "Creating fallback thread to notify the user about their infraction"
1534     })) as PrivateThreadChannel;
1535    
1536     await thread.members.add(user, "Adding the target user");
1537    
1538     await this.sendDMEmbedToChannel(thread, embed, actionDoneName, user);
1539     } catch (e) {
1540     logError(e);
1541     return false;
1542     }
1543    
1544     return true;
1545     }
1546     }
1547    
1548     interface NotifyUserFallbackOptions {
1549     infraction: Infraction;
1550     user: User;
1551     guild: Guild;
1552     sendDMOptions: SendDMOptions;
1553     embed: EmbedBuilder;
1554     }
1555    
1556     export type CreateUserMassBanOptions = Omit<
1557     CreateUserBanOptions & {
1558     users: readonly string[];
1559     callback?: (options: {
1560     count: number;
1561     users: readonly string[];
1562     completedUsers: readonly string[];
1563     skippedUsers: readonly string[];
1564     completedIn?: number;
1565     reason?: string;
1566     }) => Promise<unknown> | undefined | void | null;
1567     callAfterEach?: number;
1568     },
1569     "duration" | "autoRemoveQueue" | "notifyUser"
1570     >;
1571    
1572     export type CommonOptions = {
1573     reason?: string;
1574     guild: Guild;
1575     moderator: User;
1576     notifyUser?: boolean;
1577     sendLog?: boolean;
1578     abortOnTemplateNotFound?: boolean;
1579     };
1580    
1581     export type CreateUserBanOptions = CommonOptions & {
1582     deleteMessageSeconds?: number;
1583     duration?: number;
1584     autoRemoveQueue?: boolean;
1585     };
1586    
1587     export type CreateMemberMuteOptions = CommonOptions & {
1588     duration?: number;
1589     messagesToDelete?: MessageResolvable[];
1590     messageChannel?: TextChannel;
1591     bulkDeleteReason?: string;
1592     autoRemoveQueue?: boolean;
1593     };
1594    
1595     export type BulkDeleteMessagesOptions = CommonOptions & {
1596     user?: User;
1597     messagesToDelete?: MessageResolvable[];
1598     messageChannel?: TextChannel;
1599     count?: number;
1600     offset?: number;
1601     logOnly?: boolean;
1602     filters?: BulkDeleteFilter[];
1603     };
1604    
1605     export type ActionDoneName =
1606     | "banned"
1607     | "muted"
1608     | "kicked"
1609     | "warned"
1610     | "unbanned"
1611     | "unmuted"
1612     | "softbanned"
1613     | "beaned"
1614     | "noted";
1615    
1616     export type SendDMOptions = {
1617     fields?:
1618     | APIEmbedField[]
1619     | ((internalFields: APIEmbedField[]) => Promise<APIEmbedField[]> | APIEmbedField[]);
1620     description?: string;
1621     actionDoneName?: ActionDoneName;
1622     id: string | number;
1623     reason?: string;
1624     color?: ColorResolvable;
1625     title?: string;
1626     fallback?: boolean;
1627     infraction?: Infraction;
1628     };
1629    
1630     type BulkDeleteFilter = (message: Message) => boolean;

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26