From 6c686b8b3cd9e5769156321a8d05f737196ee375 Mon Sep 17 00:00:00 2001 From: Nathan Perry Date: Sat, 17 Aug 2024 03:02:31 -0400 Subject: fix stats and playback queue --- src/bot.rs | 61 ++++++++++++++++++++-- src/commands/meme/history.rs | 104 ++++++++++++++++++++----------------- src/commands/meme/invoke.rs | 2 +- src/commands/meme/mod.rs | 25 ++++++++- src/commands/playback.rs | 121 +++++++++++++++++++++++++++++++++---------- src/commands/today/mod.rs | 15 +++++- src/db/mod.rs | 2 +- src/util/mod.rs | 34 ++++++++---- 8 files changed, 269 insertions(+), 95 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 4ad22c6..38029cf 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -72,7 +72,7 @@ impl TypeMapKey for VolumeKey { pub struct PlaybackKey; impl TypeMapKey for PlaybackKey { - type Value = Arc>; + type Value = Arc>; } #[cfg(debug_assertions)] @@ -97,18 +97,73 @@ async fn ready_guild(ctx: &Context, guild_id: GuildId) { call.add_global_event(Event::Track(TrackEvent::End), SongbirdHandler(c.clone())); } +async fn perm_check(ctx: &Context, guild: &Guild) -> anyhow::Result<()> { + // "Requested permissions" through the discord OAuth flow don't actually apply in a way that's + // reflected in the guild member permission object. There is a dedicated role type created to + // protect bot perms when you do this, which I'd like to read and verify, but I suspect it's + // behind BotIntegration, which you need `GUILD_MANAGE` to even read. I don't want to give this + // permission only for this reason. + + let me = ctx.cache.current_user().id; + + let member = guild.member(&ctx, me).await?; + let perms = member.permissions(&ctx)?; + + let lacking_perms = util::REQUIRED_PERMS.difference(perms); + + if !lacking_perms.is_empty() { + tracing::error!( + guild_id = %guild.id, + guild_name = %guild.name, + %lacking_perms, + "insufficient permissions for guild" + ); + } + + let lacking_desired_perms = + util::DESIRED_PERMS.difference(*util::REQUIRED_PERMS).difference(perms); + + if !lacking_desired_perms.is_empty() { + tracing::warn!( + guild_id = %guild.id, + guild_name = %guild.name, + %lacking_desired_perms, + "bot is lacking requested permissions" + ); + } + + let excess_perms = perms.difference(*util::DESIRED_PERMS); + + if !excess_perms.is_empty() { + tracing::debug!( + guild_id = %guild.id, + guild_name = %guild.name, + %excess_perms, + "bot has permissions in excess of requirements" + ); + } + + Ok(()) +} + #[serenity::async_trait] impl EventHandler for Handler { - async fn ready(&self, _ctx: Context, r: Ready) { + async fn ready(&self, ctx: Context, r: Ready) { tracing::info!( - join_url = OAUTH_URL.as_str(), + join_url = %OAUTH_URL.as_str(), visible_guilds = r.guilds.len(), + my_user_id = %ctx.cache.current_user().id, "connected to discord" ); } async fn guild_create(&self, ctx: Context, guild: Guild, _is_new: Option) { tracing::info!(disc_event = "guild_create", guild_id = %guild.id, guild_name = %guild.name); + + if let Err(e) = perm_check(&ctx, &guild).await { + tracing::error!(error = %e, "checking permissions"); + } + ready_guild(&ctx, guild.id).await; } diff --git a/src/commands/meme/history.rs b/src/commands/meme/history.rs index 270ae9a..0fe809c 100644 --- a/src/commands/meme/history.rs +++ b/src/commands/meme/history.rs @@ -1,4 +1,8 @@ -use chrono::TimeZone; +use chrono::{ + NaiveTime, + TimeZone, + Utc, +}; use diesel::{ result::Error as DieselError, NotFound, @@ -8,15 +12,21 @@ use itertools::Itertools; use lazy_static::lazy_static; use serenity::{ all::{ - GuildId, + FormattedTimestamp, + FormattedTimestampStyle, Mentionable, + Timestamp, + UserId, }, futures::{ StreamExt, TryStreamExt, }, }; -use tap::Pipe; +use tap::{ + Conv, + Pipe, +}; use timeago::{ Formatter, TimeUnit, @@ -141,8 +151,8 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result< .then(|(i, rec)| async move { let mut conn = connection().await?; - let dt = chrono::Utc.from_utc_datetime(&rec.time); - let ago = TIME_FORMATTER.convert((chrono::Utc::now() - dt).to_std().unwrap()); + let dt = chrono::Utc.from_utc_datetime(&rec.time).conv::(); + let ago = FormattedTimestamp::new(dt, Some(FormattedTimestampStyle::RelativeTime)); let rand = if rec.random { "R, " @@ -157,19 +167,9 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result< Err(e) => Err(e), }; - let invoker_name = guild_id - .member(&ctx, rec.user_id as u64) - .await - .map(|m| m.display_name().to_owned()) - .unwrap_or("???".to_owned()); - let result = match meme { Ok((metadata, meme)) => { - let author_name = guild_id - .member(&ctx, metadata.created_by as u64) - .await - .map(|m| m.display_name().to_owned()) - .unwrap_or("???".to_owned()); + let created_date = metadata.created.and_utc().conv::(); format!( "{}. [{}{}] \"{}\" by {} ({}). invoked by {}.", @@ -177,9 +177,12 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result< rand, ago, meme.title, - author_name, - metadata.created.date().format(CLEAN_DATE_FORMAT), - invoker_name + UserId::from(metadata.created_by as u64).mention(), + FormattedTimestamp::new( + created_date, + Some(FormattedTimestampStyle::ShortDate) + ), + UserId::from(rec.user_id as u64).mention() ) }, Err(e) => { @@ -189,7 +192,13 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result< } } - format!("{}. [{}{}] not found. invoked by {}.", i + 1, rand, ago, invoker_name) + format!( + "{}. [{}{}] not found. invoked by {}.", + i + 1, + rand, + ago, + UserId::from(rec.user_id as u64).mention() + ) }, }; @@ -208,10 +217,7 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result< #[poise::command(prefix_command, guild_only, category = "memes", aliases("stat"))] pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use db; - use serenity::model::{ - id::UserId, - user::User, - }; + use serenity::model::id::UserId; let guild_id = util::guild_id(ctx)?; @@ -220,31 +226,33 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { tracing::debug!("reporting stats"); - async fn username(ctx: PoiseContext<'_>, guild_id: GuildId, id: u64) -> anyhow::Result { - let user: User = UserId::new(id).to_user(&ctx).await?; - Ok(user.nick_in(&ctx, guild_id).await.unwrap_or(user.name)) - } - let most_active_day = if let Some(ref most_active_day) = stats.most_active_day { - let fmt = most_active_day.format(CLEAN_DATE_FORMAT); + let fmt = Utc + .from_utc_datetime(&most_active_day.and_time(NaiveTime::default())) + .conv::(); + let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); + let count = stats.most_active_day_count; - format!("the most active day was *{fmt}* with **{count}** memes\n") + 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 fmt = + Utc.from_utc_datetime(&loudest_day.and_time(NaiveTime::default())).conv::(); + let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); + let count = stats.most_audio_active_count; - format!("and the loudest day was *{fmt}* with **{count}** audio memes\n") + 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", + "{} has memed randomly the most ({})\n", + UserId::from(rand_user).mention(), stats.most_random_meme_user_count ) } else { @@ -252,9 +260,9 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { }; 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", + "{} has memed directly the most ({})\n", + UserId::from(direct_user).mention(), stats.most_directly_named_meme_count ) } else { @@ -262,17 +270,21 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { }; 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 fmt = (*started_recording).conv::(); + + let fmt_rel = + FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::RelativeTime)).to_string(); + + let fmt = FormattedTimestamp::new(fmt, Some(FormattedTimestampStyle::LongDate)).to_string(); - format!("started recording meme invocations on *{fst}* ({snd})\n") + format!("started recording meme invocations on {fmt} ({fmt_rel})\n") } else { String::new() }; let most_requested = if let Some(ref most_requested) = stats.most_popular_named_meme { format!( - "*{most_requested}* was the meme specifically requested the most ({})\n", + "*{most_requested}* was specifically requested the most ({})\n", stats.most_popular_named_meme_count ) } else { @@ -281,7 +293,7 @@ pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { 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", + "*{most_random}* was specifically requested the most ({})\n", stats.most_popular_random_meme_count ) } else { @@ -339,11 +351,10 @@ pub async fn memers(ctx: PoiseContext<'_>) -> anyhow::Result<()> { .pipe(serenity::futures::stream::iter) .then(|info| async move { let user = UserId::new(info.user_id).to_user(&ctx).await?; - let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); let res = format!( - "**{}**: {} total, {} random, {} specific. favorite meme: *{}* ({})", - username, + "{}: {} total, {} random, {} specific. favorite meme: *{}* ({})", + user.mention(), info.random_memes + info.specific_memes, info.random_memes, info.specific_memes, @@ -432,12 +443,11 @@ pub async fn query(ctx: PoiseContext<'_>, rest: util::RestVec) -> anyhow::Result .pipe(serenity::futures::stream::iter) .then(|(meme, metadata)| async move { let user = UserId::new(metadata.created_by as u64).to_user(&ctx).await?; - let username = user.nick_in(&ctx, guild_id).await.unwrap_or(user.name); Ok(format!( "*{}* by **{}** ({}). text length: **{}**, image: **{}**, audio: **{}**", meme.title, - username, + user.mention(), metadata.created.date().format(CLEAN_DATE_FORMAT), meme.content.map_or(0, |s| s.len()), meme.image_id.map_or("NO", |_s| "YES"), diff --git a/src/commands/meme/invoke.rs b/src/commands/meme/invoke.rs index 4b361a3..d7c6b3f 100644 --- a/src/commands/meme/invoke.rs +++ b/src/commands/meme/invoke.rs @@ -87,8 +87,8 @@ pub(crate) async fn _meme( InvocationRecord::create( &mut conn, ctx.author().id.get(), - ctx.id(), guild_id.get(), + ctx.id(), x.id, false, ) diff --git a/src/commands/meme/mod.rs b/src/commands/meme/mod.rs index 3883f3e..603ec28 100644 --- a/src/commands/meme/mod.rs +++ b/src/commands/meme/mod.rs @@ -32,7 +32,14 @@ pub use self::{ invoke::*, }; use crate::{ - commands::playback::songbird, + bot::PlaybackKey, + commands::{ + playback, + playback::{ + songbird, + InvokeInfo, + }, + }, db::{ Audio, Meme, @@ -116,6 +123,11 @@ async fn send_meme( let volume = util::volume(ctx).await; tracing::debug!(volume); + let playback = { + let data = ctx.serenity_context().data.read().await; + data.get::().unwrap().clone() + }; + { let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; @@ -124,8 +136,17 @@ async fn send_meme( call.join(voice_channel).await?; } - let handle = call.enqueue_input(Input::Lazy(Box::new(audio))).await; + let input = Input::Lazy(Box::new(audio)); + + let handle = call.enqueue_input(input).await; handle.set_volume(volume as _)?; + + playback.insert(handle.uuid(), playback::Metadata { + invoker: ctx.author().id, + invoke_info: InvokeInfo::Meme { + meme: t.clone(), + }, + }); } util::react(ctx, ReactionType::Unicode("📣".to_owned())).await?; diff --git a/src/commands/playback.rs b/src/commands/playback.rs index e7bf830..d4f3ef2 100644 --- a/src/commands/playback.rs +++ b/src/commands/playback.rs @@ -1,15 +1,21 @@ -use std::{ - fmt::Debug, - sync::Arc, -}; - use grate::tracing; -use serenity::prelude::*; +use serenity::{ + all::UserId, + prelude::*, +}; use songbird::{ - input::YoutubeDl, + input::{ + AuxMetadata, + Compose, + YoutubeDl, + }, Call, Songbird, }; +use std::{ + sync::Arc, + time::Duration, +}; use tap::Conv; use crate::{ @@ -17,6 +23,7 @@ use crate::{ HttpKey, PlaybackKey, }, + db, util, PoiseContext, PoiseData, @@ -80,6 +87,17 @@ pub async fn _play(ctx: PoiseContext<'_>, url: &url::Url) -> anyhow::Result<()> let volume = util::volume(ctx).await; tracing::debug!(volume); + let playback = { + let data = ctx.serenity_context().data.read().await; + data.get::().unwrap().clone() + }; + + let mut input = + YoutubeDl::new_ytdl_like(&crate::config::YTDL_COMMAND, client.clone(), url.to_string()); + + let meta = input.aux_metadata().await?; + let track = input.conv::(); + { let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; @@ -88,15 +106,17 @@ pub async fn _play(ctx: PoiseContext<'_>, url: &url::Url) -> anyhow::Result<()> call.join(voice_channel).await?; } - let input = - YoutubeDl::new_ytdl_like(&crate::config::YTDL_COMMAND, client.clone(), url.to_string()); - - let track = input.conv::(); - // TODO: store enqueueing channel so songbird handler can switch channels - let queued = call.enqueue(track).await; - queued.set_volume(volume as _)?; + let handle = call.enqueue(track).await; + handle.set_volume(volume as _)?; + + playback.insert(handle.uuid(), Metadata { + invoker: ctx.author().id, + invoke_info: InvokeInfo::Ytdl { + metadata: meta, + }, + }); } util::react(ctx, '📣').await?; @@ -198,7 +218,7 @@ pub async fn die(ctx: PoiseContext<'_>) -> anyhow::Result<()> { } /// List queued audio. -#[poise::command(prefix_command, guild_only, category = "playback", aliases("queue"))] +#[poise::command(prefix_command, guild_only, category = "playback", aliases("queue", "q"))] pub async fn list(ctx: PoiseContext<'_>) -> anyhow::Result<()> { let current_queue = { let (_sb, call) = songbird(ctx).await?; @@ -218,35 +238,80 @@ pub async fn list(ctx: PoiseContext<'_>) -> anyhow::Result<()> { util::reply(ctx, "nothing queued").await?; } - fn fmt_option(name: &str, opt: Option) -> String + fn fmt_option(fallback: impl AsRef, opt: Option) -> String where T: std::fmt::Display, { if let Some(opt) = opt { format!("{opt}") } else { - format!("") + format!("<{}>", fallback.as_ref()) } } for track in current_queue.into_iter() { let info = track.get_info().await?; + let playback_pos = + humantime::format_duration(Duration::from_secs(info.position.as_secs())).to_string(); let meta = playback_data.get(&track.uuid()).map(|x| x.clone()); - let meta = meta.as_ref(); - - let fmt = format!( - "{}: {:?} / {}", - fmt_option("title", meta.and_then(|x| x.title.as_ref())), - humantime::format_duration(info.position), - fmt_option( - "duration", - meta.and_then(|x| x.duration.as_ref().map(|&dur| humantime::format_duration(dur))) - ) - ); + + let Some(meta) = meta else { + tracing::warn!("no track metadata"); + + util::reply(ctx, format!("`[unk ]` playing for {playback_pos}")).await?; + continue; + }; + + let fmt = match meta.invoke_info { + InvokeInfo::Ytdl { + metadata, + } => { + format!( + "`[web ]` **{}**: {playback_pos} / {}, queued by {}", + fmt_option("unknown title", metadata.title), + fmt_option( + "unknown duration", + metadata.duration.map(|dur| humantime::format_duration(dur).to_string()) + ), + meta.invoker.mention() + ) + }, + InvokeInfo::Meme { + meme, + } => { + if info.position > Duration::default() { + format!( + "`[meme]` **{}**, {playback_pos}, queued by {}", + meme.title, + meta.invoker.mention() + ) + } else { + format!("`[meme]` **{}**, queued by {}", meme.title, meta.invoker.mention()) + } + }, + }; util::reply(ctx, fmt).await?; } Ok(()) } + +#[derive(Debug, Clone)] +pub struct Metadata { + pub invoker: UserId, + pub invoke_info: InvokeInfo, +} + +#[derive(Debug, Clone)] +pub enum InvokeInfo { + Ytdl { + metadata: AuxMetadata, + }, + + #[cfg(feature = "db")] + Meme { + meme: db::Meme, + }, +} diff --git a/src/commands/today/mod.rs b/src/commands/today/mod.rs index 2c47e83..6f4dcf6 100644 --- a/src/commands/today/mod.rs +++ b/src/commands/today/mod.rs @@ -16,7 +16,13 @@ use crate::{ HttpKey, PlaybackKey, }, - commands::playback::songbird, + commands::{ + playback, + playback::{ + songbird, + InvokeInfo, + }, + }, util, PoiseContext, }; @@ -136,7 +142,12 @@ pub async fn today(ctx: PoiseContext<'_>, #[rest] _rest: Option) -> anyh data.get::().unwrap().clone() }; - playback.insert(handle.uuid(), meta); + playback.insert(handle.uuid(), playback::Metadata { + invoker: ctx.author().id, + invoke_info: InvokeInfo::Ytdl { + metadata: meta, + }, + }); let q = call.queue(); q.pause()?; diff --git a/src/db/mod.rs b/src/db/mod.rs index 3362e33..a8d66e5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -758,7 +758,7 @@ pub async fn memers(guild: u64) -> Result> { 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) AND guild = $1 + WHERE guild = $1 AND EXISTS (SELECT id FROM memes WHERE id = invocation_records.meme_id) GROUP BY user_id, meme_id ORDER BY user_id, meme_count DESC ), diff --git a/src/util/mod.rs b/src/util/mod.rs index 474d36d..395d264 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -141,9 +141,14 @@ pub async fn reply( ctx: poise::Context<'_, U, E>, text: impl AsRef, ) -> Result { - let handle = - poise::send_reply(ctx, CreateReply::default().tts(unwrap_tts(ctx)).content(text.as_ref())) - .await?; + let handle = poise::send_reply( + ctx, + CreateReply::default() + .tts(unwrap_tts(ctx)) + .content(text.as_ref()) + .allowed_mentions(Default::default()), + ) + .await?; Ok(handle) } @@ -252,28 +257,35 @@ pub async fn send_result( let text = text.as_ref(); tracing::debug!(text, %channel, tts, "sending message"); - let result = channel.send_message(ctx, CreateMessage::default().content(text).tts(tts)).await?; + let result = channel + .send_message( + ctx, + CreateMessage::default().content(text).tts(tts).allowed_mentions(Default::default()), + ) + .await?; Ok(result.id) } lazy_static! { - static ref REQUIRED_PERMS: Permissions = Permissions::EMBED_LINKS + pub static ref REQUIRED_PERMS: Permissions = Permissions::EMBED_LINKS + | Permissions::VIEW_CHANNEL | Permissions::ADD_REACTIONS + | Permissions::READ_MESSAGE_HISTORY // need this in order to delete message reactions | Permissions::SEND_MESSAGES - | Permissions::SEND_TTS_MESSAGES - | Permissions::MENTION_EVERYONE - | Permissions::USE_EXTERNAL_EMOJIS | Permissions::CONNECT | Permissions::SPEAK + | Permissions::ATTACH_FILES + | Permissions::SEND_MESSAGES_IN_THREADS; + pub static ref DESIRED_PERMS: Permissions = *REQUIRED_PERMS + | Permissions::USE_EXTERNAL_EMOJIS | Permissions::CHANGE_NICKNAME - | Permissions::USE_VAD - | Permissions::ATTACH_FILES; + | Permissions::SEND_TTS_MESSAGES; } lazy_static! { pub static ref OAUTH_URL: Url = Url::parse(&format!( "https://discordapp.com/api/oauth2/authorize?scope=bot&permissions={}&client_id={}", - REQUIRED_PERMS.bits(), + DESIRED_PERMS.bits(), CONFIG.discord.auth.client_id, )) .unwrap(); -- cgit v1.3.1