use chrono::TimeZone; use diesel::{ result::Error as DieselError, NotFound, }; use grate::tracing; use itertools::Itertools; use lazy_static::lazy_static; use serenity::{ all::{ GuildId, Mentionable, }, futures::{ StreamExt, TryStreamExt, }, }; use tap::Pipe; use timeago::{ Formatter, TimeUnit, }; use crate::{ commands::game::get_user_id, config::CONFIG, db::{ self, connection, InvocationRecord, Meme, Metadata, }, util, PoiseContext, }; lazy_static! { static ref TIME_FORMATTER: Formatter = { let mut f = Formatter::new(); f.min_unit(TimeUnit::Minutes); f.num_items(2); f }; } static CLEAN_DATE_FORMAT: &str = "%b %-e %Y"; /// Print info about the last meme. #[poise::command(prefix_command, guild_only, category = "memes", aliases("what", "hwaet", "hwæt"))] pub async fn wat(ctx: PoiseContext<'_>) -> anyhow::Result<()> { let guild_id = util::guild_id(ctx)?; let mut conn = connection().await?; let record = match InvocationRecord::last(&mut conn, guild_id.get()).await { Ok(x) => x, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { tracing::info!("found no memes in history"); util::reply(ctx, "no one has ever memed before").await?; return Ok(()); } util::reply(ctx, "BAD MEME BAD MEME").await?; return Err(e.into()); }, }; let meme = Meme::find(&mut conn, record.meme_id).await; match meme { Ok(ref meme) => { let metadata = Metadata::find(&mut conn, meme.metadata_id).await?; let author = guild_id.member(&ctx, metadata.created_by as u64).await?; util::reply( ctx, &format!( "that was \"{}\" by {} ({})", meme.title, author.mention(), metadata.created.date().format(CLEAN_DATE_FORMAT) ), ) .await?; }, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { tracing::info!("last meme not found in database"); util::reply(ctx, "heuueueeeeh?").await?; return Ok(()); } util::reply(ctx, "do i look like i know what a jpeg is").await?; return Err(e.into()); }, }; let _meme = meme?; Ok(()) } /// Print recent memes and who invoked them. #[poise::command(prefix_command, guild_only, category = "memes", aliases("hist"))] pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result<()> { let n = n.unwrap_or(CONFIG.default_hist); if n > CONFIG.max_hist { tracing::debug!( MAX_HIST = CONFIG.max_hist, "user requested more than MAX_HIST items from history", ); util::reply(ctx, "YER PUSHIN ME OVER THE FUCKIN LINE").await?; } let n = n.min(CONFIG.max_hist); let guild_id = util::guild_id(ctx)?; let records = { let mut conn = connection().await?; InvocationRecord::last_n(&mut conn, n, guild_id.get()).await? }; if records.is_empty() { tracing::info!("no memes in history"); util::reply(ctx, "i don't remember anything :(").await?; return Ok(()); } tracing::info!(len = n, "reporting meme history"); let resp = serenity::futures::stream::iter(records.into_iter().enumerate().rev()) .then(|(i, rec)| async move { let mut conn = connection().await?; let dt = chrono::Utc.from_utc_datetime(&rec.time); let ago = TIME_FORMATTER.convert((chrono::Utc::now() - dt).to_std().unwrap()); let rand = if rec.random { "R, " } else { "" }; let meme = match Meme::find(&mut conn, rec.meme_id).await { Ok(meme) => Metadata::find(&mut conn, meme.metadata_id) .await .map(|metadata| (metadata, meme)), Err(e) => Err(e), }; let invoker_name = guild_id .member(&ctx, rec.user_id as u64) .await .map(|m| m.display_name().to_owned()) .unwrap_or("???".to_owned()); let result = match meme { Ok((metadata, meme)) => { let author_name = guild_id .member(&ctx, metadata.created_by as u64) .await .map(|m| m.display_name().to_owned()) .unwrap_or("???".to_owned()); format!( "{}. [{}{}] \"{}\" by {} ({}). invoked by {}.", i + 1, rand, ago, meme.title, author_name, metadata.created.date().format(CLEAN_DATE_FORMAT), invoker_name ) }, Err(e) => { if let Some(variant) = e.downcast_ref::() { if *variant != NotFound { tracing::error!(error = %e, "error encountered loading meme history"); } } format!("{}. [{}{}] not found. invoked by {}.", i + 1, rand, ago, invoker_name) }, }; anyhow::Ok(result) }) .try_collect::>() .await?; let resp = resp.join("\n"); util::reply(ctx, resp).await?; Ok(()) } /// Print stats about the meme database. #[poise::command(prefix_command, guild_only, category = "memes", aliases("stat"))] pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use db; use serenity::model::{ id::UserId, user::User, }; let guild_id = util::guild_id(ctx)?; let mut conn = connection().await?; let stats = db::stats(&mut conn, guild_id.get()).await?; tracing::debug!("reporting stats"); async fn username(ctx: PoiseContext<'_>, guild_id: GuildId, id: u64) -> anyhow::Result { let user: User = UserId::new(id).to_user(&ctx).await?; Ok(user.nick_in(&ctx, guild_id).await.unwrap_or(user.name)) } let most_active_day = if let Some(ref most_active_day) = stats.most_active_day { let fmt = most_active_day.format(CLEAN_DATE_FORMAT); let count = stats.most_active_day_count; format!("the most active day was *{fmt}* with **{count}** memes\n") } else { String::new() }; let loudest_day = if let Some(ref loudest_day) = stats.most_audio_active_day { let fmt = loudest_day.format(CLEAN_DATE_FORMAT); let count = stats.most_audio_active_count; format!("and the loudest day was *{fmt}* with **{count}** audio memes\n") } else { String::new() }; let rand_user = if let Some(rand_user) = stats.most_random_meme_user { let rand_user = username(ctx, guild_id, rand_user).await?; format!( "**{rand_user}** has invoked the most random memes ({})\n", stats.most_random_meme_user_count ) } else { String::new() }; let direct_user = if let Some(direct_user) = stats.most_directly_named_meme_user { let direct_user = username(ctx, guild_id, direct_user).await?; format!( "**{direct_user}** has invoked the most memes by name ({})\n", stats.most_directly_named_meme_count ) } else { String::new() }; let started_recording = if let Some(ref started_recording) = stats.started_recording { let fst = started_recording.naive_local().date().format(CLEAN_DATE_FORMAT); let snd = TIME_FORMATTER.convert((chrono::Utc::now() - started_recording).to_std()?); format!("started recording meme invocations on *{fst}* ({snd})\n") } else { String::new() }; let most_requested = if let Some(ref most_requested) = stats.most_popular_named_meme { format!( "*{most_requested}* was the meme specifically requested the most ({})\n", stats.most_popular_named_meme_count ) } else { String::new() }; let most_random = if let Some(ref most_random) = stats.most_popular_random_meme { format!( "*{most_random}* was the meme specifically requested the most ({})\n", stats.most_popular_random_meme_count ) } else { String::new() }; let most_overall = if let Some(ref most_overall) = stats.most_popular_meme_overall { format!( "*{most_overall}* was the most-memed overall({})\n", stats.most_popular_meme_overall_count ) } else { String::new() }; let s = format!( r#" **{}** memes stored **{}** memes with audio ({:0.1}%) **{}** memes with images ({:0.1}%) {started_recording}**{}** total meme invocations recorded **{}** of which were random ({:0.1}%) and **{}** were audio ({:0.1}%) {most_active_day}{loudest_day} {rand_user}{direct_user} {most_requested}{most_random}{most_overall}"#, stats.memes_overall, stats.audio_memes, (stats.audio_memes as f64) / (stats.memes_overall as f64) * 100., stats.image_memes, (stats.image_memes as f64) / (stats.memes_overall as f64) * 100., stats.total_meme_invocations, stats.random_meme_invocations, (stats.random_meme_invocations as f64) / (stats.total_meme_invocations as f64) * 100., stats.audio_meme_invocations, (stats.audio_meme_invocations as f64) / (stats.total_meme_invocations as f64) * 100., ); util::reply(ctx, s).await?; Ok(()) } /// Print stats about memers. #[poise::command(prefix_command, guild_only, category = "memes")] pub async fn memers(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use serenity::model::id::UserId; let guild_id = util::guild_id(ctx)?; let s = db::memers(guild_id.get()) .await? .into_iter() .pipe(serenity::futures::stream::iter) .then(|info| async move { let user = UserId::new(info.user_id).to_user(&ctx).await?; let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); let res = format!( "**{}**: {} total, {} random, {} specific. favorite meme: *{}* ({})", username, info.random_memes + info.specific_memes, info.random_memes, info.specific_memes, info.most_used_meme, info.most_used_meme_count, ); anyhow::Ok(res) }) .try_collect::>() .await?; let mut out = String::new(); for line in s { if line.len() >= 2000 { anyhow::bail!("singular line too long"); } if out.len() + line.len() >= 2000 { let result = out.trim_end_matches('\n'); util::reply(ctx, &result).await?; out.clear(); } out.push_str(&line); out.push('\n'); } if !out.is_empty() { let result = out.trim_end_matches('\n'); util::reply(ctx, result).await?; } else { util::reply(ctx, "no memers :(").await?; } Ok(()) } /// Look up a meme by title or content. /// /// Can pass: /// - `by=username` or `creator=username` to look up memes created by a specific user. /// - `age=new` or `age=old` to sort the result by age. #[poise::command(prefix_command, guild_only, category = "memes")] pub async fn query(ctx: PoiseContext<'_>, rest: util::RestVec) -> anyhow::Result<()> { use regex::Regex; use serenity::model::id::UserId; use crate::db; lazy_static! { static ref CREATOR_REGEX: Regex = Regex::new(r"(?i)(?:by|creator)=(.*)").unwrap(); static ref AGE_REGEX: Regex = Regex::new(r"(?i)(?:age|order)=(.*)").unwrap(); } let guild_id = util::guild_id(ctx)?; let mut rest = rest.into_inner(); let creator: Option = try { let fst = rest.first()?; let captures = CREATOR_REGEX.captures(fst)?; let creator = captures.get(1)?.as_str().to_owned(); let guild = ctx.guild()?; let user_id = get_user_id(&guild, creator).ok()?.get(); rest.pop(); user_id }; let order: Option = try { let fst = rest.first()?; let captures = AGE_REGEX.captures(fst)?; let order = captures.get(1)?.as_str().to_owned(); order }; let order = order.is_some_and(|o| o.contains("new")); let iter = db::query_meme(rest.join(" "), creator, order, guild_id.get()).await?.into_iter(); let result = iter .pipe(serenity::futures::stream::iter) .then(|(meme, metadata)| async move { let user = UserId::new(metadata.created_by as u64).to_user(&ctx).await?; let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); Ok(format!( "*{}* by **{}** ({}). text length: **{}**, image: **{}**, audio: **{}**", meme.title, username, metadata.created.date().format(CLEAN_DATE_FORMAT), meme.content.map_or(0, |s| s.len()), meme.image_id.map_or("NO", |_s| "YES"), meme.audio_id.map_or("NO", |_s| "YES"), )) as anyhow::Result }) .try_collect::>() .await; let result = result? .into_iter() .scan(0, |state, line| { *state = *state + line.len() + 1; if *state < 2000 { Some(line) } else { None } }) .join("\n"); if result.is_empty() { tracing::info!("no memes matched query"); util::reply(ctx, "no match").await?; return Ok(()); } util::reply(ctx, result).await?; Ok(()) }