osu: make save command use interaction

This commit is contained in:
Natsu Kagami 2025-02-22 17:13:57 +01:00
parent 247001f8c7
commit 5afe20cba8
Signed by: nki
GPG key ID: 55A032EB38B49ADB
6 changed files with 94 additions and 59 deletions

View file

@ -36,9 +36,27 @@ pub mod errors {
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
#[error("sqlx error: {:?}", .0)] #[error("sqlx error: {:?}", .0)]
SQLx(#[from] sqlx::Error), SQLx(sqlx::Error),
#[error("sqlx migration error: {:?}", .0)] #[error("sqlx migration error: {:?}", .0)]
Migration(#[from] sqlx::migrate::MigrateError), Migration(#[from] sqlx::migrate::MigrateError),
#[error("values already existed for: {}", .0)]
Duplicate(String),
}
impl From<sqlx::Error> for Error {
fn from(value: sqlx::Error) -> Self {
match value {
// if we can match a constraint error, give it a special case.
sqlx::Error::Database(database_error) => {
let msg = database_error.message();
match msg.strip_prefix("UNIQUE constraint failed: ") {
Some(con) => Error::Duplicate(con.to_owned()),
None => Error::SQLx(sqlx::Error::Database(database_error)),
}
}
e => Error::SQLx(e),
}
}
} }
} }

View file

@ -186,21 +186,31 @@ pub async fn save<U: HasOsuEnv>(
CreateReply::default() CreateReply::default()
.content(save_request_message(&u.username, score.beatmap_id, mode)) .content(save_request_message(&u.username, score.beatmap_id, mode))
.embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info)) .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info))
.components(vec![beatmap_components(mode, ctx.guild_id())]), .components(vec![
beatmap_components(mode, ctx.guild_id()),
save_button(),
]),
) )
.await?
.into_message()
.await?; .await?;
handle_save_respond( let mut p = (reply, ctx.clone());
match handle_save_respond(
ctx.serenity_context(), ctx.serenity_context(),
&env, &env,
ctx.author().id, ctx.author().id,
reply, &mut p,
&beatmap, &beatmap,
u, u,
mode, mode,
) )
.await?; .await
{
Ok(_) => (),
Err(e) => {
p.0.delete(ctx).await?;
return Err(e.into());
}
};
Ok(()) Ok(())
} }

View file

