diff options
| author | Nathan Perry <np@nathanperry.dev> | 2024-08-16 21:14:30 -0400 |
|---|---|---|
| committer | Nathan Perry <np@nathanperry.dev> | 2024-08-16 21:15:07 -0400 |
| commit | ef056edf92b678265a4666e1f9405e3b0ce66a42 (patch) | |
| tree | b3766d47ae2898d2a46f108de4e2be0ede6c400b | |
| parent | c5ce454319a7d54d3967c6ea7695543e943a37b2 (diff) | |
repo: overhaul for multitenancy
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | migrations/2024-08-16-111659_multitenant/down.sql | 12 | ||||
| -rw-r--r-- | migrations/2024-08-16-111659_multitenant/up.sql | 35 | ||||
| -rw-r--r-- | migrations/2024-08-16-230507_require_guild/down.sql | 6 | ||||
| -rw-r--r-- | migrations/2024-08-16-230507_require_guild/up.sql | 22 | ||||
| -rw-r--r-- | src/bin/convert_null_guilds.rs | 29 | ||||
| -rw-r--r-- | src/bot.rs | 61 | ||||
| -rw-r--r-- | src/commands/game.rs | 25 | ||||
| -rw-r--r-- | src/commands/meme/create.rs | 2 | ||||
| -rw-r--r-- | src/commands/meme/delete.rs | 2 | ||||
| -rw-r--r-- | src/commands/meme/history.rs | 178 | ||||
| -rw-r--r-- | src/commands/meme/invoke.rs | 77 | ||||
| -rw-r--r-- | src/commands/meme/mod.rs | 25 | ||||
| -rw-r--r-- | src/commands/playback.rs | 61 | ||||
| -rw-r--r-- | src/commands/today/mod.rs | 9 | ||||
| -rw-r--r-- | src/config.rs | 16 | ||||
| -rw-r--r-- | src/db/manual_migrate.rs | 62 | ||||
| -rw-r--r-- | src/db/mod.rs | 338 | ||||
| -rw-r--r-- | src/db/models.rs | 19 | ||||
| -rw-r--r-- | src/db/schema.rs | 3 | ||||
| -rw-r--r-- | src/util/mod.rs | 120 |
21 files changed, 803 insertions, 303 deletions
@@ -18,6 +18,10 @@ default-run = "thulani" name = "batch_delmeme" required-features = ["db"] +[[bin]] +name = "convert_null_guilds" +required-features = ["db"] + [features] default = ["db", "games", "windows_autostart_postgres"] db = ["dep:diesel", "dep:diesel-async", "dep:diesel_async_migrations", "dep:tokio-postgres", "dep:deadpool-postgres"] diff --git a/migrations/2024-08-16-111659_multitenant/down.sql b/migrations/2024-08-16-111659_multitenant/down.sql new file mode 100644 index 0000000..f0cac09 --- /dev/null +++ b/migrations/2024-08-16-111659_multitenant/down.sql @@ -0,0 +1,12 @@ +ALTER TABLE memes + DROP CONSTRAINT IF EXISTS memes_guild_title_key, + DROP CONSTRAINT IF EXISTS memes_guild_content_image_id_audio_id_key, + DROP CONSTRAINT IF EXISTS memes_guild_metadata_id_key; + +DROP INDEX IF EXISTS memes_guild_title_key, + memes_guild_content_image_id_audio_id_key, + memes_guild_metadata_id_key; + +CREATE UNIQUE INDEX IF NOT EXISTS text_memes_title_key ON memes (title); +CREATE UNIQUE INDEX IF NOT EXISTS text_memes_content_image_id_audio_id_key ON memes (content, image_id, audio_id); +CREATE UNIQUE INDEX IF NOT EXISTS text_memes_metadata_id_key ON memes (metadata_id); diff --git a/migrations/2024-08-16-111659_multitenant/up.sql b/migrations/2024-08-16-111659_multitenant/up.sql new file mode 100644 index 0000000..8bda619 --- /dev/null +++ b/migrations/2024-08-16-111659_multitenant/up.sql @@ -0,0 +1,35 @@ +ALTER TABLE memes + DROP CONSTRAINT IF EXISTS text_memes_title_key, + DROP CONSTRAINT IF EXISTS text_memes_content_image_id_audio_id_key, + DROP CONSTRAINT IF EXISTS text_memes_metadata_id_key, + DROP CONSTRAINT IF EXISTS memes_guild_title_key, + DROP CONSTRAINT IF EXISTS memes_guild_content_image_id_audio_id_key, + DROP CONSTRAINT IF EXISTS memes_guild_metadata_id_key +; + +DROP INDEX IF EXISTS text_memes_title_key, text_memes_content_image_id_audio_id_key, text_memes_metadata_id_key; + +ALTER TABLE memes + ADD COLUMN IF NOT EXISTS guild BIGINT; + +ALTER TABLE MEMES + ALTER COLUMN guild TYPE BIGINT, + ADD CONSTRAINT memes_guild_title_key UNIQUE (guild, title), + ADD CONSTRAINT memes_guild_content_image_id_audio_id_key UNIQUE (guild, content, image_id, audio_id), + ADD CONSTRAINT memes_guild_metadata_id_key UNIQUE (guild, metadata_id); + +CREATE UNIQUE INDEX IF NOT EXISTS memes_guild_title_key ON memes (guild, title); +CREATE UNIQUE INDEX IF NOT EXISTS memes_guild_content_image_id_audio_id_key ON memes (guild, content, image_id, audio_id); +CREATE UNIQUE INDEX IF NOT EXISTS memes_guild_metadata_id_key ON memes (guild, metadata_id); + +ALTER TABLE tombstones + ADD COLUMN IF NOT EXISTS guild BIGINT; + +ALTER TABLE tombstones + ALTER COLUMN guild TYPE BIGINT; + +ALTER TABLE invocation_records + ADD COLUMN IF NOT EXISTS guild BIGINT; + +ALTER TABLE invocation_records + ALTER COLUMN guild TYPE BIGINT; diff --git a/migrations/2024-08-16-230507_require_guild/down.sql b/migrations/2024-08-16-230507_require_guild/down.sql new file mode 100644 index 0000000..e7191c8 --- /dev/null +++ b/migrations/2024-08-16-230507_require_guild/down.sql @@ -0,0 +1,6 @@ +ALTER TABLE memes + ALTER COLUMN guild DROP NOT NULL; +ALTER TABLE invocation_records + ALTER COLUMN guild DROP NOT NULL; +ALTER TABLE tombstones + ALTER COLUMN guild DROP NOT NULL; diff --git a/migrations/2024-08-16-230507_require_guild/up.sql b/migrations/2024-08-16-230507_require_guild/up.sql new file mode 100644 index 0000000..06e5cd3 --- /dev/null +++ b/migrations/2024-08-16-230507_require_guild/up.sql @@ -0,0 +1,22 @@ +-- This migration will fail with any records in the db. If you are working with a normal server, you +-- can run: +-- +-- $ convert_null_guilds --guild $PREVIOUS_SINGLETENANT_GUILD +-- +-- to set all existing db records to the specified guild. A mixed / fucked up db sceneario will +-- require you to manually edit the db and ensure `guild` is set in `memes`, `invocation_records`, +-- and `tombstones`. + +DO LANGUAGE plpgsql +$$ + BEGIN + RAISE NOTICE 'if this migration fails, consider running "convert_null_guilds"'; + END +$$; + +ALTER TABLE memes + ALTER COLUMN guild SET NOT NULL; +ALTER TABLE invocation_records + ALTER COLUMN guild SET NOT NULL; +ALTER TABLE tombstones + ALTER COLUMN guild SET NOT NULL; diff --git a/src/bin/convert_null_guilds.rs b/src/bin/convert_null_guilds.rs new file mode 100644 index 0000000..69f9267 --- /dev/null +++ b/src/bin/convert_null_guilds.rs @@ -0,0 +1,29 @@ +use clap::Parser; +use diesel_async::{ + scoped_futures::ScopedFutureExt, + AsyncConnection, + RunQueryDsl, +}; +use dotenv::dotenv; + +use thulani::db; + +#[derive(clap::Parser)] +struct Opts { + #[arg(short, long)] + guild: u64, +} + +#[tokio::main] +pub async fn main() -> anyhow::Result<()> { + thulani::log_setup::init(); + dotenv().ok(); + + let opts = Opts::parse(); + + let mut conn = db::manual_migrate::connection_no_migrate().await?; + + db::manual_migrate::set_default_guild(conn, opts.guild).await?; + + Ok(()) +} @@ -22,7 +22,9 @@ use poise::{ }; use serenity::{ all::{ + Guild, GuildId, + PartialGuild, ReactionType, }, builder::CreateMessage, @@ -61,40 +63,41 @@ impl TypeMapKey for HttpKey { type Value = reqwest::Client; } -struct Handler; - -#[serenity::async_trait] -impl EventHandler for Handler { - async fn ready(&self, ctx: Context, r: Ready) { - let guild = r.guilds.iter().find(|g| g.id == CONFIG.discord.guild()); - - if guild.is_none() { - tracing::info!(join_url = OAUTH_URL.as_str(), "bot isn't in configured guild"); - return; - } +#[cfg(debug_assertions)] +const BOTNAME: &str = "thulani (dev)"; - tracing::info!("connected"); +#[cfg(not(debug_assertions))] +const BOTNAME: &str = "thulani"; - #[cfg(debug_assertions)] - let botname = "thulani (dev)"; +struct Handler; - #[cfg(not(debug_assertions))] - let botname = "thulani"; +async fn ready_guild(ctx: &Context, guild_id: GuildId) { + let sb = songbird::get(ctx).await.unwrap(); - if let Some(guild) = guild { - if let Err(e) = guild.id.edit_nickname(&ctx, Some(botname)).await { - tracing::error!("changing nickname: {:?}", e); - } - } + if let Err(e) = guild_id.edit_nickname(ctx, Some(BOTNAME)).await { + tracing::error!(error = %e, %guild_id, "changing nickname"); + } - let sb = songbird::get(&ctx).await.unwrap(); + let c = sb.get_or_insert(guild_id); + let mut call = c.lock().await; - let c = sb.get_or_insert(CONFIG.discord.guild()); - let mut call = c.lock().await; + call.remove_all_global_events(); + call.add_global_event(Event::Track(TrackEvent::End), SongbirdHandler(c.clone())); +} - call.remove_all_global_events(); +#[serenity::async_trait] +impl EventHandler for Handler { + async fn ready(&self, _ctx: Context, r: Ready) { + tracing::info!( + join_url = OAUTH_URL.as_str(), + visible_guilds = r.guilds.len(), + "connected to discord" + ); + } - call.add_global_event(Event::Track(TrackEvent::End), SongbirdHandler(c.clone())); + async fn guild_create(&self, ctx: Context, guild: Guild, _is_new: Option<bool>) { + tracing::info!(guild_id = %guild.id, guild_name = %guild.name, "received guild_create"); + ready_guild(&ctx, guild.id).await; } async fn resume(&self, _ctx: Context, _resume: ResumedEvent) { @@ -332,12 +335,6 @@ fn check(ctx: PoiseContext) -> BoxFuture<anyhow::Result<bool>> { ) .entered(); - if !ctx.guild_id().map_or(false, |x| x == CONFIG.discord.guild()) { - tracing::info!("rejecting command, wrong guild"); - - return Ok(false); - } - if ctx.author().id == CONFIG.discord.owner() { tracing::info!("author is owner"); diff --git a/src/commands/game.rs b/src/commands/game.rs index 5c51da6..78c08ee 100644 --- a/src/commands/game.rs +++ b/src/commands/game.rs @@ -288,19 +288,20 @@ async fn _game( .filter_map(|(uid, voice)| voice.channel_id.map(|cid| (*uid, cid))) .collect::<FnvHashMap<_, _>>(); - let channel = - pairs.get(&ctx.author().id).cloned().unwrap_or(CONFIG.discord.voice_channel()); + if let Some(channel) = pairs.get(&ctx.author().id).cloned() { + tracing::debug!("identified user channel"); - users = pairs - .iter() - .filter_map(|(uid, cid)| { - if *cid == channel { - DISCORD_MAP.get(uid).map(|s| s.to_lowercase()) - } else { - None - } - }) - .collect::<FnvHashSet<_>>(); + users = pairs + .iter() + .filter_map(|(uid, cid)| { + if *cid == channel { + DISCORD_MAP.get(uid).map(|s| s.to_lowercase()) + } else { + None + } + }) + .collect::<FnvHashSet<_>>(); + } } users diff --git a/src/commands/meme/create.rs b/src/commands/meme/create.rs index c6ed9c6..2d0ee9a 100644 --- a/src/commands/meme/create.rs +++ b/src/commands/meme/create.rs @@ -52,6 +52,7 @@ pub async fn addmeme( let save_result = NewMeme { title, + guild: util::guild_id(ctx)?.get() as _, content: text, image_id, audio_id: None, @@ -177,6 +178,7 @@ pub async fn addaudiomeme( let save_result = NewMeme { title, content: text, + guild: util::guild_id(ctx)?.get() as _, image_id, audio_id: Some(audio_id), metadata_id: 0, diff --git a/src/commands/meme/delete.rs b/src/commands/meme/delete.rs index 8adaa6c..96598d0 100644 --- a/src/commands/meme/delete.rs +++ b/src/commands/meme/delete.rs @@ -19,7 +19,7 @@ use crate::{ pub async fn delmeme(ctx: PoiseContext<'_>, title: String) -> anyhow::Result<()> { let mut conn = connection().await?; - match delete_meme(&mut conn, &title, ctx.author().id.get()).await { + match delete_meme(&mut conn, &title, ctx.author().id.get(), util::guild_id(ctx)?.get()).await { Ok(_) => { util::react(ctx, ReactionType::Unicode("💀".to_owned())).await?; Ok(()) diff --git a/src/commands/meme/history.rs b/src/commands/meme/history.rs index d6ccdcf..1acb019 100644 --- a/src/commands/meme/history.rs +++ b/src/commands/meme/history.rs @@ -1,3 +1,16 @@ +use crate::{ + commands::game::get_user_id, + db::{ + self, + connection, + InvocationRecord, + Meme, + Metadata, + }, + util, + PoiseContext, + CONFIG, +}; use chrono::TimeZone; use diesel::{ result::Error as DieselError, @@ -7,31 +20,21 @@ use grate::tracing; use itertools::Itertools; use lazy_static::lazy_static; use serenity::{ + all::{ + GuildId, + Mentionable, + }, 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, -}; +use windows::core::s; lazy_static! { static ref TIME_FORMATTER: Formatter = { @@ -48,9 +51,11 @@ 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).await { + let record = match InvocationRecord::last(&mut conn, guild_id.get()).await { Ok(x) => x, Err(e) => { if let Some(NotFound) = e.downcast_ref::<DieselError>() { @@ -70,7 +75,7 @@ pub async fn wat(ctx: PoiseContext<'_>) -> anyhow::Result<()> { 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?; + let author = guild_id.member(&ctx, metadata.created_by as u64).await?; util::reply( ctx, @@ -116,9 +121,11 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option<usize>) -> anyhow::Result< 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).await? + InvocationRecord::last_n(&mut conn, n, guild_id.get()).await? }; if records.is_empty() { @@ -150,9 +157,7 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option<usize>) -> anyhow::Result< Err(e) => Err(e), }; - let invoker_name = CONFIG - .discord - .guild() + let invoker_name = guild_id .member(&ctx, rec.user_id as u64) .await .map(|m| m.display_name().to_owned()) @@ -160,9 +165,7 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option<usize>) -> anyhow::Result< let result = match meme { Ok((metadata, meme)) => { - let author_name = CONFIG - .discord - .guild() + let author_name = guild_id .member(&ctx, metadata.created_by as u64) .await .map(|m| m.display_name().to_owned()) @@ -210,17 +213,89 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { user::User, }; + let guild_id = util::guild_id(ctx)?; + let mut conn = connection().await?; - let stats = db::stats(&mut conn).await?; + let stats = db::stats(&mut conn, guild_id.get()).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?; + async fn username(ctx: PoiseContext<'_>, guild_id: GuildId, id: u64) -> anyhow::Result<String> { + 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()?); - 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); + 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#" @@ -228,46 +303,23 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { **{}** memes with audio ({:0.1}%) **{}** memes with images ({:0.1}%) -started recording meme invocations on *{}* ({}) -**{}** total meme invocations recorded +{started_recording}**{}** 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 ({})"#, +{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.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?; @@ -279,13 +331,15 @@ and *{}* was the most-memed overall ({})"#, pub async fn memers(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use serenity::model::id::UserId; - let s = db::memers() + 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, CONFIG.discord.guild()).await.unwrap_or(user.name); + let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); let res = format!( "**{}**: {} total, {} random, {} specific. favorite meme: *{}* ({})", @@ -349,6 +403,8 @@ pub async fn query(ctx: PoiseContext<'_>, rest: util::RestVec) -> anyhow::Result 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<u64> = try { @@ -373,13 +429,13 @@ pub async fn query(ctx: PoiseContext<'_>, rest: util::RestVec) -> anyhow::Result let order = order.is_some_and(|o| o.contains("new")); - let iter = db::query_meme(rest.join(" "), creator, order).await?.into_iter(); + 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, CONFIG.discord.guild()).await.unwrap_or(user.name); + let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); Ok(format!( "*{}* by **{}** ({}). text length: **{}**, image: **{}**, audio: **{}**", diff --git a/src/commands/meme/invoke.rs b/src/commands/meme/invoke.rs index 37a78f5..aff5c23 100644 --- a/src/commands/meme/invoke.rs +++ b/src/commands/meme/invoke.rs @@ -1,5 +1,6 @@ use diesel::{ result::Error as DieselError, + row::NamedRow, NotFound, }; use grate::tracing; @@ -79,14 +80,23 @@ pub(crate) async fn _meme( return rand_meme(ctx, audio_playback).await; } + let guild_id = util::guild_id(ctx)?; + let mut conn = connection().await?; - let mem = match find_meme(&mut conn, args).await { + let mem = match find_meme(&mut conn, args, guild_id.get()).await { Ok(x) => { - InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), x.id, false) + InvocationRecord::create( + &mut conn, + ctx.author().id.get(), + ctx.id(), + guild_id.get(), + x.id, + false, + ) .await?; x - }, + } Err(e) => { return if let Some(NotFound) = e.downcast_ref::<DieselError>() { tracing::info!("requested meme not found in database"); @@ -97,30 +107,43 @@ pub(crate) async fn _meme( util::reply(ctx, "what in ryan's name").await?; Err(e.into()) }; - }, + } }; send_meme(ctx, &mem, &mut conn).await } async fn rand_meme(ctx: PoiseContext<'_>, audio_playback: AudioPlayback) -> anyhow::Result<()> { - let mut conn = connection().await?; + let should_audio = util::users_listening(ctx).await?; + let guild_id = util::guild_id(ctx)?; - let should_audio = util::users_listening(ctx.serenity_context()).await?; + let mut conn = connection().await?; let mem = match audio_playback { - AudioPlayback::Required => db::rand_audio_meme(&mut conn).await, - AudioPlayback::Optional => db::rand_meme(&mut conn, should_audio).await, - AudioPlayback::Prohibited => db::rand_silent_meme(&mut conn).await, + AudioPlayback::Required => db::rand_audio_meme(&mut conn, guild_id.get()).await, + AudioPlayback::Optional => db::rand_meme(&mut conn, should_audio, guild_id.get()).await, + AudioPlayback::Prohibited => db::rand_silent_meme(&mut conn, guild_id.get()).await, }; match mem { - Ok(mem) => { - InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), mem.id, true) + Ok(Some(mem)) => { + InvocationRecord::create( + &mut conn, + ctx.author().id.get(), + util::guild_id(ctx)?.get(), + ctx.id(), + mem.id, + true, + ) .await?; send_meme(ctx, &mem, &mut conn).await?; Ok(()) - }, + } + Ok(None) => { + tracing::info!("random meme not found"); + util::reply(ctx, "i don't know any :(").await?; + Ok(()) + } Err(e) => { if let Some(NotFound) = e.downcast_ref::<DieselError>() { tracing::info!("random meme not found"); @@ -131,24 +154,40 @@ async fn rand_meme(ctx: PoiseContext<'_>, audio_playback: AudioPlayback) -> anyh util::reply(ctx, "HELP").await?; Err(e.into()) - }, + } } } /// Post a rare meme. #[poise::command(prefix_command, guild_only, category = "memes", aliases("raremem", "rarememe"))] pub async fn rare_meme(ctx: PoiseContext<'_>) -> anyhow::Result<()> { - let should_audio = util::users_listening(ctx.serenity_context()).await?; + let guild = util::guild_id(ctx)?; + let should_audio = util::users_listening(ctx).await?; let mut conn = connection().await?; - let meme = db::rare_meme(&mut conn, should_audio).await; + let meme = db::rare_meme(&mut conn, should_audio, guild.get()).await; match meme { - Ok(meme) => { - InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), meme.id, true) + Ok(Some(meme)) => { + InvocationRecord::create( + &mut conn, + ctx.author().id.get(), + util::guild_id(ctx)?.get(), + ctx.id(), + meme.id, + true, + ) .await?; send_meme(ctx, &meme, &mut conn).await - }, + } + + Ok(None) => { + tracing::info!("rare meme not found"); + util::reply(ctx, "i don't know any :(").await?; + + Ok(()) + } + Err(e) => { if let Some(NotFound) = e.downcast_ref::<DieselError>() { tracing::info!("rare meme not found"); @@ -160,6 +199,6 @@ pub async fn rare_meme(ctx: PoiseContext<'_>) -> anyhow::Result<()> { util::reply(ctx, "THE MEME MARKET IS IN FREEFALL").await?; Err(e.into()) - }, + } } } diff --git a/src/commands/meme/mod.rs b/src/commands/meme/mod.rs index 7bc8b2e..eb6aa1d 100644 --- a/src/commands/meme/mod.rs +++ b/src/commands/meme/mod.rs @@ -19,6 +19,10 @@ use songbird::input::{ Compose, Input, }; +use std::{ + borrow::ToOwned, + default::Default, +}; pub use self::{ create::*, @@ -102,14 +106,23 @@ async fn send_meme( if let Some(audio) = audio { let audio = audio?; - let (_sb, call) = songbird(ctx).await?; - let mut call = call.lock().await; + let Some(voice_channel) = util::best_voice_channel(ctx) else { + tracing::error!(?ctx, "couldn't find a relevant voice channel"); + util::react(ctx, '🔇').await?; - if call.current_channel().is_none() { - call.join(CONFIG.discord.voice_channel()).await?; - } + return Ok(()); + }; + + { + let (_sb, call) = songbird(ctx).await?; + let mut call = call.lock().await; - call.enqueue_input(Input::Lazy(Box::new(audio))).await; + if call.current_channel().is_none() { + call.join(voice_channel).await?; + } + + call.enqueue_input(Input::Lazy(Box::new(audio))).await; + } util::react(ctx, ReactionType::Unicode("📣".to_owned())).await?; } diff --git a/src/commands/playback.rs b/src/commands/playback.rs index 50ac8bb..8121136 100644 --- a/src/commands/playback.rs +++ b/src/commands/playback.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use grate::tracing; use serenity::prelude::*; use songbird::{ @@ -5,7 +7,7 @@ use songbird::{ Call, Songbird, }; -use std::sync::Arc; +use tap::Conv; use crate::{ bot::HttpKey, @@ -16,7 +18,7 @@ use crate::{ }; pub fn commands() -> impl IntoIterator<Item = poise::Command<PoiseData, anyhow::Error>> { - vec![play(), pause(), resume(), die(), list(), skip()] + vec![play(), pause(), resume(), die(), list(), skip(), move_()] } pub async fn songbird(ctx: PoiseContext<'_>) -> anyhow::Result<(Arc<Songbird>, Arc<Mutex<Call>>)> { @@ -58,23 +60,36 @@ pub async fn _play(ctx: PoiseContext<'_>, url: &url::Url) -> anyhow::Result<()> return Ok(()); } - util::react(ctx, '🔃').await?; + let Some(voice_channel) = util::best_voice_channel(ctx) else { + tracing::error!(?ctx, "couldn't find a relevant voice channel"); + util::react(ctx, '🔇').await?; - let (_sb, call) = songbird(ctx).await?; - let mut call = call.lock().await; + return Ok(()); + }; - if call.current_channel().is_none() { - call.join(CONFIG.discord.voice_channel()).await?; - } + util::react(ctx, '🔃').await?; let client = { let data = ctx.serenity_context().data.read().await; data.get::<HttpKey>().unwrap().clone() }; - let input = - YoutubeDl::new_ytdl_like(&crate::config::YTDL_COMMAND, client.clone(), url.to_string()); - call.enqueue_input(input.into()).await; + { + let (_sb, call) = songbird(ctx).await?; + let mut call = call.lock().await; + + if call.current_channel().is_none() { + call.join(voice_channel).await?; + } + + let input = + YoutubeDl::new_ytdl_like(&crate::config::YTDL_COMMAND, client.clone(), url.to_string()); + + let track = input.conv::<songbird::tracks::Track>(); + // TODO: store enqueueing channel so songbird handler can switch channels + + call.enqueue(track).await; + } util::react(ctx, '📣').await?; util::unreact(ctx, '🔃').await?; @@ -82,6 +97,30 @@ pub async fn _play(ctx: PoiseContext<'_>, url: &url::Url) -> anyhow::Result<()> Ok(()) } +/// Move audio to the caller's voice channel. +#[poise::command(rename = "move", prefix_command, guild_only, category = "playback")] +pub async fn move_(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; + let mut call = call.lock().await; + + if call.current_channel().is_none() { + tracing::debug!("no current channel"); + util::reply(ctx, "let's get yer head screwed on straight").await?; + + return Ok(()); + } + + let Some(voice_channel) = util::best_voice_channel(ctx) else { + tracing::error!(?ctx, "couldn't find a relevant voice channel"); + util::react(ctx, '🔇').await?; + + return Ok(()); + }; + + call.join(voice_channel).await?; + Ok(()) +} + /// Play a link. #[poise::command(prefix_command, guild_only, category = "playback")] pub async fn play( diff --git a/src/commands/today/mod.rs b/src/commands/today/mod.rs index 35059e5..e48983c 100644 --- a/src/commands/today/mod.rs +++ b/src/commands/today/mod.rs @@ -96,11 +96,18 @@ pub async fn today(ctx: PoiseContext<'_>, #[rest] _rest: Option<String>) -> anyh let play_args = options.choose(&mut thread_rng()); if let Some(play_args) = play_args { + let Some(voice_channel) = util::best_voice_channel(ctx) else { + tracing::error!(?ctx, "couldn't find a relevant voice channel"); + util::react(ctx, '🔇').await?; + + return Ok(()); + }; + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; if call.current_channel().is_none() { - call.join(CONFIG.discord.voice_channel()).await?; + call.join(voice_channel).await?; } let client = { diff --git a/src/config.rs b/src/config.rs index a5ac928..fae5a23 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,31 +68,15 @@ pub struct DiscordConfig { #[envconfig(nested = true)] pub auth: DiscordAuth, - #[envconfig(from = "TARGET_GUILD")] - guild: u64, - #[envconfig(from = "OWNER_ID")] owner: u64, - - #[envconfig(from = "VOICE_CHANNEL")] - voice_channel: u64, } impl DiscordConfig { #[inline] - pub fn guild(&self) -> GuildId { - self.guild.into() - } - - #[inline] pub fn owner(&self) -> UserId { self.owner.into() } - - #[inline] - pub fn voice_channel(&self) -> ChannelId { - self.voice_channel.into() - } } #[derive(Envconfig)] diff --git a/src/db/manual_migrate.rs b/src/db/manual_migrate.rs new file mode 100644 index 0000000..851f0d8 --- /dev/null +++ b/src/db/manual_migrate.rs @@ -0,0 +1,62 @@ +use crate::{ + db::{ + do_migrate, + schema::*, + POOL, + }, + guild_id, +}; +use anyhow::anyhow; +use diesel::{ + associations::HasTable, + backend::Backend, + pg::Pg, + query_builder::AsQuery, + ExpressionMethods, +}; +use diesel_async::{ + scoped_futures::ScopedFutureExt, + AsyncConnection, + AsyncPgConnection, + RunQueryDsl, +}; + +#[inline] +pub async fn connection_no_migrate() +-> anyhow::Result<diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>> { + let conn = super::POOL.get().await?; + Ok(conn) +} + +pub async fn set_default_guild<Conn>(mut conn: Conn, guild_id: u64) -> anyhow::Result<()> +where + Conn: AsyncConnection<Backend = Pg> + 'static, +{ + conn.transaction::<_, anyhow::Error, _>(|tx| { + (async move { + diesel::update(memes::table) + .filter(memes::guild.is_null()) + .set(memes::guild.eq(guild_id as i64)) + .execute(tx) + .await?; + + diesel::update(invocation_records::table) + .filter(invocation_records::guild.is_null()) + .set(invocation_records::guild.eq(guild_id as i64)) + .execute(tx) + .await?; + + diesel::update(tombstones::table) + .filter(tombstones::guild.is_null()) + .set(tombstones::guild.eq(guild_id as i64)) + .execute(tx) + .await?; + + Ok(()) + }) + .scope_boxed() + }) + .await?; + + Ok(()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index e8e6596..5f19d54 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,9 +1,6 @@ -use std::{ - convert::AsRef, - env, - str::FromStr, -}; - +pub use self::models::*; +use self::schema::*; +use crate::db::schema::memes::title; use anyhow::{ anyhow, Error, @@ -22,7 +19,12 @@ use deadpool_postgres::{ }; use diesel::{ prelude::*, + row::NamedRow, + BoolExpressionMethods, + ExpressionMethods, NotFound, + PgTextExpressionMethods, + QueryDsl, }; use diesel_async::{ pooled_connection::{ @@ -35,11 +37,19 @@ use diesel_async::{ RunQueryDsl, }; use grate::tracing; +use rand::Rng; use serenity::FutureExt; +use std::{ + convert::{ + AsRef, + From, + }, + env, + str::FromStr, +}; +use tokio_postgres::types::FromSql; -pub use self::models::*; -use self::schema::*; - +pub mod manual_migrate; mod models; mod schema; @@ -72,7 +82,7 @@ lazy_static::lazy_static! { #[inline] pub async fn connection() --> Result<diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>> { + -> Result<diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>> { POOL.get() .then(|mut conn| async move { if let Ok(ref mut conn) = conn { @@ -105,16 +115,30 @@ async fn raw_connection() -> Result<deadpool_postgres::Object> { RAW_CONN_MGR.get().await.map_err(Error::from) } -pub async fn find_meme<T: AsRef<str>>(conn: &mut AsyncPgConnection, search: T) -> Result<Meme> { +pub async fn find_meme<T: AsRef<str>>( + conn: &mut AsyncPgConnection, + search: T, + guild_id: u64, +) -> Result<Meme> { let search = search.as_ref(); + let guild_match = memes::guild.eq(guild_id as i64); - let mut meme = memes::table.filter(memes::title.eq(search)).limit(1).first::<Meme>(conn).await; + let mut meme = memes::table + .filter(memes::title.eq(search).and(guild_match)) + .limit(1) + .first::<Meme>(conn) + .await; if let Err(NotFound) = meme { let format_search = format!("%{}%", search); meme = memes::table - .filter(memes::title.ilike(&format_search).or(memes::content.ilike(&format_search))) + .filter( + memes::title + .ilike(&format_search) + .or(memes::content.ilike(&format_search)) + .and(guild_match), + ) .limit(1) .first::<Meme>(conn) .await; @@ -127,9 +151,11 @@ pub async fn query_meme<T: AsRef<str>>( search: T, user_id: Option<u64>, age_desc: bool, + guild: u64, ) -> Result<Vec<(Meme, Metadata)>> { let raw_conn = raw_connection().await?; + let guild = guild as i64; let search = format!("%{}%", search.as_ref()); let rows = raw_conn @@ -140,7 +166,7 @@ pub async fn query_meme<T: AsRef<str>>( FROM memes INNER JOIN metadata ON memes.metadata_id = metadata.id WHERE (memes.title ILIKE $1 OR memes.content ILIKE $1) - AND (metadata.created_by = $2 OR $3) + AND (metadata.created_by = $2 OR $3) AND memes.guild = $4 ORDER BY metadata.created {} LIMIT 100 "#, @@ -150,7 +176,7 @@ pub async fn query_meme<T: AsRef<str>>( "ASC" }, ), - &[&search, &(user_id.unwrap_or(0) as i64), &user_id.is_none()], + &[&search, &(user_id.unwrap_or(0) as i64), &user_id.is_none(), &guild], ) .await?; @@ -158,17 +184,18 @@ pub async fn query_meme<T: AsRef<str>>( .iter() .map(|row| { let meme = Meme { - id: row.get(0), - title: row.get(1), - content: row.get(2), - image_id: row.get(3), - audio_id: row.get(4), + id: row.get(0), + guild, + title: row.get(1), + content: row.get(2), + image_id: row.get(3), + audio_id: row.get(4), metadata_id: row.get(5), }; let metadata = Metadata { - id: row.get(5), - created: row.get(6), + id: row.get(5), + created: row.get(6), created_by: row.get(7), }; @@ -196,7 +223,7 @@ pub async fn del_memes_by_userid( meme_deletes AS ( DELETE FROM memes WHERE metadata_id IN (SELECT id FROM metadata WHERE created_by = $1) - RETURNING id, metadata_id + RETURNING id, guild, metadata_id ) , image_deletes AS ( DELETE FROM images @@ -209,8 +236,8 @@ pub async fn del_memes_by_userid( RETURNING id, metadata_id ) , tombstone_inserts AS ( - INSERT INTO tombstones (meme_id, metadata_id, deleted_at, deleted_by) - SELECT id, metadata_id, NOW(), $2 FROM meme_deletes + INSERT INTO tombstones (meme_id, guild, metadata_id, deleted_at, deleted_by) + SELECT id, guild, metadata_id, NOW(), $2 FROM meme_deletes RETURNING meme_id, metadata_id ) @@ -229,12 +256,16 @@ pub async fn delete_meme<T: AsRef<str>>( conn: &mut AsyncPgConnection, search: T, deleted_by: u64, + guild: u64, ) -> Result<()> { let search = search.as_ref(); conn.transaction::<(), Error, _>(|tx| { async move { - let deleted = memes::table.filter(memes::title.eq(search)).first::<Meme>(tx).await?; + let deleted = memes::table + .filter(memes::title.eq(search).and(memes::guild.eq(guild as i64))) + .first::<Meme>(tx) + .await?; diesel::delete(memes::table).filter(memes::id.eq(deleted.id)).execute(tx).await?; @@ -263,23 +294,29 @@ pub async fn delete_meme<T: AsRef<str>>( } let tombstone = NewTombstone { - deleted_by: deleted_by as i64, + guild: guild as i64, + deleted_by: deleted_by as i64, metadata_id: deleted.metadata_id, - meme_id: deleted.id, + meme_id: deleted.id, }; let _ = diesel::insert_into(tombstones::table).values(&tombstone).execute(tx).await?; Ok(()) } - .scope_boxed() + .scope_boxed() }) - .await + .await } -pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme> { +pub async fn rare_meme( + conn: &mut AsyncPgConnection, + audio: bool, + guild: u64, +) -> Result<Option<Meme>> { use rand::prelude::*; + let guild = guild as i64; let raw_conn = raw_connection().await?; let rows = raw_conn @@ -291,6 +328,7 @@ pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme meme_id, COUNT(*) AS ct FROM invocation_records + WHERE guild = $3 GROUP BY meme_id ), aggregate AS ( @@ -301,7 +339,7 @@ pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme FROM meme_count RIGHT JOIN memes ON memes.id = meme_count.meme_id INNER JOIN metadata ON metadata.id = memes.metadata_id - WHERE (memes.audio_id IS NULL) = $1 OR $2 + WHERE memes.guild = $3 AND ((memes.audio_id IS NULL) = $1 OR $2) ), least_used AS ( SELECT @@ -315,7 +353,7 @@ pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme FROM least_used LIMIT 100; "#, - &[&!audio, &audio], + &[&!audio, &audio, &guild], ) .await?; @@ -325,7 +363,7 @@ pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme .collect::<Vec<_>>(); if elems.is_empty() { - return Err(anyhow!("no rare memes found")); + return Ok(None); } let target_prob = { @@ -339,10 +377,14 @@ pub async fn rare_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme .ok_or_else(|| anyhow!("couldn't locate meme satisfying target probability"))? .0; - Meme::find(conn, meme_id).await + Meme::find(conn, meme_id).await.map(Some) } -pub async fn rand_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme> { +pub async fn rand_meme( + conn: &mut AsyncPgConnection, + audio: bool, + guild: u64, +) -> Result<Option<Meme>> { use rand::{ seq::SliceRandom, thread_rng, @@ -352,10 +394,12 @@ pub async fn rand_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme memes::table .select(memes::id) .filter( - memes::content - .is_not_null() - .or(memes::image_id.is_not_null()) - .or(memes::audio_id.is_not_null()), + memes::guild.eq(guild as i64).and( + memes::content + .is_not_null() + .or(memes::image_id.is_not_null()) + .or(memes::audio_id.is_not_null()), + ), ) .load(conn) .await @@ -363,18 +407,26 @@ pub async fn rand_meme(conn: &mut AsyncPgConnection, audio: bool) -> Result<Meme } else { memes::table .select(memes::id) - .filter(memes::content.is_not_null().or(memes::image_id.is_not_null())) + .filter( + memes::guild + .eq(guild as i64) + .and(memes::content.is_not_null().or(memes::image_id.is_not_null())), + ) .load(conn) .await .map_err(Error::from)? }; + if ids.is_empty() { + return Ok(None); + } + let id = ids.choose(&mut thread_rng()).ok_or_else(|| anyhow!("couldn't load meme"))?; - memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from) + memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from).map(Some) } -pub async fn rand_audio_meme(conn: &mut AsyncPgConnection) -> Result<Meme> { +pub async fn rand_audio_meme(conn: &mut AsyncPgConnection, guild: u64) -> Result<Option<Meme>> { use rand::{ seq::SliceRandom, thread_rng, @@ -382,17 +434,21 @@ pub async fn rand_audio_meme(conn: &mut AsyncPgConnection) -> Result<Meme> { let ids: Vec<i32> = memes::table .select(memes::id) - .filter(memes::audio_id.is_not_null()) + .filter(memes::audio_id.is_not_null().and(memes::guild.eq(guild as i64))) .load(conn) .await .map_err(Error::from)?; + if ids.is_empty() { + return Ok(None); + } + let id = ids.choose(&mut thread_rng()).ok_or_else(|| anyhow!("couldn't load audio meme"))?; - memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from) + memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from).map(Some) } -pub async fn rand_silent_meme(conn: &mut AsyncPgConnection) -> Result<Meme> { +pub async fn rand_silent_meme(conn: &mut AsyncPgConnection, guild: u64) -> Result<Option<Meme>> { use rand::{ seq::SliceRandom, thread_rng, @@ -400,218 +456,246 @@ pub async fn rand_silent_meme(conn: &mut AsyncPgConnection) -> Result<Meme> { let ids: Vec<i32> = memes::table .select(memes::id) - .filter(memes::audio_id.is_null()) + .filter(memes::audio_id.is_null().and(memes::guild.eq(guild as i64))) .load(conn) .await .map_err(Error::from)?; + if ids.is_empty() { + return Ok(None); + } + let id = ids.choose(&mut thread_rng()).ok_or_else(|| anyhow!("couldn't load audio meme"))?; - memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from) + memes::table.find(id).first::<Meme>(conn).await.map_err(Error::from).map(Some) } #[derive(Debug, Clone)] pub struct Stats { - pub memes_overall: usize, - pub audio_memes: usize, - pub image_memes: usize, - pub started_recording: DateTime<Utc>, - pub total_meme_invocations: usize, - pub audio_meme_invocations: usize, + pub memes_overall: usize, + pub audio_memes: usize, + pub image_memes: usize, + pub started_recording: Option<DateTime<Utc>>, + pub total_meme_invocations: usize, + pub audio_meme_invocations: usize, pub random_meme_invocations: usize, - pub most_active_day: NaiveDate, + pub most_active_day: Option<NaiveDate>, pub most_active_day_count: usize, - pub most_audio_active_day: NaiveDate, + pub most_audio_active_day: Option<NaiveDate>, pub most_audio_active_count: usize, - pub most_random_meme_user: u64, - pub most_random_meme_user_count: usize, - pub most_directly_named_meme_user: u64, + pub most_random_meme_user: Option<u64>, + pub most_random_meme_user_count: usize, + pub most_directly_named_meme_user: Option<u64>, pub most_directly_named_meme_count: usize, - pub most_popular_named_meme: String, + pub most_popular_named_meme: Option<String>, pub most_popular_named_meme_count: usize, - pub most_popular_random_meme: String, + pub most_popular_random_meme: Option<String>, pub most_popular_random_meme_count: usize, - pub most_popular_meme_overall: String, + pub most_popular_meme_overall: Option<String>, pub most_popular_meme_overall_count: usize, } -pub async fn stats(conn: &mut AsyncPgConnection) -> Result<Stats> { +pub async fn stats(conn: &mut AsyncPgConnection, guild: u64) -> Result<Stats> { use chrono::NaiveDateTime; use diesel::dsl::{ count, count_star, }; + let guild = guild as i64; + + let guild_match = memes::guild.eq(guild); + let invoke_guild_match = invocation_records::guild.eq(guild); + #[inline] fn to_utc(ndt: NaiveDateTime) -> DateTime<Utc> { Utc.from_utc_datetime(&ndt) } - let total_count: i64 = - memes::table.select(count_star()).first(conn).await.map_err(Error::from)?; + let total_count: i64 = memes::table + .select(count_star()) + .filter(guild_match) + .first(conn) + .await + .map_err(Error::from)?; let image_count: i64 = memes::table .select(count(memes::image_id)) - .filter(memes::image_id.is_not_null()) + .filter(memes::image_id.is_not_null().and(guild_match)) .first(conn) .await .map_err(Error::from)?; let audio_count: i64 = memes::table .select(count(memes::audio_id)) - .filter(memes::audio_id.is_not_null()) + .filter(memes::audio_id.is_not_null().and(guild_match)) .first(conn) .await .map_err(Error::from)?; - let started_recording: NaiveDateTime = invocation_records::table + let started_recording = invocation_records::table .select(invocation_records::time) + .filter(invoke_guild_match) .order(invocation_records::time) .first(conn) .await - .map_err(Error::from)?; + .optional(); + + tracing::debug!(?started_recording); - let started_recording = to_utc(started_recording); + let started_recording: Option<NaiveDateTime> = started_recording.map_err(Error::from)?; + let started_recording = started_recording.map(to_utc); - let total_meme_invocations: i64 = - invocation_records::table.select(count_star()).first(conn).await.map_err(Error::from)?; + let total_meme_invocations: i64 = invocation_records::table + .select(count_star()) + .filter(invoke_guild_match) + .first(conn) + .await + .map_err(Error::from)?; let audio_meme_invocations: i64 = invocation_records::table .inner_join(memes::table) .select(count_star()) - .filter(memes::audio_id.is_not_null()) + .filter(memes::audio_id.is_not_null().and(guild_match)) .first(conn) .await .map_err(Error::from)?; let random_meme_invocations: i64 = invocation_records::table .select(count_star()) - .filter(invocation_records::random.eq(true)) + .filter(invocation_records::random.eq(true).and(invoke_guild_match)) .first(conn) .await .map_err(Error::from)?; let raw_conn = raw_connection().await?; + #[inline] + fn option_first_count<T>(rows: Vec<tokio_postgres::Row>) -> (Option<T>, i64) + where + for<'a> T: FromSql<'a>, + { + let Some(row) = <[_]>::first(&rows) else { + return (None, 0); + }; + + (row.get(0), row.get(1)) + } + let row = raw_conn - .query_one( + .query( r#" SELECT DATE(time) as dt, COUNT(*) FROM invocation_records + WHERE guild = $1 GROUP BY dt ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_active_day = row.get(0); - let most_active_day_count: i64 = row.get(1); + let (most_active_day, most_active_day_count) = option_first_count(row); let row = raw_conn - .query_one( + .query( r#" SELECT DATE(time) as dt, COUNT(*) FROM invocation_records - INNER JOIN memes ON invocation_records.meme_id = memes.id - WHERE memes.audio_id IS NOT NULL + INNER JOIN memes ON (invocation_records.meme_id = memes.id AND invocation_records.guild = memes.guild) + WHERE memes.audio_id IS NOT NULL AND memes.guild = $1 GROUP BY dt ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_active_audio_day = row.get(0); - let most_active_audio_day_count: i64 = row.get(1); + let (most_active_audio_day, most_active_audio_day_count) = option_first_count(row); let row = raw_conn - .query_one( + .query( r#" SELECT user_id, COUNT(*) FROM invocation_records - WHERE random IS TRUE + WHERE random IS TRUE AND guild = $1 GROUP BY user_id ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_random_invoker: i64 = row.get(0); - let most_random_invoker_count: i64 = row.get(1); + let (most_random_invoker, most_random_invoker_count) = option_first_count::<i64>(row); let row = raw_conn - .query_one( + .query( r#" SELECT user_id, COUNT(*) FROM invocation_records - WHERE random IS FALSE + WHERE random IS FALSE AND guild = $1 GROUP BY user_id ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_specific_invoker: i64 = row.get(0); - let most_specific_invoker_count: i64 = row.get(1); + let (most_specific_invoker, most_specific_invoker_count) = option_first_count::<i64>(row); let row = raw_conn - .query_one( + .query( r#" SELECT memes.title, COUNT(*) FROM invocation_records - INNER JOIN memes ON meme_id = memes.id - WHERE random IS FALSE + INNER JOIN memes ON (meme_id = memes.id AND invocation_records.guild = memes.guild) + WHERE random IS FALSE AND memes.guild = $1 GROUP BY memes.title ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_requested_meme = row.get(0); - let most_requested_meme_count: i64 = row.get(1); + let (most_requested_meme, most_requested_meme_count) = option_first_count(row); let row = raw_conn - .query_one( + .query( r#" SELECT memes.title, COUNT(*) FROM invocation_records - INNER JOIN memes ON meme_id = memes.id - WHERE random IS TRUE + INNER JOIN memes ON (meme_id = memes.id AND invocation_records.guild = memes.guild) + WHERE random IS TRUE AND memes.guild = $1 GROUP BY memes.title ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_random_meme = row.get(0); - let most_random_meme_count: i64 = row.get(1); + let (most_random_meme, most_random_meme_count) = option_first_count(row); let row = raw_conn - .query_one( + .query( r#" SELECT memes.title, COUNT(*) FROM invocation_records - INNER JOIN memes ON meme_id = memes.id + INNER JOIN memes ON (meme_id = memes.id AND invocation_records.guild = memes.guild) + WHERE memes.guild = $1 GROUP BY memes.title ORDER BY COUNT(*) DESC LIMIT 1; "#, - &[], + &[&guild], ) .await?; - let most_invoked_meme = row.get(0); - let most_invoked_meme_count: i64 = row.get(1); + let (most_invoked_meme, most_invoked_meme_count) = option_first_count(row); Ok(Stats { memes_overall: total_count as usize, @@ -624,12 +708,13 @@ pub async fn stats(conn: &mut AsyncPgConnection) -> Result<Stats> { most_active_day, most_active_day_count: most_active_day_count as usize, + most_audio_active_day: most_active_audio_day, most_audio_active_count: most_active_audio_day_count as usize, - most_random_meme_user: most_random_invoker as u64, + most_random_meme_user: most_random_invoker.map(|x| x as u64), most_random_meme_user_count: most_random_invoker_count as usize, - most_directly_named_meme_user: most_specific_invoker as u64, + most_directly_named_meme_user: most_specific_invoker.map(|x| x as u64), most_directly_named_meme_count: most_specific_invoker_count as usize, most_popular_named_meme: most_requested_meme, @@ -645,33 +730,34 @@ pub async fn stats(conn: &mut AsyncPgConnection) -> Result<Stats> { #[derive(Clone, Debug, Hash, PartialEq, Eq, Default)] pub struct MemerInfo { - pub user_id: u64, - pub random_memes: usize, - pub specific_memes: usize, - pub most_used_meme: String, + pub user_id: u64, + pub random_memes: usize, + pub specific_memes: usize, + pub most_used_meme: String, pub most_used_meme_count: usize, } -pub async fn memers() -> Result<Vec<MemerInfo>> { +pub async fn memers(guild: u64) -> Result<Vec<MemerInfo>> { + let guild = guild as i64; let raw_conn = raw_connection().await?; let rows = raw_conn.query(r#" WITH random_count AS ( SELECT user_id, COUNT(*) as count FROM invocation_records - WHERE random = TRUE + WHERE random = TRUE AND guild = $1 GROUP BY user_id ), specific_count AS ( SELECT user_id, COUNT(*) as count FROM invocation_records - WHERE random = FALSE + WHERE random = FALSE AND guild = $1 GROUP BY user_id ), user_meme_counts AS ( SELECT user_id, meme_id, COUNT(meme_id) as meme_count FROM invocation_records - WHERE EXISTS (SELECT id FROM memes WHERE id = invocation_records.meme_id) + WHERE EXISTS (SELECT id FROM memes WHERE id = invocation_records.meme_id) AND guild = $1 GROUP BY user_id, meme_id ORDER BY user_id, meme_count DESC ), @@ -692,7 +778,7 @@ pub async fn memers() -> Result<Vec<MemerInfo>> { INNER JOIN specific_count ON specific_count.user_id = random_count.user_id INNER JOIN memes ON memes.id = most_memed.meme_id ORDER BY (random_count.count + specific_count.count) DESC - "#, &[]).await?; + "#, &[&guild]).await?; let result = rows .iter() @@ -704,10 +790,10 @@ pub async fn memers() -> Result<Vec<MemerInfo>> { let most_memed_count: i64 = row.get(4); MemerInfo { - user_id: user_id as u64, - random_memes: random_count as usize, - specific_memes: specific_count as usize, - most_used_meme: most_memed_meme, + user_id: user_id as u64, + random_memes: random_count as usize, + specific_memes: specific_count as usize, + most_used_meme: most_memed_meme, most_used_meme_count: most_memed_count as usize, } }) diff --git a/src/db/models.rs b/src/db/models.rs index 603887e..33fa34e 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -4,6 +4,7 @@ use anyhow::{ }; use chrono::naive::NaiveDateTime; use diesel::{ + associations::HasTable, prelude::*, Identifiable, Insertable, @@ -21,6 +22,7 @@ use crate::db::schema::*; #[diesel(table_name = memes)] pub struct Meme { pub id: i32, + pub guild: i64, pub title: String, pub content: Option<String>, pub image_id: Option<i32>, @@ -55,6 +57,7 @@ impl Meme { #[diesel(table_name = memes)] pub struct NewMeme { pub title: String, + pub guild: i64, pub content: Option<String>, pub image_id: Option<i32>, pub audio_id: Option<i32>, @@ -250,6 +253,7 @@ pub struct NewAuditRecord { #[diesel(table_name = tombstones)] pub struct Tombstone { pub id: i32, + pub guild: i64, pub deleted: NaiveDateTime, pub deleted_by: i64, pub metadata_id: i32, @@ -260,6 +264,7 @@ pub struct Tombstone { #[diesel(table_name = tombstones)] pub struct NewTombstone { pub deleted_by: i64, + pub guild: i64, pub metadata_id: i32, pub meme_id: i32, } @@ -269,6 +274,7 @@ pub struct NewTombstone { pub struct InvocationRecord { pub id: i32, pub user_id: i64, + pub guild: i64, pub message_id: i64, pub meme_id: i32, pub time: NaiveDateTime, @@ -279,6 +285,7 @@ pub struct InvocationRecord { #[diesel(table_name = invocation_records)] pub struct NewInvocationRecord { pub user_id: i64, + pub guild: i64, pub message_id: i64, pub meme_id: i32, pub random: bool, @@ -288,6 +295,7 @@ impl InvocationRecord { pub async fn create( conn: &mut AsyncPgConnection, user_id: u64, + guild_id: u64, message_id: u64, meme_id: i32, random: bool, @@ -296,6 +304,7 @@ impl InvocationRecord { .values(&NewInvocationRecord { user_id: user_id as i64, message_id: message_id as i64, + guild: guild_id as i64, meme_id, random, }) @@ -304,16 +313,22 @@ impl InvocationRecord { .map_err(Error::from) } - pub async fn last(conn: &mut AsyncPgConnection) -> Result<Self> { + pub async fn last(conn: &mut AsyncPgConnection, guild_id: u64) -> Result<Self> { invocation_records::table + .filter(invocation_records::guild.eq(guild_id as i64)) .order(invocation_records::time.desc()) .first(conn) .await .map_err(Error::from) } - pub async fn last_n(conn: &mut AsyncPgConnection, n: usize) -> Result<Vec<Self>> { + pub async fn last_n( + conn: &mut AsyncPgConnection, + n: usize, + guild_id: u64, + ) -> Result<Vec<Self>> { invocation_records::table + .filter(invocation_records::guild.eq(guild_id as i64)) .order(invocation_records::time.desc()) .limit(n as i64) .load(conn) diff --git a/src/db/schema.rs b/src/db/schema.rs index e89b24a..bc17e29 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -30,6 +30,7 @@ diesel::table! { invocation_records (id) { id -> Int4, user_id -> Int8, + guild -> Int8, message_id -> Int8, meme_id -> Int4, time -> Timestamp, @@ -40,6 +41,7 @@ diesel::table! { diesel::table! { memes (id) { id -> Int4, + guild -> Int8, title -> Varchar, content -> Nullable<Text>, image_id -> Nullable<Int4>, @@ -60,6 +62,7 @@ diesel::table! { tombstones (id) { id -> Int4, meme_id -> Int4, + guild -> Int8, deleted_by -> Int8, deleted_at -> Timestamp, metadata_id -> Nullable<Int4>, diff --git a/src/util/mod.rs b/src/util/mod.rs index 9355e48..38f4e55 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,6 +1,19 @@ -use std::process::Stdio; +use std::{ + cmp::max_by, + collections::HashMap, + process::{ + id, + Stdio, + }, +}; +use anyhow::anyhow; use chrono::Duration; +use diesel::row::NamedRow; +use fnv::{ + FnvHashMap, + FnvHashSet, +}; use grate::tracing; use lazy_static::lazy_static; use poise::{ @@ -13,10 +26,14 @@ use regex::{ }; use serenity::{ all::{ + ChannelType, CreateMessage, + GuildId, + GuildRef, Message, Reaction, ReactionType, + VoiceState, }, client::Context, model::{ @@ -27,6 +44,7 @@ use serenity::{ permissions::Permissions, }, }; +use tap::Pipe; use url::Url; use crate::{ @@ -49,29 +67,25 @@ pub async fn currently_playing(ctx: PoiseContext<'_>) -> bool { call.queue().current().is_some() } -pub async fn users_listening(ctx: &Context) -> anyhow::Result<bool> { - let channel = CONFIG.discord.voice_channel().to_channel(&ctx).await?; +pub async fn users_listening(ctx: PoiseContext<'_>) -> anyhow::Result<bool> { + let Some(channel) = best_voice_channel(ctx) else { + return Ok(false); + }; - let res = channel - .guild() - .and_then(|ch| ch.guild(&ctx)) - .map(|g| { - g.voice_states - .iter() - .any(|(_, state)| state.channel_id == Some(CONFIG.discord.voice_channel())) - }) - .unwrap_or(false); + let Some(guild) = ctx.guild() else { + return Ok(false); + }; - Ok(res) + guild.voice_states.iter().any(|(_, state)| state.channel_id == Some(channel)).pipe(Ok) } #[inline] pub fn msg<U, E>(ctx: poise::Context<'_, U, E>) -> Option<&Message> { match ctx { poise::Context::Prefix(poise::PrefixContext { - msg, - .. - }) => Some(msg), + msg, + .. + }) => Some(msg), _ => None, } } @@ -154,6 +168,80 @@ pub async fn unreact( Ok(()) } +#[inline] +pub fn guild_id(ctx: PoiseContext<'_>) -> anyhow::Result<GuildId> { + ctx.guild_id().ok_or_else(|| anyhow::anyhow!("not in guild")) +} + +#[inline] +pub fn guild(ctx: PoiseContext<'_>) -> anyhow::Result<GuildRef<'_>> { + ctx.guild().ok_or_else(|| anyhow::anyhow!("not in guild")) +} + +#[inline] +pub fn author_voice_state(ctx: PoiseContext<'_>) -> Option<(VoiceState, GuildRef)> { + let guild = ctx.guild()?; + let caller_voice = guild.voice_states.get(&ctx.author().id)?.clone(); + + Some((caller_voice, guild)) +} + +#[inline] +pub fn author_voice_channel(ctx: PoiseContext<'_>) -> Option<ChannelId> { + let (vs, _guild) = author_voice_state(ctx)?; + vs.channel_id +} + +pub fn voice_states_by_channel(ctx: PoiseContext<'_>) -> HashMap<ChannelId, Vec<VoiceState>> { + let Some(guild) = ctx.guild() else { + return Default::default(); + }; + + guild + .voice_states + .values() + .cloned() + .filter_map(|x| { + let id = x.channel_id?; + Some((id, x)) + }) + .fold(HashMap::new(), |mut acc, (id, state)| { + acc.entry(id).or_insert_with(|| vec![]).push(state); + + acc + }) +} + +/// Select the most relevant voice channel for a given poise context. +/// +/// - If the message's author is in a voice channel, use that. +/// - If not, pick the most populated channel (channel age tiebreaks). +pub fn best_voice_channel(ctx: PoiseContext<'_>) -> Option<ChannelId> { + if let Some(channel) = author_voice_channel(ctx) { + return Some(channel); + } + + let voice_states = voice_states_by_channel(ctx); + let max_pop = voice_states.iter().map(|(_, states)| states.len()).max(); + + let matching_channels = voice_states + .iter() + .filter_map(|(&channel, state)| { + if state.len() == max_pop? { + return Some(channel); + } + + None + }) + .collect::<Vec<_>>(); + + if matching_channels.len() == 1 { + return matching_channels.first().cloned(); + } + + matching_channels.into_iter().min() +} + pub async fn send_result( ctx: &Context, channel: ChannelId, |
