Move Pagination to some generic replyable trait

This commit is contained in:
Natsu Kagami 2024-02-27 00:28:13 +01:00
parent d5fb2cce69
commit bcd59c673c
Signed by: nki
GPG key ID: 55A032EB38B49ADB
20 changed files with 509 additions and 267 deletions

3
Cargo.lock generated
View file

@ -3213,6 +3213,7 @@ dependencies = [
"dashmap", "dashmap",
"lazy_static", "lazy_static",
"log", "log",
"poise",
"regex", "regex",
"reqwest", "reqwest",
"serde", "serde",
@ -3230,6 +3231,7 @@ dependencies = [
"dashmap", "dashmap",
"flume 0.10.14", "flume 0.10.14",
"futures-util", "futures-util",
"poise",
"rand", "rand",
"serde", "serde",
"serenity", "serenity",
@ -3280,6 +3282,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serenity", "serenity",
"thiserror",
"time", "time",
"youmubot-db", "youmubot-db",
"youmubot-db-sql", "youmubot-db-sql",

View file

@ -10,6 +10,7 @@ serde = { version = "1.0.137", features = ["derive"] }
tokio = { version = "1.19.2", features = ["time"] } tokio = { version = "1.19.2", features = ["time"] }
reqwest = "0.11.10" reqwest = "0.11.10"
serenity = "0.12" serenity = "0.12"
poise = "0.6"
Inflector = "0.11.4" Inflector = "0.11.4"
codeforces = "0.3.1" codeforces = "0.3.1"
regex = "1.5.6" regex = "1.5.6"

View file

@ -1,4 +1,5 @@
use codeforces::Contest; use codeforces::Contest;
use poise::CreateReply;
use serenity::{ use serenity::{
builder::{CreateMessage, EditMessage}, builder::{CreateMessage, EditMessage},
framework::standard::{ framework::standard::{
@ -173,14 +174,14 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult {
let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap(); let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap();
paginate_reply_fn( paginate_reply_fn(
move |page, ctx, msg| { move |page, _| {
let ranks = ranks.clone(); let ranks = ranks.clone();
Box::pin(async move { Box::pin(async move {
let page = page as usize; let page = page as usize;
let start = ITEMS_PER_PAGE * page; let start = ITEMS_PER_PAGE * page;
let end = ranks.len().min(start + ITEMS_PER_PAGE); let end = ranks.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let ranks = &ranks[start..end]; let ranks = &ranks[start..end];
@ -233,12 +234,11 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult {
last_updated.to_rfc2822() last_updated.to_rfc2822()
)); ));
msg.edit(ctx, EditMessage::new().content(m.build())).await?; Ok(Some(CreateReply::default().content(m.build())))
Ok(true)
}) })
}, },
ctx, ctx,
m, m.clone(),
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
) )
.await?; .await?;
@ -328,7 +328,7 @@ pub(crate) async fn contest_rank_table(
let ranks = Arc::new(ranks); let ranks = Arc::new(ranks);
paginate_reply_fn( paginate_reply_fn(
move |page, ctx, msg| { move |page, ctx| {
let contest = contest.clone(); let contest = contest.clone();
let problems = problems.clone(); let problems = problems.clone();
let ranks = ranks.clone(); let ranks = ranks.clone();
@ -337,7 +337,7 @@ pub(crate) async fn contest_rank_table(
let start = page * ITEMS_PER_PAGE; let start = page * ITEMS_PER_PAGE;
let end = ranks.len().min(start + ITEMS_PER_PAGE); let end = ranks.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let ranks = &ranks[start..end]; let ranks = &ranks[start..end];
let hw = ranks let hw = ranks
@ -412,12 +412,11 @@ pub(crate) async fn contest_rank_table(
.push_line(contest.url()) .push_line(contest.url())
.push_codeblock(table.build(), None) .push_codeblock(table.build(), None)
.push_line(format!("Page **{}/{}**", page + 1, total_pages)); .push_line(format!("Page **{}/{}**", page + 1, total_pages));
msg.edit(ctx, EditMessage::new().content(m.build())).await?; Ok(Some(CreateReply::default().content(m.build())))
Ok(true)
}) })
}, },
ctx, ctx,
reply_to, reply_to.clone(),
Duration::from_secs(60), Duration::from_secs(60),
) )
.await .await

View file

@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
serenity = { version = "0.12", features = ["collector"] } serenity = { version = "0.12", features = ["collector"] }
poise = "0.6"
rand = "0.8.5" rand = "0.8.5"
serde = { version = "1.0.137", features = ["derive"] } serde = { version = "1.0.137", features = ["derive"] }
chrono = "0.4.19" chrono = "0.4.19"

View file

@ -1,6 +1,6 @@
use crate::db::Roles as DB; use crate::db::Roles as DB;
use poise::CreateReply;
use serenity::{ use serenity::{
builder::EditMessage,
framework::standard::{macros::command, Args, CommandResult}, framework::standard::{macros::command, Args, CommandResult},
model::{ model::{
channel::{Message, ReactionType}, channel::{Message, ReactionType},
@ -41,14 +41,14 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult {
let pages = (roles.len() + ROLES_PER_PAGE - 1) / ROLES_PER_PAGE; let pages = (roles.len() + ROLES_PER_PAGE - 1) / ROLES_PER_PAGE;
paginate_reply_fn( paginate_reply_fn(
|page, ctx, msg| { |page, _| {
let roles = roles.clone(); let roles = roles.clone();
Box::pin(async move { Box::pin(async move {
let page = page as usize; let page = page as usize;
let start = page * ROLES_PER_PAGE; let start = page * ROLES_PER_PAGE;
let end = roles.len().min(start + ROLES_PER_PAGE); let end = roles.len().min(start + ROLES_PER_PAGE);
if end <= start { if end <= start {
return Ok(false); return Ok(None);
} }
let roles = &roles[start..end]; let roles = &roles[start..end];
let nw = roles // name width let nw = roles // name width
@ -101,13 +101,11 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult {
m.push_line("```"); m.push_line("```");
m.push(format!("Page **{}/{}**", page + 1, pages)); m.push(format!("Page **{}/{}**", page + 1, pages));
msg.edit(ctx, EditMessage::new().content(m.to_string())) Ok(Some(CreateReply::default().content(m.to_string())))
.await?;
Ok(true)
}) })
}, },
ctx, ctx,
m, m.clone(),
std::time::Duration::from_secs(60 * 10), std::time::Duration::from_secs(60 * 10),
) )
.await?; .await?;

View file

@ -1,3 +1,4 @@
use poise::CreateReply;
use serde::Deserialize; use serde::Deserialize;
use serenity::builder::EditMessage; use serenity::builder::EditMessage;
use serenity::framework::standard::CommandError as Error; use serenity::framework::standard::CommandError as Error;
@ -66,30 +67,24 @@ async fn message_command(
} }
let images = std::sync::Arc::new(images); let images = std::sync::Arc::new(images);
paginate_reply_fn( paginate_reply_fn(
move |page, ctx, msg: &mut Message| { move |page, _| {
let images = images.clone(); let images = images.clone();
Box::pin(async move { Box::pin(async move {
let page = page as usize; let page = page as usize;
if page >= images.len() { if page >= images.len() {
Ok(false) Ok(None)
} else { } else {
msg.edit( Ok(Some(CreateReply::default().content(format!(
ctx, "[🖼️ **{}/{}**] Here's the image you requested!\n\n{}",
EditMessage::new().content(format!( page + 1,
"[🖼️ **{}/{}**] Here's the image you requested!\n\n{}", images.len(),
page + 1, images[page]
images.len(), ))))
images[page]
)),
)
.await
.map(|_| true)
.map_err(|e| e.into())
} }
}) })
}, },
ctx, ctx,
msg, msg.clone(),
std::time::Duration::from_secs(120), std::time::Duration::from_secs(120),
) )
.await?; .await?;

View file

@ -23,6 +23,7 @@ serenity = "0.12"
poise = "0.6" poise = "0.6"
zip = "0.6.2" zip = "0.6.2"
rand = "0.8" rand = "0.8"
thiserror = "1"
youmubot-db = { path = "../youmubot-db" } youmubot-db = { path = "../youmubot-db" }
youmubot-db-sql = { path = "../youmubot-db-sql" } youmubot-db-sql = { path = "../youmubot-db-sql" }

View file

@ -320,9 +320,13 @@ impl<'a> CollectedScore<'a> {
}), }),
) )
.await?; .await?;
save_beatmap(&*ctx.data.read().await, channel, bm) save_beatmap(
.await ctx.data.read().await.get::<crate::discord::Env>().unwrap(),
.pls_ok(); channel,
bm,
)
.await
.pls_ok();
Ok(m) Ok(m)
} }
} }

