aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml4
-rw-r--r--migrations/2024-08-16-111659_multitenant/down.sql12
-rw-r--r--migrations/2024-08-16-111659_multitenant/up.sql35
-rw-r--r--migrations/2024-08-16-230507_require_guild/down.sql6
-rw-r--r--migrations/2024-08-16-230507_require_guild/up.sql22
-rw-r--r--src/bin/convert_null_guilds.rs29
-rw-r--r--src/bot.rs61
-rw-r--r--src/commands/game.rs25
-rw-r--r--src/commands/meme/create.rs2
-rw-r--r--src/commands/meme/delete.rs2
-rw-r--r--src/commands/meme/history.rs178
-rw-r--r--src/commands/meme/invoke.rs77
-rw-r--r--src/commands/meme/mod.rs25
-rw-r--r--src/commands/playback.rs61
-rw-r--r--src/commands/today/mod.rs9
-rw-r--r--src/config.rs16
-rw-r--r--src/db/manual_migrate.rs62
-rw-r--r--src/db/mod.rs338
-rw-r--r--src/db/models.rs19
-rw-r--r--src/db/schema.rs3
-rw-r--r--src/util/mod.rs120
21 files changed, 803 insertions, 303 deletions
diff --git a/Cargo.toml b/Cargo.toml
index fbac972..face9a2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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(())
+}
diff --git a/src/bot.rs b/src/bot.rs
index 6da1ac4..7d85c89 100644
--- a/src/bot.rs
+++ b/src/bot.rs
@@ -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,