From 2b668388d59b0fec57d67c7765b859d541a25ee0 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 30 Aug 2026 15:40:01 +1000 Subject: [PATCH] COR-03: fix verification failing on a member's first attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insert into dsec_discord_members deserialised the response into Vec, but without `.returning(...)` supabase-lib-rs never sends `Prefer: return=representation`, so PostgREST returns a 201 with an empty body. Parsing that empty body failed and the `?` aborted before add_role and before any reply — so every member's first click died with "This interaction failed", while the row was still written (which is why the second attempt worked). Add `.returning("student_id,discord_id")` so the row comes back and parses. Also wrap the post-modal verification work so any failure sends the user an ephemeral "something went wrong" instead of a dead interaction (poise's on_error has no handle to the modal submission), and set an explicit on_error on FrameworkOptions that replies ephemerally on command errors and keeps the default logging. The database-write-first ordering is left unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE --- src/events/interaction_create.rs | 133 +++++++++++++++++++------------ src/main.rs | 23 ++++++ 2 files changed, 106 insertions(+), 50 deletions(-) diff --git a/src/events/interaction_create.rs b/src/events/interaction_create.rs index 5577a73..4804276 100644 --- a/src/events/interaction_create.rs +++ b/src/events/interaction_create.rs @@ -86,12 +86,18 @@ async fn add_dsec_discord_table( "discord_id": member_id, }); + // `.returning(...)` makes supabase-lib-rs send `Prefer: return=representation`. + // Without it PostgREST defaults a POST to `return=minimal` — a 201 with an + // empty body — and deserialising that empty body into Vec + // failed, aborting before the role grant even though the row was written. + // That is why verification failed on every member's first attempt (COR-03). let _: Vec = data .state .supabase .database() .insert("dsec_discord_members") .values(new_member)? + .returning("student_id,discord_id") .execute() .await?; @@ -220,66 +226,93 @@ async fn handle_verify( 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. + // 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. Wrap that + // work so any failure (e.g. a database error) still sends the user an + // ephemeral message rather than leaving a dead "This interaction failed" + // interaction — poise's on_error cannot reach this modal submission (COR-03). 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); + let verify_result: Result<(), Error> = async { + let discord_member = GuildId::member(guild_id, ctx, user_id).await?; - if cached_name_matches(data, student_id, &modal_data.name) { - grant_verified_role( - ctx, - data, - &modal_submit, - &discord_member, - student_id, - verified_role_id, - true, - ) - .await?; - return Ok(()); + 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, + data, + &modal_submit, + &discord_member, + student_id, + verified_role_id, + true, + ) + .await?; + return Ok(()); + } + + let Some(student) = fetch_student(data, student_id).await? else { + modal_submit + .create_response( + ctx, + ephemeral_embed( + CreateEmbed::new().title("Student ID not found!").description( + "Your student ID is not found.\nIt takes up to **a week** for your membership to be updated in the database since sign up.\nTry again later.", + ), + ), + ) + .await?; + return Ok(()); + }; + + cache_student(data, student_id, &student.full_name); + + if student.full_name.to_lowercase() == modal_data.name.to_lowercase() { + grant_verified_role( + ctx, + data, + &modal_submit, + &discord_member, + student_id, + verified_role_id, + false, + ) + .await?; + } else { + modal_submit + .create_response( + ctx, + ephemeral_embed(CreateEmbed::new().title("Name mismatch ❌").description( + "Your student ID is present, however the name does not match. Try again.", + )), + ) + .await?; + } + + Ok(()) } + .await; - let Some(student) = fetch_student(data, student_id).await? else { - modal_submit + if let Err(err) = verify_result { + eprintln!("[verify] verification failed after modal submit: {err}"); + // Best-effort ephemeral error so the user does not see the generic + // "This interaction failed" with no way forward. + let _ = modal_submit .create_response( ctx, ephemeral_embed( - CreateEmbed::new().title("Student ID not found!").description( - "Your student ID is not found.\nIt takes up to **a week** for your membership to be updated in the database since sign up.\nTry again later.", - ), + CreateEmbed::new() + .title("Something went wrong") + .description( + "A maintainer has been notified. Please try again in a minute.", + ), ), ) - .await?; - return Ok(()); - }; - - cache_student(data, student_id, &student.full_name); - - if student.full_name.to_lowercase() == modal_data.name.to_lowercase() { - grant_verified_role( - ctx, - data, - &modal_submit, - &discord_member, - student_id, - verified_role_id, - false, - ) - .await?; - } else { - modal_submit - .create_response( - ctx, - ephemeral_embed(CreateEmbed::new().title("Name mismatch ❌").description( - "Your student ID is present, however the name does not match. Try again.", - )), - ) - .await?; + .await; } Ok(()) diff --git a/src/main.rs b/src/main.rs index a9b8b96..9df5e95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,28 @@ async fn event_handler( Ok(()) } +/// Framework-level error handler. On a slash-command error it replies ephemerally +/// so the user is not left with a dead interaction, and it always runs poise's +/// default logging. `FrameworkOptions::default()` installs this default logger +/// too, but nothing surfaces it (no tracing subscriber, no log shipping — OPS-04), +/// so an explicit handler is set (COR-03). +async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { + if let poise::FrameworkError::Command { ctx, .. } = &error { + let _ = ctx + .send( + poise::CreateReply::default() + .content( + "Something went wrong — a maintainer has been notified. Please try again in a minute.", + ) + .ephemeral(true), + ) + .await; + } + if let Err(e) = poise::builtins::on_error(error).await { + eprintln!("[on_error] failed while handling a framework error: {e}"); + } +} + #[tokio::main] async fn main() { dotenv().ok(); // load env @@ -155,6 +177,7 @@ async fn main() { event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data)) }, + on_error: |error| Box::pin(on_error(error)), ..Default::default() })