View file

@ -1,9 +1,21 @@
use serenity::all::Member;
use youmubot_prelude::*; use youmubot_prelude::*;
use crate::{discord::args::ScoreDisplay, models::Mods};
#[poise::command(slash_command, subcommands("check"))]
pub async fn osu<T: AsRef<crate::Env> + Sync>(_ctx: CmdContext<'_, T>) -> Result<(), Error> {
Ok(())
}
#[poise::command(slash_command)] #[poise::command(slash_command)]
pub async fn example<T: AsRef<crate::Env> + Sync>( /// Check your/someone's score on the last beatmap in the channel
context: poise::Context<'_, T, Error>, async fn check<T: AsRef<crate::Env> + Sync>(
arg: String, ctx: CmdContext<'_, T>,
) -> Result<(), Error> { #[description = "Pass an osu! username to check for scores"] osu_id: Option<String>,
#[description = "Pass a member of the guild to check for scores"] member: Option<Member>,
#[description = "Filter mods that should appear in the scores returned"] mods: Option<Mods>,
#[description = "Score display style"] style: Option<ScoreDisplay>,
) -> Result<()> {
todo!() todo!()
} }

View file

@ -0,0 +1,70 @@
use serenity::all::Message;
use youmubot_prelude::*;
// One of the interaction sources.
pub enum InteractionSrc<'a, 'c: 'a, T, E> {
Serenity(&'a Message),
Poise(&'a poise::Context<'c, T, E>),
}
impl<'a, 'c, T, E> InteractionSrc<'a, 'c, T, E> {
pub async fn reply(&self, ctx: &Context, msg: impl Into<String>) -> Result<Message> {
Ok(match self {
InteractionSrc::Serenity(m) => m.reply(ctx, msg).await?,
InteractionSrc::Poise(ctx) => ctx.reply(msg).await?.message().await?.into_owned(),
})
}
}
impl<'a, 'c, T, E> From<&'a Message> for InteractionSrc<'a, 'c, T, E> {
fn from(value: &'a Message) -> Self {
Self::Serenity(value)
}
}
impl<'a, 'c, T, E> From<&'a poise::Context<'c, T, E>> for InteractionSrc<'a, 'c, T, E> {
fn from(value: &'a poise::Context<'c, T, E>) -> Self {
Self::Poise(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, poise::ChoiceParameter, Default)]
pub enum ScoreDisplay {
#[name = "table"]
#[default]
Table,
#[name = "grid"]
Grid,
}
impl std::str::FromStr for ScoreDisplay {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"--table" => Ok(Self::Table),
"--grid" => Ok(Self::Grid),
_ => Err(Error::unknown(s)),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unknown value: {0}")]
UnknownValue(String),
#[error("parse error: {0}")]
Custom(String),
}
impl Error {
fn unknown(s: impl AsRef<str>) -> Self {
Self::UnknownValue(s.as_ref().to_owned())
}
}
impl From<String> for Error {
fn from(value: String) -> Self {
Error::Custom(value)
}
}

View file

@ -1,29 +1,24 @@
use super::db::OsuLastBeatmap;
use super::BeatmapWithMode; use super::BeatmapWithMode;
use serenity::model::id::ChannelId; use serenity::model::id::ChannelId;
use youmubot_prelude::*; use youmubot_prelude::*;
/// Save the beatmap into the server data storage. /// Save the beatmap into the server data storage.
pub(crate) async fn save_beatmap( pub(crate) async fn save_beatmap(
data: &TypeMap, env: &crate::discord::Env,
channel_id: ChannelId, channel_id: ChannelId,
bm: &BeatmapWithMode, bm: &BeatmapWithMode,
) -> Result<()> { ) -> Result<()> {
data.get::<OsuLastBeatmap>() env.last_beatmaps.save(channel_id, &bm.0, bm.1).await?;
.unwrap()
.save(channel_id, &bm.0, bm.1)
.await?;
Ok(()) Ok(())
} }
/// Get the last beatmap requested from this channel. /// Get the last beatmap requested from this channel.
pub(crate) async fn get_beatmap( pub(crate) async fn get_beatmap(
data: &TypeMap, env: &crate::discord::Env,
channel_id: ChannelId, channel_id: ChannelId,
) -> Result<Option<BeatmapWithMode>> { ) -> Result<Option<BeatmapWithMode>> {
data.get::<OsuLastBeatmap>() env.last_beatmaps
.unwrap()
.by_channel(channel_id) .by_channel(channel_id)
.await .await
.map(|v| v.map(|(bm, mode)| BeatmapWithMode(bm, mode))) .map(|v| v.map(|(bm, mode)| BeatmapWithMode(bm, mode)))

View file

@ -1,47 +1,26 @@
pub use beatmapset::display_beatmapset; pub use beatmapset::display_beatmapset;
pub use scores::ScoreListStyle; pub use scores::ScoreListStyle;
mod scores { pub(in crate::discord) mod scores {
use crate::models::{Mode, Score}; use crate::models::{Mode, Score};
use serenity::{framework::standard::CommandResult, model::channel::Message}; use serenity::{all::ChannelId, framework::standard::CommandResult};
use youmubot_prelude::*; use youmubot_prelude::{replyable::Replyable, *};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// The style for the scores list to be displayed. /// The style for the scores list to be displayed.
pub enum ScoreListStyle { pub type ScoreListStyle = crate::discord::args::ScoreDisplay;
Table,
Grid,
}
impl Default for ScoreListStyle { pub async fn display_scores<'a>(
fn default() -> Self { style: ScoreListStyle,
Self::Table scores: Vec<Score>,
} mode: Mode,
} ctx: &'a Context,
m: impl Replyable,
impl std::str::FromStr for ScoreListStyle { channel_id: ChannelId,
type Err = Error; ) -> CommandResult {
match style {
fn from_str(s: &str) -> Result<Self, Self::Err> { ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await,
match s { ScoreListStyle::Grid => {
"--table" => Ok(Self::Table), grid::display_scores_grid(scores, mode, ctx, m, channel_id).await
"--grid" => Ok(Self::Grid),
_ => Err(Error::msg("unknown value")),
}
}
}
impl ScoreListStyle {
pub async fn display_scores<'a>(
self,
scores: Vec<Score>,
mode: Mode,
ctx: &'a Context,
m: &'a Message,
) -> CommandResult {
match self {
ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await,
ScoreListStyle::Grid => grid::display_scores_grid(scores, mode, ctx, m).await,
} }
} }
} }
@ -51,15 +30,18 @@ mod scores {
cache::save_beatmap, BeatmapCache, BeatmapMetaCache, BeatmapWithMode, cache::save_beatmap, BeatmapCache, BeatmapMetaCache, BeatmapWithMode,
}; };
use crate::models::{Mode, Score}; use crate::models::{Mode, Score};
use serenity::builder::EditMessage; use poise::CreateReply;
use serenity::{framework::standard::CommandResult, model::channel::Message}; use serenity::all::ChannelId;
use serenity::framework::standard::CommandResult;
use youmubot_prelude::replyable::Replyable;
use youmubot_prelude::*; use youmubot_prelude::*;
pub async fn display_scores_grid<'a>( pub async fn display_scores_grid<'a>(
scores: Vec<Score>, scores: Vec<Score>,
mode: Mode, mode: Mode,
ctx: &'a Context, ctx: &'a Context,
m: &'a Message, m: impl Replyable,
channel_id: ChannelId,
) -> CommandResult { ) -> CommandResult {
if scores.is_empty() { if scores.is_empty() {
m.reply(&ctx, "No plays found").await?; m.reply(&ctx, "No plays found").await?;
@ -67,7 +49,11 @@ mod scores {
} }
paginate_reply( paginate_reply(
Paginate { scores, mode }, Paginate {
channel_id,
scores,
mode,
},
ctx, ctx,
m, m,
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
@ -77,13 +63,14 @@ mod scores {
} }
pub struct Paginate { pub struct Paginate {
channel_id: ChannelId,
scores: Vec<Score>, scores: Vec<Score>,
mode: Mode, mode: Mode,
} }
#[async_trait] #[async_trait]
impl pagination::Paginate for Paginate { impl pagination::Paginate for Paginate {
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> { async fn render(&mut self, page: u8, ctx: &Context) -> Result<Option<CreateReply>> {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let client = data.get::<crate::discord::OsuClient>().unwrap(); let client = data.get::<crate::discord::OsuClient>().unwrap();
let osu = data.get::<BeatmapMetaCache>().unwrap(); let osu = data.get::<BeatmapMetaCache>().unwrap();
@ -91,7 +78,6 @@ mod scores {
let page = page as usize; let page = page as usize;
let score = &self.scores[page]; let score = &self.scores[page];
let hourglass = msg.react(ctx, '⌛').await?;
let mode = self.mode; let mode = self.mode;
let beatmap = osu.get_beatmap(score.beatmap_id, mode).await?; let beatmap = osu.get_beatmap(score.beatmap_id, mode).await?;
let content = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?; let content = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?;
@ -101,20 +87,18 @@ mod scores {
.await? .await?
.ok_or_else(|| Error::msg("user not found"))?; .ok_or_else(|| Error::msg("user not found"))?;
msg.edit( let edit = CreateReply::default().embed({
ctx, crate::discord::embeds::score_embed(score, &bm, &content, &user)
EditMessage::new().embed({ .footer(format!("Page {}/{}", page + 1, self.scores.len()))
crate::discord::embeds::score_embed(score, &bm, &content, &user) .build()
.footer(format!("Page {}/{}", page + 1, self.scores.len())) });
.build() save_beatmap(
}), ctx.data.read().await.get::<crate::discord::Env>().unwrap(),
self.channel_id,
&bm,
) )
.await?; .await?;
save_beatmap(&*ctx.data.read().await, msg.channel_id, &bm).await?; Ok(Some(edit))
// End
hourglass.delete(ctx).await?;
Ok(true)
} }
fn len(&self) -> Option<usize> { fn len(&self) -> Option<usize> {
@ -129,15 +113,16 @@ mod scores {
use crate::discord::oppai_cache::Accuracy; use crate::discord::oppai_cache::Accuracy;
use crate::discord::{Beatmap, BeatmapCache, BeatmapInfo, BeatmapMetaCache}; use crate::discord::{Beatmap, BeatmapCache, BeatmapInfo, BeatmapMetaCache};
use crate::models::{Mode, Score}; use crate::models::{Mode, Score};
use serenity::builder::EditMessage; use poise::CreateReply;
use serenity::{framework::standard::CommandResult, model::channel::Message}; use serenity::framework::standard::CommandResult;
use youmubot_prelude::replyable::Replyable;
use youmubot_prelude::*; use youmubot_prelude::*;
pub async fn display_scores_table<'a>( pub async fn display_scores_table<'a>(
scores: Vec<Score>, scores: Vec<Score>,
mode: Mode, mode: Mode,
ctx: &'a Context, ctx: &'a Context,
m: &'a Message, m: impl Replyable,
) -> CommandResult { ) -> CommandResult {
if scores.is_empty() { if scores.is_empty() {
m.reply(&ctx, "No plays found").await?; m.reply(&ctx, "No plays found").await?;
@ -169,7 +154,7 @@ mod scores {
#[async_trait] #[async_trait]
impl pagination::Paginate for Paginate { impl pagination::Paginate for Paginate {
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> { async fn render(&mut self, page: u8, ctx: &Context) -> Result<Option<CreateReply>> {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let osu = data.get::<BeatmapMetaCache>().unwrap(); let osu = data.get::<BeatmapMetaCache>().unwrap();
let beatmap_cache = data.get::<BeatmapCache>().unwrap(); let beatmap_cache = data.get::<BeatmapCache>().unwrap();
@ -177,10 +162,9 @@ mod scores {
let start = page * ITEMS_PER_PAGE; let start = page * ITEMS_PER_PAGE;
let end = self.scores.len().min(start + ITEMS_PER_PAGE); let end = self.scores.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let hourglass = msg.react(ctx, '⌛').await?;
let plays = &self.scores[start..end]; let plays = &self.scores[start..end];
let mode = self.mode; let mode = self.mode;
let beatmaps = plays let beatmaps = plays
@ -330,10 +314,7 @@ mod scores {
self.total_pages() self.total_pages()
)); ));
m.push_line("[?] means pp was predicted by oppai-rs."); m.push_line("[?] means pp was predicted by oppai-rs.");
msg.edit(ctx, EditMessage::new().content(m.to_string())) Ok(Some(CreateReply::default().content(m.to_string())))
.await?;
hourglass.delete(ctx).await?;
Ok(true)
} }
fn len(&self) -> Option<usize> { fn len(&self) -> Option<usize> {
@ -350,13 +331,14 @@ mod beatmapset {
}, },
models::{Beatmap, Mode, Mods}, models::{Beatmap, Mode, Mods},
}; };
use poise::CreateReply;
use serenity::{ use serenity::{
all::Reaction, all::{ChannelId, Reaction},
builder::{CreateEmbedFooter, EditMessage}, builder::CreateEmbedFooter,
model::channel::Message,
model::channel::ReactionType, model::channel::ReactionType,
}; };
use youmubot_prelude::*; use youmubot_prelude::*;
use youmubot_prelude::{pagination::PageUpdate, replyable::Replyable};
const SHOW_ALL_EMOTE: &str = "🗒️"; const SHOW_ALL_EMOTE: &str = "🗒️";
@ -365,16 +347,18 @@ mod beatmapset {
beatmapset: Vec<Beatmap>, beatmapset: Vec<Beatmap>,
mode: Option<Mode>, mode: Option<Mode>,
mods: Option<Mods>, mods: Option<Mods>,
reply_to: &Message, reply_to: impl Replyable + Send + 'static,
channel_id: ChannelId,
message: impl AsRef<str>, message: impl AsRef<str>,
) -> Result<bool> { ) -> Result<()> {
let mods = mods.unwrap_or(Mods::NOMOD); let mods = mods.unwrap_or(Mods::NOMOD);
if beatmapset.is_empty() { if beatmapset.is_empty() {
return Ok(false); return Ok(());
} }
let p = Paginate { let p = Paginate {
channel_id,
infos: vec![None; beatmapset.len()], infos: vec![None; beatmapset.len()],
maps: beatmapset, maps: beatmapset,
mode, mode,
@ -383,16 +367,17 @@ mod beatmapset {
}; };
let ctx = ctx.clone(); let ctx = ctx.clone();
let reply_to = reply_to.clone(); // let reply_to = reply_to.clone();
spawn_future(async move { spawn_future(async move {
pagination::paginate_reply(p, &ctx, &reply_to, std::time::Duration::from_secs(60)) pagination::paginate_reply(p, &ctx, reply_to, std::time::Duration::from_secs(60))
.await .await
.pls_ok(); .pls_ok();
}); });
Ok(true) Ok(())
} }
struct Paginate { struct Paginate {
channel_id: ChannelId,
maps: Vec<Beatmap>, maps: Vec<Beatmap>,
infos: Vec<Option<BeatmapInfoWithPP>>, infos: Vec<Option<BeatmapInfoWithPP>>,
mode: Option<Mode>, mode: Option<Mode>,
@ -417,26 +402,15 @@ mod beatmapset {
Some(self.maps.len()) Some(self.maps.len())
} }
async fn render( async fn render(&mut self, page: u8, ctx: &Context) -> Result<Option<CreateReply>> {
&mut self,
page: u8,
ctx: &Context,
m: &mut serenity::model::channel::Message,
) -> Result<bool> {
let page = page as usize; let page = page as usize;
if page == self.maps.len() { if page == self.maps.len() {
m.edit( return Ok(Some(CreateReply::default().embed(
ctx, crate::discord::embeds::beatmapset_embed(&self.maps[..], self.mode),
EditMessage::new().embed(crate::discord::embeds::beatmapset_embed( )));
&self.maps[..],
self.mode,
)),
)
.await?;
return Ok(true);
} }
if page > self.maps.len() { if page > self.maps.len() {
return Ok(false); return Ok(None);
} }
let map = &self.maps[page]; let map = &self.maps[page];
@ -448,8 +422,7 @@ mod beatmapset {
info info
} }
}; };
m.edit(ctx, let edit = CreateReply::default().content(self.message.as_str()).embed(
EditMessage::new().content(self.message.as_str()).embed(
crate::discord::embeds::beatmap_embed( crate::discord::embeds::beatmap_embed(
map, map,
self.mode.unwrap_or(map.mode), self.mode.unwrap_or(map.mode),
@ -464,47 +437,46 @@ mod beatmapset {
SHOW_ALL_EMOTE, SHOW_ALL_EMOTE,
)) ))
}) })
) );
)
.await?;
save_beatmap( save_beatmap(
&*ctx.data.read().await, ctx.data.read().await.get::<crate::discord::Env>().unwrap(),
m.channel_id, self.channel_id,
&BeatmapWithMode(map.clone(), self.mode.unwrap_or(map.mode)), &BeatmapWithMode(map.clone(), self.mode.unwrap_or(map.mode)),
) )
.await .await
.pls_ok(); .pls_ok();
Ok(true) Ok(Some(edit))
} }
async fn prerender( async fn prerender(&mut self, ctx: &Context) -> Result<PageUpdate> {
&mut self, // m.react(&ctx, SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap())
ctx: &Context, // .await?;
m: &mut serenity::model::channel::Message, Ok(PageUpdate {
) -> Result<()> { react: vec![SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap()],
m.react(&ctx, SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap()) ..Default::default()
.await?; })
Ok(())
} }
async fn handle_reaction( async fn handle_reaction(
&mut self, &mut self,
page: u8, page: u8,
ctx: &Context, ctx: &Context,
message: &mut serenity::model::channel::Message,
reaction: &Reaction, reaction: &Reaction,
) -> Result<Option<u8>> { ) -> Result<PageUpdate> {
// Render the old style. // Render the old style.
if let ReactionType::Unicode(s) = &reaction.emoji { if let ReactionType::Unicode(s) = &reaction.emoji {
if s == SHOW_ALL_EMOTE { if s == SHOW_ALL_EMOTE {
self.render(self.maps.len() as u8, ctx, message).await?; let message = self.render(self.maps.len() as u8, ctx).await?;
return Ok(Some(self.maps.len() as u8)); let update = PageUpdate {
message,
page: Some(self.maps.len() as u8),
..Default::default()
};
return Ok(update);
} }
} }
pagination::handle_pagination_reaction(page, self, ctx, message, reaction) pagination::handle_pagination_reaction(page, self, ctx, reaction).await
.await
.map(Some)
} }
} }
} }

View file

@ -134,7 +134,7 @@ pub fn hook<'a>(
let mode = l.mode.unwrap_or(b.mode); let mode = l.mode.unwrap_or(b.mode);
let bm = super::BeatmapWithMode(*b, mode); let bm = super::BeatmapWithMode(*b, mode);
crate::discord::cache::save_beatmap( crate::discord::cache::save_beatmap(
&*ctx.data.read().await, ctx.data.read().await.get::<crate::discord::Env>().unwrap(),
msg.channel_id, msg.channel_id,
&bm, &bm,
) )
@ -413,7 +413,8 @@ async fn handle_beatmapset<'a, 'b>(
beatmaps, beatmaps,
mode, mode,
None, None,
reply_to, reply_to.clone(),
reply_to.channel_id,
format!("Beatmapset information for `{}`", link), format!("Beatmapset information for `{}`", link),
) )
.await .await

View file

@ -8,6 +8,7 @@ use crate::{
}; };
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
use serenity::{ use serenity::{
all::{ChannelId, Member},
builder::{CreateMessage, EditMessage}, builder::{CreateMessage, EditMessage},
collector, collector,
framework::standard::{ framework::standard::{
@ -18,10 +19,11 @@ use serenity::{
utils::MessageBuilder, utils::MessageBuilder,
}; };
use std::{str::FromStr, sync::Arc}; use std::{str::FromStr, sync::Arc};
use youmubot_prelude::*; use youmubot_prelude::{replyable::Replyable, *};
mod announcer; mod announcer;
pub mod app_commands; pub mod app_commands;
mod args;
pub(crate) mod beatmap_cache; pub(crate) mod beatmap_cache;
mod cache; mod cache;
mod db; mod db;
@ -386,17 +388,16 @@ impl FromStr for ModeArg {
async fn to_user_id_query( async fn to_user_id_query(
s: Option<UsernameArg>, s: Option<UsernameArg>,
data: &TypeMap, env: &Env,
msg: &Message, sender: &serenity::all::User,
) -> Result<UserID, Error> { ) -> Result<UserID, Error> {
let id = match s { let id = match s {
Some(UsernameArg::Raw(s)) => return Ok(UserID::from_string(s)), Some(UsernameArg::Raw(s)) => return Ok(UserID::from_string(s)),
Some(UsernameArg::Tagged(r)) => r, Some(UsernameArg::Tagged(r)) => r,
None => msg.author.id, None => sender.id,
}; };
data.get::<OsuSavedUsers>() env.saved_users
.unwrap()
.by_user_id(id) .by_user_id(id)
.await? .await?
.map(|u| UserID::ID(u.id)) .map(|u| UserID::ID(u.id))
@ -431,13 +432,14 @@ impl FromStr for Nth {
#[max_args(4)] #[max_args(4)]
pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let env = data.get::<Env>().unwrap();
let nth = args.single::<Nth>().unwrap_or(Nth::All); let nth = args.single::<Nth>().unwrap_or(Nth::All);
let style = args.single::<ScoreListStyle>().unwrap_or_default(); let style = args.single::<ScoreListStyle>().unwrap_or_default();
let mode = args.single::<ModeArg>().unwrap_or(ModeArg(Mode::Std)).0; let mode = args.single::<ModeArg>().unwrap_or(ModeArg(Mode::Std)).0;
let user = to_user_id_query( let user = to_user_id_query(
args.quoted().trimmed().single::<UsernameArg>().ok(), args.quoted().trimmed().single::<UsernameArg>().ok(),
&data, &env,
msg, &msg.author,
) )
.await?; .await?;
@ -471,13 +473,14 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu
.await?; .await?;
// Save the beatmap... // Save the beatmap...
cache::save_beatmap(&data, msg.channel_id, &beatmap_mode).await?; cache::save_beatmap(&env, msg.channel_id, &beatmap_mode).await?;
} }
Nth::All => { Nth::All => {
let plays = osu let plays = osu
.user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50)) .user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50))
.await?; .await?;
style.display_scores(plays, mode, ctx, msg).await?; display::scores::display_scores(style, plays, mode, ctx, msg.clone(), msg.channel_id)
.await?;
} }
} }
Ok(()) Ok(())
@ -499,12 +502,11 @@ impl FromStr for OptBeatmapset {
/// Load the mentioned beatmap from the given message. /// Load the mentioned beatmap from the given message.
pub(crate) async fn load_beatmap( pub(crate) async fn load_beatmap(
ctx: &Context, env: &Env,
msg: &Message, msg: Option<&Message>,
channel_id: ChannelId,
) -> Option<(BeatmapWithMode, Option<Mods>)> { ) -> Option<(BeatmapWithMode, Option<Mods>)> {
let data = ctx.data.read().await; if let Some(replied) = msg.and_then(|m| m.referenced_message.as_ref()) {
if let Some(replied) = &msg.referenced_message {
// Try to look for a mention of the replied message. // Try to look for a mention of the replied message.
let beatmap_id = SHORT_LINK_REGEX.captures(&replied.content).or_else(|| { let beatmap_id = SHORT_LINK_REGEX.captures(&replied.content).or_else(|| {
replied.embeds.iter().find_map(|e| { replied.embeds.iter().find_map(|e| {
@ -526,8 +528,8 @@ pub(crate) async fn load_beatmap(
let mods = caps let mods = caps
.name("mods") .name("mods")
.and_then(|m| m.as_str().parse::<Mods>().ok()); .and_then(|m| m.as_str().parse::<Mods>().ok());
let osu = data.get::<OsuClient>().unwrap(); let bms = env
let bms = osu .client
.beatmaps(BeatmapRequestKind::Beatmap(id), |f| f.maybe_mode(mode)) .beatmaps(BeatmapRequestKind::Beatmap(id), |f| f.maybe_mode(mode))
.await .await
.ok() .ok()
@ -536,19 +538,14 @@ pub(crate) async fn load_beatmap(
let bm_mode = beatmap.mode; let bm_mode = beatmap.mode;
let bm = BeatmapWithMode(beatmap, mode.unwrap_or(bm_mode)); let bm = BeatmapWithMode(beatmap, mode.unwrap_or(bm_mode));
// Store the beatmap in history // Store the beatmap in history
cache::save_beatmap(&data, msg.channel_id, &bm) cache::save_beatmap(env, channel_id, &bm).await.pls_ok();
.await
.pls_ok();
return Some((bm, mods)); return Some((bm, mods));
} }
} }
} }
let b = cache::get_beatmap(&data, msg.channel_id) let b = cache::get_beatmap(env, channel_id).await.ok().flatten();
.await
.ok()
.flatten();
b.map(|b| (b, None)) b.map(|b| (b, None))
} }
@ -560,7 +557,8 @@ pub(crate) async fn load_beatmap(
#[max_args(2)] #[max_args(2)]
pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let b = load_beatmap(ctx, msg).await; let env = data.get::<Env>().unwrap();
let b = load_beatmap(&env, Some(msg), msg.channel_id).await;
let beatmapset = args.find::<OptBeatmapset>().is_ok(); let beatmapset = args.find::<OptBeatmapset>().is_ok();
match b { match b {
@ -574,7 +572,8 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
beatmapset, beatmapset,
None, None,
Some(mods), Some(mods),
msg, msg.clone(),
msg.channel_id,
"Here is the beatmapset you requested!", "Here is the beatmapset you requested!",
) )
.await?; .await?;
@ -612,7 +611,8 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
#[max_args(3)] #[max_args(3)]
pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let bm = load_beatmap(ctx, msg).await; let env = data.get::<Env>().unwrap();
let bm = load_beatmap(&env, Some(msg), msg.channel_id).await;
match bm { match bm {
None => { None => {
@ -632,7 +632,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
None => Some(msg.author.id), None => Some(msg.author.id),
_ => None, _ => None,
}; };
let user = to_user_id_query(username_arg, &data, msg).await?; let user = to_user_id_query(username_arg, &env, &msg.author).await?;
let osu = data.get::<OsuClient>().unwrap(); let osu = data.get::<OsuClient>().unwrap();
@ -662,7 +662,63 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
.pls_ok(); .pls_ok();
} }
style.display_scores(scores, m, ctx, msg).await?; display::scores::display_scores(style, scores, m, ctx, msg.clone(), msg.channel_id)
.await?;
}
}
Ok(())
}
pub async fn check_impl(
env: &Env,
ctx: &Context,
reply: impl Replyable,
channel_id: ChannelId,
sender: &serenity::all::User,
msg: Option<&Message>,
osu_id: Option<String>,
member: Option<Member>,
mods: Option<Mods>,
style: Option<ScoreListStyle>,
) -> CommandResult {
let bm = load_beatmap(&env, msg, channel_id).await;
match bm {
None => {
reply
.reply(&ctx, "No beatmap queried on this channel.")
.await?;
}
Some((bm, mods_def)) => {
let mods = mods.unwrap_or_default();
let b = &bm.0;
let m = bm.1;
let style = style.unwrap_or_default();
let username_arg = member
.map(|m| UsernameArg::Tagged(m.user.id))
.or(osu_id.map(|id| UsernameArg::Raw(id)));
let user = to_user_id_query(username_arg, env, sender).await?;
let user = env
.client
.user(user, |f| f)
.await?
.ok_or_else(|| Error::msg("User not found"))?;
let mut scores = env
.client
.scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m))
.await?
.into_iter()
.filter(|s| s.mods.contains(mods))
.collect::<Vec<_>>();
scores.sort_by(|a, b| b.pp.unwrap().partial_cmp(&a.pp.unwrap()).unwrap());
if scores.is_empty() {
reply.reply(&ctx, "No scores found").await?;
return Ok(());
}
display::scores::display_scores(style, scores, m, ctx, reply, channel_id).await?;
} }
} }
@ -677,6 +733,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
#[max_args(4)] #[max_args(4)]
pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let env = data.get::<Env>().unwrap();
let nth = args.single::<Nth>().unwrap_or(Nth::All); let nth = args.single::<Nth>().unwrap_or(Nth::All);
let style = args.single::<ScoreListStyle>().unwrap_or_default(); let style = args.single::<ScoreListStyle>().unwrap_or_default();
let mode = args let mode = args
@ -684,7 +741,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
.map(|ModeArg(t)| t) .map(|ModeArg(t)| t)
.unwrap_or(Mode::Std); .unwrap_or(Mode::Std);
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &data, msg).await?; let user = to_user_id_query(args.single::<UsernameArg>().ok(), &env, &msg.author).await?;
let meta_cache = data.get::<BeatmapMetaCache>().unwrap(); let meta_cache = data.get::<BeatmapMetaCache>().unwrap();
let osu = data.get::<OsuClient>().unwrap(); let osu = data.get::<OsuClient>().unwrap();
@ -726,13 +783,14 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
.await?; .await?;
// Save the beatmap... // Save the beatmap...
cache::save_beatmap(&data, msg.channel_id, &beatmap).await?; cache::save_beatmap(data.get::<Env>().unwrap(), msg.channel_id, &beatmap).await?;
} }
Nth::All => { Nth::All => {
let plays = osu let plays = osu
.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100))
.await?; .await?;
style.display_scores(plays, mode, ctx, msg).await?; display::scores::display_scores(style, plays, mode, ctx, msg.clone(), msg.channel_id)
.await?;
} }
} }
Ok(()) Ok(())
@ -757,7 +815,8 @@ pub async fn clean_cache(ctx: &Context, msg: &Message, args: Args) -> CommandRes
async fn get_user(ctx: &Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult { async fn get_user(ctx: &Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &data, msg).await?; let env = data.get::<Env>().unwrap();
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &env, &msg.author).await?;
let osu = data.get::<OsuClient>().unwrap(); let osu = data.get::<OsuClient>().unwrap();
let cache = data.get::<BeatmapMetaCache>().unwrap(); let cache = data.get::<BeatmapMetaCache>().unwrap();
let user = osu.user(user, |f| f.mode(mode)).await?; let user = osu.user(user, |f| f.mode(mode)).await?;

View file

@ -10,6 +10,7 @@ use crate::{
request::UserID, request::UserID,
}; };
use poise::CreateReply;
use serenity::{ use serenity::{
builder::EditMessage, builder::EditMessage,
framework::standard::{macros::command, Args, CommandResult}, framework::standard::{macros::command, Args, CommandResult},
@ -88,14 +89,14 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
let users = std::sync::Arc::new(users); let users = std::sync::Arc::new(users);
let last_update = last_update.unwrap(); let last_update = last_update.unwrap();
paginate_reply_fn( paginate_reply_fn(
move |page: u8, ctx: &Context, m: &mut Message| { move |page: u8, ctx: &Context| {
const ITEMS_PER_PAGE: usize = 10; const ITEMS_PER_PAGE: usize = 10;
let users = users.clone(); let users = users.clone();
Box::pin(async move { Box::pin(async move {
let start = (page as usize) * ITEMS_PER_PAGE; let start = (page as usize) * ITEMS_PER_PAGE;
let end = (start + ITEMS_PER_PAGE).min(users.len()); let end = (start + ITEMS_PER_PAGE).min(users.len());
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let total_len = users.len(); let total_len = users.len();
let users = &users[start..end]; let users = &users[start..end];
@ -142,13 +143,11 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
(total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE, (total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE,
last_update.format("<t:%s:R>"), last_update.format("<t:%s:R>"),
)); ));
m.edit(ctx, EditMessage::new().content(content.to_string())) Ok(Some(CreateReply::default().content(content.to_string())))
.await?;
Ok(true)
}) })
}, },
ctx, ctx,
m, m.clone(),
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
) )
.await?; .await?;
@ -191,9 +190,10 @@ pub async fn show_leaderboard(ctx: &Context, m: &Message, mut args: Args) -> Com
let style = args.single::<ScoreListStyle>().unwrap_or_default(); let style = args.single::<ScoreListStyle>().unwrap_or_default();
let data = ctx.data.read().await; let data = ctx.data.read().await;
let env = data.get::<crate::discord::Env>().unwrap();
let member_cache = data.get::<MemberCache>().unwrap(); let member_cache = data.get::<MemberCache>().unwrap();
let (bm, _) = match super::load_beatmap(ctx, m).await { let (bm, _) = match super::load_beatmap(env, Some(m), m.channel_id).await {
Some((bm, mods_def)) => { Some((bm, mods_def)) => {
let mods = args.find::<Mods>().ok().or(mods_def).unwrap_or(Mods::NOMOD); let mods = args.find::<Mods>().ok().or(mods_def).unwrap_or(Mods::NOMOD);
(bm, mods) (bm, mods)
@ -295,25 +295,26 @@ pub async fn show_leaderboard(ctx: &Context, m: &Message, mut args: Args) -> Com
} }
if let ScoreListStyle::Grid = style { if let ScoreListStyle::Grid = style {
style crate::discord::display::scores::display_scores(
.display_scores( style,
scores.into_iter().map(|(_, _, a)| a).collect(), scores.into_iter().map(|(_, _, a)| a).collect(),
mode, mode,
ctx, ctx,
m, m.clone(),
) m.channel_id,
.await?; )
.await?;
return Ok(()); return Ok(());
} }
let has_lazer_score = scores.iter().any(|(_, _, v)| v.score.is_none()); let has_lazer_score = scores.iter().any(|(_, _, v)| v.score.is_none());
paginate_reply_fn( paginate_reply_fn(
move |page: u8, ctx: &Context, m: &mut Message| { move |page: u8, ctx: &Context| {
const ITEMS_PER_PAGE: usize = 5; const ITEMS_PER_PAGE: usize = 5;
let start = (page as usize) * ITEMS_PER_PAGE; let start = (page as usize) * ITEMS_PER_PAGE;
let end = (start + ITEMS_PER_PAGE).min(scores.len()); let end = (start + ITEMS_PER_PAGE).min(scores.len());
if start >= end { if start >= end {
return Box::pin(future::ready(Ok(false))); return Box::pin(future::ready(Ok(None)));
} }
let total_len = scores.len(); let total_len = scores.len();
let scores = scores[start..end].to_vec(); let scores = scores[start..end].to_vec();
@ -436,12 +437,11 @@ pub async fn show_leaderboard(ctx: &Context, m: &Message, mut args: Args) -> Com
content.push_line("PP was calculated by `oppai-rs`, **not** official values."); content.push_line("PP was calculated by `oppai-rs`, **not** official values.");
} }
m.edit(&ctx, EditMessage::new().content(content.build())).await?; Ok(Some(CreateReply::default().content(content.build())))
Ok(true)
}) })
}, },
ctx, ctx,
m, m.clone(),
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
) )
.await?; .await?;

