aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bot.rs61
-rw-r--r--src/commands/meme/history.rs104
-rw-r--r--src/commands/meme/invoke.rs2
-rw-r--r--src/commands/meme/mod.rs25
-rw-r--r--src/commands/playback.rs119
-rw-r--r--src/commands/today/mod.rs15
-rw-r--r--src/db/mod.rs2
-rw-r--r--src/util/mod.rs34
8 files changed, 268 insertions, 94 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<DashMap<uuid::Uuid, songbird::input::AuxMetadata>>;
+ type Value = Arc<DashMap<uuid::Uuid, commands::playback::Metadata>>;
}
#[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<bool>) {
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<usize>) -> 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::<Timestamp>();
+ 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<usize>) -> 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::<Timestamp>();
format!(
"{}. [{}{}] \"{}\" by {} ({}). invoked by {}.",
@@ -177,9 +177,12 @@ pub async fn history(ctx: PoiseContext<'_>, n: Option<usize>) -> 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<usize>) -> 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<usize>) -> 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<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 fmt = Utc
+ .from_utc_datetime(&most_active_day.and_time(NaiveTime::default()))
+ .conv::<Timestamp>();
+ 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::<Timestamp>();
+ 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::<Timestamp>();
+
+ 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::<PlaybackKey>().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::<PlaybackKey>().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::<songbird::tracks::Track>();
+
{
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::<songbird::tracks::Track>();
-
// 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<T>(name: &str, opt: Option<T>) -> String
+ fn fmt_option<T>(fallback: impl AsRef<str>, opt: Option<T>) -> String
where
T: std::fmt::Display,
{
if let Some(opt) = opt {
format!("{opt}")
} else {
- format!("<no {name}>")
+ 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<String>) -> anyh
data.get::<PlaybackKey>().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<Vec<MemerInfo>> {
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<U, E>(
ctx: poise::Context<'_, U, E>,
text: impl AsRef<str>,
) -> Result<poise::ReplyHandle, serenity::Error> {
- 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();