updated verify command

This commit is contained in:
liyunze 2025-11-28 16:09:53 +08:00
parent 163be0c990
commit 27e84e98a7
3 changed files with 225 additions and 230 deletions

View file

@ -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<StudentRow> = 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(())
}

View file

@ -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<String>,
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<Context> for ContextRef<'_> {
fn as_ref(&self) -> &Context {
self.0
}
}
async fn embed_response(
ctx: &serenity::Context,
interaction: &ComponentInteraction,
title: impl Into<String>,
description: impl Into<String>,
) -> 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<String>,
description: impl Into<String>,
) -> 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::<VerificationModal>(
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<String, String>> =
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<StudentRow> = 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?;
}
}

View file

@ -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<dyn std::error::Error + Send + Sync>;
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<Client>,
pub student_cache: Arc<Mutex<HashMap<String, String>>>,
@ -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?;
}
_ => {}
}