added verification command

This commit is contained in:
liyunze 2025-11-23 19:20:11 +11:00
parent b9d0640707
commit 2687731bfa
5 changed files with 1683 additions and 18 deletions

1432
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,3 +11,5 @@ serde_json = "1.0.145"
serenity = "0.12" serenity = "0.12"
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
tracing-subscriber = "0.3.20" tracing-subscriber = "0.3.20"
supabase-lib-rs = "0.5.3"
serde = "1.0.228"

View file

@ -1,3 +1,4 @@
// 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 verification;
pub mod weather; pub mod weather;

View file

@ -0,0 +1,223 @@
use crate::{Data, Error};
use poise::{CreateReply, Modal};
use serde::{Deserialize, Serialize};
use serenity::all::{
ComponentInteractionCollector, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter,
GuildId, RoleId,
};
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
#[derive(Deserialize, Serialize, Debug)]
struct StudentRow {
full_name: String,
student_id: String,
}
#[derive(Debug, Modal)]
#[name = "Club Verification"] // Struct name by default
struct VerificationModal {
#[name = "Full Name"]
#[placeholder = "John Doe"]
#[max_length = 50]
name: String,
#[name = "Student ID"]
#[placeholder = "s123456789"]
student_id: String,
}
#[poise::command(slash_command)]
pub async fn verify(ctx: ApplicationContext<'_>) -> Result<(), Error> {
let reply: CreateReply = {
let embed: CreateEmbed = CreateEmbed::new()
.title("Verify your DSEC membership")
.description("Click **Verify Here** and enter your **Full name** and **Student ID** (e.g., s123456789). Your responses are private.");
let button: CreateButton = CreateButton::new("verify").label("Verify Here");
let components = vec![CreateActionRow::Buttons(vec![button])];
CreateReply::default()
.ephemeral(false)
.embed(embed)
.components(components)
};
ctx.send(reply).await?;
while let Some(_) = ComponentInteractionCollector::new(ctx.serenity_context())
.filter(move |mci| mci.data.custom_id == "verify")
.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> {
// 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 server = GuildId::new(735865924829315072);
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(1441965955822649344); // 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,17 +1,47 @@
#![warn(clippy::str_to_string)]
use dotenv::dotenv; use dotenv::dotenv;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use supabase::prelude::*;
mod commands; mod commands;
struct Data {} pub struct Data {
pub state: AppState,
}
// Types used by all command functions // Types used by all command functions
type Error = Box<dyn std::error::Error + Send + Sync>; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
#[derive(Clone)]
pub struct AppState {
pub supabase: Arc<Client>,
pub student_cache: Arc<Mutex<HashMap<String, String>>>,
}
impl AppState {
pub fn new() -> Result<Self> {
dotenv().ok();
let supabase_url = std::env::var("SUPABASE_URL").expect("missing SUPABASE_URL");
let supabase_key = std::env::var("SUPABASE_KEY").expect("missing SUPABASE_KEY");
let client = Client::new(&supabase_url, &supabase_key)?;
Ok(Self {
supabase: Arc::new(client),
student_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok(); // load env
let app_state = AppState::new().expect("Failed to initialize AppState");
// -- 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(); let intents = serenity::GatewayIntents::non_privileged();
@ -24,13 +54,16 @@ async fn main() {
commands::info::serverinfo(), commands::info::serverinfo(),
commands::info::botinfo(), commands::info::botinfo(),
commands::weather::weather(), commands::weather::weather(),
commands::verification::verify(),
], ],
..Default::default() ..Default::default()
}) })
.setup(|ctx, _ready, framework| { .setup(|ctx, _ready, framework| {
Box::pin(async move { Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?; poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data {}) Ok(Data {
state: app_state.clone(),
})
}) })
}) })
.build(); .build();
@ -43,4 +76,6 @@ async fn main() {
.start() .start()
.await .await
.expect("Client failed to start 2"); .expect("Client failed to start 2");
// -- discord bot end --
} }