use chrono::{ NaiveTime, TimeZone, Utc, }; use diesel::{ result::Error as DieselError, NotFound, }; use grate::tracing; use itertools::Itertools; use lazy_static::lazy_static; use serenity::{ all::{ FormattedTimestamp, FormattedTimestampStyle, Mentionable, Timestamp, UserId, }, futures::{ StreamExt, TryStreamExt, }, }; use tap::{ Conv, 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).conv::(); let ago = FormattedTimestamp::new(dt, Some(FormattedTimestampStyle::RelativeTime)); 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 result = match meme { Ok((metadata, meme)) => { let created_date = metadata.created.and_utc().conv::(); format!( "{}. [{}{}] \"{}\" by {} ({}). invoked by {}.", i + 1, rand, ago, meme.title, UserId::from(metadata.created_by as u64).mention(), FormattedTimestamp::new( created_date, Some(FormattedTimestampStyle::ShortDate) ), UserId::from(rec.user_id as u64).mention() ) }, 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, UserId::from(rec.user_id as u64).mention() ) }, }; 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; 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"); let most_active_day = if let Some(ref most_active_day) = stats.most_active_day { let fmt = Utc .from_utc_datetime(&most_active_day.and_time(NaiveTime::default())) .conv::(); let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); 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 = Utc.from_utc_datetime(&loudest_day.and_time(NaiveTime::default())).conv::(); let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); 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 { format!( "{} has memed randomly the most ({})\n", UserId::from(rand_user).mention(), stats.most_random_meme_user_count ) } else { String::new() }; let direct_user = if let Some(direct_user) = stats.most_directly_named_meme_user { format!( "{} has memed directly the most ({})\n", UserId::from(direct_user).mention(), stats.most_directly_named_meme_count ) } else { String::new() }; let started_recording = if let Some(ref started_recording) = stats.started_recording { let fmt = (*started_recording).conv::(); let fmt_rel = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::RelativeTime)).to_string(); let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); format!("started recording meme invocations on {fmt} ({fmt_rel})\n") } else { String::new() }; let most_requested = if let Some(ref most_requested) = stats.most_popular_named_meme { format!( "*{most_requested}* was 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 randomly 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 res = format!( "{}: {} total, {} random, {} specific. favorite meme: *{}* ({})", user.mention(), 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?; Ok(format!( "*{}* by **{}** ({}). text length: **{}**, image: **{}**, audio: **{}**", meme.title, user.mention(), 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(()) }