use chrono::TimeZone; use diesel::{ result::Error as DieselError, NotFound, }; use grate::tracing; use itertools::Itertools; use lazy_static::lazy_static; use serenity::{ futures::{ StreamExt, TryStreamExt, }, prelude::*, }; use tap::Pipe; use timeago::{ Formatter, TimeUnit, }; use crate::{ commands::game::get_user_id, db::{ self, connection, InvocationRecord, Meme, Metadata, }, util, PoiseContext, CONFIG, }; 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 mut conn = connection().await?; let record = match InvocationRecord::last(&mut conn).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 = CONFIG.discord.guild().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!( "user requested more than MAX_HIST ({}) items from history", CONFIG.max_hist ); util::reply(ctx, "YER PUSHIN ME OVER THE FUCKIN LINE").await?; } let n = n.min(CONFIG.max_hist); let records = { let mut conn = connection().await?; InvocationRecord::last_n(&mut conn, n).await? }; if records.is_empty() { tracing::info!("no memes in history"); util::reply(ctx, "i don't remember anything :(").await?; return Ok(()); } tracing::info!("reporting meme history (len {})", n); 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 = CONFIG .discord .guild() .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 = CONFIG .discord .guild() .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 encountered loading meme history: {}", e); } } 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 mut conn = connection().await?; let stats = db::stats(&mut conn).await?; tracing::debug!("reporting stats"); let rand_user: User = UserId::new(stats.most_random_meme_user).to_user(&ctx).await?; let direct_user: User = UserId::new(stats.most_directly_named_meme_user).to_user(&ctx).await?; let rand_user = rand_user.nick_in(&ctx, CONFIG.discord.guild()).await.unwrap_or(rand_user.name); let direct_user = direct_user.nick_in(&ctx, CONFIG.discord.guild()).await.unwrap_or(direct_user.name); let s = format!( r#" **{}** memes stored **{}** memes with audio ({:0.1}%) **{}** memes with images ({:0.1}%) started recording meme invocations on *{}* ({}) **{}** total meme invocations recorded **{}** of which were random ({:0.1}%) and **{}** were audio ({:0.1}%) the most active day was *{}* with **{}** memes and the loudest day was *{}* with **{}** audio memes **{}** has invoked the most random memes ({}) **{}** has invoked the most memes by name ({}) *{}* was the meme specifically requested the most ({}) *{}* was the meme randomly invoked the most ({}) and *{}* was the most-memed 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.started_recording.naive_local().date().format(CLEAN_DATE_FORMAT), TIME_FORMATTER.convert((chrono::Utc::now() - stats.started_recording).to_std().unwrap()), 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., stats.most_active_day.format(CLEAN_DATE_FORMAT), stats.most_active_day_count, stats.most_audio_active_day.format(CLEAN_DATE_FORMAT), stats.most_audio_active_count, rand_user, stats.most_random_meme_user_count, direct_user, stats.most_directly_named_meme_count, stats.most_popular_named_meme, stats.most_popular_named_meme_count, stats.most_popular_random_meme, stats.most_popular_random_meme_count, stats.most_popular_meme_overall, stats.most_popular_meme_overall_count, ); 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 s = db::memers() .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, CONFIG.discord.guild()).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, CONFIG, }; 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 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).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, CONFIG.discord.guild()).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(()) }