diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66758d2..3060ce3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model Guild { PublicModLogChannelId String @default("") LoggingChannelId String @default("") BanAppealLink String @default("") + ModPingRoleId String @default("") VerifyDetailEnabled Boolean @default(false) VerifyAttachEnabled Boolean @default(false) VerifyTempPaused Boolean @default(false) diff --git a/src/classes/dbUtils.ts b/src/classes/dbUtils.ts index 8b2aab1..3c64427 100644 --- a/src/classes/dbUtils.ts +++ b/src/classes/dbUtils.ts @@ -13,6 +13,7 @@ import { ButtonBuilder, ComponentBuilder, ButtonStyle, + Collection, } from "discord.js"; import { GuildFeatures, ModerationAction, ModerationCase, VerificationStatus } from "@prisma/client"; import MeteoriumClient from "./client.js"; @@ -54,9 +55,19 @@ export type NewCaseData = { export default class MeteoriumDatabaseUtilities { public client: MeteoriumClient; + public modPingData: Collection< + string, + { + amount: number; + repeated: number; + channelId: string; + callMessageIds: Array; + } + >; public constructor(client: MeteoriumClient) { this.client = client; + this.modPingData = new Collection(); } public async getCaseData(guildId: string, caseId: number, historyTake?: number): Promise { @@ -706,4 +717,201 @@ export default class MeteoriumDatabaseUtilities { return; } + + public async generateModPingMentions(guildId: string, amount: number, pingRole: boolean) { + const guildSettings = await this.client.db.guild.findUnique({ where: { GuildId: guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + if (guildSettings.ModPingRoleId == "") return false; + + if (!pingRole) { + const guild = await this.client.guilds.fetch(guildId).catch(() => null); + if (!guild) throw new Error(`failed to fetch guild ${guildId}`); + + const role = await guild.roles.fetch(guildSettings.ModPingRoleId).catch(() => null); + if (!role) throw new Error(`failed to fetch role ${guildSettings.ModPingRoleId} from ${guildId}`); + + const onlineUsers = role.members.map((v) => v); + //const onlineUsers = role.members + // .map((v) => { + // if (v.presence?.status != "offline") return v; + // return; + // }) + // .filter((v) => v != undefined); + + const picked: GuildMember[] = []; + while (picked.length <= amount && picked.length != onlineUsers.length) { + const selected = onlineUsers[this.getRandomInt(0, onlineUsers.length - 1)]; + if (picked.indexOf(selected) == -1) picked.push(selected); + } + + //return picked.map((v) => `<@!${v.user.id}>`).join(" "); + return picked.map((v) => `${v.user.username}<${v.user.id}>`).join(" "); + } + + return `<@!${guildSettings.ModPingRoleId}>`; + } + + public async generateModPingEmbedActionRow( + requester: User, + guildId: string, + repeated: number, + finished: boolean, + concludor?: User, + ) { + const embed = new MeteoriumEmbedBuilder(requester); + embed.setTitle("Mod ping"); + embed.setDescription( + finished + ? "This mod ping call has been concluded." + : "A mod ping was initiated. Once attended by a moderator press the button below to end the mod ping call.", + ); + embed.addFields([ + { name: "Requested by", value: userMention(requester.id) }, + { name: "Repeated", value: repeated.toString() }, + ]); + + if (concludor) embed.addFields([{ name: "Concluded by", value: userMention(concludor.id) }]); + + const btn = new ButtonBuilder(); + btn.setCustomId(`MeteoriumModPingConclude-${requester.id}-${guildId}`); + btn.setLabel("Conclude"); + btn.setStyle(ButtonStyle.Success); + btn.setDisabled(finished); + + const ar = new ActionRowBuilder(); + ar.addComponents(btn); + + return { embed: embed, actionRow: ar }; + } + + public async processModPing(guildId: string, userId: string, interaction?: Interaction<"cached">) { + const guildSettings = await this.client.db.guild.findUnique({ where: { GuildId: guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + + if (guildSettings.ModPingRoleId == "") { + if (interaction && interaction.isChatInputCommand()) + return await interaction.reply({ + ephemeral: true, + content: + "This server's configuration is incomplete to have mod ping functionality. Contact a server admin about this.", + }); + return; + } + const data = this.modPingData.get(`${userId}-${guildId}`); + + if (interaction) { + if (interaction.isButton() && interaction.customId.startsWith("MeteoriumModPingConclude-")) { + if (!interaction.member.roles.cache.has(guildSettings.ModPingRoleId)) + return await interaction.reply({ + ephemeral: true, + content: "Only moderators may press this button.", + }); + + const modPingId = interaction.customId.replaceAll("MeteoriumModPingConclude-", ""); + const [userId, guildId] = modPingId.split("-"); + + const data = this.modPingData.get(modPingId); + let requesterUser = interaction.user; + + if (data) { + this.modPingData.delete(modPingId); + const channel = await this.client.channels.fetch(data.channelId).catch(() => null); + requesterUser = (await this.client.users.fetch(userId).catch(() => null)) || requesterUser; + if (channel && channel.isTextBased()) + data.callMessageIds.forEach(async (v) => { + const msg = await channel.messages.fetch(v).catch(() => null); + if (msg) { + const { embed, actionRow } = await this.generateModPingEmbedActionRow( + requesterUser, + guildId, + 0, + true, + interaction.user, + ); + await msg.edit({ embeds: [embed], components: [actionRow] }); + } + }); + } + + return await interaction.reply({ ephemeral: true, content: "Mod ping concluded." }); + } + if (interaction.isChatInputCommand()) { + if (data) { + return await interaction.reply({ + ephemeral: true, + content: "You have already initiated a mod ping and it is still on-going.", + }); + } + + const mentions = await this.generateModPingMentions(guildId, 3, false); + const msgData = await this.generateModPingEmbedActionRow( + interaction.user, + interaction.guildId, + 0, + false, + ); + if (!mentions) return; + + const msg = await interaction.channel?.send({ + content: mentions, + embeds: [msgData.embed], + components: [msgData.actionRow], + }); + if (!msg) return; + + this.modPingData.set(`${userId}-${guildId}`, { + amount: 3, + repeated: 0, + channelId: interaction.channelId, + callMessageIds: [msg.id], + }); + + return await interaction.reply({ ephemeral: true, content: "Mod ping initiated." }); + } + return; + } + + if (!data) return; + data.repeated += 1; + data.amount += 1; + + const user = await this.client.users.fetch(userId).catch(() => null); + if (!user) throw new Error(`could not fetch user ${user}`); + + const channel = await this.client.channels.fetch(data.channelId).catch(() => null); + if (!channel) throw new Error(`could not fetch chanel ${data.channelId}`); + if (!channel.isTextBased()) return this.modPingData.delete(`${userId}-${guildId}`); + + if (data.repeated > 4) { + const mentions = await this.generateModPingMentions(guildId, data.amount, true); + const msgData = await this.generateModPingEmbedActionRow(user, guildId, data.repeated, false); + if (!mentions) return; + + const msg = await channel.send({ + content: mentions, + embeds: [msgData.embed], + components: [msgData.actionRow], + }); + data.callMessageIds.push(msg.id); + return; + } + + const mentions = await this.generateModPingMentions(guildId, data.amount, false); + const msgData = await this.generateModPingEmbedActionRow(user, guildId, data.repeated, false); + if (!mentions) return; + + const msg = await channel.send({ + content: mentions, + embeds: [msgData.embed], + components: [msgData.actionRow], + }); + data.callMessageIds.push(msg.id); + return; + } + + public getRandomInt(min: number, max: number) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; + } } diff --git a/src/events/interactionHandler.ts b/src/events/interactionHandler.ts index e58a8c4..cacac26 100644 --- a/src/events/interactionHandler.ts +++ b/src/events/interactionHandler.ts @@ -6,6 +6,12 @@ export const Event: MeteoriumEvent<"interactionCreate"> = { async callback(client, interaction) { await client.interactions.dispatchInteraction(interaction); await client.dbUtils.processVerification(interaction as Interaction<"cached">, true); + if (!interaction.isChatInputCommand()) + await client.dbUtils.processModPing( + interaction.guildId!, + interaction.user.id, + interaction as Interaction<"cached">, + ); return; }, once: false, diff --git a/src/index.ts b/src/index.ts index a32c9c2..27bc0d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ const client = new MeteoriumClient({ IntentsBitField.Flags.GuildMessages, IntentsBitField.Flags.GuildModeration, IntentsBitField.Flags.GuildVoiceStates, + IntentsBitField.Flags.GuildPresences, ], }); diff --git a/src/interactions/commands/index.ts b/src/interactions/commands/index.ts index 3a3780c..bcd6cf7 100644 --- a/src/interactions/commands/index.ts +++ b/src/interactions/commands/index.ts @@ -16,6 +16,7 @@ export * as TempBan from "./moderation/tempBan.js"; export * as Ban from "./moderation/ban.js"; export * as UnBan from "./moderation/unban.js"; export * as Purge from "./moderation/purge.js"; +export * as ModPing from "./moderation/modPing.js"; // Management export * as Settings from "./management/settings.js"; diff --git a/src/interactions/commands/moderation/modPing.ts b/src/interactions/commands/moderation/modPing.ts new file mode 100644 index 0000000..ac28738 --- /dev/null +++ b/src/interactions/commands/moderation/modPing.ts @@ -0,0 +1,22 @@ +import { SlashCommandBuilder } from "discord.js"; +import { GuildFeatures } from "@prisma/client"; +import type { MeteoriumChatCommand } from "../../index.js"; + +export const Command: MeteoriumChatCommand = { + interactionData: new SlashCommandBuilder() + .setName("modping") + .setDescription("Initiates a mod ping to call available moderators") + .setDMPermission(false), + requiredFeature: GuildFeatures.Moderation, + async callback(interaction, client) { + return await client.dbUtils.processModPing(interaction.guildId, interaction.user.id, interaction); + }, + initialize(client) { + setInterval(async () => { + client.dbUtils.modPingData.forEach(async (_, i) => { + const [reqId, guildId] = i.split("-"); + await client.dbUtils.processModPing(guildId, reqId); + }); + }, 120000); + }, +};