@ -58,7 +58,14 @@ impl OsuSavedUsers {
let mut t = self.pool.begin().await?; let mut t = self.pool.begin().await?;
model::OsuUser::delete(u.user_id.get() as i64, &mut *t).await?; model::OsuUser::delete(u.user_id.get() as i64, &mut *t).await?;
assert!( assert!(
model::OsuUser::from(u).store(&mut t).await?, match model::OsuUser::from(u).store(&mut t).await {
Ok(v) => v,
Err(youmubot_db_sql::Error::Duplicate(_)) =>
return Err(Error::msg(
"another Discord user has already saved your account with the same id!"
)),
Err(e) => return Err(e.into()),
},
"Should be updated" "Should be updated"
); );
t.commit().await?; t.commit().await?;

View file

@ -8,8 +8,8 @@ use link_parser::EmbedType;
use oppai_cache::BeatmapInfoWithPP; use oppai_cache::BeatmapInfoWithPP;
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
use serenity::{ use serenity::{
builder::{CreateMessage, EditMessage}, all::{CreateActionRow, CreateButton},
collector, builder::CreateMessage,
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
Args, CommandResult, Args, CommandResult,
@ -257,10 +257,17 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
CreateMessage::new() CreateMessage::new()
.content(save_request_message(&u.username, score.beatmap_id, mode)) .content(save_request_message(&u.username, score.beatmap_id, mode))
.embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info)) .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info))
.components(vec![beatmap_components(mode, msg.guild_id)]), .components(vec![beatmap_components(mode, msg.guild_id), save_button()]),
) )
.await?; .await?;
handle_save_respond(ctx, &env, msg.author.id, reply, &beatmap, u, mode).await?; let mut p = (reply, ctx);
match handle_save_respond(ctx, &env, msg.author.id, &mut p, &beatmap, u, mode).await {
Ok(_) => (),
Err(e) => {
p.0.delete(&ctx).await?;
return Err(e.into());
}
};
Ok(()) Ok(())
} }
@ -325,11 +332,18 @@ pub(crate) async fn find_save_requirements(
Ok((u, mode, score, beatmap, info)) Ok((u, mode, score, beatmap, info))
} }
const SAVE_BUTTON: &str = "youmubot::osu::save";
pub(crate) fn save_button() -> CreateActionRow {
CreateActionRow::Buttons(vec![CreateButton::new(SAVE_BUTTON)
.label("I'm done!")
.emoji('👌')
.style(serenity::all::ButtonStyle::Primary)])
}
pub(crate) async fn handle_save_respond( pub(crate) async fn handle_save_respond(
ctx: &Context, ctx: &Context,
env: &OsuEnv, env: &OsuEnv,
sender: serenity::all::UserId, sender: serenity::all::UserId,
mut reply: Message, reply: &mut impl CanEdit,
beatmap: &Beatmap, beatmap: &Beatmap,
user: crate::models::User, user: crate::models::User,
mode: Mode, mode: Mode,
@ -343,50 +357,36 @@ pub(crate) async fn handle_save_respond(
.take(1) .take(1)
.any(|s| s.beatmap_id == map_id)) .any(|s| s.beatmap_id == map_id))
} }
let reaction = reply.react(&ctx, '👌').await?; let msg_id = reply.get_message().await?.id;
let recv = InteractionCollector::create(&ctx, msg_id).await?;
let timeout = std::time::Duration::from_secs(300) + beatmap.difficulty.total_length;
let completed = loop { let completed = loop {
let emoji = reaction.emoji.clone(); let Some(reaction) = recv.next(timeout).await else {
let user_reaction = collector::ReactionCollector::new(ctx)
.message_id(reply.id)
.author_id(sender)
.filter(move |r| r.emoji == emoji)
.timeout(std::time::Duration::from_secs(300) + beatmap.difficulty.total_length)
.next()
.await;
if let Some(ur) = user_reaction {
if check(osu_client, &user, mode, beatmap.beatmap_id).await? {
break true;
}
ur.delete(&ctx).await?;
} else {
break false; break false;
};
if reaction == SAVE_BUTTON && check(osu_client, &user, mode, beatmap.beatmap_id).await? {
break true;
} }
}; };
if !completed { if !completed {
reply reply
.edit( .apply_edit(
&ctx, CreateReply::default()
EditMessage::new()
.content(format!( .content(format!(
"Setting username to **{}** failed due to timeout. Please try again!", "Setting username to **{}** failed due to timeout. Please try again!",
user.username user.username
)) ))
.embeds(vec![])
.components(vec![]), .components(vec![]),
) )
.await?; .await?;
reaction.delete(&ctx).await?;
return Ok(()); return Ok(());
} }
add_user(sender, &user, &env).await?; add_user(sender, &user, &env).await?;
let ex = UserExtras::from_user(env, &user, mode).await?; let ex = UserExtras::from_user(env, &user, mode).await?;
reply reply
.channel_id .apply_edit(
.send_message( CreateReply::default()
&ctx,
CreateMessage::new()
.reference_message(&reply)
.content( .content(
MessageBuilder::new() MessageBuilder::new()
.push("Youmu is now tracking user ") .push("Youmu is now tracking user ")
@ -395,7 +395,8 @@ pub(crate) async fn handle_save_respond(
.push(user.mention().to_string()) .push(user.mention().to_string())
.build(), .build(),
) )
.add_embed(user_embed(user.clone(), ex)), .embed(user_embed(user.clone(), ex))
.components(vec![]),
) )
.await?; .await?;
Ok(()) Ok(())

View file

@ -3,7 +3,7 @@ use serenity::{
all::{CreateInteractionResponse, Interaction, MessageId}, all::{CreateInteractionResponse, Interaction, MessageId},
prelude::TypeMapKey, prelude::TypeMapKey,
}; };
use std::{ops::Deref, sync::Arc}; use std::sync::Arc;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Handles distributing interaction to the handlers. /// Handles distributing interaction to the handlers.
@ -13,16 +13,25 @@ pub struct InteractionCollector {
/// Wraps the interfaction receiver channel, automatically cleaning up upon drop. /// Wraps the interfaction receiver channel, automatically cleaning up upon drop.
#[derive(Debug)] #[derive(Debug)]
struct InteractionCollectorGuard { pub struct InteractionCollectorGuard {
msg_id: MessageId, msg_id: MessageId,
ch: flume::Receiver<String>, ch: flume::Receiver<String>,
collector: InteractionCollector, collector: InteractionCollector,
} }
impl Deref for InteractionCollectorGuard { impl InteractionCollectorGuard {
type Target = flume::Receiver<String>; /// Returns the next fetched interaction, with the given timeout.
pub async fn next(&self, timeout: std::time::Duration) -> Option<String> {
match tokio::time::timeout(timeout, self.ch.clone().into_recv_async()).await {
Err(_) => None,
Ok(Err(_)) => None,
Ok(Ok(interaction)) => Some(interaction),
}
}
}
fn deref(&self) -> &Self::Target { impl AsRef<flume::Receiver<String>> for InteractionCollectorGuard {
fn as_ref(&self) -> &flume::Receiver<String> {
&self.ch &self.ch
} }
} }
@ -40,7 +49,7 @@ impl InteractionCollector {
} }
} }
/// Create a new collector, returning a receiver. /// Create a new collector, returning a receiver.
pub fn create_collector(&self, msg: MessageId) -> impl Deref<Target = flume::Receiver<String>> { pub fn create_collector(&self, msg: MessageId) -> InteractionCollectorGuard {
let (send, recv) = flume::unbounded(); let (send, recv) = flume::unbounded();
self.channels.insert(msg.clone(), send); self.channels.insert(msg.clone(), send);
InteractionCollectorGuard { InteractionCollectorGuard {
@ -51,10 +60,7 @@ impl InteractionCollector {
} }
/// Create a new collector, returning a receiver. /// Create a new collector, returning a receiver.
pub(crate) async fn create( pub async fn create(ctx: &Context, msg: MessageId) -> Result<InteractionCollectorGuard> {
ctx: &Context,
msg: MessageId,
) -> Result<impl Deref<Target = flume::Receiver<String>>> {
Ok(ctx Ok(ctx
.data .data
.read() .read()

View file

@ -8,12 +8,6 @@ use serenity::{
builder::CreateMessage, builder::CreateMessage,
model::{channel::Message, id::ChannelId}, model::{channel::Message, id::ChannelId},
}; };
use tokio::time as tokio_time;
// const ARROW_RIGHT: &str = "➡️";
// const ARROW_LEFT: &str = "⬅️";
// const REWIND: &str = "⏪";
// const FAST_FORWARD: &str = "⏩";
const NEXT: &str = "youmubot_pagination_next"; const NEXT: &str = "youmubot_pagination_next";
const PREV: &str = "youmubot_pagination_prev"; const PREV: &str = "youmubot_pagination_prev";
@ -269,10 +263,9 @@ pub async fn paginate_with_first_message(
// Loop the handler function. // Loop the handler function.
let res: Result<()> = loop { let res: Result<()> = loop {
match tokio_time::timeout(timeout, recv.clone().into_recv_async()).await { match recv.next(timeout).await {
Err(_) => break Ok(()), None => break Ok(()),
Ok(Err(_)) => break Ok(()), Some(reaction) => {
Ok(Ok(reaction)) => {
page = match pager page = match pager
.handle_reaction(page, ctx, &mut message, &reaction) .handle_reaction(page, ctx, &mut message, &reaction)
.await .await