diff --git a/prisma/migrations/20240902170604_userverification/migration.sql b/prisma/migrations/20240902170604_userverification/migration.sql new file mode 100644 index 0000000..50f3411 --- /dev/null +++ b/prisma/migrations/20240902170604_userverification/migration.sql @@ -0,0 +1,52 @@ +-- CreateEnum +CREATE TYPE "VerificationStatus" AS ENUM ('Waiting', 'Approved', 'Rejected', 'Revoked'); + +-- AlterTable +ALTER TABLE "Guild" ADD COLUMN "VerifyAttachEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "VerifyDetailEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "VerifyTempPaused" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "VerifyUnverifiedRoleId" TEXT NOT NULL DEFAULT '', +ADD COLUMN "VerifyVerifiedRoleId" TEXT NOT NULL DEFAULT ''; + +-- CreateTable +CREATE TABLE "UserVerificationData" ( + "GuildId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Banned" BOOLEAN NOT NULL DEFAULT false +); + +-- CreateTable +CREATE TABLE "UserVerificationHistory" ( + "VerificationHistoryId" SERIAL NOT NULL, + "GuildId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Status" "VerificationStatus" NOT NULL DEFAULT 'Waiting', + "CheckerUserId" TEXT NOT NULL DEFAULT '', + "CheckerNote" TEXT NOT NULL DEFAULT '', + "Detail" TEXT NOT NULL DEFAULT '', + "Attachment" TEXT NOT NULL DEFAULT '', + "RejectReason" TEXT NOT NULL DEFAULT '', + "CreatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "UpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserVerificationHistory_pkey" PRIMARY KEY ("VerificationHistoryId") +); + +-- CreateTable +CREATE TABLE "UserVerificationAttachment" ( + "GuildId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Attachment" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserVerificationData_GuildId_UserId_key" ON "UserVerificationData"("GuildId", "UserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserVerificationAttachment_GuildId_UserId_key" ON "UserVerificationAttachment"("GuildId", "UserId"); + +-- AddForeignKey +ALTER TABLE "UserVerificationData" ADD CONSTRAINT "UserVerificationData_GuildId_fkey" FOREIGN KEY ("GuildId") REFERENCES "Guild"("GuildId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserVerificationHistory" ADD CONSTRAINT "UserVerificationHistory_GuildId_UserId_fkey" FOREIGN KEY ("GuildId", "UserId") REFERENCES "UserVerificationData"("GuildId", "UserId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9ad2b33..66758d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,16 +30,29 @@ enum GuildFeatures { RobloxAPI } +enum VerificationStatus { + Waiting + Approved + Rejected + Revoked +} + model Guild { - GuildId String @unique - EnforceSayInExecutor Boolean @default(false) - JoinLeaveLogChannelId String @default("") - PublicModLogChannelId String @default("") - LoggingChannelId String @default("") - BanAppealLink String @default("") - EnabledGuildFeatures GuildFeatures[] - ModerationCase ModerationCase[] - Tag Tag[] + GuildId String @unique + EnforceSayInExecutor Boolean @default(false) + JoinLeaveLogChannelId String @default("") + PublicModLogChannelId String @default("") + LoggingChannelId String @default("") + BanAppealLink String @default("") + VerifyDetailEnabled Boolean @default(false) + VerifyAttachEnabled Boolean @default(false) + VerifyTempPaused Boolean @default(false) + VerifyVerifiedRoleId String @default("") + VerifyUnverifiedRoleId String @default("") + EnabledGuildFeatures GuildFeatures[] + ModerationCase ModerationCase[] + Tag Tag[] + UserVerificationData UserVerificationData[] } model ModerationCase { @@ -95,3 +108,36 @@ model Tag { Content String Attachment String @default("") } + +model UserVerificationData { + Guild Guild @relation(fields: [GuildId], references: [GuildId], onDelete: Cascade) + GuildId String + UserId String + Banned Boolean @default(false) + History UserVerificationHistory[] + + @@unique(name: "UniqueUserPerGuild", fields: [GuildId, UserId]) +} + +model UserVerificationHistory { + VerificationHistoryId Int @id @default(autoincrement()) + MainData UserVerificationData @relation(fields: [GuildId, UserId], references: [GuildId, UserId], onDelete: Cascade) + GuildId String + UserId String + Status VerificationStatus @default(Waiting) + CheckerUserId String @default("") + CheckerNote String @default("") + Detail String @default("") + Attachment String @default("") + RejectReason String @default("") + CreatedAt DateTime @default(now()) + UpdatedAt DateTime @default(now()) +} + +model UserVerificationAttachment { + GuildId String + UserId String + Attachment String + + @@unique(name: "UniqueAttachmentPerUserPerGuild", fields: [GuildId, UserId]) +} diff --git a/src/classes/dbUtils.ts b/src/classes/dbUtils.ts index f20a64a..8b2aab1 100644 --- a/src/classes/dbUtils.ts +++ b/src/classes/dbUtils.ts @@ -1,5 +1,20 @@ -import { EmbedBuilder, MessageCreateOptions, MessagePayload, User, time, userMention } from "discord.js"; -import { ModerationAction, ModerationCase } from "@prisma/client"; +import { + Interaction, + MessageCreateOptions, + MessagePayload, + User, + ModalBuilder, + ActionRowBuilder, + TextInputBuilder, + time, + userMention, + TextInputStyle, + GuildMember, + ButtonBuilder, + ComponentBuilder, + ButtonStyle, +} from "discord.js"; +import { GuildFeatures, ModerationAction, ModerationCase, VerificationStatus } from "@prisma/client"; import MeteoriumClient from "./client.js"; import MeteoriumEmbedBuilder from "./embedBuilder.js"; @@ -294,4 +309,401 @@ export default class MeteoriumDatabaseUtilities { fullEmbed: fullEmbed, }; } + + public async generateUserVerificationDataEmbed(guildId: string, userId: string, requester?: User) { + const mainDataDb = await this.client.db.userVerificationData.findUnique({ + where: { UniqueUserPerGuild: { UserId: userId, GuildId: guildId } }, + }); + const dataDb = await this.client.db.userVerificationHistory.findFirst({ + where: { UserId: userId, GuildId: guildId }, + orderBy: { VerificationHistoryId: "desc" }, + }); + const guildSettings = await this.client.db.guild.findUnique({ where: { GuildId: guildId } }); + const embed = new MeteoriumEmbedBuilder(requester); + const user = await this.client.users.fetch(userId).catch(() => null); + const updateDate = dataDb?.UpdatedAt || new Date(); + const createdDate = dataDb?.CreatedAt || new Date(); + + // Set author field + embed.setAuthor({ + name: `User Verification Details | ${user != null ? `${user.username} (${user.id})` : userId}`, + iconURL: user != null ? user.displayAvatarURL({ extension: "png", size: 256 }) : undefined, + }); + + embed.addFields([ + { name: "Status", value: dataDb?.Status.toString() || "Never sent a request" }, + { name: "Banned from verification", value: mainDataDb?.Banned ? "Yes" : "No" }, + { name: "Last request updated at", value: `${time(updateDate, "F")} (${time(updateDate, "R")})` }, + { name: "Last request created at", value: `${time(createdDate, "F")} (${time(createdDate, "R")})` }, + ]); + + if (guildSettings?.VerifyDetailEnabled) { + embed.addFields([{ name: "Details", value: dataDb?.Detail || "N/A" }]); + } + + if (guildSettings?.VerifyAttachEnabled) { + embed.addFields([{ name: "Attachment", value: dataDb?.Attachment || "N/A" }]); + embed.setImage(dataDb?.Attachment || null); + } + + if (dataDb?.Status == VerificationStatus.Rejected) { + embed.addFields([{ name: "Rejection reason", value: dataDb.RejectReason }]); + } + + return embed; + } + + public async processVerification(interaction: Interaction<"cached">, fromEvent: boolean) { + const guildSettings = await this.client.db.guild.findUnique({ where: { GuildId: interaction.guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + + let verificationData = await this.client.db.userVerificationData.findUnique({ + where: { UniqueUserPerGuild: { GuildId: interaction.guildId, UserId: interaction.user.id } }, + select: { History: true, Banned: true }, + }); + if (!verificationData) + verificationData = await this.client.db.userVerificationData.create({ + data: { GuildId: interaction.guildId, UserId: interaction.user.id }, + select: { History: true, Banned: true }, + }); + + if (!verificationData) + throw new Error(`could not get/create verification data for ${interaction.user.id}@${interaction.guildId}`); + + // Request submission modal + if ( + (interaction.isChatInputCommand() && interaction.commandName == "verify" && !fromEvent) || + (interaction.isButton() && interaction.customId == "MeteoriumUserVerificationButtonRequest") + ) { + if (guildSettings.VerifyTempPaused) + return await interaction.reply({ + content: + "Verification for this server is currently paused. Contact a server admin for more details.", + ephemeral: true, + }); + if (verificationData.Banned) + return await interaction.reply({ + content: "You have been banned from verifying. Contact a server admin for more details.", + ephemeral: true, + }); + + const histData = verificationData.History[verificationData.History.length - 1]; + if (histData) { + if (histData.Status == VerificationStatus.Waiting) + return await interaction.reply({ + content: + "You already have a existing verification request. Wait for a server admin to check it.", + ephemeral: true, + }); + if (histData.Status == VerificationStatus.Approved) + return await interaction.reply({ + content: "You are already verified in this server.", + ephemeral: true, + }); + } + + if (guildSettings.VerifyDetailEnabled) { + const modal = new ModalBuilder(); + modal.setCustomId("MeteoriumUserVerificationModal"); + modal.setTitle("Verification request submission"); + + const ti = new TextInputBuilder(); + ti.setLabel("Details"); + ti.setCustomId("MeteoriumUserVerificationModalDetails"); + ti.setRequired(true); + ti.setPlaceholder("Put the details required for verification here"); + ti.setStyle(TextInputStyle.Paragraph); + + const ar = new ActionRowBuilder(); + ar.addComponents(ti); + + modal.addComponents(ar); + + return await interaction.showModal(modal); + } + + const attachment = guildSettings.VerifyAttachEnabled + ? await this.client.db.userVerificationAttachment.findUnique({ + where: { + UniqueAttachmentPerUserPerGuild: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + }, + }, + }) + : undefined; + + if (guildSettings.VerifyAttachEnabled && !attachment) { + return await interaction.reply({ + ephemeral: true, + content: "You have not uploaded a attachment to include with the verification request.", + }); + } + + await this.client.db.userVerificationHistory.create({ + data: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + Attachment: attachment?.Attachment || "", + }, + }); + + if (guildSettings.VerifyAttachEnabled) + await this.client.db.userVerificationAttachment.delete({ + where: { + UniqueAttachmentPerUserPerGuild: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + }, + }, + }); + + return await interaction.reply({ ephemeral: true, content: "Verification request submitted." }); + } + + // Modal submission + if (interaction.isModalSubmit() && interaction.customId == "MeteoriumUserVerificationModal") { + const details = guildSettings.VerifyDetailEnabled + ? interaction.fields.getTextInputValue("MeteoriumUserVerificationModalDetails") + : undefined; + const attachment = guildSettings.VerifyAttachEnabled + ? await this.client.db.userVerificationAttachment.findUnique({ + where: { + UniqueAttachmentPerUserPerGuild: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + }, + }, + }) + : undefined; + + const histData = verificationData.History[verificationData.History.length - 1]; + if (histData && histData.Status == VerificationStatus.Waiting) + return await interaction.reply({ + content: "You already have a existing verification request. Wait for a server admin to check it.", + ephemeral: true, + }); + + await interaction.deferReply({ ephemeral: true }); + + if (guildSettings.VerifyAttachEnabled && !attachment) { + return await interaction.editReply( + "You have not uploaded a attachment to include with the verification request.", + ); + } + + await this.client.db.userVerificationHistory.create({ + data: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + Detail: details, + Attachment: attachment?.Attachment || "", + }, + }); + + if (guildSettings.VerifyAttachEnabled) + await this.client.db.userVerificationAttachment.delete({ + where: { + UniqueAttachmentPerUserPerGuild: { + GuildId: interaction.guildId, + UserId: interaction.user.id, + }, + }, + }); + + return await interaction.editReply("Verification request submitted."); + } + + // Approve request action + if (interaction.isButton() && interaction.customId.startsWith("MeteoriumUserVerificationApprove-")) { + const targetUserId = interaction.customId.replaceAll("MeteoriumUserVerificationApprove-", ""); + await interaction.deferUpdate(); + + const data = await this.client.db.userVerificationHistory.findFirst({ + where: { GuildId: interaction.guildId, UserId: targetUserId }, + orderBy: { VerificationHistoryId: "desc" }, + }); + if (!data) + return await interaction.editReply({ + content: "internal error: data doesn't exist?", + embeds: [], + components: [], + }); + + await this.client.db.userVerificationHistory.update({ + where: { VerificationHistoryId: data.VerificationHistoryId }, + data: { + Status: VerificationStatus.Approved, + CheckerUserId: interaction.user.id, + UpdatedAt: new Date(), + }, + }); + + const promises: Promise[] = []; + const updateRolePromise = new Promise(async (resolve) => { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await this.updateMemberVerificationRole(member); + return resolve(); + }); + try { + const user = await this.client.users.fetch(targetUserId).catch(() => null); + if (user) { + const embed = new MeteoriumEmbedBuilder(); + embed.setTitle("Verification status"); + embed.setDescription("Congratulations, your verification request was approved!"); + embed.addFields([{ name: "Server", value: `${interaction.guild.name} (${interaction.guild.id})` }]); + embed.setColor("Green"); + promises.push(user.send({ embeds: [embed] })); + } + } catch (e) {} + promises.push(updateRolePromise); + await Promise.all(promises); + + return await interaction.editReply({ + content: "Processing completed - user verification request approved.", + embeds: [], + components: [], + }); + } + + // Reject request action + if (interaction.isButton() && interaction.customId.startsWith("MeteoriumUserVerificationReject-")) { + const targetUserId = interaction.customId.replaceAll("MeteoriumUserVerificationReject-", ""); + await interaction.deferUpdate(); + + const openReasonButton = new ButtonBuilder(); + openReasonButton.setLabel("Open modal"); + openReasonButton.setCustomId(`MeteoriumUserVerificationRejectReasonButton-${targetUserId}`); + openReasonButton.setStyle(ButtonStyle.Primary); + + const actionRow = new ActionRowBuilder().addComponents([openReasonButton]); + await interaction.followUp({ + content: "Click the button below to open a modal to fill the rejection reason.", + ephemeral: true, + components: [actionRow], + }); + + return await interaction.editReply({ + content: "See follow-up message for the next step.", + embeds: [], + components: [], + }); + } + + // Button for handling rejection open modal + if (interaction.isButton() && interaction.customId.startsWith("MeteoriumUserVerificationRejectReasonButton-")) { + const targetUserId = interaction.customId.replaceAll("MeteoriumUserVerificationRejectReasonButton-", ""); + + const modal = new ModalBuilder(); + modal.setCustomId(`MeteoriumUserVerificationRejectFinal-${targetUserId}`); + modal.setTitle(`Verification rejection reason`); + + const ti = new TextInputBuilder(); + ti.setLabel("Details"); + ti.setCustomId("MeteoriumUserVerificationRejectFinalReason"); + ti.setRequired(true); + ti.setPlaceholder("Why was this user's verification rejected?"); + ti.setStyle(TextInputStyle.Short); + + const ar = new ActionRowBuilder(); + ar.addComponents(ti); + modal.addComponents(ar); + + return await interaction.showModal(modal); + } + + // Reject final modal (reason modal) + if (interaction.isModalSubmit() && interaction.customId.startsWith("MeteoriumUserVerificationRejectFinal-")) { + const targetUserId = interaction.customId.replaceAll("MeteoriumUserVerificationRejectFinal-", ""); + const rejectReason = interaction.fields.getTextInputValue("MeteoriumUserVerificationRejectFinalReason"); + await interaction.deferUpdate(); + + const data = await this.client.db.userVerificationHistory.findFirst({ + where: { GuildId: interaction.guildId, UserId: targetUserId }, + orderBy: { VerificationHistoryId: "desc" }, + }); + if (!data) + return await interaction.editReply({ + content: "internal error: data doesn't exist?", + embeds: [], + components: [], + }); + + await this.client.db.userVerificationHistory.update({ + where: { VerificationHistoryId: data.VerificationHistoryId }, + data: { + Status: VerificationStatus.Rejected, + CheckerUserId: interaction.user.id, + RejectReason: rejectReason, + UpdatedAt: new Date(), + }, + }); + + const promises: Promise[] = []; + const updateRolePromise = new Promise(async (resolve) => { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await this.updateMemberVerificationRole(member); + return resolve(); + }); + try { + const user = await this.client.users.fetch(targetUserId).catch(() => null); + if (user) { + const embed = new MeteoriumEmbedBuilder(); + embed.setTitle("Verification status"); + embed.setDescription("Unfortunately, your verification request was rejected."); + embed.addFields([ + { name: "Server", value: `${interaction.guild.name} (${interaction.guild.id})` }, + { name: "Reason", value: rejectReason }, + ]); + embed.setColor("Red"); + promises.push(user.send({ embeds: [embed] })); + } + } catch (e) {} + promises.push(updateRolePromise); + await Promise.all(promises); + + return await interaction.editReply({ + content: "Processing completed - user verification request rejected.", + embeds: [], + components: [], + }); + } + + return; + } + + public async updateMemberVerificationRole(member: GuildMember) { + const guildSettings = await this.client.db.guild.findUnique({ where: { GuildId: member.guild.id } }); + if (!guildSettings) throw new Error("could not get settings from database"); + if (guildSettings.EnabledGuildFeatures.indexOf(GuildFeatures.UserVerification) == -1) return; + + const verRole = + guildSettings.VerifyVerifiedRoleId != "" + ? await member.guild.roles.fetch(guildSettings.VerifyVerifiedRoleId).catch(() => null) + : null; + const unVerRole = + guildSettings.VerifyUnverifiedRoleId != "" + ? await member.guild.roles.fetch(guildSettings.VerifyUnverifiedRoleId).catch(() => null) + : null; + + const verHistData = await this.client.db.userVerificationHistory.findFirst({ + where: { GuildId: member.guild.id, UserId: member.id }, + orderBy: { VerificationHistoryId: "desc" }, + include: { MainData: true }, + }); + + if (verHistData?.Status == VerificationStatus.Approved) { + if (unVerRole && member.roles.cache.has(unVerRole.id)) { + await member.roles.remove(unVerRole); + } + if (verRole) await member.roles.add(verRole); + } else { + if (verRole && member.roles.cache.has(verRole.id)) { + await member.roles.remove(verRole); + } + if (unVerRole) await member.roles.add(unVerRole); + } + + return; + } } diff --git a/src/events/guildMemberJoinLogging.ts b/src/events/guildMemberJoinLogging.ts index 9115c0b..0b02370 100644 --- a/src/events/guildMemberJoinLogging.ts +++ b/src/events/guildMemberJoinLogging.ts @@ -33,6 +33,15 @@ export const Event: MeteoriumEvent<"guildMemberAdd"> = { if (channel && channel.isTextBased()) sendPromises.push(channel.send({ embeds: [embed] })); } + sendPromises.push( + client.db.userVerificationData.upsert({ + where: { UniqueUserPerGuild: { GuildId: member.guild.id, UserId: member.id } }, + update: {}, + create: { GuildId: member.guild.id, UserId: member.id }, + }), + ); + sendPromises.push(client.dbUtils.updateMemberVerificationRole(member)); + await Promise.all(sendPromises); return; }, diff --git a/src/events/interactionHandler.ts b/src/events/interactionHandler.ts index ae65fee..e58a8c4 100644 --- a/src/events/interactionHandler.ts +++ b/src/events/interactionHandler.ts @@ -1,9 +1,11 @@ +import { Interaction } from "discord.js"; import type { MeteoriumEvent } from "./eventsEntry.js"; export const Event: MeteoriumEvent<"interactionCreate"> = { event: "interactionCreate", async callback(client, interaction) { await client.interactions.dispatchInteraction(interaction); + await client.dbUtils.processVerification(interaction as Interaction<"cached">, true); return; }, once: false, diff --git a/src/interactions/commands/index.ts b/src/interactions/commands/index.ts index fdb4c23..3a3780c 100644 --- a/src/interactions/commands/index.ts +++ b/src/interactions/commands/index.ts @@ -19,6 +19,8 @@ export * as Purge from "./moderation/purge.js"; // Management export * as Settings from "./management/settings.js"; +export * as Verify from "./management/verify.js"; +export * as Verification from "./management/verification.js"; // Fun export * as Music from "./fun/music.js"; diff --git a/src/interactions/commands/management/settings.ts b/src/interactions/commands/management/settings.ts index fa9dc78..0297690 100644 --- a/src/interactions/commands/management/settings.ts +++ b/src/interactions/commands/management/settings.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, SlashCommandBuilder, channelMention, codeBlock } from "discord.js"; +import { PermissionFlagsBits, SlashCommandBuilder, channelMention, codeBlock, roleMention } from "discord.js"; import MeteoriumEmbedBuilder from "../../../classes/embedBuilder.js"; import type { MeteoriumChatCommand } from "../../index.js"; import type { Guild } from "@prisma/client"; @@ -7,6 +7,7 @@ enum SettingType { Boolean, String, Channel, + Role, } // A mapping of guild settings between interaction and database @@ -16,7 +17,12 @@ type DbSettingNames = | "PublicModLogChannelId" | "LoggingChannelId" | "BanAppealLink" - | "EnabledGuildFeatures"; + | "EnabledGuildFeatures" + | "VerifyDetailEnabled" + | "VerifyAttachEnabled" + | "VerifyTempPaused" + | "VerifyVerifiedRoleId" + | "VerifyUnverifiedRoleId"; type SettingData = { type: SettingType; inName: string; dbName: DbSettingNames; description: string }; const settingsMapping: Array = [ { @@ -49,6 +55,36 @@ const settingsMapping: Array = [ dbName: "BanAppealLink", description: "Ban appeals link, this link will be sent when someone gets banned", }, + { + type: SettingType.Boolean, + inName: "verdetailenabled", + dbName: "VerifyDetailEnabled", + description: "Is the detail section during request submission enabled for verification?", + }, + { + type: SettingType.Boolean, + inName: "verattachenabled", + dbName: "VerifyAttachEnabled", + description: "Is the attachment section during request submission enabled for verification?", + }, + { + type: SettingType.Boolean, + inName: "verpaused", + dbName: "VerifyTempPaused", + description: "Verification paused for the whole server", + }, + { + type: SettingType.Role, + inName: "verifiedrole", + dbName: "VerifyVerifiedRoleId", + description: "The role to be given for those who got approved", + }, + { + type: SettingType.Role, + inName: "unverifiedrole", + dbName: "VerifyUnverifiedRoleId", + description: "The role to be given for those who failed verification", + }, ]; const interactionData = new SlashCommandBuilder() @@ -79,6 +115,11 @@ for (const setting of settingsMapping) option.setName("value").setDescription("The channel value").setRequired(false), ); break; + case SettingType.Role: + option.addRoleOption((option) => + option.setName("value").setDescription("The role value").setRequired(false), + ); + break; default: throw new Error("invalid setting type"); } @@ -154,6 +195,23 @@ export const Command: MeteoriumChatCommand = { ]); break; } + case SettingType.Role: { + const oldValue = guildSettings[data.dbName]; + const newValue = interaction.options.getRole("value", false); + if (newValue == null) { + embed.addFields([{ name: "Value", value: roleMention(oldValue.toString()) }]); + return await interaction.editReply({ embeds: [embed] }); + } + + // @ts-ignore + guildSettings[data.dbName] = newValue!.id.toString(); + await client.db.guild.update({ where: { GuildId: interaction.guildId }, data: guildSettings }); + embed.addFields([ + { name: "New value", value: roleMention(newValue.id) }, + { name: "Old value", value: roleMention(oldValue.toString()) }, + ]); + break; + } default: throw new Error("invalid setting type"); } diff --git a/src/interactions/commands/management/verification.ts b/src/interactions/commands/management/verification.ts new file mode 100644 index 0000000..1fdef50 --- /dev/null +++ b/src/interactions/commands/management/verification.ts @@ -0,0 +1,358 @@ +import util from "node:util"; +import { + PermissionFlagsBits, + SlashCommandBuilder, + userMention, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + time, +} from "discord.js"; +import { GuildFeatures, ModerationAction, VerificationStatus } from "@prisma/client"; +import ms from "../../../classes/ms.js"; +import type { MeteoriumChatCommand } from "../../index.js"; +import MeteoriumEmbedBuilder from "../../../classes/embedBuilder.js"; + +export const Command: MeteoriumChatCommand = { + interactionData: new SlashCommandBuilder() + .setName("verification") + .setDescription("User verification feature") + .addSubcommand((option) => + option + .setName("check") + .setDescription("Check the verification status and info of a user") + .addUserOption((option) => option.setName("user").setDescription("The target user").setRequired(true)), + ) + .addSubcommand((option) => + option + .setName("revoke") + .setDescription("Revoke the verification status of a user") + .addUserOption((option) => option.setName("user").setDescription("The target user").setRequired(true)), + ) + .addSubcommand((option) => + option + .setName("ban") + .setDescription("Bans a user from using the verify command. Useful to deter abusers") + .addUserOption((option) => option.setName("user").setDescription("The target user").setRequired(true)), + ) + .addSubcommand((option) => + option + .setName("unban") + .setDescription("Revokes the ban status of a user, allowing them to verify again") + .addUserOption((option) => option.setName("user").setDescription("The target user").setRequired(true)), + ) + .addSubcommand((option) => + option + .setName("pause") + .setDescription("Pauses verification for the entire server, temporarily not allowing users to verify") + .addBooleanOption((option) => + option + .setName("denyexist") + .setDescription("Deny all existing requests. Defaults to false") + .setRequired(false), + ), + ) + .addSubcommand((option) => + option.setName("resume").setDescription("Resumes verification for the entire server"), + ) + .addSubcommand((option) => + option.setName("stats").setDescription("View verification statistics for this server"), + ) + .addSubcommand((option) => + option + .setName("sendmsg") + .setDescription("Send a message with a button for verification instead") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The target channel, if unspecified the current channel will be used") + .setRequired(false), + ), + ) + .addSubcommand((option) => + option + .setName("waitlist") + .setDescription("Returns a list of waiting verifications") + .addNumberOption((option) => + option.setName("page").setDescription("The page").setRequired(false).setMinValue(1), + ), + ) + .addSubcommand((option) => + option + .setName("process") + .setDescription("Process a waiting verification") + .addUserOption((option) => + option.setName("user").setDescription("The user who has a waiting request").setRequired(true), + ), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false), + requiredFeature: GuildFeatures.UserVerification, + async callback(interaction, client) { + const subcommand = interaction.options.getSubcommand(true); + + const sentReplyMsg = await interaction.deferReply({ ephemeral: true, fetchReply: true }); + + switch (subcommand) { + case "check": { + const user = interaction.options.getUser("user", true); + const embed = await client.dbUtils.generateUserVerificationDataEmbed( + interaction.guildId, + user.id, + interaction.user, + ); + return interaction.editReply({ embeds: [embed] }); + } + + case "revoke": { + const user = interaction.options.getUser("user", true); + + const dataDb = await client.db.userVerificationHistory.findFirst({ + where: { UserId: user.id, GuildId: interaction.guildId }, + orderBy: { VerificationHistoryId: "desc" }, + }); + if (!dataDb) return await interaction.editReply("This user has never made a verification request"); + if (dataDb.Status == VerificationStatus.Rejected) + return await interaction.editReply("This user's request has been rejected"); + if (dataDb.Status == VerificationStatus.Revoked) + return await interaction.editReply("This user's request has already been revoked"); + if (dataDb.Status == VerificationStatus.Waiting) + return await interaction.editReply("This user's request is currently waiting to be verified"); + + const dataEmbed = await client.dbUtils.generateUserVerificationDataEmbed( + interaction.guildId, + user.id, + interaction.user, + ); + + // Action row buttons + const actionRow = new ActionRowBuilder().addComponents([ + new ButtonBuilder().setLabel("No").setCustomId("no").setStyle(ButtonStyle.Primary), + new ButtonBuilder().setLabel("Yes").setCustomId("yes").setStyle(ButtonStyle.Danger), + ]); + + // Edit the reply + await interaction.editReply({ + content: "Are you sure you want to revoke this user's verification?", + embeds: [dataEmbed], + components: [actionRow], + }); + + // Collector + const collector = sentReplyMsg.createMessageComponentCollector({ idle: 150000 }); + collector.on("collect", async (collectInteraction) => { + switch (collectInteraction.customId) { + case "yes": { + await client.db.userVerificationHistory.update({ + where: { VerificationHistoryId: dataDb.VerificationHistoryId }, + data: { Status: VerificationStatus.Revoked, UpdatedAt: new Date() }, + }); + await interaction.editReply({ + content: `Revoked ${userMention(user.id)} (${user.username} - ${user.id})`, + embeds: [], + components: [], + }); + break; + } + case "no": { + await interaction.editReply({ + content: "User verification revokal cancelled.", + embeds: [], + components: [], + }); + break; + } + } + + return; + }); + collector.on("end", async () => { + await interaction.editReply({ content: "Timed out.", embeds: [], components: [] }); + return; + }); + + break; + } + + case "ban": { + const user = interaction.options.getUser("user", true); + await client.db.userVerificationData.upsert({ + where: { UniqueUserPerGuild: { UserId: user.id, GuildId: interaction.guildId } }, + create: { UserId: user.id, GuildId: interaction.guildId, Banned: true }, + update: { Banned: true }, + }); + return await interaction.editReply( + `Banned ${userMention(user.id)} (${user.username} - ${user.id}) from the verification system`, + ); + break; + } + + case "unban": { + const user = interaction.options.getUser("user", true); + const dataDb = await client.db.userVerificationData.findUnique({ + where: { UniqueUserPerGuild: { UserId: user.id, GuildId: interaction.guildId } }, + select: { Banned: true }, + }); + if (!dataDb || !dataDb?.Banned) + return await interaction.editReply("This user isn't banned from the verification system"); + await client.db.userVerificationData.update({ + where: { UniqueUserPerGuild: { UserId: user.id, GuildId: interaction.guildId } }, + data: { Banned: false }, + }); + return await interaction.editReply( + `Unbanned ${userMention(user.id)} (${user.username} - ${user.id}) from the verification system`, + ); + } + + case "pause": { + const guildSettings = await client.db.guild.findUnique({ where: { GuildId: interaction.guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + if (guildSettings.VerifyTempPaused) + return await interaction.editReply("Verification already paused for this server"); + await client.db.guild.update({ + where: { GuildId: interaction.guildId }, + data: { VerifyTempPaused: true }, + }); + return await interaction.editReply(`Verification paused for this server`); + } + + case "resume": { + const denyExisting = interaction.options.getBoolean("denyexist", false) || false; + const guildSettings = await client.db.guild.findUnique({ where: { GuildId: interaction.guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + if (!guildSettings.VerifyTempPaused) + return await interaction.editReply("Verification isn't paused for this server"); + await client.db.guild.update({ + where: { GuildId: interaction.guildId }, + data: { VerifyTempPaused: false }, + }); + return await interaction.editReply(`Verification resumed for this server`); + } + + case "stats": { + const verifiedCount = await client.db.userVerificationHistory.count({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Approved }, + }); + const waitingCount = await client.db.userVerificationHistory.count({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Waiting }, + }); + const rejectedCount = await client.db.userVerificationHistory.count({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Rejected }, + }); + const revokedCount = await client.db.userVerificationHistory.count({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Revoked }, + }); + const bannedCount = await client.db.userVerificationData.count({ + where: { GuildId: interaction.guildId, Banned: true }, + }); + + const embed = new MeteoriumEmbedBuilder(interaction.user); + embed.setTitle(`${interaction.guild.name} - User Verification Statistics`); + embed.setThumbnail(interaction.guild.iconURL({ size: 512 })); + + embed.addFields([ + { name: "Verified users", value: verifiedCount.toString() }, + { name: "Unverified users", value: (interaction.guild.memberCount - verifiedCount).toString() }, + { name: "Banned users", value: bannedCount.toString() }, + { name: "Rejected requests", value: rejectedCount.toString() }, + { name: "Waiting requests", value: waitingCount.toString() }, + { name: "Revoked requests", value: revokedCount.toString() }, + ]); + + return await interaction.editReply({ embeds: [embed] }); + } + + case "sendmsg": { + const channel = interaction.options.getChannel("channel", false) || interaction.channel; + if (!channel) throw new Error("channel is null"); + if (!channel?.isTextBased()) + return await interaction.editReply("The specified channel is not a text channel"); + + const bt = new ButtonBuilder(); + bt.setCustomId("MeteoriumUserVerificationButtonRequest"); + bt.setLabel("Verify"); + bt.setStyle(ButtonStyle.Primary); + + const ar = new ActionRowBuilder(); + ar.addComponents(bt); + + await channel.send({ + content: "Click the button below to verify", + components: [ar], + }); + + return await interaction.editReply({ content: "Sent message." }); + } + + case "waitlist": { + const page = interaction.options.getNumber("page", false) || 1; + + const totalPages = + (await client.db.userVerificationHistory.count({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Waiting }, + })) / 25; + const ids = await client.db.userVerificationHistory.findMany({ + where: { GuildId: interaction.guildId, Status: VerificationStatus.Waiting }, + orderBy: { VerificationHistoryId: "desc" }, + skip: (page - 1) * 25, + take: 25, + }); + const idsForEmbed = await Promise.all( + ids.map(async (v) => { + const user = await client.users.fetch(v.UserId).catch(() => null); + return { + name: `${user ? `${user.username} (${user.id})` : v.UserId} - ${v.VerificationHistoryId}`, + value: `${time(v.CreatedAt, "F")} (${time(v.CreatedAt, "R")})`, + }; + }), + ); + + const embed = new MeteoriumEmbedBuilder(); + embed.setTitle("Verification wait list"); + embed.setFields(idsForEmbed); + embed.setFooter({ text: `Page: ${page}/${totalPages}` }); + + return await interaction.editReply({ embeds: [embed] }); + } + + case "process": { + const user = interaction.options.getUser("user", true); + const targetHistoryData = await client.db.userVerificationHistory.findFirst({ + where: { GuildId: interaction.guildId, UserId: user.id }, + orderBy: { VerificationHistoryId: "desc" }, + }); + if (!targetHistoryData) + return await interaction.editReply({ content: "This user has never sent a verification request." }); + if (targetHistoryData.Status != VerificationStatus.Waiting) + return await interaction.editReply({ + content: "This user's latest request has already been processed.", + }); + + const embed = await client.dbUtils.generateUserVerificationDataEmbed( + interaction.guildId, + user.id, + interaction.user, + ); + + const btReject = new ButtonBuilder(); + btReject.setCustomId(`MeteoriumUserVerificationReject-${user.id}`); + btReject.setLabel("Reject"); + btReject.setStyle(ButtonStyle.Danger); + + const btApprove = new ButtonBuilder(); + btApprove.setCustomId(`MeteoriumUserVerificationApprove-${user.id}`); + btApprove.setLabel("Approve"); + btApprove.setStyle(ButtonStyle.Success); + + const ar = new ActionRowBuilder(); + ar.addComponents(btReject, btApprove); + + return await interaction.editReply({ + content: "Processing the user's request - please check and approve or reject:", + embeds: [embed], + components: [ar], + }); + } + } + }, +}; diff --git a/src/interactions/commands/management/verify.ts b/src/interactions/commands/management/verify.ts new file mode 100644 index 0000000..82f9bba --- /dev/null +++ b/src/interactions/commands/management/verify.ts @@ -0,0 +1,57 @@ +import { SlashCommandBuilder } from "discord.js"; +import { GuildFeatures } from "@prisma/client"; +import type { MeteoriumChatCommand } from "../../index.js"; + +export const Command: MeteoriumChatCommand = { + interactionData: new SlashCommandBuilder() + .setName("verify") + .setDescription("Verify yourself with this command") + .setDMPermission(false) + .addSubcommand((option) => + option.setName("start").setDescription("Start the verification request submission process"), + ) + .addSubcommand((option) => + option + .setName("attach") + .setDescription("Upload a attachment for the verification process") + .addAttachmentOption((option) => + option + .setName("attachment") + .setDescription("The attachment for the verification request that you're doing") + .setRequired(true), + ), + ), + requiredFeature: GuildFeatures.UserVerification, + async callback(interaction, client) { + const subcommand = interaction.options.getSubcommand(true); + switch (subcommand) { + case "start": + return await client.dbUtils.processVerification(interaction, false); + case "attach": { + const attachment = interaction.options.getAttachment("attachment", true); + + await interaction.deferReply({ ephemeral: true }); + const guildSettings = await client.db.guild.findUnique({ where: { GuildId: interaction.guildId } }); + if (!guildSettings) throw new Error("could not get settings from database"); + if (!guildSettings.VerifyAttachEnabled) + return await interaction.editReply( + "You do not need to upload a attachment to verify in this server.", + ); + + await client.db.userVerificationAttachment.upsert({ + where: { + UniqueAttachmentPerUserPerGuild: { GuildId: interaction.guildId, UserId: interaction.user.id }, + }, + create: { GuildId: interaction.guildId, UserId: interaction.user.id, Attachment: attachment.url }, + update: { Attachment: attachment.url }, + }); + + return await interaction.editReply( + "Uploaded attachment for verification. You may now begin the verification process by clicking the button or running ``/verify start``.", + ); + } + default: + break; + } + }, +};