aboutsummaryrefslogtreecommitdiff
path: root/src/util
diff options
context:
space:
mode:
authorNathan Perry <np@nathanperry.dev>2024-08-06 10:45:06 -0400
committerNathan Perry <np@nathanperry.dev>2024-08-06 10:45:06 -0400
commit72d9bbe15220c21909dec8e30fb80729a24cec72 (patch)
tree5025c799e3065553c1e6a91b82cb2eae8e00c43e /src/util
parent9319e0b9987114ffef2cc2be2d00f127925ba3a8 (diff)
first pass convert to poise
Diffstat (limited to 'src/util')
-rw-r--r--src/util/mod.rs232
-rw-r--r--src/util/rest_vec.rs84
2 files changed, 316 insertions, 0 deletions
diff --git a/src/util/mod.rs b/src/util/mod.rs
new file mode 100644
index 0000000..a0105ac
--- /dev/null
+++ b/src/util/mod.rs
@@ -0,0 +1,232 @@
+use std::process::Stdio;
+
+use chrono::Duration;
+use lazy_static::lazy_static;
+use log::debug;
+use poise::CreateReply;
+use regex::{
+ Match,
+ Regex,
+};
+use serenity::{
+ all::{
+ CreateMessage,
+ Message,
+ Reaction,
+ ReactionType,
+ },
+ client::Context,
+ model::{
+ id::{
+ ChannelId,
+ MessageId,
+ },
+ permissions::Permissions,
+ },
+};
+use url::Url;
+
+use crate::{
+ commands::playback::songbird,
+ PoiseContext,
+ Result,
+ CONFIG,
+};
+
+mod rest_vec;
+
+pub use rest_vec::RestVec;
+
+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: &Context) -> Result<bool> {
+ let channel = CONFIG.discord.voice_channel().to_channel(&ctx).await?;
+
+ 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);
+
+ Ok(res)
+}
+
+#[inline]
+pub fn msg(ctx: PoiseContext<'_>) -> Option<&Message> {
+ match ctx {
+ PoiseContext::Prefix(poise::PrefixContext {
+ msg,
+ ..
+ }) => Some(msg),
+ _ => None,
+ }
+}
+
+#[inline]
+pub fn tts(ctx: PoiseContext<'_>) -> Option<bool> {
+ msg(ctx).map(|msg| msg.tts)
+}
+
+#[inline]
+pub fn unwrap_tts(ctx: PoiseContext<'_>) -> bool {
+ tts(ctx).unwrap_or(false)
+}
+
+#[inline]
+pub async fn send(
+ ctx: &Context,
+ channel: ChannelId,
+ text: impl AsRef<str>,
+ tts: bool,
+) -> Result<()> {
+ send_result(ctx, channel, text, tts).await.map(|_| ())
+}
+
+#[inline]
+pub async fn reply(ctx: PoiseContext<'_>, text: impl AsRef<str>) -> Result<poise::ReplyHandle> {
+ let handle =
+ poise::send_reply(ctx, CreateReply::default().tts(unwrap_tts(ctx)).content(text.as_ref()))
+ .await?;
+
+ Ok(handle)
+}
+
+#[inline]
+pub async fn react(ctx: PoiseContext<'_>, react: ReactionType) -> Result<Reaction> {
+ let react = msg(ctx).ok_or_else(|| anyhow::anyhow!("elp"))?.react(ctx, react).await?;
+ Ok(react)
+}
+
+pub async fn send_result(
+ ctx: &Context,
+ channel: ChannelId,
+ text: impl AsRef<str>,
+ tts: bool,
+) -> Result<MessageId> {
+ let text = text.as_ref();
+ debug!("sending message {:?} to channel {:?} (tts: {})", text, channel, tts);
+
+ let result = channel.send_message(ctx, CreateMessage::default().content(text).tts(tts)).await?;
+ Ok(result.id)
+}
+
+lazy_static! {
+ static ref REQUIRED_PERMS: Permissions = Permissions::EMBED_LINKS
+ | Permissions::ADD_REACTIONS
+ | Permissions::SEND_MESSAGES
+ | Permissions::SEND_TTS_MESSAGES
+ | Permissions::MENTION_EVERYONE
+ | Permissions::USE_EXTERNAL_EMOJIS
+ | Permissions::CONNECT
+ | Permissions::SPEAK
+ | Permissions::CHANGE_NICKNAME
+ | Permissions::USE_VAD
+ | Permissions::ATTACH_FILES;
+}
+
+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(),
+ CONFIG.discord.auth.client_id,
+ ))
+ .unwrap();
+}
+
+pub async fn ytdl_url(uri: &str) -> Result<String> {
+ use serde_json::Value;
+ use tokio::process::Command;
+
+ lazy_static! {
+ static ref YTDL_COMMAND: String = {
+ let result = CONFIG.ytdl.clone().unwrap_or("youtube-dl".to_owned());
+ debug!("got ytdl: {}", result);
+
+ result
+ };
+ }
+
+ let args = [
+ "-f",
+ "webm[abr>0]/bestaudio/best",
+ "--no-playlist",
+ "--print-json",
+ "--skip-download",
+ uri,
+ ];
+
+ debug!("downloading info for uri: {uri}");
+
+ let mut command = Command::new(&*YTDL_COMMAND);
+ command.args(args).stdin(Stdio::null());
+
+ debug!("running command: {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<A: AsRef<str>>(s: A) -> (Option<Duration>, Option<Duration>) {
+ lazy_static! {
+ static ref START_REGEX: Regex =
+ Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap();
+
+ static ref DUR_REGEX: Regex =
+ Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap();
+
+ static ref END_REGEX: Regex =
+ Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap();
+ }
+
+ fn parse_match(m: Option<Match>) -> u64 {
+ m.and_then(|s| s.as_str().parse::<u64>().ok()).unwrap_or(0)
+ }
+
+ fn parse_captures<B: AsRef<str>>(r: &Regex, s: B) -> Option<Duration> {
+ 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)
+}
diff --git a/src/util/rest_vec.rs b/src/util/rest_vec.rs
new file mode 100644
index 0000000..82889cd
--- /dev/null
+++ b/src/util/rest_vec.rs
@@ -0,0 +1,84 @@
+use serenity::all::{
+ Context,
+ Message,
+};
+use std::error::Error;
+
+/// Pop a whitespace-separated word from the front of the arguments. Supports quotes and quote
+/// escaping.
+///
+/// Leading whitespace will be trimmed; trailing whitespace is not consumed.
+// From https://github.com/serenity-rs/poise/blob/current/src/prefix_argument/mod.rs
+fn pop_string(args: &str) -> Result<(&str, String), poise::TooFewArguments> {
+ // TODO: consider changing the behavior to parse quotes literally if they're in the middle
+ // of the string:
+ // - `"hello world"` => `hello world`
+ // - `"hello "world"` => `"hello "world`
+ // - `"hello" world"` => `hello`
+
+ let args = args.trim_start();
+ if args.is_empty() {
+ return Err(poise::TooFewArguments::default());
+ }
+
+ let mut output = String::new();
+ let mut inside_string = false;
+ let mut escaping = false;
+
+ let mut chars = args.chars();
+ // .clone().next() is poor man's .peek(), but we can't do peekable because then we can't
+ // call as_str on the Chars iterator
+ while let Some(c) = chars.clone().next() {
+ if escaping {
+ output.push(c);
+ escaping = false;
+ } else if !inside_string && c.is_whitespace() {
+ break;
+ } else if c == '"' {
+ inside_string = !inside_string;
+ } else if c == '\\' {
+ escaping = true;
+ } else {
+ output.push(c);
+ }
+
+ chars.next();
+ }
+
+ Ok((chars.as_str(), output))
+}
+
+pub struct RestVec(Vec<String>);
+
+impl RestVec {
+ #[inline]
+ pub fn into_inner(self) -> Vec<String> {
+ self.0
+ }
+}
+
+impl From<RestVec> for Vec<String> {
+ #[inline]
+ fn from(value: RestVec) -> Self {
+ value.0
+ }
+}
+
+#[poise::async_trait]
+impl<'a> poise::PopArgument<'a> for RestVec {
+ async fn pop_from(
+ mut args: &'a str,
+ attachment_index: usize,
+ _ctx: &Context,
+ _msg: &Message,
+ ) -> Result<(&'a str, usize, Self), (Box<dyn Error + Send + Sync>, Option<String>)> {
+ let mut v = vec![];
+
+ while let Ok((remaining, s)) = pop_string(args) {
+ args = remaining;
+ v.push(s);
+ }
+
+ Ok(("", attachment_index, Self(v)))
+ }
+}