use chrono::Duration; use grate::tracing; use lazy_static::lazy_static; use poise::FrameworkError; use regex::{ Match, Regex, }; use serenity::{ all::{ CreateMessage, GuildId, GuildRef, Message, Reaction, ReactionType, VoiceState, }, client::Context, model::{ id::{ ChannelId, MessageId, }, permissions::Permissions, }, }; use std::{ collections::HashMap, process::Stdio, }; use tap::Pipe; use url::Url; use crate::{ CONFIG, PoiseContext, commands::playback::songbird, }; mod rest_vec; #[cfg(windows)] pub mod windows; pub use rest_vec::*; pub async fn currently_playing(ctx: PoiseContext<'_>) -> bool { let (_sb, call) = songbird(ctx).await.expect("no songbird"); let call = call.lock().await; call.queue().current().is_some() } pub async fn users_listening(ctx: PoiseContext<'_>) -> anyhow::Result { let Some(channel) = best_voice_channel(ctx) else { return Ok(false); }; let Some(guild) = ctx.guild() else { return Ok(false); }; guild.voice_states.iter().any(|(_, state)| state.channel_id == Some(channel)).pipe(Ok) } #[inline] pub fn msg(ctx: poise::Context<'_, U, E>) -> Option<&Message> { match ctx { poise::Context::Prefix(poise::PrefixContext { msg, .. }) => Some(msg), _ => None, } } #[inline] pub fn err_msg<'a, U, E>(err: &'a FrameworkError) -> Option<&'a Message> { use FrameworkError::*; if let Some(ctx) = err.ctx() { return msg(ctx); } match *err { UnknownCommand { msg, .. } | DynamicPrefix { msg, .. } => Some(msg), _ => None, } } #[inline] pub fn tts(ctx: poise::Context<'_, U, E>) -> Option { msg(ctx).map(|msg| msg.tts) } #[inline] pub fn unwrap_tts(ctx: poise::Context<'_, U, E>) -> bool { tts(ctx).unwrap_or(false) } #[inline] pub async fn volume(ctx: PoiseContext<'_>) -> f64 { let Some(guild_id) = ctx.guild_id() else { return 1.; }; let data = ctx.serenity_context().data.read().await; let vol = data.get::().unwrap(); vol.get(&guild_id).map(|g| *g).unwrap_or(1.) } #[inline] pub async fn send( ctx: &Context, channel: ChannelId, text: impl AsRef, tts: bool, ) -> anyhow::Result<()> { send_result(ctx, channel, text, tts).await.map(|_| ()) } #[inline] pub async fn reply( ctx: poise::Context<'_, U, E>, text: impl AsRef, ) -> anyhow::Result<()> { let msg = msg(ctx).ok_or_else(|| anyhow::anyhow!("couldn't find referenced message"))?; let reply = CreateMessage::new() .reference_message(msg) .tts(msg.tts) .content(text.as_ref()) .allowed_mentions(Default::default()); let channel = ctx.guild_channel().await.ok_or_else(|| anyhow::anyhow!("not in a guild"))?; channel.send_message(ctx.http(), reply).await?; Ok(()) } #[inline] pub async fn react( ctx: PoiseContext<'_>, react: impl Into + Send, ) -> anyhow::Result { msg(ctx) .ok_or_else(|| anyhow::anyhow!("elp"))? .react(ctx, react) .await .map_err(anyhow::Error::from) } #[inline] pub async fn unreact( ctx: PoiseContext<'_>, react: impl Into + Send, ) -> anyhow::Result<()> { msg(ctx).ok_or_else(|| anyhow::anyhow!("elp"))?.delete_reaction(ctx, None, react).await?; Ok(()) } #[inline] pub fn guild_id(ctx: PoiseContext<'_>) -> anyhow::Result { ctx.guild_id().ok_or_else(|| anyhow::anyhow!("not in guild")) } #[inline] pub fn guild(ctx: PoiseContext<'_>) -> anyhow::Result> { 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 { let (vs, _guild) = author_voice_state(ctx)?; vs.channel_id } pub fn voice_states_by_channel(ctx: PoiseContext<'_>) -> HashMap> { 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::new).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 { if let Some(channel) = author_voice_channel(ctx) { return Some(channel); } let voice_states = voice_states_by_channel(ctx); let max_pop = voice_states.values().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::>(); if matching_channels.len() == 1 { return matching_channels.first().cloned(); } matching_channels.into_iter().min() } pub async fn send_result( ctx: &Context, channel: ChannelId, text: impl AsRef, tts: bool, ) -> anyhow::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).allowed_mentions(Default::default()), ) .await?; Ok(result.id) } lazy_static! { 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::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::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={}", DESIRED_PERMS.bits(), CONFIG.discord.auth.client_id, )) .unwrap(); } pub async fn ytdl_url(uri: &str) -> anyhow::Result { use serde_json::Value; use tokio::process::Command; let args = [ "-f", "webm[abr>0]/bestaudio/best", "--no-playlist", "--print-json", "--skip-download", uri, ]; tracing::debug!(uri, "downloading info"); let mut command = Command::new(&*crate::config::YTDL_COMMAND); command.args(args).stdin(Stdio::null()); tracing::debug!(?command, "running command"); let out = command.output().await?; if !out.status.success() { return Err(anyhow::anyhow!("running ytdl: {out:?}")); } let value = serde_json::from_reader(&out.stdout[..])?; let mut obj = match value { Value::Object(obj) => obj, other => return Err(anyhow::anyhow!("ytdl output not object: {other:?}")), }; match obj.remove("url") { Some(v) => match v { Value::String(uri) => Ok(uri), other => Err(anyhow::anyhow!("url not string: {other:?}")), }, None => Err(anyhow::anyhow!("no url")), } } pub fn parse_times>(s: A) -> (Option, Option) { lazy_static! { static ref START_REGEX: Regex = Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); static ref DUR_REGEX: Regex = Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); static ref END_REGEX: Regex = Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); } fn parse_match(m: Option) -> u64 { m.and_then(|s| s.as_str().parse::().ok()).unwrap_or(0) } fn parse_captures>(r: &Regex, s: B) -> Option { r.captures(s.as_ref()).map(|capt| { let hours = parse_match(capt.name("hours")); let minutes = parse_match(capt.name("minutes")); let seconds = parse_match(capt.name("seconds")); let result = Duration::hours(hours as i64) + Duration::minutes(minutes as i64) + Duration::seconds(seconds as i64); assert!(result >= Duration::zero()); result }) } let start_time = parse_captures(&START_REGEX, &s); let dur = parse_captures(&DUR_REGEX, &s); let end_time = parse_captures(&END_REGEX, s) .or_else(|| start_time.and_then(|start| dur.map(|d| start + d))); (start_time, end_time) }