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

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

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26