From 27e84e98a7cbf79a2ba2d44f24d61c6227a61a16 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:09:53 +0800 Subject: [PATCH] updated verify command --- src/commands/verification.rs | 228 ++----------------------------- src/events/interaction_create.rs | 220 +++++++++++++++++++++++++++-- src/main.rs | 7 +- 3 files changed, 225 insertions(+), 230 deletions(-) diff --git a/src/commands/verification.rs b/src/commands/verification.rs index 4954722..9f481f9 100644 --- a/src/commands/verification.rs +++ b/src/commands/verification.rs @@ -1,44 +1,32 @@ use crate::{ApplicationContext, Error}; -use dotenv::dotenv; use poise::{CreateReply, Modal}; use serde::{Deserialize, Serialize}; -use serenity::all::{ - CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, GuildId, RoleId, -}; +use serenity::all::{CreateActionRow, CreateButton, CreateEmbed}; #[derive(Deserialize, Serialize, Debug)] -struct StudentRow { - full_name: String, - student_id: String, +pub struct StudentRow { + pub full_name: String, + pub student_id: String, } #[derive(Debug, Modal)] -#[name = "Club Verification"] // Struct name by default -struct VerificationModal { +#[name = "Club Verification"] +pub struct VerificationModal { #[name = "Full Name"] #[placeholder = "John Doe"] #[max_length = 50] - name: String, + pub name: String, #[name = "Student ID"] #[placeholder = "s123456789"] - student_id: String, + pub student_id: String, } -#[poise::command(slash_command, subcommands("embed", "myself"))] -pub async fn verify(_: ApplicationContext<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Verify your DSEC club membership to obtain role -#[poise::command(slash_command)] -pub async fn myself(ctx: ApplicationContext<'_>) -> Result<(), Error> { - verify_member(ctx).await?; - - Ok(()) -} - -#[poise::command(slash_command)] -pub async fn embed(ctx: ApplicationContext<'_>) -> Result<(), Error> { +/// Embed message with verify button to verify membership +#[poise::command( + slash_command, + required_permissions = "MANAGE_MESSAGES | MANAGE_THREADS" +)] +pub async fn verify(ctx: ApplicationContext<'_>) -> Result<(), Error> { let reply: CreateReply = { let embed: CreateEmbed = CreateEmbed::new() .title("Verify your DSEC membership") @@ -56,193 +44,5 @@ pub async fn embed(ctx: ApplicationContext<'_>) -> Result<(), Error> { ctx.send(reply).await?; - // let mut stream = ComponentInteractionCollector::new(ctx.serenity_context()) - // .filter(move |mci| mci.data.custom_id == "verify") - // .stream(); - - // while let Some(_) = stream.next().await { - // verify_member(ctx).await?; - // } - - Ok(()) -} - -// flow: -// 1. check correct discord server -> layer 1 -// 2. check if user has role -> layer 1 -// 3. get user input -// 4. check cache for membership -> layer 2 -// 5. check db for membership + update cache -> layer 3 - -// Student ID not found: - add student ID to negative cache (cooldown) if ID not present (pending) -// Student ID found: -// - check if name matches full name -// - if match, assign role -async fn verify_member(ctx: ApplicationContext<'_>) -> Result<(), Error> { - dotenv().ok(); - - // check if user is in a Discord server, then check if user is in THE Discord server - let guild_id = ctx.guild_id(); - - if guild_id.is_none() { - ctx.say("Error, not in server").await?; - return Ok(()); - } - - let guild_id_string = std::env::var("GUILD_ID").expect("missing GUILD_ID"); - let role_id_string = std::env::var("VERIFIED_ROLE_ID").expect("missing VERIFIED_ROLE_ID"); - - let guild_id_u64: u64 = guild_id_string - .parse() - .expect("Unable to parse GUILD_ID into number"); - - let role_id_u64: u64 = role_id_string - .parse() - .expect("Unable to parse VERIFIED_ROLE_ID into number"); - - let server = GuildId::new(guild_id_u64); - - let in_correct_server = &guild_id.unwrap() == &server; // CHANGE - - if !in_correct_server { - ctx.say("Wrong server error. If you're in the DSEC server, let DSEC admins know") - .await?; - return Ok(()); - } - - // role ID 1441965955822649344 - let user_id = ctx.author().id; - - let verified_role_id = RoleId::new(role_id_u64); // hardcoded for now - let discord_member = GuildId::member(ctx.guild_id().unwrap(), ctx, user_id).await?; - let has_role = discord_member.roles.contains(&verified_role_id); - - if has_role { - let already_verified_embed = - CreateEmbed::new() - .title("Already Verified ✅") - .description(format!( - "You already have the <@&{}> role!", - verified_role_id - )); - - ctx.send( - CreateReply::default() - .embed(already_verified_embed) - .ephemeral(true), - ) - .await?; - return Ok(()); - } - - // get user input - let modal_data = VerificationModal::execute(ctx) - .await? - .expect("Modal failed"); - - // remove s if input student ID starts with it - let input_student_id: &str = &modal_data.student_id.to_lowercase(); - let student_id = input_student_id - .strip_prefix("s") - .unwrap_or(input_student_id); - - let state = &ctx.data().state; - - let student_in_cache: bool = { - let cache = state.student_cache.lock().expect("Failed to get cache"); - - println!("{:?}", cache.get(student_id)); - - match cache.get(student_id) { - Some(cached_name) => cached_name == &modal_data.name.to_lowercase(), - None => false, - } - }; - - if student_in_cache { - discord_member.add_role(ctx, verified_role_id).await?; - - let verified_cache_embed = CreateEmbed::new() - .title("Verified ✅") - .description(format!( - "You have been assigned the <@&{}> role!", - verified_role_id - )) - .footer(CreateEmbedFooter::new("⚡ via cache")); - - ctx.send( - CreateReply::default() - .embed(verified_cache_embed) - .ephemeral(true), - ) - .await?; - - return Ok(()); - } - - // fetch from DB - let student_data: Vec = state - .supabase - .database() - .from("active_members") - .select("full_name, student_id") - .eq("student_id", &student_id) - .execute() - .await?; - - let result = student_data.iter().next(); - - // Student ID not found - if result.is_none() { - // TODO: add user to "don't use this command for 5 minutes" - - let id_not_found_embed = CreateEmbed::new() - .title("Student ID not found!") - .description("Your student ID is not found. It takes up to **a week** for your membership to be updated in the database since sign up. Try again later."); - - ctx.send( - CreateReply::default() - .embed(id_not_found_embed) - .ephemeral(true), - ) - .await?; - - return Ok(()); - } - - // get name from result - let result_name = &result.unwrap().full_name; - - { - let mut cache = state.student_cache.lock().unwrap(); - cache.insert( - student_id.to_string(), - result_name.to_string().to_lowercase(), - ); - } - - if &result_name.to_lowercase() == &modal_data.name.to_lowercase() { - discord_member.add_role(ctx, verified_role_id).await?; - - let verified_embed = CreateEmbed::new().title("Verified ✅").description(format!( - "You have been assigned the <@&{}> role!", - verified_role_id - )); - - ctx.send(CreateReply::default().embed(verified_embed)) - .await?; - } else { - let name_mismatched_embed = CreateEmbed::new() - .title("Name mismatch ❌") - .description("Your student ID is present, however the name does not match. Try again."); - - ctx.send( - CreateReply::default() - .embed(name_mismatched_embed) - .ephemeral(true), - ) - .await?; - } - Ok(()) } diff --git a/src/events/interaction_create.rs b/src/events/interaction_create.rs index 489a428..fe2e588 100644 --- a/src/events/interaction_create.rs +++ b/src/events/interaction_create.rs @@ -1,24 +1,218 @@ -use crate::Error; -use poise::serenity_prelude as serenity; +use std::time::Duration; -#[derive(Debug, poise::Modal)] -#[allow(dead_code)] // fields only used for Debug print -struct MyModal { - first_input: String, - second_input: Option, +use crate::{ + Data, Error, + commands::verification::{StudentRow, VerificationModal}, +}; +use ::serenity::all::{ + ComponentInteraction, Context, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, + CreateInteractionResponseFollowup, CreateInteractionResponseMessage, GuildId, RoleId, +}; +use dotenv::dotenv; +use poise::{modal, serenity_prelude as serenity}; + +struct ContextRef<'a>(&'a Context); +impl AsRef for ContextRef<'_> { + fn as_ref(&self) -> &Context { + self.0 + } +} + +async fn embed_response( + ctx: &serenity::Context, + interaction: &ComponentInteraction, + title: impl Into, + description: impl Into, +) -> Result<(), Error> { + let embed = CreateEmbed::new().title(title).description(description); + + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .add_embed(embed) + .ephemeral(true), + ); + + interaction.create_response(ctx, response).await?; + + Ok(()) +} + +async fn embed_followup( + ctx: &serenity::Context, + interaction: &ComponentInteraction, + title: impl Into, + description: impl Into, +) -> Result<(), Error> { + let embed = CreateEmbed::new().title(title).description(description); + + let response = CreateInteractionResponseFollowup::new() + .add_embed(embed) + .ephemeral(true); + + interaction.create_followup(ctx, response).await?; + + Ok(()) } pub async fn on_interaction_create( - _ctx: &serenity::Context, + ctx: &serenity::Context, interaction: &serenity::Interaction, + data: &Data, ) -> Result<(), Error> { - let some_message_component = interaction.as_message_component(); + let Some(interaction) = interaction.as_message_component() else { + return Ok(()); + }; - if !some_message_component.is_none() { - let component = some_message_component.unwrap(); + if interaction.data.custom_id == "verify" { + dotenv().ok(); + let guild_id = match interaction.guild_id { + Some(id) => id, + None => { + // user not in a server at all + embed_response( + ctx, + interaction, + "Unable to perform action", + "Action can only be performed in the DSEC server", + ) + .await?; - if component.data.custom_id == "verify" { - println!("Component data: {:?}", component); + return Ok(()); + } + }; + + let role_id_string = std::env::var("VERIFIED_ROLE_ID").expect("missing VERIFIED_ROLE_ID"); + + let role_id_u64: u64 = role_id_string + .parse() + .expect("Unable to parse VERIFIED_ROLE_ID into number"); + + let user_id = interaction.user.id; + let verified_role_id = RoleId::new(role_id_u64); + + let discord_member = GuildId::member(guild_id, ctx, user_id).await?; + let has_role = discord_member.roles.contains(&verified_role_id); + + // Has role + if has_role { + embed_response( + ctx, + interaction, + "Already Verified ✅", + format!("You already have the <@&{}> role!", verified_role_id), + ) + .await?; + + return Ok(()); + } + + // modal + let timeout = Duration::from_secs(120); + + let modal_data = modal::execute_modal_on_component_interaction::( + ContextRef(ctx), + interaction.clone(), + None, + Some(timeout), + ) + .await?; + + let modal_data = match modal_data { + Some(data) => data, + None => return Ok(()), + }; + + let input_student_id: &str = &modal_data.student_id.to_lowercase(); + let student_id = input_student_id + .strip_prefix("s") + .unwrap_or(input_student_id); + + let state = &data.state; + + let student_in_cache: bool = { + let cache: std::sync::MutexGuard<'_, std::collections::HashMap> = + state.student_cache.lock().expect("Failed to get cache"); + + match cache.get(student_id) { + Some(cached_name) => cached_name == &modal_data.name.to_lowercase(), + None => false, + } + }; + + if student_in_cache { + discord_member.add_role(ctx, verified_role_id).await?; + + let verified_cache_embed = CreateEmbed::new() + .title("Verified ✅") + .description(format!( + "You have been assigned the <@&{}> role!", + verified_role_id + )) + .footer(CreateEmbedFooter::new("⚡ via cache")); + + let verified_msg = CreateInteractionResponseFollowup::new() + .add_embed(verified_cache_embed) + .ephemeral(true); + + interaction.create_followup(ctx, verified_msg).await?; + + return Ok(()); + } + + // fetch from DB + let student_data: Vec = state + .supabase + .database() + .from("active_members") + .select("full_name, student_id") + .eq("student_id", &student_id) + .execute() + .await?; + + let result = student_data.iter().next(); + + if result.is_none() { + // TODO: add user to "don't use this command for 5 minutes" + + embed_followup(ctx, + interaction, + "Student ID not found!", + "Your student ID is not found. + It takes up to **a week** for your membership to be updated in the database since sign up. + Try again later.").await?; + + return Ok(()); + } + + // get name from result + let result_name = &result.unwrap().full_name; + + { + let mut cache = state.student_cache.lock().unwrap(); + cache.insert( + student_id.to_string(), + result_name.to_string().to_lowercase(), + ); + } + + if &result_name.to_lowercase() == &modal_data.name.to_lowercase() { + discord_member.add_role(ctx, verified_role_id).await?; + + embed_followup( + ctx, + interaction, + "Verified ✅", + format!("You have been assigned the <@&{}> role!", verified_role_id), + ) + .await?; + } else { + embed_followup( + ctx, + interaction, + "Name mismatch ❌", + "Your student ID is present, however the name does not match. Try again.", + ) + .await?; } } diff --git a/src/main.rs b/src/main.rs index 9a7f23c..fc90f8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use supabase::Client; mod commands; mod events; +#[derive(Debug)] pub struct Data { pub state: AppState, } @@ -17,7 +18,7 @@ type Error = Box; type Context<'a> = poise::Context<'a, Data, Error>; type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct AppState { pub supabase: Arc, pub student_cache: Arc>>, @@ -41,14 +42,14 @@ async fn event_handler( ctx: &serenity::Context, event: &serenity::FullEvent, _framework: poise::FrameworkContext<'_, Data, Error>, - _data: &Data, + data: &Data, ) -> Result<(), Error> { match event { serenity::FullEvent::Ready { data_about_bot, .. } => { events::ready::on_ready(ctx, data_about_bot).await?; } serenity::FullEvent::InteractionCreate { interaction } => { - events::interaction_create::on_interaction_create(ctx, interaction).await?; + events::interaction_create::on_interaction_create(ctx, interaction, &data).await?; } _ => {} }