View file

@ -97,8 +97,16 @@ impl Mods {
} }
} }
#[derive(Debug, thiserror::Error)]
pub enum ModParseError {
#[error("String of odd length is not a mod string")]
OddLength,
#[error("{0} is not a valid mod")]
InvalidMod(String),
}
impl std::str::FromStr for Mods { impl std::str::FromStr for Mods {
type Err = String; type Err = ModParseError;
fn from_str(mut s: &str) -> Result<Self, Self::Err> { fn from_str(mut s: &str) -> Result<Self, Self::Err> {
let mut res = Self::default(); let mut res = Self::default();
// Strip leading + // Strip leading +
@ -134,11 +142,11 @@ impl std::str::FromStr for Mods {
"8K" => res |= Mods::KEY8, "8K" => res |= Mods::KEY8,
"9K" => res |= Mods::KEY9, "9K" => res |= Mods::KEY9,
"??" => res |= Mods::UNKNOWN, "??" => res |= Mods::UNKNOWN,
v => return Err(format!("{} is not a valid mod", v)), v => return Err(ModParseError::InvalidMod(v.to_owned())),
} }
} }
if !s.is_empty() { if !s.is_empty() {
Err("String of odd length is not a mod string".to_owned()) Err(ModParseError::OddLength)
} else { } else {
Ok(res) Ok(res)
} }

View file

@ -18,7 +18,7 @@ use serenity::{
prelude::*, prelude::*,
utils::MessageBuilder, utils::MessageBuilder,
}; };
use std::{arch::x86_64::_bittestandcomplement, collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use youmubot_db::DB; use youmubot_db::DB;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -1,4 +1,3 @@
use announcer::AnnouncerChannels;
/// Module `prelude` provides a sane set of default imports that can be used inside /// Module `prelude` provides a sane set of default imports that can be used inside
/// a Youmubot source file. /// a Youmubot source file.
pub use serenity::prelude::*; pub use serenity::prelude::*;
@ -11,6 +10,7 @@ pub mod hook;
pub mod member_cache; pub mod member_cache;
pub mod pagination; pub mod pagination;
pub mod ratelimit; pub mod ratelimit;
pub mod replyable;
pub mod setup; pub mod setup;
pub use announcer::{Announcer, AnnouncerHandler}; pub use announcer::{Announcer, AnnouncerHandler};

View file

@ -1,10 +1,14 @@
use crate::{Context, OkPrint, Result}; use crate::{
replyable::{Replyable, Updateable},
Context, OkPrint, Result,
};
use futures_util::{future::Future, StreamExt as _}; use futures_util::{future::Future, StreamExt as _};
use poise::CreateReply;
use serenity::{ use serenity::{
builder::CreateMessage, builder::CreateMessage,
collector, collector,
model::{ model::{
channel::{Message, Reaction, ReactionType}, channel::{Reaction, ReactionType},
id::ChannelId, id::ChannelId,
}, },
}; };
@ -16,15 +20,41 @@ const ARROW_LEFT: &str = "⬅️";
const REWIND: &str = ""; const REWIND: &str = "";
const FAST_FORWARD: &str = ""; const FAST_FORWARD: &str = "";
/// Represents a page update.
#[derive(Default)]
pub struct PageUpdate {
pub message: Option<CreateReply>,
pub page: Option<u8>,
pub react: Vec<ReactionType>,
}
impl From<u8> for PageUpdate {
fn from(value: u8) -> Self {
PageUpdate {
page: Some(value),
..Default::default()
}
}
}
impl From<CreateReply> for PageUpdate {
fn from(value: CreateReply) -> Self {
PageUpdate {
message: Some(value),
..Default::default()
}
}
}
/// A trait that provides the implementation of a paginator. /// A trait that provides the implementation of a paginator.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Paginate: Send + Sized { pub trait Paginate: Send + Sized {
/// Render the given page. /// Render the given page.
async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result<bool>; async fn render(&mut self, page: u8, ctx: &Context) -> Result<Option<CreateReply>>;
/// Any setting-up before the rendering stage. /// Any setting-up before the rendering stage.
async fn prerender(&mut self, _ctx: &Context, _m: &mut Message) -> Result<()> { async fn prerender(&mut self, _ctx: &Context) -> Result<PageUpdate> {
Ok(()) Ok(PageUpdate::default())
} }
/// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling /// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling
@ -35,12 +65,9 @@ pub trait Paginate: Send + Sized {
&mut self, &mut self,
page: u8, page: u8,
ctx: &Context, ctx: &Context,
message: &mut Message,
reaction: &Reaction, reaction: &Reaction,
) -> Result<Option<u8>> { ) -> Result<PageUpdate> {
handle_pagination_reaction(page, self, ctx, message, reaction) handle_pagination_reaction(page, self, ctx, reaction).await
.await
.map(Some)
} }
/// Return the number of pages, if it is known in advance. /// Return the number of pages, if it is known in advance.
@ -60,12 +87,12 @@ where
T: for<'m> FnMut( T: for<'m> FnMut(
u8, u8,
&'m Context, &'m Context,
&'m mut Message, ) -> std::pin::Pin<
) -> std::pin::Pin<Box<dyn Future<Output = Result<bool>> + Send + 'm>> Box<dyn Future<Output = Result<Option<CreateReply>>> + Send + 'm>,
+ Send, > + Send,
{ {
async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result<bool> { async fn render(&mut self, page: u8, ctx: &Context) -> Result<Option<CreateReply>> {
self(page, ctx, m).await self(page, ctx).await
} }
} }
@ -74,13 +101,13 @@ where
pub async fn paginate_reply( pub async fn paginate_reply(
pager: impl Paginate, pager: impl Paginate,
ctx: &Context, ctx: &Context,
reply_to: &Message, reply_to: impl Replyable,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<()> { ) -> Result<()> {
let message = reply_to let update = reply_to
.reply(&ctx, "Youmu is loading the first page...") .reply(&ctx, "Youmu is loading the first page...")
.await?; .await?;
paginate_with_first_message(pager, ctx, message, timeout).await paginate_with_first_message(pager, ctx, update, timeout).await
} }
// Paginate! with a pager function. // Paginate! with a pager function.
@ -103,11 +130,17 @@ pub async fn paginate(
async fn paginate_with_first_message( async fn paginate_with_first_message(
mut pager: impl Paginate, mut pager: impl Paginate,
ctx: &Context, ctx: &Context,
mut message: Message, mut update: impl Updateable,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<()> { ) -> Result<()> {
pager.prerender(ctx, &mut message).await?; let message = update.message().await?;
pager.render(0, ctx, &mut message).await?; let prerender = pager.prerender(ctx).await?;
if let Some(cr) = prerender.message {
update.edit(ctx, cr).await?;
}
if let Some(cr) = pager.render(0, ctx).await? {
update.edit(ctx, cr).await?;
}
// Just quit if there is only one page // Just quit if there is only one page
if pager.len().filter(|&v| v == 1).is_some() { if pager.len().filter(|&v| v == 1).is_some() {
return Ok(()); return Ok(());
@ -115,7 +148,7 @@ async fn paginate_with_first_message(
// React to the message // React to the message
let large_count = pager.len().filter(|&p| p > 10).is_some(); let large_count = pager.len().filter(|&p| p > 10).is_some();
let reactions = { let reactions = {
let mut rs = Vec::<Reaction>::with_capacity(4); let mut rs = Vec::<Reaction>::with_capacity(4 + prerender.react.len());
if large_count { if large_count {
// add >> and << buttons // add >> and << buttons
rs.push(message.react(&ctx, ReactionType::try_from(REWIND)?).await?); rs.push(message.react(&ctx, ReactionType::try_from(REWIND)?).await?);
@ -138,6 +171,9 @@ async fn paginate_with_first_message(
.await?, .await?,
); );
} }
for r in prerender.react.into_iter() {
rs.push(message.react(&ctx, r).await?);
}
rs rs
}; };
// Build a reaction collector // Build a reaction collector
@ -161,12 +197,16 @@ async fn paginate_with_first_message(
Err(_) => break Ok(()), Err(_) => break Ok(()),
Ok(None) => break Ok(()), Ok(None) => break Ok(()),
Ok(Some(reaction)) => { Ok(Some(reaction)) => {
page = match pager page = match pager.handle_reaction(page, ctx, &reaction).await {
.handle_reaction(page, ctx, &mut message, &reaction) Ok(pu) => {
.await if let Some(cr) = pu.message {
{ update.edit(ctx, cr).await?;
Ok(Some(v)) => v, }
Ok(None) => break Ok(()), match pu.page {
Some(v) => v,
None => break Ok(()),
}
}
Err(e) => break Err(e), Err(e) => break Err(e),
}; };
} }
@ -188,9 +228,9 @@ pub async fn paginate_fn(
pager: impl for<'m> FnMut( pager: impl for<'m> FnMut(
u8, u8,
&'m Context, &'m Context,
&'m mut Message, ) -> std::pin::Pin<
) -> std::pin::Pin<Box<dyn Future<Output = Result<bool>> + Send + 'm>> Box<dyn Future<Output = Result<Option<CreateReply>>> + Send + 'm>,
+ Send, > + Send,
ctx: &Context, ctx: &Context,
channel: ChannelId, channel: ChannelId,
timeout: std::time::Duration, timeout: std::time::Duration,
@ -203,11 +243,11 @@ pub async fn paginate_reply_fn(
pager: impl for<'m> FnMut( pager: impl for<'m> FnMut(
u8, u8,
&'m Context, &'m Context,
&'m mut Message, ) -> std::pin::Pin<
) -> std::pin::Pin<Box<dyn Future<Output = Result<bool>> + Send + 'm>> Box<dyn Future<Output = Result<Option<CreateReply>>> + Send + 'm>,
+ Send, > + Send,
ctx: &Context, ctx: &Context,
reply_to: &Message, reply_to: impl Replyable,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<()> { ) -> Result<()> {
paginate_reply(pager, ctx, reply_to, timeout).await paginate_reply(pager, ctx, reply_to, timeout).await
@ -218,15 +258,14 @@ pub async fn handle_pagination_reaction(
page: u8, page: u8,
pager: &mut impl Paginate, pager: &mut impl Paginate,
ctx: &Context, ctx: &Context,
message: &mut Message,
reaction: &Reaction, reaction: &Reaction,
) -> Result<u8> { ) -> Result<PageUpdate> {
let pages = pager.len(); let pages = pager.len();
let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8; let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8;
match &reaction.emoji { match &reaction.emoji {
ReactionType::Unicode(ref s) => { ReactionType::Unicode(ref s) => {
let new_page = match s.as_str() { let new_page = match s.as_str() {
ARROW_LEFT | REWIND if page == 0 => return Ok(page), ARROW_LEFT | REWIND if page == 0 => return Ok(page.into()),
ARROW_LEFT => page - 1, ARROW_LEFT => page - 1,
REWIND => { REWIND => {
if page < fast { if page < fast {
@ -236,18 +275,26 @@ pub async fn handle_pagination_reaction(
} }
} }
ARROW_RIGHT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => { ARROW_RIGHT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => {
return Ok(page) return Ok(page.into())
} }
ARROW_RIGHT => page + 1, ARROW_RIGHT => page + 1,
FAST_FORWARD => (pages.unwrap() as u8 - 1).min(page + fast), FAST_FORWARD => (pages.unwrap() as u8 - 1).min(page + fast),
_ => return Ok(page), _ => return Ok(page.into()),
}; };
Ok(if pager.render(new_page, ctx, message).await? { let reply = pager.render(new_page, ctx).await?;
new_page Ok(reply
} else { .map(|cr| PageUpdate {
page message: Some(cr),
}) page: Some(page),
..Default::default()
})
.unwrap_or_else(|| page.into()))
// Ok(if pager.render(new_page, ctx, message).await? {
// new_page
// } else {
// page
// })
} }
_ => Ok(page), _ => Ok(page.into()),
} }
} }

View file

@ -0,0 +1,76 @@
use poise::{CreateReply, ReplyHandle};
use serenity::{all::Message, builder::EditMessage};
use crate::*;
/// Represents a target where replying is possible and returns a message.
#[async_trait]
pub trait Replyable {
type Resp: Updateable + Send;
/// Reply to the context.
async fn reply(
&self,
ctx: impl CacheHttp + Send,
content: impl Into<String> + Send,
) -> Result<Self::Resp>;
}
#[async_trait]
impl Replyable for Message {
type Resp = Message;
async fn reply(
&self,
ctx: impl CacheHttp + Send,
content: impl Into<String> + Send,
) -> Result<Self::Resp> {
Ok(Message::reply(self, ctx, content).await?)
}
}
#[async_trait]
impl<'c, T: Sync, E> Replyable for poise::Context<'c, T, E> {
type Resp = (ReplyHandle<'c>, Self);
async fn reply(
&self,
_ctx: impl CacheHttp + Send,
content: impl Into<String> + Send,
) -> Result<Self::Resp> {
let handle = poise::Context::reply(*self, content).await?;
Ok((handle, *self))
}
}
/// Represents a message representation that allows deletion and editing.
#[async_trait]
pub trait Updateable {
async fn message(&self) -> Result<Message>;
async fn edit(&mut self, ctx: impl CacheHttp + Send, content: CreateReply) -> Result<()>;
async fn delete(&self, ctx: impl CacheHttp + Send) -> Result<()>;
}
#[async_trait]
impl Updateable for Message {
async fn message(&self) -> Result<Message> {
Ok(self.clone())
}
async fn edit(&mut self, ctx: impl CacheHttp + Send, content: CreateReply) -> Result<()> {
let content = content.to_prefix_edit(EditMessage::new());
Ok(Message::edit(self, ctx, content).await?)
}
async fn delete(&self, ctx: impl CacheHttp + Send) -> Result<()> {
Ok(Message::delete(self, ctx).await?)
}
}
#[async_trait]
impl<'a, T: Sync, E> Updateable for (poise::ReplyHandle<'a>, poise::Context<'a, T, E>) {
async fn message(&self) -> Result<Message> {
Ok(poise::ReplyHandle::message(&self.0).await?.into_owned())
}
async fn edit(&mut self, _ctx: impl CacheHttp, content: CreateReply) -> Result<()> {
Ok(poise::ReplyHandle::edit(&self.0, self.1, content).await?)
}
async fn delete(&self, _ctx: impl CacheHttp) -> Result<()> {
Ok(poise::ReplyHandle::delete(&self.0, self.1).await?)
}
}