member info command

This commit is contained in:
liyunze 2026-07-07 23:53:23 +10:00
parent 9f88bf05b8
commit 4595ee4cf7
4 changed files with 206 additions and 15 deletions

View file

@ -1,5 +1,6 @@
// this file is to let main.rs know the existence of the "commands" folder // this file is to let main.rs know the existence of the "commands" folder
pub mod info; pub mod info;
pub mod member_info;
pub mod mods_only; pub mod mods_only;
pub mod verification; pub mod verification;
pub mod weather; pub mod weather;

110
src/commands/member_info.rs Normal file
View file

@ -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<Option<MemberRow>, Error> {
let rows: Vec<MemberRow> = 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<Option<String>, Error> {
let rows: Vec<DiscordLinkRow> = 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(())
}

View file

@ -15,6 +15,13 @@ use ::serenity::{
use dotenv::dotenv; use dotenv::dotenv;
use poise::Modal as _; use poise::Modal as _;
use poise::serenity_prelude as serenity; 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. /// Read the verified role id from the environment.
fn verified_role_id() -> RoleId { 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<DiscordMemberRow> = data
.state
.supabase
.database()
.insert("dsec_discord_members")
.values(new_member)?
.execute()
.await?;
Ok(())
}
/// Assign the verified role and send the success response. /// Assign the verified role and send the success response.
async fn grant_verified_role( async fn grant_verified_role(
ctx: &Context, ctx: &Context,
data: &Data,
modal_submit: &ModalInteraction, modal_submit: &ModalInteraction,
discord_member: &Member, discord_member: &Member,
student_id: &String,
verified_role_id: RoleId, verified_role_id: RoleId,
via_cache: bool, via_cache: bool,
) -> Result<(), Error> { ) -> 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?; discord_member.add_role(ctx, verified_role_id).await?;
let mut embed = CreateEmbed::new().title("Verified ✅").description(format!( let mut embed = CreateEmbed::new().title("Verified ✅").description(format!(
@ -132,6 +169,19 @@ async fn fetch_student(data: &Data, student_id: &str) -> Result<Option<StudentRo
Ok(student_data.into_iter().next()) Ok(student_data.into_iter().next())
} }
async fn member_recorded(data: &Data, user_id: &String) -> Result<bool, Error> {
let rows: Vec<serde_json::Value> = 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 /// Handle a click on the "verify" button: collect the modal, then verify the
/// submitted student id/name against the cache and database. /// submitted student id/name against the cache and database.
async fn handle_verify( async fn handle_verify(
@ -154,34 +204,53 @@ async fn handle_verify(
}; };
let verified_role_id = verified_role_id(); 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) { // Fast, no-network "already verified" check using the member data that is
component_interaction // already attached to the button interaction. Anything slower than this
.create_response( // (a DB query, a member fetch) must NOT run before the modal is shown, or
ctx, // Discord's ~3s acknowledgement window elapses and the click fails.
ephemeral_embed(CreateEmbed::new().title("Already Verified ✅").description( if let Some(member) = &component_interaction.member {
format!("You already have the <@&{}> role!", verified_role_id), if member.roles.contains(&verified_role_id) {
)), component_interaction
) .create_response(
.await?; ctx,
return Ok(()); 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)) = let Some((modal_submit, modal_data)) =
collect_verification_modal(ctx, component_interaction).await? collect_verification_modal(ctx, component_interaction).await?
else { else {
return Ok(()); 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 input_student_id = modal_data.student_id.to_lowercase();
let student_id = input_student_id let student_id = input_student_id
.strip_prefix("s") .strip_prefix("s")
.unwrap_or(&input_student_id); .unwrap_or(&input_student_id);
if cached_name_matches(data, student_id, &modal_data.name) { 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(()); return Ok(());
} }
@ -202,7 +271,16 @@ async fn handle_verify(
cache_student(data, student_id, &student.full_name); cache_student(data, student_id, &student.full_name);
if student.full_name.to_lowercase() == modal_data.name.to_lowercase() { 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 { } else {
modal_submit modal_submit
.create_response( .create_response(

View file

@ -64,7 +64,8 @@ async fn main() {
// -- discord bot start -- // -- discord bot start --
let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); 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() let framework = poise::Framework::builder()
.options(poise::FrameworkOptions { .options(poise::FrameworkOptions {
@ -77,6 +78,7 @@ async fn main() {
commands::weather::weather(), commands::weather::weather(),
commands::verification::verify(), commands::verification::verify(),
commands::mods_only::embed(), commands::mods_only::embed(),
commands::member_info::member_info(),
], ],
event_handler: |ctx, event, framework, data| { event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data)) Box::pin(event_handler(ctx, event, framework, data))