mirror of
https://github.com/dsec-hub/dsec-discord-bot.git
synced 2026-09-22 07:44:26 +00:00
member info command
This commit is contained in:
parent
9f88bf05b8
commit
4595ee4cf7
4 changed files with 206 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
110
src/commands/member_info.rs
Normal file
110
src/commands/member_info.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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<DiscordMemberRow> = 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<Option<StudentRo
|
|||
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
|
||||
/// submitted student id/name against the cache and database.
|
||||
async fn handle_verify(
|
||||
|
|
@ -154,10 +204,13 @@ 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) {
|
||||
// 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,
|
||||
|
|
@ -168,20 +221,36 @@ async fn handle_verify(
|
|||
.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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue