From dd0b1d652dfb0f2e3ccb28218d00f3f469806f86 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 6 Oct 2024 16:53:12 +0200 Subject: [PATCH 01/19] feat: implemented new slash commands --- src/commands/mod.rs | 9 ++++- src/commands/moover/mod.rs | 7 ++++ src/commands/{ => moover}/moove.rs | 0 src/commands/{ => moover}/say.rs | 0 src/commands/notice/birthday.rs | 0 src/commands/notice/events.rs | 0 src/commands/notice/mod.rs | 2 + src/commands/user_interactions/headpat.rs | 20 ++++++++++ src/commands/user_interactions/hug.rs | 20 ++++++++++ src/commands/user_interactions/interaction.rs | 38 +++++++++++++++++++ src/commands/user_interactions/mod.rs | 6 +++ src/message_handler.rs | 2 +- src/other/notice.rs | 4 +- src/types.rs | 2 +- src/util/gifs.rs | 9 +++++ src/util/mod.rs | 3 +- src/util/utilities.rs | 2 +- 17 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 src/commands/moover/mod.rs rename src/commands/{ => moover}/moove.rs (100%) rename src/commands/{ => moover}/say.rs (100%) create mode 100644 src/commands/notice/birthday.rs create mode 100644 src/commands/notice/events.rs create mode 100644 src/commands/notice/mod.rs create mode 100644 src/commands/user_interactions/headpat.rs create mode 100644 src/commands/user_interactions/hug.rs create mode 100644 src/commands/user_interactions/interaction.rs create mode 100644 src/commands/user_interactions/mod.rs create mode 100644 src/util/gifs.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 98ea7cd..440afec 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,7 @@ -pub mod moove; -pub mod say; \ No newline at end of file +pub use moover::*; +pub use notice::*; +pub use user_interactions::*; + +pub mod moover; +pub mod notice; +pub mod user_interactions; diff --git a/src/commands/moover/mod.rs b/src/commands/moover/mod.rs new file mode 100644 index 0000000..4b6fd56 --- /dev/null +++ b/src/commands/moover/mod.rs @@ -0,0 +1,7 @@ +pub use moove::*; +pub use say::*; +pub use gif::*; + +pub mod moove; +pub mod say; +pub mod gif; \ No newline at end of file diff --git a/src/commands/moove.rs b/src/commands/moover/moove.rs similarity index 100% rename from src/commands/moove.rs rename to src/commands/moover/moove.rs diff --git a/src/commands/say.rs b/src/commands/moover/say.rs similarity index 100% rename from src/commands/say.rs rename to src/commands/moover/say.rs diff --git a/src/commands/notice/birthday.rs b/src/commands/notice/birthday.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/notice/events.rs b/src/commands/notice/events.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/notice/mod.rs b/src/commands/notice/mod.rs new file mode 100644 index 0000000..ef275cb --- /dev/null +++ b/src/commands/notice/mod.rs @@ -0,0 +1,2 @@ +pub mod birthday; +pub mod events; \ No newline at end of file diff --git a/src/commands/user_interactions/headpat.rs b/src/commands/user_interactions/headpat.rs new file mode 100644 index 0000000..8d33ad8 --- /dev/null +++ b/src/commands/user_interactions/headpat.rs @@ -0,0 +1,20 @@ +use poise; +use serenity::all::User; + +use super::interaction::send_with_embed; +use crate::types::{Error, Context}; + +#[poise::command( + slash_command, + description_localized("en-US", "Headpat all your friends!") +)] +pub async fn headpat(ctx: Context<'_>, + #[description = "Who is the lucky one?"] + user: User +) -> Result<(), Error> { + let title = "HEADPATS!"; + let desc = format!("{} headpats {}", ctx.author(), user); + send_with_embed(ctx, "headpat", &title, &desc).await?; + ctx.reply("Done!").await?; + Ok(()) +} \ No newline at end of file diff --git a/src/commands/user_interactions/hug.rs b/src/commands/user_interactions/hug.rs new file mode 100644 index 0000000..31dbe8c --- /dev/null +++ b/src/commands/user_interactions/hug.rs @@ -0,0 +1,20 @@ +use poise; +use serenity::all::User; + +use super::interaction::send_with_embed; +use crate::types::{Error, Context}; + +#[poise::command( + slash_command, + description_localized("en-US", "Hug all your friends!") +)] +pub async fn hug(ctx: Context<'_>, + #[description = "Who is the lucky one?"] + user: User +) -> Result<(), Error> { + let title = "HUGS!"; + let desc = format!("{} hugs {}", ctx.author(), user); + send_with_embed(ctx, "hug", &title, &desc).await?; + ctx.reply("Done!").await?; + Ok(()) +} \ No newline at end of file diff --git a/src/commands/user_interactions/interaction.rs b/src/commands/user_interactions/interaction.rs new file mode 100644 index 0000000..439ef72 --- /dev/null +++ b/src/commands/user_interactions/interaction.rs @@ -0,0 +1,38 @@ +use anyhow::anyhow; +use serenity::all::{Colour, CreateEmbed, CreateMessage}; +use tenorv2::tenor_builder::Tenor; + +use crate::{types::Context, util::{gifs::get_random_tenor_gif, utilities}}; + +pub(super) async fn send_with_embed(ctx: Context<'_>, query: &str, title: &str, desc: &str) -> anyhow::Result<()> { + let tenor_response = Tenor::new()? + .random(true) + .search(query).await?; + + const LIMIT: u8 = 20; + let url = get_random_tenor_gif(tenor_response, LIMIT).await?; + + let embed = CreateEmbed::new() + .color(Colour::new(rand::random::() % 0xFFFFFF)) + .title(title) + .description(desc) + .image(url.as_str()); + + if ctx.guild_id().is_none() { + return Err(anyhow!("Guild id not available in context")); + } + + utilities::get_system_channel( + ctx.guild_id().unwrap(), ctx.http() + ).await? + .send_message( + ctx.http(), + CreateMessage::new().add_embed(embed) + ).await?; + + Ok(()) +} + +pub(super) async fn send(ctx: Context<'_>, msg: &str) -> anyhow::Result<()> { + Ok(()) +} \ No newline at end of file diff --git a/src/commands/user_interactions/mod.rs b/src/commands/user_interactions/mod.rs new file mode 100644 index 0000000..0e8654b --- /dev/null +++ b/src/commands/user_interactions/mod.rs @@ -0,0 +1,6 @@ +pub use headpat::*; +pub use hug::*; + +pub mod interaction; +pub mod headpat; +pub mod hug; \ No newline at end of file diff --git a/src/message_handler.rs b/src/message_handler.rs index 1183998..767448a 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use crate::util::debug::send_error; use crate::util::utilities; -use crate::commands::moove::{moove, moove_check}; +use crate::commands::moover::moove::{moove, moove_check}; pub async fn handle(ctx: Context, msg: Message) { if msg.author.bot { diff --git a/src/other/notice.rs b/src/other/notice.rs index e7fde2e..2294637 100644 --- a/src/other/notice.rs +++ b/src/other/notice.rs @@ -30,7 +30,7 @@ async fn announce_event(guild_id: GuildId, name: &str, special_message: &str, ht .color(Colour::new(rand::random::() % 0xFFFFFF)) .title("Today's event is:"); - let system_channel = utilities::get_system_channel(guild_id, http.clone()).await?; + let system_channel = utilities::get_system_channel(guild_id, &http).await?; if special_message.contains("http") { event_embed = event_embed.description(name); @@ -65,7 +65,7 @@ async fn celebrate_birthday(guild_id: GuildId, user_id: UserId, nick: &str, http } }; - let system_channel = utilities::get_system_channel(guild_id, http.clone()).await?; + let system_channel = utilities::get_system_channel(guild_id, &http).await?; let mut embed = CreateEmbed::new() .color(Colour::new(rand::random::() % 0xFFFFFF)) diff --git a/src/types.rs b/src/types.rs index 2571aab..b5ebced 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,4 +2,4 @@ pub type Error = Box; // replace () with Data if you ever need to store some additional data -// pub type Context<'a> = poise::Context<'a, (), Error>; +pub type Context<'a> = poise::Context<'a, (), Error>; diff --git a/src/util/gifs.rs b/src/util/gifs.rs new file mode 100644 index 0000000..f128ea9 --- /dev/null +++ b/src/util/gifs.rs @@ -0,0 +1,9 @@ +use tenorv2::{tenor, tenor_types::{MediaFilter, TenorError}, JsonValue}; + +pub async fn get_random_tenor_gif(tenor_response: JsonValue, limit: u8) -> Result { + let index = rand::random::() % limit as usize; + match tenor::get_gif_url(MediaFilter::gif, tenor_response) { + Ok(urls) => Ok(urls[index].clone()), + Err(e) => Err(e) + } +} \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index d3d2689..a259bb4 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod debug; -pub mod utilities; \ No newline at end of file +pub mod utilities; +pub mod gifs; \ No newline at end of file diff --git a/src/util/utilities.rs b/src/util/utilities.rs index 7669eb7..8b5f470 100644 --- a/src/util/utilities.rs +++ b/src/util/utilities.rs @@ -4,7 +4,7 @@ use serenity::{all::{ChannelId, CreateMessage, GuildId, Message}, http::Http}; use anyhow::Context; -pub async fn get_system_channel(guild_id: GuildId, http: Arc) -> anyhow::Result { +pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { return http.get_guild(guild_id).await?.system_channel_id .context(format!("System channel of guild: {} not found", guild_id.get())); } From aadad9d0b87c537841510514537600537ce5623d Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 6 Oct 2024 16:54:08 +0200 Subject: [PATCH 02/19] feat: add commands to main, guild/global command registration --- Cargo.toml | 1 + src/main.rs | 32 +++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7b8e356..359cd1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ tenorv2 = { path = "./tenor-v2/tenorv2" } [features] DEBUG = [] RELEASE = [] +GUILD_COMMAND = [] \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index de3c551..59da021 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,14 @@ use std::time::Duration; use std::error; use std::env; -use poise::samples::register_in_guild; +use poise::samples::register_globally; +use poise::samples::register_in_guild; use serenity::async_trait; use serenity::prelude::GatewayIntents; use serenity::client::Context; use serenity::model::gateway::Ready; -use serenity::all::{EventHandler, GuildId, Message}; +use serenity::all::{EventHandler, Message}; use serenity::Client; use dotenv::dotenv; @@ -82,7 +83,10 @@ async fn main() -> anyhow::Result<()> { // create poise framework for registering commands let options: poise::FrameworkOptions<(), Box> = poise::FrameworkOptions { - commands: vec![], + commands: vec![ + commands::say(), + commands::hug() + ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( @@ -99,15 +103,21 @@ async fn main() -> anyhow::Result<()> { ..Default::default() }; - let debug_guild_id = env::var("DEBUG_GUILD_ID") - .context("DEBUG_GUILD_ID not found in env")? - .parse::().unwrap(); - + let framework = poise::Framework::builder() - .setup(move |ctx, _ready, framework| { - Box::pin(async move { - register_in_guild(ctx, &framework.options().commands, GuildId::new(debug_guild_id)).await?; - Ok(()) + .setup(move |ctx, _ready, framework| { + Box::pin(async move { + #[cfg(feature="GUILD_COMMAND")] { + let debug_guild_id = env::var("DEBUG_GUILD_ID") + .context("DEBUG_GUILD_ID not found in env")? + .parse::().unwrap(); + + register_in_guild(ctx, &framework.options().commands, debug_guild_id).await?; + } + #[cfg(not(feature="GUILD_COMMAND"))] { + register_globally(ctx, &framework.options().commands).await?; + } + Ok(()) }) }) .options(options) From 0b95d6f7e59a61215ae6537badbd7cfc3052bf11 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Mon, 9 Dec 2024 15:39:54 +0100 Subject: [PATCH 03/19] chore: documentation, dependency updates, etc. --- .env.example | 10 ++++++ Cargo.toml | 6 ++-- README.md | 35 +++++++++++++++++++ makefile | 2 +- src/commands/moover/moove.rs | 10 +++--- src/commands/notice/mod.rs | 3 ++ src/commands/other/mod.rs | 0 src/commands/user_interactions/interaction.rs | 2 ++ src/other/notice.rs | 4 +++ 9 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 README.md create mode 100644 src/commands/other/mod.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1363c8f --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +TOKEN=token used for release version +DEBUGTOKEN=token I use for debugging on standalone bot +DBPASS=password for database (currently not used) +DEBUG=ON + +DEBUG_CHANNEL_ID=channel where debug info will be sent +DEBUG_GUILD_ID=guild where local slash commands will be registered + +DATABASE_URL=sqlite://path_to_database +TENORV2_TOKEN=token to tenor API v2 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 359cd1d..1a16780 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,10 @@ anyhow = "1.0.89" tokio-cron-scheduler = "0.13.0" dotenv = "0.15.0" poise = "0.6.1" -serenity = { version = "0.12.2", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "http", "cache"] } +serenity = { version = "0.12.4", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "http", "cache"] } # serenity_utils = "0.7.0" -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } -regex = "1.11.0" +tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread"] } +regex = "1.11.1" chrono = "0.4.38" sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ff7f51 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Discord bot made in rust + +## Current feature list: + - move message from one channel to another + - send hug and headpat embed and tag user in it + - announces events and birthdays that are in database + +## Technologies used + - Discord API - serenity, poise + - Database - sqlite + - gifs - my partial implementation of tenor API + +## Compilation +Make sure you have cargo installed! +Edit .env.example and rename to .env + +Compile debug version +``` +make dev +``` + +Compile release version +``` +make release +``` + +Compile debug version and run it +``` +make run +``` + +Run release version +``` +make run_rel +``` diff --git a/makefile b/makefile index e16831f..7815468 100644 --- a/makefile +++ b/makefile @@ -8,5 +8,5 @@ run: cargo build --features DEBUG ./target/debug/moover_rust -run_release: +run_rel: ./target/release/moover_rust \ No newline at end of file diff --git a/src/commands/moover/moove.rs b/src/commands/moover/moove.rs index 85539d8..046e433 100644 --- a/src/commands/moover/moove.rs +++ b/src/commands/moover/moove.rs @@ -9,10 +9,9 @@ use tokio::time::sleep; use regex::Regex; use serenity::model::id::ChannelId; -// Checks if the message should be mooved -// If the message should be mooved, try to move it and return Ok if mooved succesfully -// else returns Err() +/// Checks if the message should be mooved +/// If the message should be mooved, try to move it and return Ok if mooved succesfully pub async fn moove_check(msg: &Message) -> Option { let word_count = msg.content.trim().split_whitespace().count(); let re = Regex::new(r"<#[0-9]*>$").unwrap(); @@ -29,8 +28,11 @@ pub async fn moove_check(msg: &Message) -> Option { return Some(channel_id); } +/// Move message to new channel (will delete the old message) pub async fn moove(http: Arc, msg: Message, m_channel_id: u64) -> anyhow::Result<()> { - // this should be in moove_check, but I need to find a good way to return in with channel_id + msg.react(http.clone(), '🐮').await?; + + // this should be in moove_check, but I need to find a good way to return it with channel_id let msg_to_moove = msg.clone().referenced_message.context("Referenced message not found")?; //steals all attachments, but sets all of them as Image urls, so rip actual docs etc diff --git a/src/commands/notice/mod.rs b/src/commands/notice/mod.rs index ef275cb..d63767d 100644 --- a/src/commands/notice/mod.rs +++ b/src/commands/notice/mod.rs @@ -1,2 +1,5 @@ +pub use birthday::*; +pub use events::*; + pub mod birthday; pub mod events; \ No newline at end of file diff --git a/src/commands/other/mod.rs b/src/commands/other/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/user_interactions/interaction.rs b/src/commands/user_interactions/interaction.rs index 439ef72..6db121a 100644 --- a/src/commands/user_interactions/interaction.rs +++ b/src/commands/user_interactions/interaction.rs @@ -4,6 +4,8 @@ use tenorv2::tenor_builder::Tenor; use crate::{types::Context, util::{gifs::get_random_tenor_gif, utilities}}; +/// Sends embed with random tenor gif from searched query +/// title and desc are used in the embed pub(super) async fn send_with_embed(ctx: Context<'_>, query: &str, title: &str, desc: &str) -> anyhow::Result<()> { let tenor_response = Tenor::new()? .random(true) diff --git a/src/other/notice.rs b/src/other/notice.rs index 2294637..69b39ed 100644 --- a/src/other/notice.rs +++ b/src/other/notice.rs @@ -24,6 +24,7 @@ pub async fn notice_wrapper(ctx: Context) { } } +/// Send embed with event name and optional special message to guild's general channel async fn announce_event(guild_id: GuildId, name: &str, special_message: &str, http: Arc) -> anyhow::Result<()> { let mut event_embed = CreateEmbed::new() @@ -46,6 +47,7 @@ async fn announce_event(guild_id: GuildId, name: &str, special_message: &str, ht Ok(()) } +/// Send birthday embed to guild's general channel async fn celebrate_birthday(guild_id: GuildId, user_id: UserId, nick: &str, http: Arc) -> anyhow::Result<()> { const LIMIT: u8 = 20; @@ -98,6 +100,8 @@ struct EventRow { special_message: String, } +/// Fetches guild/global events and birthdays for current day +/// Sends notification to relevant channels async fn notice(http: Arc) -> anyhow::Result<()> { use anyhow::Context; From 54f332e00528518f610fc5e85b5ad062143f6991 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 8 Dec 2024 15:40:33 +0100 Subject: [PATCH 04/19] chore: preparation for player --- Cargo.toml | 2 ++ makefile | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1a16780..6f16d12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ chrono = "0.4.38" sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } +librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } +songbird = "0.4.6" [features] DEBUG = [] diff --git a/makefile b/makefile index 7815468..6b47a53 100644 --- a/makefile +++ b/makefile @@ -1,11 +1,11 @@ dev: - cargo build --features DEBUG + cargo build --features DEBUG,GUILD_COMMAND release: cargo build --release --features RELEASE run: - cargo build --features DEBUG + cargo build --features DEBUG,GUILD_COMMAND ./target/debug/moover_rust run_rel: From c3b3b84c46e1a36cc74e1c2a9bc4442605b88227 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Mon, 9 Dec 2024 15:40:46 +0100 Subject: [PATCH 05/19] feat: connect command implementation --- src/commands/mod.rs | 3 + src/commands/voice/mod.rs | 1 + src/commands/voice/spotify/mod.rs | 1 + src/commands/voice/spotify/player.rs | 159 +++++++++++++++++++++++++++ src/main.rs | 25 +++-- src/util/utilities.rs | 27 ++++- 6 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 src/commands/voice/mod.rs create mode 100644 src/commands/voice/spotify/mod.rs create mode 100644 src/commands/voice/spotify/player.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 440afec..d680d50 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,7 +1,10 @@ pub use moover::*; pub use notice::*; pub use user_interactions::*; +pub use voice::*; pub mod moover; pub mod notice; pub mod user_interactions; +pub mod other; +pub mod voice; diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs new file mode 100644 index 0000000..9c66493 --- /dev/null +++ b/src/commands/voice/mod.rs @@ -0,0 +1 @@ +pub mod spotify; \ No newline at end of file diff --git a/src/commands/voice/spotify/mod.rs b/src/commands/voice/spotify/mod.rs new file mode 100644 index 0000000..d44230b --- /dev/null +++ b/src/commands/voice/spotify/mod.rs @@ -0,0 +1 @@ +pub mod player; \ No newline at end of file diff --git a/src/commands/voice/spotify/player.rs b/src/commands/voice/spotify/player.rs new file mode 100644 index 0000000..37e9a16 --- /dev/null +++ b/src/commands/voice/spotify/player.rs @@ -0,0 +1,159 @@ +use poise; +use serenity::{all::ChannelId, async_trait}; +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::TrackEvent; + +use crate::util::debug::send_error; +use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; + +use serenity::all::User; + +#[poise::command( + slash_command, + description_localized("en-US", "Play song") +)] +pub async fn play(ctx: Context<'_>, + #[description = "Song: "] + _url: String +) -> Result<(), Error> { + + ctx.reply("Done!").await?; + Ok(()) +} + + + +#[poise::command( + slash_command, + description_localized("en-US", "Headpat all your friends!") +)] +pub async fn headpat(ctx: Context<'_>, + #[description = "Who is the lucky one?"] + user: User +) -> Result<(), Error> { + let _title = "HEADPATS!"; + let _desc = format!("{} headpats {}", ctx.author(), user); + // send_with_embed(ctx, "headpat", &title, &desc).await?; + ctx.reply("Done!").await?; + Ok(()) +} + + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn connect(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + name: Option +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply("Can't use this outside of guild").await?; + return Ok(()) + } + + let channel: ChannelId; + if name.is_none() || name.clone().unwrap() == "" { + // Ugly one liner since I don't know how to do this another way yet + // TODO fix please + let Some(voice_channel) = ctx.guild().unwrap().voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) else { + ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; + return Ok(()) + }; + + channel = voice_channel; + } + else { + channel = match get_channel_by_name(ctx.guild().unwrap(), name.unwrap()) { + Some(channel) => channel, + None => { + ctx.reply("Channel with this name does not exist").await?; + return Ok(()); + } + }; + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Ok(handler_lock) = manager.join(ctx.guild_id().unwrap(), channel).await { + let mut handler = handler_lock.lock().await; + handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + } + + ctx.reply("Done!").await?; + Ok(()) +} + + +struct TrackErrorNotifier; + +#[async_trait] +impl VoiceEventHandler for TrackErrorNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(track_list) = ctx { + for (state, handle) in *track_list { + println!( + "Track {:?} encountered an error: {:?}", + handle.uuid(), + state.playing + ); + } + } + + None + } +} + +async fn autocomplete_channel( + ctx: Context<'_>, + _partial: &str, +) -> Vec { + + use crate::util::utilities::get_vc_names; + match ctx.guild() { + Some(guild) => get_vc_names(guild), + None => [].to_vec() + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Disconnect from voice channel") +)] +async fn disconnect( + ctx: Context<'_> +) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply("Can't use this outside of guild").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let has_handler = manager.get(guild_id).is_some(); + + if ! has_handler { + ctx.reply("I am not connected to a channel!").await?; + return Ok(()) + } + + match manager.remove(guild_id).await { + Ok(()) => { + ctx.reply("Disconnected").await?; + return Ok(()) + } + Err(e) => { + let _ = send_error(ctx.serenity_context().http.clone(), e.to_string()).await; + return Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index 59da021..abfaf56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,6 @@ use std::time::Duration; use std::error; use std::env; -use poise::samples::register_globally; - -use poise::samples::register_in_guild; use serenity::async_trait; use serenity::prelude::GatewayIntents; use serenity::client::Context; @@ -17,6 +14,7 @@ use serenity::Client; use dotenv::dotenv; +use songbird::SerenityInit; use tokio_cron_scheduler::{JobScheduler, Job}; mod message_handler; @@ -85,7 +83,9 @@ async fn main() -> anyhow::Result<()> { let options: poise::FrameworkOptions<(), Box> = poise::FrameworkOptions { commands: vec![ commands::say(), - commands::hug() + commands::hug(), + // commands::spotify::player::play(), + commands::spotify::player::connect(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -107,14 +107,20 @@ async fn main() -> anyhow::Result<()> { let framework = poise::Framework::builder() .setup(move |ctx, _ready, framework| { Box::pin(async move { + + #[cfg(feature="GUILD_COMMAND")] { + use poise::samples::register_in_guild; + use serenity::all::GuildId; + let debug_guild_id = env::var("DEBUG_GUILD_ID") .context("DEBUG_GUILD_ID not found in env")? .parse::().unwrap(); - - register_in_guild(ctx, &framework.options().commands, debug_guild_id).await?; + + register_in_guild(ctx, &framework.options().commands, GuildId::new(debug_guild_id)).await?; } #[cfg(not(feature="GUILD_COMMAND"))] { + use poise::samples::register_globally; register_globally(ctx, &framework.options().commands).await?; } Ok(()) @@ -130,11 +136,16 @@ async fn main() -> anyhow::Result<()> { let token = env::var(token_str).context("TOKEN not found in env")?; - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; + let intents = GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::GUILD_VOICE_STATES + | GatewayIntents::GUILD_MESSAGE_REACTIONS + | GatewayIntents::GUILD_MESSAGE_TYPING; let mut client = Client::builder(&token, intents) .event_handler(Handler) .framework(framework) + .register_songbird() .await .context("Failed to build client")?; diff --git a/src/util/utilities.rs b/src/util/utilities.rs index 8b5f470..3bac776 100644 --- a/src/util/utilities.rs +++ b/src/util/utilities.rs @@ -1,7 +1,8 @@ use std::sync::Arc; -use serenity::{all::{ChannelId, CreateMessage, GuildId, Message}, http::Http}; +use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildChannel, GuildId, GuildRef, Message}, http::Http}; +use crate::types; use anyhow::Context; pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { @@ -13,4 +14,28 @@ pub async fn replace_msg(http: Arc, msg: Message, content: String) -> Resu msg.delete(http.clone()).await?; return ChannelId::new(msg.channel_id.get()).send_message(http.clone(), CreateMessage::new().content(content)).await; +} + +pub fn get_vc_names(guild: GuildRef) -> Vec { + + let mut result: Vec = [].to_vec(); + for (_, channel) in &guild.channels { + if channel.kind == ChannelType::Voice { + result.push(channel.name.clone()); + } + } + + result +} + +pub fn get_channel_by_name(guild: GuildRef, name: String) -> Option { + let mut result = None; + for (_, channel) in &guild.channels { + if channel.name == name { + result = Some(channel.id); + break; + } + } + + result } \ No newline at end of file From d5e61413397efc42d4dfd6d2adc63674dc51b7ed Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 8 Dec 2024 18:08:53 +0100 Subject: [PATCH 06/19] future send error showcase --- src/commands/voice/spotify/player.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/commands/voice/spotify/player.rs b/src/commands/voice/spotify/player.rs index 37e9a16..9b3b9d5 100644 --- a/src/commands/voice/spotify/player.rs +++ b/src/commands/voice/spotify/player.rs @@ -56,6 +56,33 @@ pub async fn connect(ctx: Context<'_>, let channel: ChannelId; if name.is_none() || name.clone().unwrap() == "" { + // ERROR: <> + // !### This will work if you comment out one of the replies ###! + // let Some(guild) = ctx.guild() else { + // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; + // return Ok(()) + // }; + + // let Some(voice_channel) = guild.voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) else { + // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; + // return Ok(()) + // }; + + // channel = voice_channel; + // !### ###! + + // This one liner works only if you comment out the reply + // channel = match ctx.guild().unwrap().voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) { + // Some(channel) => channel, + // None => { + // // ERROR: <> + // // Will work if you comment this out + // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; + // return Ok(()) + // } + // } + + // This one works // Ugly one liner since I don't know how to do this another way yet // TODO fix please let Some(voice_channel) = ctx.guild().unwrap().voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) else { From 439f5680572231197e5d2989e27565172991e986 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Tue, 17 Dec 2024 15:28:52 +0100 Subject: [PATCH 07/19] feat: implemented local song player --- Cargo.toml | 2 +- makefile | 5 +- src/commands/voice/mod.rs | 2 +- src/commands/voice/player.rs | 255 +++++++++++++++++++++ src/commands/voice/soundcloud/souncloud.rs | 0 src/commands/voice/spotify/spotify.rs | 0 src/main.rs | 13 +- src/util/mod.rs | 3 +- src/util/poise_context_extension.rs | 22 ++ src/util/utilities.rs | 33 ++- 10 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 src/commands/voice/player.rs create mode 100644 src/commands/voice/soundcloud/souncloud.rs create mode 100644 src/commands/voice/spotify/spotify.rs create mode 100644 src/util/poise_context_extension.rs diff --git a/Cargo.toml b/Cargo.toml index 6f16d12..b53e91f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } -songbird = "0.4.6" +songbird = { version = "0.4.6", features = ["driver"] } [features] DEBUG = [] diff --git a/makefile b/makefile index 6b47a53..ad262f5 100644 --- a/makefile +++ b/makefile @@ -4,9 +4,8 @@ dev: release: cargo build --release --features RELEASE -run: - cargo build --features DEBUG,GUILD_COMMAND +run: dev ./target/debug/moover_rust -run_rel: +run_rel: release ./target/release/moover_rust \ No newline at end of file diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index 9c66493..d44230b 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1 +1 @@ -pub mod spotify; \ No newline at end of file +pub mod player; \ No newline at end of file diff --git a/src/commands/voice/player.rs b/src/commands/voice/player.rs new file mode 100644 index 0000000..ddffb7d --- /dev/null +++ b/src/commands/voice/player.rs @@ -0,0 +1,255 @@ +use std::vec; + +use serenity::{all::ChannelId, async_trait}; +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::input::{File, Input}; +use songbird::TrackEvent; + +use crate::util::debug::send_error; +use crate::util::poise_context_extension::ContextExt; +use crate::util::utilities::get_local_songs; +use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; + +#[poise::command( + slash_command, + description_localized("en-US", "Play song") +)] +pub async fn play(ctx: Context<'_>, + #[description = "Song: "] + _url: String +) -> Result<(), Error> { + + ctx.reply("Done!").await?; + Ok(()) +} + +/// Returns either voice channel to which the user is currently connected to or the one passed via name +async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { + if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { + match ctx.guild().and_then(|guild| + guild.voice_states.get(&ctx.author().id).and_then(|voice_state| + voice_state.channel_id + ) + ) { + Some(c) => Ok(c), + None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) + } + } + else { + match ctx.guild().and_then(|guild| + get_channel_by_name(guild, name.unwrap()) + ) { + Some(c) => Ok(c), + None => Err("Channel with this name does not exist".to_string()) + } + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn connect(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + name: Option +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let voice_channel = match get_voice_channel(&ctx, name).await { + Ok(c) => c, + Err(e) => { + ctx.reply_ephemeral(e.as_str()).await?; + return Ok(()) + } + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { + let mut handler = handler_lock.lock().await; + handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + +struct TrackErrorNotifier; + +#[async_trait] +impl VoiceEventHandler for TrackErrorNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(track_list) = ctx { + for (state, handle) in *track_list { + println!( + "Track {:?} encountered an error: {:?}", + handle.uuid(), + state.playing + ); + } + } + + None + } +} + +async fn autocomplete_channel( + ctx: Context<'_>, + _partial: &str, +) -> Vec { + + use crate::util::utilities::get_vc_names; + match ctx.guild() { + Some(guild) => get_vc_names(guild), + None => [].to_vec() + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Disconnect from voice channel") +)] +pub async fn disconnect( + ctx: Context<'_> +) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply("Can't use this outside of guild").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let has_handler = manager.get(guild_id).is_some(); + + if ! has_handler { + ctx.reply("I am not connected to a channel!").await?; + return Ok(()) + } + + match manager.remove(guild_id).await { + Ok(()) => { + ctx.reply("Disconnected").await?; + return Ok(()) + } + Err(e) => { + let _ = send_error(ctx.serenity_context().http.clone(), e.to_string()).await; + return Ok(()) + } + } +} + +async fn autocomplete_song( + _ctx: Context<'_>, + partial: &str, +) -> Vec { + + match get_local_songs(partial) { + Ok(names) => names, + Err(_) => vec![] + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn play_local(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + _channel: Option, + #[autocomplete = "autocomplete_song"] + #[description = "Filename of local song: "] + file_name: String +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let input_file = File::new(format!("/home/emil/Music/{file_name}")); + let input = Input::Lazy(Box::new(input_file)); + let _ = handler.play_input(input); + } + else { + + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + +// #[poise::command( +// slash_command, +// description_localized("en-US", "Connect to channel") +// )] +// pub async fn play_local(ctx: Context<'_>, +// #[autocomplete = "autocomplete_song"] +// #[description = "Voice channel name: "] +// file_name: String +// ) -> Result<(), Error> { + +// if ctx.guild().is_none() { +// ctx.reply_ephemeral("Can't use this outside of guild").await?; +// return Ok(()); +// } + +// let manager = songbird::get(ctx.serenity_context()) +// .await +// .expect("Songbird Voice client placed in at initialisation.") +// .clone(); + +// let Some(guild_id) = ctx.guild_id() else { +// ctx.reply_ephemeral("Guild id not found").await?; +// return Ok(()) +// }; + +// if let Some(handler_lock) = manager.get(guild_id) { +// let mut handler = handler_lock.lock().await; + +// let input_file = File::new(format!("/home/emil/Music/{file_name}")); +// let input = Input::Lazy(Box::new(input_file)); +// let _ = handler.play_input(input); +// } +// else { +// ctx.reply_ephemeral("Not in a voice channel").await?; +// return Ok(()) +// } + +// ctx.reply_ephemeral("Done!").await?; +// Ok(()) +// } diff --git a/src/commands/voice/soundcloud/souncloud.rs b/src/commands/voice/soundcloud/souncloud.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/voice/spotify/spotify.rs b/src/commands/voice/spotify/spotify.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/main.rs b/src/main.rs index abfaf56..e4d139f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,7 @@ impl EventHandler for Handler { #[cfg(feature="RELEASE")] { use util::debug::hello; - hello(ctx.http.clone()).await; + let _ = hello(ctx.http.clone()).await; } let sched = JobScheduler::new().await.unwrap(); @@ -84,8 +84,9 @@ async fn main() -> anyhow::Result<()> { commands: vec![ commands::say(), commands::hug(), - // commands::spotify::player::play(), - commands::spotify::player::connect(), + commands::player::play_local(), + commands::player::connect(), + commands::player::disconnect(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -107,7 +108,6 @@ async fn main() -> anyhow::Result<()> { let framework = poise::Framework::builder() .setup(move |ctx, _ready, framework| { Box::pin(async move { - #[cfg(feature="GUILD_COMMAND")] { use poise::samples::register_in_guild; @@ -119,10 +119,12 @@ async fn main() -> anyhow::Result<()> { register_in_guild(ctx, &framework.options().commands, GuildId::new(debug_guild_id)).await?; } + #[cfg(not(feature="GUILD_COMMAND"))] { use poise::samples::register_globally; register_globally(ctx, &framework.options().commands).await?; } + Ok(()) }) }) @@ -136,7 +138,8 @@ async fn main() -> anyhow::Result<()> { let token = env::var(token_str).context("TOKEN not found in env")?; - let intents = GatewayIntents::GUILD_MESSAGES + let intents = GatewayIntents::GUILDS + | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_VOICE_STATES | GatewayIntents::GUILD_MESSAGE_REACTIONS diff --git a/src/util/mod.rs b/src/util/mod.rs index a259bb4..f268cfa 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ pub mod debug; pub mod utilities; -pub mod gifs; \ No newline at end of file +pub mod gifs; +pub mod poise_context_extension; \ No newline at end of file diff --git a/src/util/poise_context_extension.rs b/src/util/poise_context_extension.rs new file mode 100644 index 0000000..39a9475 --- /dev/null +++ b/src/util/poise_context_extension.rs @@ -0,0 +1,22 @@ +use poise::CreateReply; +use serenity::async_trait; + +use crate::types::Context; + + +/// Trait to extend `Context` with additional methods. +#[async_trait] +pub trait ContextExt { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()>; +} + +#[async_trait] +impl ContextExt for Context<'_> { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()> { + self.send(CreateReply::default() + .ephemeral(true) + .content(content) + ).await?; + Ok(()) + } +} diff --git a/src/util/utilities.rs b/src/util/utilities.rs index 3bac776..c36912b 100644 --- a/src/util/utilities.rs +++ b/src/util/utilities.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; +use std::{fs, io, path::Path, sync::Arc, vec}; -use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildChannel, GuildId, GuildRef, Message}, http::Http}; - -use crate::types; -use anyhow::Context; +use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildId, GuildRef, Message}, http::Http}; pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { + use anyhow::Context; return http.get_guild(guild_id).await?.system_channel_id .context(format!("System channel of guild: {} not found", guild_id.get())); } @@ -31,11 +29,32 @@ pub fn get_vc_names(guild: GuildRef) -> Vec { pub fn get_channel_by_name(guild: GuildRef, name: String) -> Option { let mut result = None; for (_, channel) in &guild.channels { - if channel.name == name { + if channel.name.contains(&name) { result = Some(channel.id); break; } } result -} \ No newline at end of file +} + +pub fn get_local_songs(partial: &str) -> io::Result> { + let mut result: Vec = vec![]; + let path = Path::new("/home/emil/Music"); + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + continue; + } + + let name = entry.file_name(); + if name.to_str().is_some_and(|name| !name.to_lowercase().contains(&partial.to_lowercase())) { + continue; + } + + result.push(name.to_str().unwrap().into()); + } + + Ok(result) +} From 392f416da9f0e26d79e538940d7e435ae1e5166d Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Tue, 17 Dec 2024 15:54:51 +0100 Subject: [PATCH 08/19] feat: removed the need to manualy connect if not in vc --- src/commands/voice/spotify/player.rs | 167 +++++++++++++++------------ src/main.rs | 1 - 2 files changed, 96 insertions(+), 72 deletions(-) diff --git a/src/commands/voice/spotify/player.rs b/src/commands/voice/spotify/player.rs index 9b3b9d5..5c00980 100644 --- a/src/commands/voice/spotify/player.rs +++ b/src/commands/voice/spotify/player.rs @@ -1,13 +1,18 @@ -use poise; +use std::vec; + +use poise::{self, CreateReply}; +use serenity::all::{CacheHttp, Guild, PartialGuild, UserId}; use serenity::{all::ChannelId, async_trait}; use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::input::{File, Input}; +use songbird::tracks::Track; use songbird::TrackEvent; use crate::util::debug::send_error; +use crate::util::poise_context_extension::ContextExt; +use crate::util::utilities::get_local_songs; use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; -use serenity::all::User; - #[poise::command( slash_command, description_localized("en-US", "Play song") @@ -21,24 +26,28 @@ pub async fn play(ctx: Context<'_>, Ok(()) } - - -#[poise::command( - slash_command, - description_localized("en-US", "Headpat all your friends!") -)] -pub async fn headpat(ctx: Context<'_>, - #[description = "Who is the lucky one?"] - user: User -) -> Result<(), Error> { - let _title = "HEADPATS!"; - let _desc = format!("{} headpats {}", ctx.author(), user); - // send_with_embed(ctx, "headpat", &title, &desc).await?; - ctx.reply("Done!").await?; - Ok(()) +/// Returns either voice channel to which the user is currently connected to or the one passed via name +async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { + if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { + match ctx.guild().and_then(|guild| + guild.voice_states.get(&ctx.author().id).and_then(|voice_state| + voice_state.channel_id + ) + ) { + Some(c) => Ok(c), + None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) + } + } + else { + match ctx.guild().and_then(|guild| + get_channel_by_name(guild, name.unwrap()) + ) { + Some(c) => Ok(c), + None => Err("Channel with this name does not exist".to_string()) + } + } } - #[poise::command( slash_command, description_localized("en-US", "Connect to channel") @@ -50,73 +59,37 @@ pub async fn connect(ctx: Context<'_>, ) -> Result<(), Error> { if ctx.guild().is_none() { - ctx.reply("Can't use this outside of guild").await?; - return Ok(()) + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); } - let channel: ChannelId; - if name.is_none() || name.clone().unwrap() == "" { - // ERROR: <> - // !### This will work if you comment out one of the replies ###! - // let Some(guild) = ctx.guild() else { - // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; - // return Ok(()) - // }; - - // let Some(voice_channel) = guild.voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) else { - // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; - // return Ok(()) - // }; - - // channel = voice_channel; - // !### ###! - - // This one liner works only if you comment out the reply - // channel = match ctx.guild().unwrap().voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) { - // Some(channel) => channel, - // None => { - // // ERROR: <> - // // Will work if you comment this out - // ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; - // return Ok(()) - // } - // } - - // This one works - // Ugly one liner since I don't know how to do this another way yet - // TODO fix please - let Some(voice_channel) = ctx.guild().unwrap().voice_states.get(&ctx.author().id).and_then(|voice_state| voice_state.channel_id) else { - ctx.reply("You must be in a voice channel or specify explicit voice channel by name").await?; + let voice_channel = match get_voice_channel(&ctx, name).await { + Ok(c) => c, + Err(e) => { + ctx.reply_ephemeral(e.as_str()).await?; return Ok(()) - }; - - channel = voice_channel; - } - else { - channel = match get_channel_by_name(ctx.guild().unwrap(), name.unwrap()) { - Some(channel) => channel, - None => { - ctx.reply("Channel with this name does not exist").await?; - return Ok(()); - } - }; - } + } + }; let manager = songbird::get(ctx.serenity_context()) .await .expect("Songbird Voice client placed in at initialisation.") .clone(); - if let Ok(handler_lock) = manager.join(ctx.guild_id().unwrap(), channel).await { + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { let mut handler = handler_lock.lock().await; handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); } - ctx.reply("Done!").await?; + ctx.reply_ephemeral("Done!").await?; Ok(()) } - struct TrackErrorNotifier; #[async_trait] @@ -152,7 +125,7 @@ async fn autocomplete_channel( slash_command, description_localized("en-US", "Disconnect from voice channel") )] -async fn disconnect( +pub async fn disconnect( ctx: Context<'_> ) -> Result<(), Error> { @@ -184,3 +157,55 @@ async fn disconnect( } } } + +async fn autocomplete_song( + _ctx: Context<'_>, + partial: &str, +) -> Vec { + + match get_local_songs(partial) { + Ok(names) => names, + Err(_) => vec![] + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn play_local(ctx: Context<'_>, + #[autocomplete = "autocomplete_song"] + #[description = "Voice channel name: "] + file_name: String +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let input_file = File::new(format!("/home/emil/Music/{file_name}")); + let input = Input::Lazy(Box::new(input_file)); + let _ = handler.play_input(input); + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index e4d139f..3eedf7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,7 +85,6 @@ async fn main() -> anyhow::Result<()> { commands::say(), commands::hug(), commands::player::play_local(), - commands::player::connect(), commands::player::disconnect(), ], prefix_options: poise::PrefixFrameworkOptions { From b34c353ba9467c8c39bd3448609c10e746059103 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Tue, 31 Dec 2024 12:20:44 +0100 Subject: [PATCH 09/19] chore: updated cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b53e91f..63a2b9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ chrono = "0.4.38" sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } -librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } +# librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } songbird = { version = "0.4.6", features = ["driver"] } [features] From e20ab64408ff5effcb938209b10038817c040fd1 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Mon, 6 Jan 2025 12:51:57 +0100 Subject: [PATCH 10/19] feat: playing from URL works now --- .TODO | 2 + Cargo.toml | 7 +- src/commands/voice/mod.rs | 6 +- src/commands/voice/player.rs | 180 +++---------------- src/commands/voice/radio/mod.rs | 3 + src/commands/voice/radio/radio_player.rs | 66 +++++++ src/commands/voice/spotify/player.rs | 211 ----------------------- src/commands/voice/util.rs | 76 ++++++++ src/main.rs | 1 + 9 files changed, 184 insertions(+), 368 deletions(-) create mode 100644 .TODO create mode 100644 src/commands/voice/radio/mod.rs create mode 100644 src/commands/voice/radio/radio_player.rs delete mode 100644 src/commands/voice/spotify/player.rs create mode 100644 src/commands/voice/util.rs diff --git a/.TODO b/.TODO new file mode 100644 index 0000000..f6e1060 --- /dev/null +++ b/.TODO @@ -0,0 +1,2 @@ +handle disconnect by someone, other than command +disconnect after some time when not playing \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 63a2b9c..999bd3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,12 @@ sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } # librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } -songbird = { version = "0.4.6", features = ["driver"] } +songbird = { version = "0.4.6", features = ["driver", "builtin-queue"] } +reqwest = "0.11.27" # songbird depends on ^0.11 + +[dependencies.symphonia] +version = "0.5.2" +features = ["aac", "mp3", "isomp4", "alac"] [features] DEBUG = [] diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index d44230b..0df6866 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1 +1,5 @@ -pub mod player; \ No newline at end of file +pub mod util; +pub mod player; +pub mod radio; +// pub mod spotify; +// pub mod yt; \ No newline at end of file diff --git a/src/commands/voice/player.rs b/src/commands/voice/player.rs index ddffb7d..5444b86 100644 --- a/src/commands/voice/player.rs +++ b/src/commands/voice/player.rs @@ -1,122 +1,26 @@ use std::vec; -use serenity::{all::ChannelId, async_trait}; -use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; use songbird::input::{File, Input}; -use songbird::TrackEvent; use crate::util::debug::send_error; use crate::util::poise_context_extension::ContextExt; use crate::util::utilities::get_local_songs; -use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; +use crate::types::{Context, Error}; -#[poise::command( - slash_command, - description_localized("en-US", "Play song") -)] -pub async fn play(ctx: Context<'_>, - #[description = "Song: "] - _url: String -) -> Result<(), Error> { +use super::util::{connect, autocomplete_channel}; - ctx.reply("Done!").await?; - Ok(()) -} +// #[poise::command( +// slash_command, +// description_localized("en-US", "Play song") +// )] +// pub async fn play(ctx: Context<'_>, +// #[description = "Song: "] +// _url: String +// ) -> Result<(), Error> { -/// Returns either voice channel to which the user is currently connected to or the one passed via name -async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { - if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { - match ctx.guild().and_then(|guild| - guild.voice_states.get(&ctx.author().id).and_then(|voice_state| - voice_state.channel_id - ) - ) { - Some(c) => Ok(c), - None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) - } - } - else { - match ctx.guild().and_then(|guild| - get_channel_by_name(guild, name.unwrap()) - ) { - Some(c) => Ok(c), - None => Err("Channel with this name does not exist".to_string()) - } - } -} - -#[poise::command( - slash_command, - description_localized("en-US", "Connect to channel") -)] -pub async fn connect(ctx: Context<'_>, - #[autocomplete = "autocomplete_channel"] - #[description = "Voice channel name: "] - name: Option -) -> Result<(), Error> { - - if ctx.guild().is_none() { - ctx.reply_ephemeral("Can't use this outside of guild").await?; - return Ok(()); - } - - let voice_channel = match get_voice_channel(&ctx, name).await { - Ok(c) => c, - Err(e) => { - ctx.reply_ephemeral(e.as_str()).await?; - return Ok(()) - } - }; - - let manager = songbird::get(ctx.serenity_context()) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let Some(guild_id) = ctx.guild_id() else { - ctx.reply_ephemeral("Guild id not found").await?; - return Ok(()) - }; - - if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { - let mut handler = handler_lock.lock().await; - handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); - } - - ctx.reply_ephemeral("Done!").await?; - Ok(()) -} - -struct TrackErrorNotifier; - -#[async_trait] -impl VoiceEventHandler for TrackErrorNotifier { - async fn act(&self, ctx: &EventContext<'_>) -> Option { - if let EventContext::Track(track_list) = ctx { - for (state, handle) in *track_list { - println!( - "Track {:?} encountered an error: {:?}", - handle.uuid(), - state.playing - ); - } - } - - None - } -} - -async fn autocomplete_channel( - ctx: Context<'_>, - _partial: &str, -) -> Vec { - - use crate::util::utilities::get_vc_names; - match ctx.guild() { - Some(guild) => get_vc_names(guild), - None => [].to_vec() - } -} +// ctx.reply("Done!").await?; +// Ok(()) +// } #[poise::command( slash_command, @@ -168,12 +72,12 @@ async fn autocomplete_song( #[poise::command( slash_command, - description_localized("en-US", "Connect to channel") + description_localized("en-US", "Play song from server storage") )] pub async fn play_local(ctx: Context<'_>, #[autocomplete = "autocomplete_channel"] #[description = "Voice channel name: "] - _channel: Option, + channel: Option, #[autocomplete = "autocomplete_song"] #[description = "Filename of local song: "] file_name: String @@ -194,17 +98,24 @@ pub async fn play_local(ctx: Context<'_>, return Ok(()) }; - + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } if let Some(handler_lock) = manager.get(guild_id) { let mut handler = handler_lock.lock().await; let input_file = File::new(format!("/home/emil/Music/{file_name}")); let input = Input::Lazy(Box::new(input_file)); - let _ = handler.play_input(input); + let _ = handler.play_only_input(input); } else { - ctx.reply_ephemeral("Not in a voice channel").await?; return Ok(()) } @@ -212,44 +123,3 @@ pub async fn play_local(ctx: Context<'_>, ctx.reply_ephemeral("Done!").await?; Ok(()) } - -// #[poise::command( -// slash_command, -// description_localized("en-US", "Connect to channel") -// )] -// pub async fn play_local(ctx: Context<'_>, -// #[autocomplete = "autocomplete_song"] -// #[description = "Voice channel name: "] -// file_name: String -// ) -> Result<(), Error> { - -// if ctx.guild().is_none() { -// ctx.reply_ephemeral("Can't use this outside of guild").await?; -// return Ok(()); -// } - -// let manager = songbird::get(ctx.serenity_context()) -// .await -// .expect("Songbird Voice client placed in at initialisation.") -// .clone(); - -// let Some(guild_id) = ctx.guild_id() else { -// ctx.reply_ephemeral("Guild id not found").await?; -// return Ok(()) -// }; - -// if let Some(handler_lock) = manager.get(guild_id) { -// let mut handler = handler_lock.lock().await; - -// let input_file = File::new(format!("/home/emil/Music/{file_name}")); -// let input = Input::Lazy(Box::new(input_file)); -// let _ = handler.play_input(input); -// } -// else { -// ctx.reply_ephemeral("Not in a voice channel").await?; -// return Ok(()) -// } - -// ctx.reply_ephemeral("Done!").await?; -// Ok(()) -// } diff --git a/src/commands/voice/radio/mod.rs b/src/commands/voice/radio/mod.rs new file mode 100644 index 0000000..27ead6c --- /dev/null +++ b/src/commands/voice/radio/mod.rs @@ -0,0 +1,3 @@ +pub use radio_player::*; + +pub mod radio_player; diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs new file mode 100644 index 0000000..b9ed39d --- /dev/null +++ b/src/commands/voice/radio/radio_player.rs @@ -0,0 +1,66 @@ +use std::vec; + +use reqwest::Client; + +use songbird::input::Input; +use songbird::input::HttpRequest; + +use crate::commands::util::connect; +use crate::util::poise_context_extension::ContextExt; +use crate::types::{Context, Error}; +use crate::commands::voice::util::autocomplete_channel; + +// TODO: autocomplete radio stream URLs +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from URL source") +)] +pub async fn radio(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let client = Client::new(); + let request = HttpRequest::new(client, url); + let input = Input::from(request); + handler.play_only_input(input); + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} diff --git a/src/commands/voice/spotify/player.rs b/src/commands/voice/spotify/player.rs deleted file mode 100644 index 5c00980..0000000 --- a/src/commands/voice/spotify/player.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::vec; - -use poise::{self, CreateReply}; -use serenity::all::{CacheHttp, Guild, PartialGuild, UserId}; -use serenity::{all::ChannelId, async_trait}; -use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; -use songbird::input::{File, Input}; -use songbird::tracks::Track; -use songbird::TrackEvent; - -use crate::util::debug::send_error; -use crate::util::poise_context_extension::ContextExt; -use crate::util::utilities::get_local_songs; -use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; - -#[poise::command( - slash_command, - description_localized("en-US", "Play song") -)] -pub async fn play(ctx: Context<'_>, - #[description = "Song: "] - _url: String -) -> Result<(), Error> { - - ctx.reply("Done!").await?; - Ok(()) -} - -/// Returns either voice channel to which the user is currently connected to or the one passed via name -async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { - if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { - match ctx.guild().and_then(|guild| - guild.voice_states.get(&ctx.author().id).and_then(|voice_state| - voice_state.channel_id - ) - ) { - Some(c) => Ok(c), - None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) - } - } - else { - match ctx.guild().and_then(|guild| - get_channel_by_name(guild, name.unwrap()) - ) { - Some(c) => Ok(c), - None => Err("Channel with this name does not exist".to_string()) - } - } -} - -#[poise::command( - slash_command, - description_localized("en-US", "Connect to channel") -)] -pub async fn connect(ctx: Context<'_>, - #[autocomplete = "autocomplete_channel"] - #[description = "Voice channel name: "] - name: Option -) -> Result<(), Error> { - - if ctx.guild().is_none() { - ctx.reply_ephemeral("Can't use this outside of guild").await?; - return Ok(()); - } - - let voice_channel = match get_voice_channel(&ctx, name).await { - Ok(c) => c, - Err(e) => { - ctx.reply_ephemeral(e.as_str()).await?; - return Ok(()) - } - }; - - let manager = songbird::get(ctx.serenity_context()) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let Some(guild_id) = ctx.guild_id() else { - ctx.reply_ephemeral("Guild id not found").await?; - return Ok(()) - }; - - if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { - let mut handler = handler_lock.lock().await; - handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); - } - - ctx.reply_ephemeral("Done!").await?; - Ok(()) -} - -struct TrackErrorNotifier; - -#[async_trait] -impl VoiceEventHandler for TrackErrorNotifier { - async fn act(&self, ctx: &EventContext<'_>) -> Option { - if let EventContext::Track(track_list) = ctx { - for (state, handle) in *track_list { - println!( - "Track {:?} encountered an error: {:?}", - handle.uuid(), - state.playing - ); - } - } - - None - } -} - -async fn autocomplete_channel( - ctx: Context<'_>, - _partial: &str, -) -> Vec { - - use crate::util::utilities::get_vc_names; - match ctx.guild() { - Some(guild) => get_vc_names(guild), - None => [].to_vec() - } -} - -#[poise::command( - slash_command, - description_localized("en-US", "Disconnect from voice channel") -)] -pub async fn disconnect( - ctx: Context<'_> -) -> Result<(), Error> { - - let Some(guild_id) = ctx.guild_id() else { - ctx.reply("Can't use this outside of guild").await?; - return Ok(()); - }; - - let manager = songbird::get(ctx.serenity_context()) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let has_handler = manager.get(guild_id).is_some(); - - if ! has_handler { - ctx.reply("I am not connected to a channel!").await?; - return Ok(()) - } - - match manager.remove(guild_id).await { - Ok(()) => { - ctx.reply("Disconnected").await?; - return Ok(()) - } - Err(e) => { - let _ = send_error(ctx.serenity_context().http.clone(), e.to_string()).await; - return Ok(()) - } - } -} - -async fn autocomplete_song( - _ctx: Context<'_>, - partial: &str, -) -> Vec { - - match get_local_songs(partial) { - Ok(names) => names, - Err(_) => vec![] - } -} - -#[poise::command( - slash_command, - description_localized("en-US", "Connect to channel") -)] -pub async fn play_local(ctx: Context<'_>, - #[autocomplete = "autocomplete_song"] - #[description = "Voice channel name: "] - file_name: String -) -> Result<(), Error> { - - if ctx.guild().is_none() { - ctx.reply_ephemeral("Can't use this outside of guild").await?; - return Ok(()); - } - - let manager = songbird::get(ctx.serenity_context()) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let Some(guild_id) = ctx.guild_id() else { - ctx.reply_ephemeral("Guild id not found").await?; - return Ok(()) - }; - - if let Some(handler_lock) = manager.get(guild_id) { - let mut handler = handler_lock.lock().await; - - let input_file = File::new(format!("/home/emil/Music/{file_name}")); - let input = Input::Lazy(Box::new(input_file)); - let _ = handler.play_input(input); - } - else { - ctx.reply_ephemeral("Not in a voice channel").await?; - return Ok(()) - } - - ctx.reply_ephemeral("Done!").await?; - Ok(()) -} \ No newline at end of file diff --git a/src/commands/voice/util.rs b/src/commands/voice/util.rs new file mode 100644 index 0000000..4208b51 --- /dev/null +++ b/src/commands/voice/util.rs @@ -0,0 +1,76 @@ +use serenity::all::{ChannelId, GuildId}; +use serenity::async_trait; + +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::TrackEvent; + +use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; + +/// Returns either voice channel to which the user is currently connected to or the one passed via name +async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { + if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { + match ctx.guild().and_then(|guild| + guild.voice_states.get(&ctx.author().id).and_then(|voice_state| + voice_state.channel_id + ) + ) { + Some(c) => Ok(c), + None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) + } + } + else { + match ctx.guild().and_then(|guild| + get_channel_by_name(guild, name.unwrap()) + ) { + Some(c) => Ok(c), + None => Err("Channel with this name does not exist".to_string()) + } + } +} + +struct TrackErrorNotifier; + +#[async_trait] +impl VoiceEventHandler for TrackErrorNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(track_list) = ctx { + for (state, handle) in *track_list { + println!( + "Track {:?} encountered an error: {:?}", + handle.uuid(), + state.playing + ); + } + } + + None + } +} + +pub async fn connect(ctx: &Context<'_>, guild_id: GuildId, channel: Option) -> Result<(), Error> { + let voice_channel = get_voice_channel(&ctx, channel).await?; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { + let mut handler = handler_lock.lock().await; + handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + } + + Ok(()) +} + +pub async fn autocomplete_channel( + ctx: Context<'_>, + _partial: &str, +) -> Vec { + + use crate::util::utilities::get_vc_names; + match ctx.guild() { + Some(guild) => get_vc_names(guild), + None => [].to_vec() + } +} diff --git a/src/main.rs b/src/main.rs index 3eedf7d..6db17f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ async fn main() -> anyhow::Result<()> { commands::hug(), commands::player::play_local(), commands::player::disconnect(), + commands::radio::radio() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), From 452ac919a00ce20708d4da5f12fc0a4bc6f75fe9 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Mon, 6 Jan 2025 14:14:52 +0100 Subject: [PATCH 11/19] chore: changed version --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 6db17f5..c85ed95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,7 @@ impl EventHandler for Handler { } async fn ready(&self, ctx: Context, ready: Ready) { - println!("{} v0.3 is connected!", ready.user.name); + println!("{} v3.2.0 is connected!", ready.user.name); #[cfg(feature="RELEASE")] { use util::debug::hello; From d18e79696a0dc80de2aa56e005c991eaacdb63f4 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Thu, 9 Jan 2025 18:57:42 +0100 Subject: [PATCH 12/19] feat: implemented general player using yt-dlp --- Cargo.toml | 2 +- src/commands/moover/say.rs | 4 +- src/commands/voice/general/general_player.rs | 63 +++++++++++++++++++ src/commands/voice/general/mod.rs | 3 + src/commands/voice/mod.rs | 4 +- .../voice/{player.rs => player_common.rs} | 19 ++---- src/commands/voice/spotify/mod.rs | 4 +- .../spotify_player.rs} | 0 src/main.rs | 21 ++++--- src/types.rs | 25 +++++++- 10 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 src/commands/voice/general/general_player.rs create mode 100644 src/commands/voice/general/mod.rs rename src/commands/voice/{player.rs => player_common.rs} (88%) rename src/commands/voice/{soundcloud/souncloud.rs => spotify/spotify_player.rs} (100%) diff --git a/Cargo.toml b/Cargo.toml index 999bd3f..b6ca761 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moover_rust" -version = "0.3.0" +version = "3.3.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/commands/moover/say.rs b/src/commands/moover/say.rs index c097539..e4df757 100644 --- a/src/commands/moover/say.rs +++ b/src/commands/moover/say.rs @@ -1,8 +1,6 @@ use poise; -// use super::super::types::Data; -type Error = Box; -type Context<'a> = poise::Context<'a, (), Error>; +use crate::types::{Context, Error}; #[poise::command( slash_command, diff --git a/src/commands/voice/general/general_player.rs b/src/commands/voice/general/general_player.rs new file mode 100644 index 0000000..c3cd0d6 --- /dev/null +++ b/src/commands/voice/general/general_player.rs @@ -0,0 +1,63 @@ +use std::vec; + +use songbird::input::YoutubeDl; + +use crate::commands::util::connect; +use crate::util::poise_context_extension::ContextExt; +use crate::types::{Context, Error}; +use crate::commands::voice::util::autocomplete_channel; + +// TODO: search, queue +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from supported URL") +)] +pub async fn play(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let http_client = &ctx.data().http_client; + + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let src = YoutubeDl::new(http_client.clone(), url); + handler.enqueue_input(src.into()).await; + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} diff --git a/src/commands/voice/general/mod.rs b/src/commands/voice/general/mod.rs new file mode 100644 index 0000000..8b6dfd4 --- /dev/null +++ b/src/commands/voice/general/mod.rs @@ -0,0 +1,3 @@ +pub use general_player::*; + +pub mod general_player; \ No newline at end of file diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index 0df6866..284b48d 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1,5 +1,5 @@ pub mod util; -pub mod player; +pub mod player_common; pub mod radio; // pub mod spotify; -// pub mod yt; \ No newline at end of file +pub mod general; \ No newline at end of file diff --git a/src/commands/voice/player.rs b/src/commands/voice/player_common.rs similarity index 88% rename from src/commands/voice/player.rs rename to src/commands/voice/player_common.rs index 5444b86..38ac50a 100644 --- a/src/commands/voice/player.rs +++ b/src/commands/voice/player_common.rs @@ -9,18 +9,9 @@ use crate::types::{Context, Error}; use super::util::{connect, autocomplete_channel}; -// #[poise::command( -// slash_command, -// description_localized("en-US", "Play song") -// )] -// pub async fn play(ctx: Context<'_>, -// #[description = "Song: "] -// _url: String -// ) -> Result<(), Error> { - -// ctx.reply("Done!").await?; -// Ok(()) -// } +/** + * Common commands that are the same for every implementation + */ #[poise::command( slash_command, @@ -31,7 +22,7 @@ pub async fn disconnect( ) -> Result<(), Error> { let Some(guild_id) = ctx.guild_id() else { - ctx.reply("Can't use this outside of guild").await?; + ctx.reply_ephemeral("Can't use this outside of guild").await?; return Ok(()); }; @@ -49,7 +40,7 @@ pub async fn disconnect( match manager.remove(guild_id).await { Ok(()) => { - ctx.reply("Disconnected").await?; + ctx.reply_ephemeral("Disconnected").await?; return Ok(()) } Err(e) => { diff --git a/src/commands/voice/spotify/mod.rs b/src/commands/voice/spotify/mod.rs index d44230b..f73c81c 100644 --- a/src/commands/voice/spotify/mod.rs +++ b/src/commands/voice/spotify/mod.rs @@ -1 +1,3 @@ -pub mod player; \ No newline at end of file +pub use spotify_player::*; + +pub mod spotify_player; diff --git a/src/commands/voice/soundcloud/souncloud.rs b/src/commands/voice/spotify/spotify_player.rs similarity index 100% rename from src/commands/voice/soundcloud/souncloud.rs rename to src/commands/voice/spotify/spotify_player.rs diff --git a/src/main.rs b/src/main.rs index c85ed95..44bd0d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use serenity::model::gateway::Ready; use serenity::all::{EventHandler, Message}; use serenity::Client; +use reqwest::Client as HttpClient; + use dotenv::dotenv; use songbird::SerenityInit; @@ -27,11 +29,11 @@ mod other; use other::notice; mod types; -use types::Error; +use types::{Data, Error}; struct Handler; -async fn on_error(error: poise::FrameworkError<'_, (), Error>) { +async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { match error { poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {:?}", error), poise::FrameworkError::Command { error, ctx, .. } => { @@ -52,7 +54,7 @@ impl EventHandler for Handler { } async fn ready(&self, ctx: Context, ready: Ready) { - println!("{} v3.2.0 is connected!", ready.user.name); + println!("{} v3.3.0 is connected!", ready.user.name); #[cfg(feature="RELEASE")] { use util::debug::hello; @@ -80,13 +82,14 @@ async fn main() -> anyhow::Result<()> { dotenv().ok(); // create poise framework for registering commands - let options: poise::FrameworkOptions<(), Box> = poise::FrameworkOptions { + let options: poise::FrameworkOptions> = poise::FrameworkOptions { commands: vec![ commands::say(), commands::hug(), - commands::player::play_local(), - commands::player::disconnect(), - commands::radio::radio() + // commands::player::play_local(), + commands::player_common::disconnect(), + commands::radio::radio(), + commands::general::play() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -125,7 +128,9 @@ async fn main() -> anyhow::Result<()> { register_globally(ctx, &framework.options().commands).await?; } - Ok(()) + Ok(Data { + http_client: HttpClient::new() + }) }) }) .options(options) diff --git a/src/types.rs b/src/types.rs index b5ebced..1103f1c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,28 @@ // pub struct Data {} +use std::{ffi::OsString, path::PathBuf}; + +use serenity::prelude::TypeMapKey; + +use reqwest::Client as HttpClient; + +// TODO: this should be probably expanded also to be used by multiple servers and commands +// radio and spotify commands always create new HttpClient +pub struct HttpKey; + +impl TypeMapKey for HttpKey { + type Value = HttpClient; +} + +pub struct Data { + pub http_client: HttpClient, +} + pub type Error = Box; // replace () with Data if you ever need to store some additional data -pub type Context<'a> = poise::Context<'a, (), Error>; +pub type Context<'a> = poise::Context<'a, Data, Error>; + +pub struct Track { + pub path: PathBuf, + pub name: OsString, +} From 013367cf6b6f0979d289a09d1438e15f858573b5 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Mon, 13 Jan 2025 18:14:41 +0100 Subject: [PATCH 13/19] feat: implemented youtube player --- src/commands/voice/mod.rs | 3 +- src/commands/voice/yt/mod.rs | 3 + src/commands/voice/yt/yt_player.rs | 88 ++++++++++++++++++++++++++++++ src/main.rs | 5 +- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/commands/voice/yt/mod.rs create mode 100644 src/commands/voice/yt/yt_player.rs diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index 284b48d..f3d7e73 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -2,4 +2,5 @@ pub mod util; pub mod player_common; pub mod radio; // pub mod spotify; -pub mod general; \ No newline at end of file +pub mod general; +pub mod yt; \ No newline at end of file diff --git a/src/commands/voice/yt/mod.rs b/src/commands/voice/yt/mod.rs new file mode 100644 index 0000000..989288e --- /dev/null +++ b/src/commands/voice/yt/mod.rs @@ -0,0 +1,3 @@ +pub use yt_player::*; + +pub mod yt_player; \ No newline at end of file diff --git a/src/commands/voice/yt/yt_player.rs b/src/commands/voice/yt/yt_player.rs new file mode 100644 index 0000000..ff5098b --- /dev/null +++ b/src/commands/voice/yt/yt_player.rs @@ -0,0 +1,88 @@ +use std::vec; +use std::env; + +use songbird::input::YoutubeDl; + +use crate::commands::util::connect; +use crate::util::poise_context_extension::ContextExt; +use crate::types::{Context, Error}; +use crate::commands::voice::util::autocomplete_channel; + +// TODO: search, queue +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from YouTube URL") +)] +pub async fn play_yt(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let http_client = ctx.data().http_client.clone(); + + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let cookies_path = match env::var("COOKIES") { + Ok(path) => path, + Err(e) => { + let _ = ctx.reply_ephemeral("There was an error (did not find cookies)").await; + dbg!(e); + return Ok(()); + } + }; + + let po_token = match env::var("PO_TOKEN") { + Ok(token) => token, + Err(e) => { + let _ = ctx.reply_ephemeral("There was an error (did not find token)").await; + dbg!(e); + return Ok(()); + } + }; + + let arguments: Vec = vec![ + "--extractor-args".into(), + format!("youtube:player-client=web,default;po_token=web+{po_token}"), + "--cookies".into(), + cookies_path + ]; + let src = YoutubeDl::new(http_client, url).user_args(arguments); + handler.enqueue_input(src.into()).await; + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 44bd0d3..66380e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,7 +54,7 @@ impl EventHandler for Handler { } async fn ready(&self, ctx: Context, ready: Ready) { - println!("{} v3.3.0 is connected!", ready.user.name); + println!("{} v3.3.1 is connected!", ready.user.name); #[cfg(feature="RELEASE")] { use util::debug::hello; @@ -89,7 +89,8 @@ async fn main() -> anyhow::Result<()> { // commands::player::play_local(), commands::player_common::disconnect(), commands::radio::radio(), - commands::general::play() + commands::general::play(), + commands::yt::play_yt() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), From 62b92beb413c783f5034ecfd5e969f0c1e524535 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 19 Jan 2025 15:18:40 +0100 Subject: [PATCH 14/19] chore: change package version --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b6ca761..c9e3f30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moover_rust" -version = "3.3.0" +version = "3.3.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -30,4 +30,4 @@ features = ["aac", "mp3", "isomp4", "alac"] [features] DEBUG = [] RELEASE = [] -GUILD_COMMAND = [] \ No newline at end of file +GUILD_COMMAND = [] From d19be1d7f3387636d59d0dd49c63d88a9886cbfe Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Fri, 24 Jan 2025 21:52:13 +0100 Subject: [PATCH 15/19] chore: restructurize the whole source --- .gitignore | 4 +- Cargo.toml | 9 +- src/{commands/mod.rs => commands.rs} | 6 +- src/commands/{moover/mod.rs => moover.rs} | 4 +- src/commands/moover/gif.rs | 20 +++ src/commands/{notice/mod.rs => notice.rs} | 2 +- src/commands/other/mod.rs | 0 .../mod.rs => user_interactions.rs} | 2 +- src/commands/user_interactions/interaction.rs | 2 +- src/commands/voice.rs | 11 ++ src/commands/voice/general/mod.rs | 3 - .../voice/{general => }/general_player.rs | 16 ++- src/commands/voice/mod.rs | 6 - src/commands/voice/player_common.rs | 8 +- src/commands/voice/{radio/mod.rs => radio.rs} | 3 + src/commands/voice/radio/radio_player.rs | 115 ++++++++++++++++-- src/commands/voice/radio/radio_utils.rs | 83 +++++++++++++ .../voice/{spotify/mod.rs => spotify.rs} | 0 .../voice/{util.rs => voice_utils.rs} | 9 +- src/commands/voice/{yt/mod.rs => yt.rs} | 2 +- src/main.rs | 13 +- src/message_handler.rs | 4 +- src/other/notice.rs | 4 +- src/types.rs | 9 +- src/{util/mod.rs => utils.rs} | 0 src/{util => utils}/debug.rs | 0 src/{util => utils}/embeds.rs | 0 src/{util => utils}/gifs.rs | 0 .../poise_context_extension.rs | 0 src/{util => utils}/utilities.rs | 0 30 files changed, 267 insertions(+), 68 deletions(-) rename src/{commands/mod.rs => commands.rs} (64%) rename src/commands/{moover/mod.rs => moover.rs} (64%) create mode 100644 src/commands/moover/gif.rs rename src/commands/{notice/mod.rs => notice.rs} (78%) delete mode 100644 src/commands/other/mod.rs rename src/commands/{user_interactions/mod.rs => user_interactions.rs} (85%) create mode 100644 src/commands/voice.rs delete mode 100644 src/commands/voice/general/mod.rs rename src/commands/voice/{general => }/general_player.rs (79%) delete mode 100644 src/commands/voice/mod.rs rename src/commands/voice/{radio/mod.rs => radio.rs} (55%) create mode 100644 src/commands/voice/radio/radio_utils.rs rename src/commands/voice/{spotify/mod.rs => spotify.rs} (100%) rename src/commands/voice/{util.rs => voice_utils.rs} (86%) rename src/commands/voice/{yt/mod.rs => yt.rs} (54%) rename src/{util/mod.rs => utils.rs} (100%) rename src/{util => utils}/debug.rs (100%) rename src/{util => utils}/embeds.rs (100%) rename src/{util => utils}/gifs.rs (100%) rename src/{util => utils}/poise_context_extension.rs (100%) rename src/{util => utils}/utilities.rs (100%) diff --git a/.gitignore b/.gitignore index 2c549b2..f50483c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ # IDE config .vscode -mooverdb.db* \ No newline at end of file +mooverdb.db* + +*.txt \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index c9e3f30..79850ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,21 +7,22 @@ edition = "2021" [dependencies] rand = "0.8.5" -anyhow = "1.0.89" +anyhow = "1.0.95" tokio-cron-scheduler = "0.13.0" dotenv = "0.15.0" poise = "0.6.1" serenity = { version = "0.12.4", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "http", "cache"] } # serenity_utils = "0.7.0" -tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] } regex = "1.11.1" -chrono = "0.4.38" -sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} +chrono = "0.4.39" +sqlx = {version="0.8.3", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } # librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } songbird = { version = "0.4.6", features = ["driver", "builtin-queue"] } reqwest = "0.11.27" # songbird depends on ^0.11 +radiobrowser = "0.6.1" [dependencies.symphonia] version = "0.5.2" diff --git a/src/commands/mod.rs b/src/commands.rs similarity index 64% rename from src/commands/mod.rs rename to src/commands.rs index df0d152..6a1c0f7 100644 --- a/src/commands/mod.rs +++ b/src/commands.rs @@ -1,11 +1,13 @@ pub use moover::*; pub use notice::*; pub use user_interactions::*; -pub use other::*; +// pub use other::*; pub use voice::*; +// pub use command_utils::*; pub mod moover; pub mod notice; pub mod user_interactions; -pub mod other; +// pub mod other; pub mod voice; +// mod command_utils; diff --git a/src/commands/moover/mod.rs b/src/commands/moover.rs similarity index 64% rename from src/commands/moover/mod.rs rename to src/commands/moover.rs index 51a3f5b..f2c99f7 100644 --- a/src/commands/moover/mod.rs +++ b/src/commands/moover.rs @@ -1,7 +1,7 @@ pub use moove::*; +pub use gif::*; pub use say::*; -// pub use gif::*; pub mod moove; +pub mod gif; pub mod say; -// pub mod gif; diff --git a/src/commands/moover/gif.rs b/src/commands/moover/gif.rs new file mode 100644 index 0000000..4861a37 --- /dev/null +++ b/src/commands/moover/gif.rs @@ -0,0 +1,20 @@ +use poise; +use serenity::all::{Embed, User}; + +use crate::types::{Error, Context}; + +#[poise::command( + slash_command, + description_localized("en-US", "Send a gif from Tenor") +)] +pub async fn gif(ctx: Context<'_>, + #[description = "What should I search?"] + what: String, + #[description = "Should the gif be R-rated?"] + contentfilter: String +) -> Result<(), Error> { + // let embed; + // send_with_embed(ctx, "hug", &title, &desc).await?; + ctx.reply("Done!").await?; + Ok(()) +} \ No newline at end of file diff --git a/src/commands/notice/mod.rs b/src/commands/notice.rs similarity index 78% rename from src/commands/notice/mod.rs rename to src/commands/notice.rs index d63767d..33bed17 100644 --- a/src/commands/notice/mod.rs +++ b/src/commands/notice.rs @@ -2,4 +2,4 @@ pub use birthday::*; pub use events::*; pub mod birthday; -pub mod events; \ No newline at end of file +pub mod events; diff --git a/src/commands/other/mod.rs b/src/commands/other/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/commands/user_interactions/mod.rs b/src/commands/user_interactions.rs similarity index 85% rename from src/commands/user_interactions/mod.rs rename to src/commands/user_interactions.rs index 0e8654b..f9a7882 100644 --- a/src/commands/user_interactions/mod.rs +++ b/src/commands/user_interactions.rs @@ -3,4 +3,4 @@ pub use hug::*; pub mod interaction; pub mod headpat; -pub mod hug; \ No newline at end of file +pub mod hug; diff --git a/src/commands/user_interactions/interaction.rs b/src/commands/user_interactions/interaction.rs index 6db121a..f495f3e 100644 --- a/src/commands/user_interactions/interaction.rs +++ b/src/commands/user_interactions/interaction.rs @@ -2,7 +2,7 @@ use anyhow::anyhow; use serenity::all::{Colour, CreateEmbed, CreateMessage}; use tenorv2::tenor_builder::Tenor; -use crate::{types::Context, util::{gifs::get_random_tenor_gif, utilities}}; +use crate::{types::Context, utils::{gifs::get_random_tenor_gif, utilities}}; /// Sends embed with random tenor gif from searched query /// title and desc are used in the embed diff --git a/src/commands/voice.rs b/src/commands/voice.rs new file mode 100644 index 0000000..98fd8f5 --- /dev/null +++ b/src/commands/voice.rs @@ -0,0 +1,11 @@ +use voice_utils::*; + +mod voice_utils; +pub mod player_common; +pub mod radio; +pub mod general_player; + +// ! not working +// pub mod yt; +// TODO implement +// pub mod spotify; diff --git a/src/commands/voice/general/mod.rs b/src/commands/voice/general/mod.rs deleted file mode 100644 index 8b6dfd4..0000000 --- a/src/commands/voice/general/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub use general_player::*; - -pub mod general_player; \ No newline at end of file diff --git a/src/commands/voice/general/general_player.rs b/src/commands/voice/general_player.rs similarity index 79% rename from src/commands/voice/general/general_player.rs rename to src/commands/voice/general_player.rs index c3cd0d6..860d22f 100644 --- a/src/commands/voice/general/general_player.rs +++ b/src/commands/voice/general_player.rs @@ -2,10 +2,11 @@ use std::vec; use songbird::input::YoutubeDl; -use crate::commands::util::connect; -use crate::util::poise_context_extension::ContextExt; +use crate::utils::poise_context_extension::ContextExt; use crate::types::{Context, Error}; -use crate::commands::voice::util::autocomplete_channel; +use crate::commands::voice::voice_utils::autocomplete_channel; + +use super::connect; // TODO: search, queue #[poise::command( @@ -38,12 +39,9 @@ pub async fn play(ctx: Context<'_>, let http_client = &ctx.data().http_client; if manager.get(guild_id).is_none() { - match connect(&ctx, guild_id, channel).await { - Ok(_) => (), - Err(e) => { - ctx.reply_ephemeral(&e.to_string()).await?; - return Ok(()) - } + if let Err(e) = connect(&ctx, guild_id, channel).await { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) } } diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs deleted file mode 100644 index f3d7e73..0000000 --- a/src/commands/voice/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod util; -pub mod player_common; -pub mod radio; -// pub mod spotify; -pub mod general; -pub mod yt; \ No newline at end of file diff --git a/src/commands/voice/player_common.rs b/src/commands/voice/player_common.rs index 38ac50a..f87031c 100644 --- a/src/commands/voice/player_common.rs +++ b/src/commands/voice/player_common.rs @@ -2,12 +2,12 @@ use std::vec; use songbird::input::{File, Input}; -use crate::util::debug::send_error; -use crate::util::poise_context_extension::ContextExt; -use crate::util::utilities::get_local_songs; +use crate::utils::debug::send_error; +use crate::utils::poise_context_extension::ContextExt; +use crate::utils::utilities::get_local_songs; use crate::types::{Context, Error}; -use super::util::{connect, autocomplete_channel}; +use super::voice_utils::{connect, autocomplete_channel}; /** * Common commands that are the same for every implementation diff --git a/src/commands/voice/radio/mod.rs b/src/commands/voice/radio.rs similarity index 55% rename from src/commands/voice/radio/mod.rs rename to src/commands/voice/radio.rs index 27ead6c..0ea2063 100644 --- a/src/commands/voice/radio/mod.rs +++ b/src/commands/voice/radio.rs @@ -1,3 +1,6 @@ pub use radio_player::*; +use radio_utils::*; pub mod radio_player; + +mod radio_utils; diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs index b9ed39d..599c610 100644 --- a/src/commands/voice/radio/radio_player.rs +++ b/src/commands/voice/radio/radio_player.rs @@ -1,26 +1,41 @@ use std::vec; +use radiobrowser::RadioBrowserAPI; use reqwest::Client; use songbird::input::Input; use songbird::input::HttpRequest; -use crate::commands::util::connect; -use crate::util::poise_context_extension::ContextExt; +use super::super::connect; +use crate::utils::poise_context_extension::ContextExt; use crate::types::{Context, Error}; -use crate::commands::voice::util::autocomplete_channel; +use crate::commands::voice::voice_utils::autocomplete_channel; + +use super::radio_utils::paginate_search; + +use super::super::voice_utils::MAX_ENTRIES; + +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from URL source"), + subcommands("search", "play") +)] +pub async fn radio(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} // TODO: autocomplete radio stream URLs #[poise::command( slash_command, description_localized("en-US", "Plays music from URL source") )] -pub async fn radio(ctx: Context<'_>, +pub async fn play(ctx: Context<'_>, #[autocomplete = "autocomplete_channel"] #[description = "Voice channel name: "] channel: Option, - #[description = "Source URL: "] - url: String, + #[autocomplete = "autocomplete_radio"] + #[description = "Radio station: "] + name: String, ) -> Result<(), Error> { if ctx.guild().is_none() { @@ -39,12 +54,9 @@ pub async fn radio(ctx: Context<'_>, }; if manager.get(guild_id).is_none() { - match connect(&ctx, guild_id, channel).await { - Ok(_) => (), - Err(e) => { - ctx.reply_ephemeral(&e.to_string()).await?; - return Ok(()) - } + if let Err(e) = connect(&ctx, guild_id, channel).await { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) } } @@ -52,7 +64,7 @@ pub async fn radio(ctx: Context<'_>, let mut handler = handler_lock.lock().await; let client = Client::new(); - let request = HttpRequest::new(client, url); + let request = HttpRequest::new(client, name); let input = Input::from(request); handler.play_only_input(input); } @@ -64,3 +76,80 @@ pub async fn radio(ctx: Context<'_>, ctx.reply_ephemeral("Done!").await?; Ok(()) } + +async fn autocomplete_radio( + ctx: Context<'_>, + partial: &str +) -> Vec { + + let api = { + match &ctx.data().radio_browser { + Some(v) => v.clone(), + None => { + let Ok(radio_browser) = RadioBrowserAPI::new().await else { + return vec!["There was an error with radio API!".to_owned()] + }; + radio_browser + } + } + }; + + let stations: Vec = match api.get_stations() + .limit(MAX_ENTRIES) + .name(partial) + .hidebroken(true) + .send().await { + Ok(stations) => { + stations.iter().map(|station| + format!("Name: {} Language: {} Bitrate: {}", station.name, station.countrycode, station.bitrate) + ).collect() + }, + Err(_) => return vec!["".to_owned()] + }; + + dbg!(&stations); + + return stations + // return vec![]; +} + +// TODO +// autocomplete +// search with buttons and list in embed with direct links to stream +// embed showing currently playing song + +// TODO: autocomplete radio stream URLs +#[poise::command( + slash_command, + description_localized("en-US", "Search for a radio") +)] +pub async fn search(ctx: Context<'_>, + #[description = "Radio station: "] + name: String, + #[description = "Country: "] + country: Option, + #[description = "Tag: "] + tag: Option +) -> Result<(), Error> { + + let api = match &ctx.data().radio_browser { + Some(v) => v.clone(), + None => { + let Ok(radio_browser) = RadioBrowserAPI::new().await else { + let _ = ctx.reply_ephemeral("There was an error with radio API!").await; + return Ok(()) + }; + radio_browser + } + }; + + let station_search_builder = api.get_stations() + .name(name) + .tag(tag.unwrap_or("".to_owned())) + .country(country.unwrap_or("".to_owned())) + .hidebroken(true); + + paginate_search(ctx, station_search_builder); + + Ok(()) +} diff --git a/src/commands/voice/radio/radio_utils.rs b/src/commands/voice/radio/radio_utils.rs new file mode 100644 index 0000000..de1832c --- /dev/null +++ b/src/commands/voice/radio/radio_utils.rs @@ -0,0 +1,83 @@ +use poise::{ChoiceParameter, CreateReply}; +use radiobrowser::StationSearchBuilder; +use serenity::{all::{CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage}, futures::SinkExt}; + +use crate::types::Context; + +#[derive(ChoiceParameter)] +pub enum WelcomeChoice { + #[name = "5"] + A, + #[name = "10"] + B, + #[name = "15"] + C +} + +pub async fn paginate_search( + ctx: Context<'_>, + search_builder: StationSearchBuilder, +) -> Result<(), serenity::Error> { + // Define some unique identifiers for the navigation buttons + let ctx_id = ctx.id(); + let prev_button_id = format!("{}prev", ctx_id); + let next_button_id = format!("{}next", ctx_id); + + let Ok(stations) = search_builder.send().await else { + let _ = ctx.reply("Something went wrong, try searching again").await; + return Ok(()) + }; + + // Send the embed with the first page as content + let reply = { + let components = CreateActionRow::Buttons(vec![ + CreateButton::new(&prev_button_id).emoji('◀'), + CreateButton::new(&next_button_id).emoji('▶'), + ]); + + CreateReply::default() + .embed(CreateEmbed::new()) + .components(vec![components]) + }; + + ctx.send(reply).await?; + + // Loop through incoming interactions with the navigation buttons + let mut current_page = 0; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + // We defined our button IDs to start with `ctx_id`. If they don't, some other command's + // button was pressed + .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) + // Timeout when no navigation button has been pressed for 24 hours + .timeout(std::time::Duration::from_secs(3600 * 24)) + .await + { + // Depending on which button was pressed, go to next or previous page + if press.data.custom_id == next_button_id { + current_page += 1; + // TODO find a way to check end + // if current_page >= pages.len() { + // current_page = 0; + // } + } else if press.data.custom_id == prev_button_id { + // TODO find a way to get pages len + // current_page = current_page.checked_sub(1).unwrap_or(pages.len() - 1); + } else { + // This is an unrelated button interaction + continue; + } + + // Update the message with the new page contents + press + .create_response( + ctx.serenity_context(), + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(CreateEmbed::new()), + ), + ) + .await?; + } + + Ok(()) +} diff --git a/src/commands/voice/spotify/mod.rs b/src/commands/voice/spotify.rs similarity index 100% rename from src/commands/voice/spotify/mod.rs rename to src/commands/voice/spotify.rs diff --git a/src/commands/voice/util.rs b/src/commands/voice/voice_utils.rs similarity index 86% rename from src/commands/voice/util.rs rename to src/commands/voice/voice_utils.rs index 4208b51..d9e4a8c 100644 --- a/src/commands/voice/util.rs +++ b/src/commands/voice/voice_utils.rs @@ -1,10 +1,13 @@ -use serenity::all::{ChannelId, GuildId}; +use poise::{CreateReply}; +use serenity::all::{ChannelId, CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, Embed, GuildId}; use serenity::async_trait; use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; use songbird::TrackEvent; -use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; +use crate::{types::{Context, Error}, utils::utilities::get_channel_by_name}; + +pub const MAX_ENTRIES: &str = "15"; /// Returns either voice channel to which the user is currently connected to or the one passed via name async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { @@ -68,7 +71,7 @@ pub async fn autocomplete_channel( _partial: &str, ) -> Vec { - use crate::util::utilities::get_vc_names; + use crate::utils::utilities::get_vc_names; match ctx.guild() { Some(guild) => get_vc_names(guild), None => [].to_vec() diff --git a/src/commands/voice/yt/mod.rs b/src/commands/voice/yt.rs similarity index 54% rename from src/commands/voice/yt/mod.rs rename to src/commands/voice/yt.rs index 989288e..af9e888 100644 --- a/src/commands/voice/yt/mod.rs +++ b/src/commands/voice/yt.rs @@ -1,3 +1,3 @@ pub use yt_player::*; -pub mod yt_player; \ No newline at end of file +pub mod yt_player; diff --git a/src/main.rs b/src/main.rs index 66380e6..a36e45a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ mod message_handler; use message_handler::handle; mod commands; -mod util; +mod utils; mod other; use other::notice; @@ -57,7 +57,7 @@ impl EventHandler for Handler { println!("{} v3.3.1 is connected!", ready.user.name); #[cfg(feature="RELEASE")] { - use util::debug::hello; + use utils::debug::hello; let _ = hello(ctx.http.clone()).await; } @@ -87,10 +87,9 @@ async fn main() -> anyhow::Result<()> { commands::say(), commands::hug(), // commands::player::play_local(), - commands::player_common::disconnect(), commands::radio::radio(), - commands::general::play(), - commands::yt::play_yt() + commands::general_player::play(), + commands::player_common::disconnect(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -129,8 +128,10 @@ async fn main() -> anyhow::Result<()> { register_globally(ctx, &framework.options().commands).await?; } + Ok(Data { - http_client: HttpClient::new() + http_client: HttpClient::new(), + radio_browser: RadioBrowserAPI::new().await.ok() }) }) }) diff --git a/src/message_handler.rs b/src/message_handler.rs index 4aa263e..8033036 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,8 +5,8 @@ use serenity::model::channel::Message; use std::collections::HashMap; use std::sync::Arc; -use crate::util::debug::send_error; -use crate::util::utilities; +use crate::utils::debug::send_error; +use crate::utils::utilities; use crate::commands::moover::moove::{moove, moove_check}; diff --git a/src/other/notice.rs b/src/other/notice.rs index 69b39ed..9d75231 100644 --- a/src/other/notice.rs +++ b/src/other/notice.rs @@ -6,8 +6,8 @@ use serenity::{all::{GuildId, UserId}, builder::{CreateEmbed, CreateMessage}, cl use sqlx::{Connection, FromRow, SqliteConnection}; -use crate::util::debug::send_error; -use crate::util::utilities; +use crate::utils::debug::send_error; +use crate::utils::utilities; use std::{env, sync::Arc}; diff --git a/src/types.rs b/src/types.rs index 1103f1c..068d8e4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,20 +2,15 @@ use std::{ffi::OsString, path::PathBuf}; -use serenity::prelude::TypeMapKey; - +use radiobrowser::RadioBrowserAPI; use reqwest::Client as HttpClient; // TODO: this should be probably expanded also to be used by multiple servers and commands // radio and spotify commands always create new HttpClient -pub struct HttpKey; - -impl TypeMapKey for HttpKey { - type Value = HttpClient; -} pub struct Data { pub http_client: HttpClient, + pub radio_browser: Option, } pub type Error = Box; diff --git a/src/util/mod.rs b/src/utils.rs similarity index 100% rename from src/util/mod.rs rename to src/utils.rs diff --git a/src/util/debug.rs b/src/utils/debug.rs similarity index 100% rename from src/util/debug.rs rename to src/utils/debug.rs diff --git a/src/util/embeds.rs b/src/utils/embeds.rs similarity index 100% rename from src/util/embeds.rs rename to src/utils/embeds.rs diff --git a/src/util/gifs.rs b/src/utils/gifs.rs similarity index 100% rename from src/util/gifs.rs rename to src/utils/gifs.rs diff --git a/src/util/poise_context_extension.rs b/src/utils/poise_context_extension.rs similarity index 100% rename from src/util/poise_context_extension.rs rename to src/utils/poise_context_extension.rs diff --git a/src/util/utilities.rs b/src/utils/utilities.rs similarity index 100% rename from src/util/utilities.rs rename to src/utils/utilities.rs From f0f99b0bf573d36c4bca3ef828c4a2e03fcdfff2 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 26 Jan 2025 15:58:07 +0100 Subject: [PATCH 16/19] chore: add radiobrowser fork as submodule --- .gitmodules | 3 +++ Cargo.toml | 3 ++- radiobrowser | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) create mode 160000 radiobrowser diff --git a/.gitmodules b/.gitmodules index 3ad93ca..ae57159 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "tenor-v2"] path = tenor-v2 url = ssh://git@forgejo.djkato.net:222/HLadislav/tenor-v2.git +[submodule "radiobrowser"] + path = radiobrowser + url = ./radiobrowser diff --git a/Cargo.toml b/Cargo.toml index 79850ab..c557b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,8 @@ tenorv2 = { path = "./tenor-v2/tenorv2" } # librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } songbird = { version = "0.4.6", features = ["driver", "builtin-queue"] } reqwest = "0.11.27" # songbird depends on ^0.11 -radiobrowser = "0.6.1" +# radiobrowser = "0.6.1" +radiobrowser = { path = "./radiobrowser" } [dependencies.symphonia] version = "0.5.2" diff --git a/radiobrowser b/radiobrowser new file mode 160000 index 0000000..0786c89 --- /dev/null +++ b/radiobrowser @@ -0,0 +1 @@ +Subproject commit 0786c897260c7f180917af101ae5ea686cb60c2e From 8f82cb3b0901334605a25a57ef043943c5af8ee1 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 26 Jan 2025 21:39:54 +0100 Subject: [PATCH 17/19] chore: update tenor-v2 --- tenor-v2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenor-v2 b/tenor-v2 index fd8c8bd..66cf5cc 160000 --- a/tenor-v2 +++ b/tenor-v2 @@ -1 +1 @@ -Subproject commit fd8c8bde1be41116fe398776c45625a19ec10281 +Subproject commit 66cf5cc388af947846caf5b7118257df01d60725 From 00c783bbfa8a226a202ea1b30616f68e620cdee1 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 26 Jan 2025 21:45:24 +0100 Subject: [PATCH 18/19] feat: fully implemented radio player --- src/commands/voice.rs | 1 + src/commands/voice/general_player.rs | 3 +- src/commands/voice/player_common.rs | 49 ++++++- src/commands/voice/radio/radio_player.rs | 108 ++++++++++----- src/commands/voice/radio/radio_utils.rs | 165 ++++++++++++++++------- src/commands/voice/voice_types.rs | 91 +++++++++++++ src/commands/voice/voice_utils.rs | 3 +- src/main.rs | 8 +- src/types.rs | 30 +++-- src/utils.rs | 3 +- src/utils/poise_context_extension.rs | 22 --- src/utils/utilities.rs | 15 ++- 12 files changed, 375 insertions(+), 123 deletions(-) create mode 100644 src/commands/voice/voice_types.rs delete mode 100644 src/utils/poise_context_extension.rs diff --git a/src/commands/voice.rs b/src/commands/voice.rs index 98fd8f5..a86fe29 100644 --- a/src/commands/voice.rs +++ b/src/commands/voice.rs @@ -4,6 +4,7 @@ mod voice_utils; pub mod player_common; pub mod radio; pub mod general_player; +pub mod voice_types; // ! not working // pub mod yt; diff --git a/src/commands/voice/general_player.rs b/src/commands/voice/general_player.rs index 860d22f..3ad9b7b 100644 --- a/src/commands/voice/general_player.rs +++ b/src/commands/voice/general_player.rs @@ -2,8 +2,7 @@ use std::vec; use songbird::input::YoutubeDl; -use crate::utils::poise_context_extension::ContextExt; -use crate::types::{Context, Error}; +use crate::types::{Context, ContextExt, Error}; use crate::commands::voice::voice_utils::autocomplete_channel; use super::connect; diff --git a/src/commands/voice/player_common.rs b/src/commands/voice/player_common.rs index f87031c..8eee740 100644 --- a/src/commands/voice/player_common.rs +++ b/src/commands/voice/player_common.rs @@ -1,11 +1,11 @@ use std::vec; +use poise::CreateReply; use songbird::input::{File, Input}; use crate::utils::debug::send_error; -use crate::utils::poise_context_extension::ContextExt; +use crate::types::{Context, ContextExt, Error}; use crate::utils::utilities::get_local_songs; -use crate::types::{Context, Error}; use super::voice_utils::{connect, autocomplete_channel}; @@ -114,3 +114,48 @@ pub async fn play_local(ctx: Context<'_>, ctx.reply_ephemeral("Done!").await?; Ok(()) } + + +#[poise::command( + slash_command, + description_localized("en-US", "Display currently playing info") +)] +pub async fn playing(ctx: Context<'_>) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(_) = manager.get(guild_id) else { + ctx.reply_ephemeral("I am not connected to any voice channel right now").await?; + return Ok(()) + }; + + // println!("here"); + + let embed = { + let mutex_hashmap = ctx.data().playing_info.lock().await; + let Some(playing_info) = mutex_hashmap.get(&guild_id) else { + ctx.reply_ephemeral("Entry not found, try to reconnect me").await?; + return Ok(()); + }; + + playing_info.generate_embed().await + }; + + dbg!(&embed); + + ctx.send( + CreateReply::default() + .embed(embed) + ).await?; + + Ok(()) +} + diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs index 599c610..f669429 100644 --- a/src/commands/voice/radio/radio_player.rs +++ b/src/commands/voice/radio/radio_player.rs @@ -1,17 +1,22 @@ use std::vec; -use radiobrowser::RadioBrowserAPI; +use poise::ChoiceParameter; use reqwest::Client; use songbird::input::Input; use songbird::input::HttpRequest; use super::super::connect; -use crate::utils::poise_context_extension::ContextExt; +use super::link_or_string; +use super::parse_radio_autocomplete; +use super::LinkString; +use crate::commands::voice_types::NumberOfEntries; +use crate::commands::voice_types::PlayingInfoType; +use crate::types::ContextExt; use crate::types::{Context, Error}; use crate::commands::voice::voice_utils::autocomplete_channel; -use super::radio_utils::paginate_search; +use super::radio_utils::paginate_search_stations; use super::super::voice_utils::MAX_ENTRIES; @@ -24,6 +29,8 @@ pub async fn radio(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } +// TODO: deduplicate you use a lot of the same code + // TODO: autocomplete radio stream URLs #[poise::command( slash_command, @@ -43,6 +50,47 @@ pub async fn play(ctx: Context<'_>, return Ok(()); } + let api = &ctx.data().radio_browser; + + let stations_result = match link_or_string(&name) { + LinkString::Link => { + if name.ends_with(".m3u") { + ctx.reply_ephemeral("Sorry, currently I don't support m3u files").await?; + return Ok(()); + } + + api.get_stations().byurl(name).await + }, + LinkString::String => { + let (name, country, language) = match parse_radio_autocomplete(&name) { + Some(tuple) => tuple, + None => { + ctx.reply_ephemeral("Please use either direct URL or provided autocomplete").await?; + return Ok(()) + } + }; + + api.get_stations() + .name_exact(true) + .name(name) + .country_exact(true) + .country(country) + .language_exact(true) + .language(language) + .send().await + } + }; + + let Ok(stations) = stations_result else { + ctx.reply_ephemeral("There was an error with radio API!").await?; + return Ok(()) + }; + + let Some(station) = stations.first() else { + ctx.reply_ephemeral("Radio station not found!").await?; + return Ok(()) + }; + let manager = songbird::get(ctx.serenity_context()) .await .expect("Songbird Voice client placed in at initialisation.") @@ -54,8 +102,9 @@ pub async fn play(ctx: Context<'_>, }; if manager.get(guild_id).is_none() { - if let Err(e) = connect(&ctx, guild_id, channel).await { + if let Err(e) = connect(&ctx, guild_id, channel).await { ctx.reply_ephemeral(&e.to_string()).await?; + println!("SONGBIRD MANAGER ERROR: {}", e.to_string()); return Ok(()) } } @@ -64,7 +113,7 @@ pub async fn play(ctx: Context<'_>, let mut handler = handler_lock.lock().await; let client = Client::new(); - let request = HttpRequest::new(client, name); + let request = HttpRequest::new(client, station.url.clone()); let input = Input::from(request); handler.play_only_input(input); } @@ -73,6 +122,11 @@ pub async fn play(ctx: Context<'_>, return Ok(()) } + { + let mut hash_map = ctx.data().playing_info.lock().await; + hash_map.insert(guild_id, PlayingInfoType::RadioInfo(station.clone())); + } + ctx.reply_ephemeral("Done!").await?; Ok(()) } @@ -82,17 +136,7 @@ async fn autocomplete_radio( partial: &str ) -> Vec { - let api = { - match &ctx.data().radio_browser { - Some(v) => v.clone(), - None => { - let Ok(radio_browser) = RadioBrowserAPI::new().await else { - return vec!["There was an error with radio API!".to_owned()] - }; - radio_browser - } - } - }; + let api = &ctx.data().radio_browser; let stations: Vec = match api.get_stations() .limit(MAX_ENTRIES) @@ -100,14 +144,14 @@ async fn autocomplete_radio( .hidebroken(true) .send().await { Ok(stations) => { - stations.iter().map(|station| - format!("Name: {} Language: {} Bitrate: {}", station.name, station.countrycode, station.bitrate) + stations.iter().map(|station: &radiobrowser::ApiStation| + format!("Name: {} Country: {} Language: {}", station.name, station.country, station.language) ).collect() }, Err(_) => return vec!["".to_owned()] }; - dbg!(&stations); + // dbg!(&stations); return stations // return vec![]; @@ -118,7 +162,11 @@ async fn autocomplete_radio( // search with buttons and list in embed with direct links to stream // embed showing currently playing song +// use trace::trace; +// trace::init_depth_var!(); + // TODO: autocomplete radio stream URLs +// #[trace] #[poise::command( slash_command, description_localized("en-US", "Search for a radio") @@ -129,27 +177,23 @@ pub async fn search(ctx: Context<'_>, #[description = "Country: "] country: Option, #[description = "Tag: "] - tag: Option + tag: Option, + #[description = "Number of entries on page: "] + entries: Option ) -> Result<(), Error> { - let api = match &ctx.data().radio_browser { - Some(v) => v.clone(), - None => { - let Ok(radio_browser) = RadioBrowserAPI::new().await else { - let _ = ctx.reply_ephemeral("There was an error with radio API!").await; - return Ok(()) - }; - radio_browser - } - }; + let api = &ctx.data().radio_browser; - let station_search_builder = api.get_stations() + let entries = entries.unwrap_or(NumberOfEntries::Ten); + + let search_builder = api.get_stations() + .limit(entries.name()) .name(name) .tag(tag.unwrap_or("".to_owned())) .country(country.unwrap_or("".to_owned())) .hidebroken(true); - paginate_search(ctx, station_search_builder); + paginate_search_stations(&ctx, &search_builder, entries).await?; Ok(()) } diff --git a/src/commands/voice/radio/radio_utils.rs b/src/commands/voice/radio/radio_utils.rs index de1832c..9889928 100644 --- a/src/commands/voice/radio/radio_utils.rs +++ b/src/commands/voice/radio/radio_utils.rs @@ -1,83 +1,144 @@ -use poise::{ChoiceParameter, CreateReply}; -use radiobrowser::StationSearchBuilder; -use serenity::{all::{CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage}, futures::SinkExt}; +use poise::CreateReply; +use radiobrowser::{ApiStation, StationSearchBuilder}; +use regex::Regex; +use serenity::all::{CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage}; -use crate::types::Context; +use crate::{commands::voice_types::NumberOfEntries, types::Context}; -#[derive(ChoiceParameter)] -pub enum WelcomeChoice { - #[name = "5"] - A, - #[name = "10"] - B, - #[name = "15"] - C -} - -pub async fn paginate_search( - ctx: Context<'_>, - search_builder: StationSearchBuilder, +pub async fn paginate_search_stations( + ctx: &Context<'_>, + search_builder: &StationSearchBuilder, + limit: NumberOfEntries ) -> Result<(), serenity::Error> { // Define some unique identifiers for the navigation buttons let ctx_id = ctx.id(); let prev_button_id = format!("{}prev", ctx_id); let next_button_id = format!("{}next", ctx_id); - - let Ok(stations) = search_builder.send().await else { - let _ = ctx.reply("Something went wrong, try searching again").await; + + let search_builder = search_builder; + + let Ok(stations) = search_builder.clone().send().await else { + ctx.reply("Something went wrong, try searching again").await?; return Ok(()) }; - + + let mut page = 0; + + let embed = create_station_list_embed(&stations, page); + // Send the embed with the first page as content let reply = { let components = CreateActionRow::Buttons(vec![ CreateButton::new(&prev_button_id).emoji('◀'), CreateButton::new(&next_button_id).emoji('▶'), - ]); - - CreateReply::default() - .embed(CreateEmbed::new()) + ]); + + CreateReply::default() + .embed(embed) .components(vec![components]) - }; - - ctx.send(reply).await?; - - // Loop through incoming interactions with the navigation buttons - let mut current_page = 0; - while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + }; + + ctx.send(reply).await?; + + // Loop through incoming interactions with the navigation buttons + let mut offset = 0; + let limit_int = limit as u32; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) // We defined our button IDs to start with `ctx_id`. If they don't, some other command's // button was pressed .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) // Timeout when no navigation button has been pressed for 24 hours .timeout(std::time::Duration::from_secs(3600 * 24)) .await - { - // Depending on which button was pressed, go to next or previous page + { + // Depending on which button was pressed, go to next or previous page if press.data.custom_id == next_button_id { - current_page += 1; - // TODO find a way to check end - // if current_page >= pages.len() { - // current_page = 0; - // } + offset += limit_int; + page += 1; } else if press.data.custom_id == prev_button_id { - // TODO find a way to get pages len - // current_page = current_page.checked_sub(1).unwrap_or(pages.len() - 1); + offset = if offset < limit_int { offset } else { offset - limit_int }; + page = if page == 0 { 0 } else { page - 1 }; } else { // This is an unrelated button interaction continue; } + let Ok(mut stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + + if stations.is_empty() { + offset = 0; + page = 0; + + let Ok(new_stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + stations = new_stations; + } + + let embed = create_station_list_embed(&stations, page); + // Update the message with the new page contents press - .create_response( - ctx.serenity_context(), - CreateInteractionResponse::UpdateMessage( - CreateInteractionResponseMessage::new() - .embed(CreateEmbed::new()), - ), - ) - .await?; - } - - Ok(()) + .create_response( + ctx.serenity_context(), + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(embed), + ), + ) + .await?; +} + +Ok(()) +} + +fn create_station_list_embed(stations: &Vec, page: u32) -> CreateEmbed { + let result = CreateEmbed::new() + .fields( + stations.iter().map(|station| { + ( + station.name.clone(), + format!("Country: {} Stream: {}", + station.country, station.url), + false + ) + }) + ) + .footer(CreateEmbedFooter::new(format!("Page: {}", page + 1))); + + result +} + +pub enum LinkString { + Link, + String +} + + +pub fn link_or_string(haystack: &str) -> LinkString { + let Ok(re) = Regex::new(r"^https?://([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$") else { + panic!("Wrong regex expression!"); + }; + + return if re.is_match(haystack) { LinkString::Link } else { LinkString::String } +} + +pub fn parse_radio_autocomplete(haystack: &str) -> Option<(String, String, String)> { + let Ok(re) = Regex::new(r"^Name: (.*) Country: (.*) Language: (.*)") else { + panic!("Wrong regex expression!"); + }; + + let Some(captures) = re.captures(haystack) else { + return None + }; + + return Some(( + String::from(&captures[1]), + String::from(&captures[2]), + String::from(&captures[3]) + )) } diff --git a/src/commands/voice/voice_types.rs b/src/commands/voice/voice_types.rs new file mode 100644 index 0000000..60fe865 --- /dev/null +++ b/src/commands/voice/voice_types.rs @@ -0,0 +1,91 @@ +use poise::ChoiceParameter; +use radiobrowser::ApiStation; +use serenity::{all::CreateEmbed, async_trait}; + +use crate::utils::utilities::hash_to_u32; + +#[derive(ChoiceParameter, Debug)] +pub enum NumberOfEntries { + #[name = "5"] + Five = 5, + #[name = "10"] + Ten = 10, + #[name = "15"] + Fifteen = 15 +} + +#[async_trait] +pub trait GenerateEmbed { + async fn generate_embed(&self) -> CreateEmbed; +} + +#[allow(dead_code)] +pub struct SpotifyInfo { + pub name: String, + pub interpret: String, + // client + pub duration: String, +} + +#[allow(dead_code)] +pub struct GeneralInfo { + pub name: String, + pub site: String, + // client + pub duration: Option +} + + +#[async_trait] +impl GenerateEmbed for GeneralInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for SpotifyInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for ApiStation { + async fn generate_embed(&self) -> CreateEmbed { + let embed= CreateEmbed::new() + .title("Current station:") + .thumbnail(&self.favicon) + .field("Name:", &self.name, true) + .field("Country:", &self.country, true) + .field("Language:", &self.language, true) + .field("Website:", &self.homepage, true) + .field("Stream:", &self.url, true) + .field("Tags:", &self.tags, false) + .color(hash_to_u32(&self.stationuuid)); + + embed + } +} + +// pub struct PlayingInfo { +// pub data: T, +// pub create_embed: Box CreateEmbed + Send + Sync> +// } + +#[allow(dead_code)] +pub enum PlayingInfoType { + SpotifyInfo(SpotifyInfo), + RadioInfo(ApiStation), + GeneralInfo(GeneralInfo) +} + +impl PlayingInfoType { + pub async fn generate_embed(&self) -> CreateEmbed { + match self { + PlayingInfoType::SpotifyInfo(v) => v.generate_embed().await, + PlayingInfoType::RadioInfo(v) => v.generate_embed().await, + PlayingInfoType::GeneralInfo(v) => v.generate_embed().await + } + } +} diff --git a/src/commands/voice/voice_utils.rs b/src/commands/voice/voice_utils.rs index d9e4a8c..45f490b 100644 --- a/src/commands/voice/voice_utils.rs +++ b/src/commands/voice/voice_utils.rs @@ -1,5 +1,4 @@ -use poise::{CreateReply}; -use serenity::all::{ChannelId, CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, Embed, GuildId}; +use serenity::all::{ChannelId, GuildId}; use serenity::async_trait; use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; diff --git a/src/main.rs b/src/main.rs index a36e45a..417f31c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -6,6 +7,7 @@ use std::error; use std::env; use serenity::async_trait; +use serenity::futures::lock::Mutex; use serenity::prelude::GatewayIntents; use serenity::client::Context; use serenity::model::gateway::Ready; @@ -19,6 +21,8 @@ use dotenv::dotenv; use songbird::SerenityInit; use tokio_cron_scheduler::{JobScheduler, Job}; +use radiobrowser::RadioBrowserAPI; + mod message_handler; use message_handler::handle; @@ -90,6 +94,7 @@ async fn main() -> anyhow::Result<()> { commands::radio::radio(), commands::general_player::play(), commands::player_common::disconnect(), + commands::player_common::playing() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -131,7 +136,8 @@ async fn main() -> anyhow::Result<()> { Ok(Data { http_client: HttpClient::new(), - radio_browser: RadioBrowserAPI::new().await.ok() + radio_browser: RadioBrowserAPI::new().await?, + playing_info: Mutex::new(HashMap::new()) }) }) }) diff --git a/src/types.rs b/src/types.rs index 068d8e4..00ef4f9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,23 +1,37 @@ -// pub struct Data {} - -use std::{ffi::OsString, path::PathBuf}; +use std::collections::HashMap; +use poise::CreateReply; use radiobrowser::RadioBrowserAPI; use reqwest::Client as HttpClient; +use serenity::{all::{futures::lock::Mutex, GuildId}, async_trait}; + +use crate::commands::voice_types::PlayingInfoType; // TODO: this should be probably expanded also to be used by multiple servers and commands // radio and spotify commands always create new HttpClient - pub struct Data { pub http_client: HttpClient, - pub radio_browser: Option, + pub radio_browser: RadioBrowserAPI, + pub playing_info: Mutex> } pub type Error = Box; // replace () with Data if you ever need to store some additional data pub type Context<'a> = poise::Context<'a, Data, Error>; -pub struct Track { - pub path: PathBuf, - pub name: OsString, +/// Trait to extend `Context` with additional methods. +#[async_trait] +pub trait ContextExt { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()>; +} + +#[async_trait] +impl ContextExt for Context<'_> { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()> { + self.send(CreateReply::default() + .ephemeral(true) + .content(content) + ).await?; + Ok(()) + } } diff --git a/src/utils.rs b/src/utils.rs index c23251b..fabda60 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ +pub use utilities::*; + pub mod debug; pub mod utilities; pub mod gifs; -pub mod poise_context_extension; diff --git a/src/utils/poise_context_extension.rs b/src/utils/poise_context_extension.rs deleted file mode 100644 index 39a9475..0000000 --- a/src/utils/poise_context_extension.rs +++ /dev/null @@ -1,22 +0,0 @@ -use poise::CreateReply; -use serenity::async_trait; - -use crate::types::Context; - - -/// Trait to extend `Context` with additional methods. -#[async_trait] -pub trait ContextExt { - async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()>; -} - -#[async_trait] -impl ContextExt for Context<'_> { - async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()> { - self.send(CreateReply::default() - .ephemeral(true) - .content(content) - ).await?; - Ok(()) - } -} diff --git a/src/utils/utilities.rs b/src/utils/utilities.rs index c36912b..f24e427 100644 --- a/src/utils/utilities.rs +++ b/src/utils/utilities.rs @@ -1,7 +1,13 @@ -use std::{fs, io, path::Path, sync::Arc, vec}; +use std::{fs, hash::{DefaultHasher, Hash, Hasher}, io, path::Path, sync::Arc, vec}; use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildId, GuildRef, Message}, http::Http}; +use poise::CreateReply; +use serenity::async_trait; + +use crate::types::Context; + + pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { use anyhow::Context; return http.get_guild(guild_id).await?.system_channel_id @@ -58,3 +64,10 @@ pub fn get_local_songs(partial: &str) -> io::Result> { Ok(result) } + +pub fn hash_to_u32(from: &T) -> u32 { + let mut hasher = DefaultHasher::new(); + from.hash(&mut hasher); + let hash_val = hasher.finish(); + (hash_val & 0xFFFFFF) as u32 +} From 695a77950fe1c0aa2234b01af4d09b8edfde8cfb Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Sun, 26 Jan 2025 22:47:06 +0100 Subject: [PATCH 19/19] chore: cleaned the code a bit --- src/commands/user_interactions/interaction.rs | 4 --- src/commands/voice/player_common.rs | 1 + src/commands/voice/radio/radio_player.rs | 29 ++++--------------- src/commands/voice/radio/radio_utils.rs | 1 - 4 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/commands/user_interactions/interaction.rs b/src/commands/user_interactions/interaction.rs index f495f3e..d53002e 100644 --- a/src/commands/user_interactions/interaction.rs +++ b/src/commands/user_interactions/interaction.rs @@ -34,7 +34,3 @@ pub(super) async fn send_with_embed(ctx: Context<'_>, query: &str, title: &str, Ok(()) } - -pub(super) async fn send(ctx: Context<'_>, msg: &str) -> anyhow::Result<()> { - Ok(()) -} \ No newline at end of file diff --git a/src/commands/voice/player_common.rs b/src/commands/voice/player_common.rs index 8eee740..ed92390 100644 --- a/src/commands/voice/player_common.rs +++ b/src/commands/voice/player_common.rs @@ -50,6 +50,7 @@ pub async fn disconnect( } } +#[allow(dead_code)] async fn autocomplete_song( _ctx: Context<'_>, partial: &str, diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs index f669429..f33b54a 100644 --- a/src/commands/voice/radio/radio_player.rs +++ b/src/commands/voice/radio/radio_player.rs @@ -8,18 +8,15 @@ use songbird::input::HttpRequest; use super::super::connect; use super::link_or_string; -use super::parse_radio_autocomplete; use super::LinkString; -use crate::commands::voice_types::NumberOfEntries; -use crate::commands::voice_types::PlayingInfoType; -use crate::types::ContextExt; -use crate::types::{Context, Error}; -use crate::commands::voice::voice_utils::autocomplete_channel; - +use super::parse_radio_autocomplete; use super::radio_utils::paginate_search_stations; - use super::super::voice_utils::MAX_ENTRIES; +use crate::commands::voice_types::{NumberOfEntries, PlayingInfoType}; +use crate::types::{Context, Error, ContextExt}; +use crate::commands::voice::voice_utils::autocomplete_channel; + #[poise::command( slash_command, description_localized("en-US", "Plays music from URL source"), @@ -29,9 +26,6 @@ pub async fn radio(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } -// TODO: deduplicate you use a lot of the same code - -// TODO: autocomplete radio stream URLs #[poise::command( slash_command, description_localized("en-US", "Plays music from URL source") @@ -151,22 +145,9 @@ async fn autocomplete_radio( Err(_) => return vec!["".to_owned()] }; - // dbg!(&stations); - return stations - // return vec![]; } -// TODO -// autocomplete -// search with buttons and list in embed with direct links to stream -// embed showing currently playing song - -// use trace::trace; -// trace::init_depth_var!(); - -// TODO: autocomplete radio stream URLs -// #[trace] #[poise::command( slash_command, description_localized("en-US", "Search for a radio") diff --git a/src/commands/voice/radio/radio_utils.rs b/src/commands/voice/radio/radio_utils.rs index 9889928..c207dfc 100644 --- a/src/commands/voice/radio/radio_utils.rs +++ b/src/commands/voice/radio/radio_utils.rs @@ -118,7 +118,6 @@ pub enum LinkString { String } - pub fn link_or_string(haystack: &str) -> LinkString { let Ok(re) = Regex::new(r"^https?://([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$") else { panic!("Wrong regex expression!");