From 4595ee4cf7f5ad20fb73ba925f5b6bb066b9a38b Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:53:23 +1000 Subject: [PATCH] member info command --- src/commands.rs | 1 + src/commands/member_info.rs | 110 +++++++++++++++++++++++++++++++ src/events/interaction_create.rs | 106 +++++++++++++++++++++++++---- src/main.rs | 4 +- 4 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 src/commands/member_info.rs diff --git a/src/commands.rs b/src/commands.rs index afb9c1c..5620c90 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,6 @@ // this file is to let main.rs know the existence of the "commands" folder pub mod info; +pub mod member_info; pub mod mods_only; pub mod verification; pub mod weather; diff --git a/src/commands/member_info.rs b/src/commands/member_info.rs new file mode 100644 index 0000000..72d5750 --- /dev/null +++ b/src/commands/member_info.rs @@ -0,0 +1,110 @@ +use crate::{ApplicationContext, Error}; +use poise::CreateReply; +use serde::{Deserialize, Serialize}; +use serenity::builder::CreateEmbed; +use supabase::Database; + +#[derive(Deserialize, Serialize, Debug)] +pub struct MemberRow { + pub full_name: String, + pub student_id: String, + pub campus: String, + pub membership_status: String, + pub end_date: String, +} + +#[derive(Deserialize, Serialize, Debug)] +struct DiscordLinkRow { + student_id: String, +} + +async fn member_data(database: &Database, user_id: &String) -> Result, Error> { + let rows: Vec = database + .from("active_members") + .select("student_id, full_name, campus, membership_status, end_date") + .eq("student_id", user_id) + .execute() + .await?; + let result = rows.into_iter().next(); + Ok(result) +} + +/// Look up the student id linked to a given discord user, if any. +async fn recorded_member(database: &Database, user_id: &String) -> Result, Error> { + let rows: Vec = database + .from("dsec_discord_members") + .select("student_id") + .eq("discord_id", user_id) + .execute() + .await?; + Ok(rows.into_iter().next().map(|row| row.student_id)) +} + +/// Retrieve DSEC member information +#[poise::command(slash_command)] +pub async fn member_info(ctx: ApplicationContext<'_>) -> Result<(), Error> { + let user = ctx.author(); + let database = ctx.data.state.supabase.database().clone(); + + // check if discord ID is in table + let linked_student_id = recorded_member(&database, &user.id.to_string()).await?; + + // if not present + let Some(student_id) = linked_student_id else { + ctx.send( + CreateReply::default().embed( + CreateEmbed::new() + .title("Re-verification required") + .description("Verify your membership in <#1433595503190474854>"), + ), + ) + .await?; + return Ok(()); + }; + + let user_data_option = member_data(&database, &student_id).await?; + + if user_data_option.is_none() { + ctx.send(CreateReply::default().embed( + CreateEmbed::new().title("Couldn't find info").description( + "Your membership may have expired. Contact a club executive to be sure.", + ), + )) + .await?; + } else { + let user_data = user_data_option.expect("Member not found"); + let member_name = user_data.full_name; + let member_campus = user_data.campus; + let membership_status = user_data.membership_status; + let expiry_date = user_data.end_date; + + ctx.send( + CreateReply::default() + .embed( + CreateEmbed::new() + .title("DSEC Membership Info") + .description(format!( + " + **Name:** {member_name} + **Campus:** {member_campus} + **Membership Status:** {membership_status} + **Membership Expiry Date**: {expiry_date} + " + )), + ) + .ephemeral(true), + ) + .await?; + } + + // let student_data = state + // .supabase + // .database() + // .from("active_members") + // .select("full_name, student_id") + // .eq("student_id", &student_id) + // .execute() + // .await?; + + Ok(()) +} diff --git a/src/events/interaction_create.rs b/src/events/interaction_create.rs index aca3c30..8715ea5 100644 --- a/src/events/interaction_create.rs +++ b/src/events/interaction_create.rs @@ -15,6 +15,13 @@ use ::serenity::{ use dotenv::dotenv; use poise::Modal as _; use poise::serenity_prelude as serenity; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug)] +pub struct DiscordMemberRow { + pub student_id: String, + pub discord_id: String, +} /// Read the verified role id from the environment. fn verified_role_id() -> RoleId { @@ -75,14 +82,44 @@ async fn collect_verification_modal( } } +/// Add to dsec_discord_members table (skips insert if already recorded). +async fn add_dsec_discord_table( + data: &Data, + student_id: &str, + member_id: &String, +) -> Result<(), Error> { + if member_recorded(data, member_id).await? { + return Ok(()); + } + + let new_member = serde_json::json!({ + "student_id": student_id, + "discord_id": member_id, + }); + + let _: Vec = data + .state + .supabase + .database() + .insert("dsec_discord_members") + .values(new_member)? + .execute() + .await?; + + Ok(()) +} + /// Assign the verified role and send the success response. async fn grant_verified_role( ctx: &Context, + data: &Data, modal_submit: &ModalInteraction, discord_member: &Member, + student_id: &String, verified_role_id: RoleId, via_cache: bool, ) -> Result<(), Error> { + add_dsec_discord_table(data, student_id, &discord_member.user.id.to_string()).await?; discord_member.add_role(ctx, verified_role_id).await?; let mut embed = CreateEmbed::new().title("Verified ✅").description(format!( @@ -132,6 +169,19 @@ async fn fetch_student(data: &Data, student_id: &str) -> Result Result { + let rows: Vec = data + .state + .supabase + .database() + .from("dsec_discord_members") + .select("discord_id") + .eq("discord_id", user_id) + .execute() + .await?; + Ok(!rows.is_empty()) +} + /// Handle a click on the "verify" button: collect the modal, then verify the /// submitted student id/name against the cache and database. async fn handle_verify( @@ -154,34 +204,53 @@ async fn handle_verify( }; let verified_role_id = verified_role_id(); - let user_id = component_interaction.user.id; - let discord_member = GuildId::member(guild_id, ctx, user_id).await?; - if discord_member.roles.contains(&verified_role_id) { - component_interaction - .create_response( - ctx, - ephemeral_embed(CreateEmbed::new().title("Already Verified ✅").description( - format!("You already have the <@&{}> role!", verified_role_id), - )), - ) - .await?; - return Ok(()); + // Fast, no-network "already verified" check using the member data that is + // already attached to the button interaction. Anything slower than this + // (a DB query, a member fetch) must NOT run before the modal is shown, or + // Discord's ~3s acknowledgement window elapses and the click fails. + if let Some(member) = &component_interaction.member { + if member.roles.contains(&verified_role_id) { + component_interaction + .create_response( + ctx, + ephemeral_embed(CreateEmbed::new().title("Already Verified ✅").description( + format!("You already have the <@&{}> role!", verified_role_id), + )), + ) + .await?; + return Ok(()); + } } + // Respond to the click with the modal immediately. let Some((modal_submit, modal_data)) = collect_verification_modal(ctx, component_interaction).await? else { return Ok(()); }; + // From here on we hold the modal-submit token, so the slower member fetch + // and database work below is no longer racing the button's ack window. + let user_id = component_interaction.user.id; + let discord_member = GuildId::member(guild_id, ctx, user_id).await?; + let input_student_id = modal_data.student_id.to_lowercase(); let student_id = input_student_id .strip_prefix("s") .unwrap_or(&input_student_id); if cached_name_matches(data, student_id, &modal_data.name) { - grant_verified_role(ctx, &modal_submit, &discord_member, verified_role_id, true).await?; + grant_verified_role( + ctx, + data, + &modal_submit, + &discord_member, + &student_id.to_string(), + verified_role_id, + true, + ) + .await?; return Ok(()); } @@ -202,7 +271,16 @@ async fn handle_verify( cache_student(data, student_id, &student.full_name); if student.full_name.to_lowercase() == modal_data.name.to_lowercase() { - grant_verified_role(ctx, &modal_submit, &discord_member, verified_role_id, false).await?; + grant_verified_role( + ctx, + data, + &modal_submit, + &discord_member, + &student_id.to_string(), + verified_role_id, + false, + ) + .await?; } else { modal_submit .create_response( diff --git a/src/main.rs b/src/main.rs index f840851..20e6e4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,8 @@ async fn main() { // -- discord bot start -- let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); - let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; + let intents = + serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; let framework = poise::Framework::builder() .options(poise::FrameworkOptions { @@ -77,6 +78,7 @@ async fn main() { commands::weather::weather(), commands::verification::verify(), commands::mods_only::embed(), + commands::member_info::member_info(), ], event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data))