initial commit
This commit is contained in:
		
						commit
						6a77189343
					
				
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| # Rust | ||||
| /target | ||||
| 
 | ||||
| # SQLite database | ||||
| *.db | ||||
| *.sqlite | ||||
| 
 | ||||
| # Secrets | ||||
| .env | ||||
							
								
								
									
										1
									
								
								.rustfmt.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.rustfmt.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| tab_spaces = 2 | ||||
							
								
								
									
										3215
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3215
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										30
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| [package] | ||||
| name = "spoticord" | ||||
| version = "2.0.0-indev" | ||||
| edition = "2021" | ||||
| 
 | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [profile.release] | ||||
| lto = true | ||||
| codegen-units = 1 | ||||
| strip = true | ||||
| opt-level = "z" | ||||
| 
 | ||||
| [dependencies] | ||||
| chrono = "0.4.22" | ||||
| dotenv = "0.15.0" | ||||
| env_logger = "0.9.0" | ||||
| ipc-channel = { version = "0.16.0", features = ["async"] } | ||||
| librespot = { version = "0.4.2",  default-features = false } | ||||
| log = "0.4.17" | ||||
| reqwest = "0.11.11" | ||||
| samplerate = "0.2.4" | ||||
| serde = "1.0.144" | ||||
| serde_json = "1.0.85" | ||||
| serenity = { version = "0.11.5", features = ["voice"] } | ||||
| shell-words = "1.1.0" | ||||
| songbird = "0.3.0" | ||||
| thiserror = "1.0.33" | ||||
| tokio = { version = "1.20.1", features = ["rt", "full"] } | ||||
| zerocopy = "0.6.1" | ||||
							
								
								
									
										167
									
								
								src/audio/backend.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								src/audio/backend.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,167 @@ | ||||
| use librespot::playback::audio_backend::{Sink, SinkAsBytes, SinkResult}; | ||||
| use librespot::playback::convert::Converter; | ||||
| use librespot::playback::decoder::AudioPacket; | ||||
| use log::{error, trace}; | ||||
| use std::io::Write; | ||||
| use std::sync::{Arc, Mutex}; | ||||
| use std::thread::JoinHandle; | ||||
| use std::time::Duration; | ||||
| 
 | ||||
| use crate::ipc; | ||||
| use crate::ipc::packet::IpcPacket; | ||||
| 
 | ||||
| pub struct StdoutSink { | ||||
|   client: ipc::Client, | ||||
|   buffer: Arc<Mutex<Vec<u8>>>, | ||||
|   is_stopped: Arc<Mutex<bool>>, | ||||
|   handle: Option<JoinHandle<()>>, | ||||
| } | ||||
| 
 | ||||
| const BUFFER_SIZE: usize = 7680; | ||||
| 
 | ||||
| impl StdoutSink { | ||||
|   pub fn start_writer(&mut self) { | ||||
|     // With 48khz, 32-bit float, 2 channels, 1 second of audio is 384000 bytes
 | ||||
|     // 384000 / 50 = 7680 bytes per 20ms
 | ||||
| 
 | ||||
|     let buffer = self.buffer.clone(); | ||||
|     let is_stopped = self.is_stopped.clone(); | ||||
|     let client = self.client.clone(); | ||||
| 
 | ||||
|     let handle = std::thread::spawn(move || { | ||||
|       let mut output = std::io::stdout(); | ||||
|       let mut act_buffer = [0u8; BUFFER_SIZE]; | ||||
| 
 | ||||
|       // Use closure to make sure lock is released as fast as possible
 | ||||
|       let is_stopped = || { | ||||
|         let is_stopped = is_stopped.lock().unwrap(); | ||||
|         *is_stopped | ||||
|       }; | ||||
| 
 | ||||
|       // Start songbird's playback
 | ||||
|       client.send(IpcPacket::StartPlayback).unwrap(); | ||||
| 
 | ||||
|       loop { | ||||
|         if is_stopped() { | ||||
|           break; | ||||
|         } | ||||
| 
 | ||||
|         std::thread::sleep(Duration::from_millis(15)); | ||||
| 
 | ||||
|         let mut buffer = buffer.lock().unwrap(); | ||||
|         let to_drain: usize; | ||||
| 
 | ||||
|         if buffer.len() < BUFFER_SIZE { | ||||
|           // Copy the buffer into the action buffer
 | ||||
|           // Fill remaining length with zeroes
 | ||||
|           act_buffer[..buffer.len()].copy_from_slice(&buffer[..]); | ||||
|           act_buffer[buffer.len()..].fill(0); | ||||
| 
 | ||||
|           to_drain = buffer.len(); | ||||
|         } else { | ||||
|           act_buffer.copy_from_slice(&buffer[..BUFFER_SIZE]); | ||||
|           to_drain = BUFFER_SIZE; | ||||
|         } | ||||
| 
 | ||||
|         output.write_all(&act_buffer).unwrap_or(()); | ||||
|         buffer.drain(..to_drain); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     self.handle = Some(handle); | ||||
|   } | ||||
| 
 | ||||
|   pub fn stop_writer(&mut self) -> std::thread::Result<()> { | ||||
|     // Use closure to avoid deadlocking the mutex
 | ||||
|     let set_stopped = |value| { | ||||
|       let mut is_stopped = self.is_stopped.lock().unwrap(); | ||||
|       *is_stopped = value; | ||||
|     }; | ||||
| 
 | ||||
|     // Notify thread to stop
 | ||||
|     set_stopped(true); | ||||
| 
 | ||||
|     // Wait for thread to stop
 | ||||
|     let result = match self.handle.take() { | ||||
|       Some(handle) => handle.join(), | ||||
|       None => Ok(()), | ||||
|     }; | ||||
| 
 | ||||
|     // Reset stopped value
 | ||||
|     set_stopped(false); | ||||
| 
 | ||||
|     result | ||||
|   } | ||||
| 
 | ||||
|   pub fn new(client: ipc::Client) -> Self { | ||||
|     StdoutSink { | ||||
|       client, | ||||
|       is_stopped: Arc::new(Mutex::new(false)), | ||||
|       buffer: Arc::new(Mutex::new(Vec::new())), | ||||
|       handle: None, | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| impl Sink for StdoutSink { | ||||
|   fn start(&mut self) -> SinkResult<()> { | ||||
|     self.start_writer(); | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| 
 | ||||
|   fn stop(&mut self) -> SinkResult<()> { | ||||
|     // Stop the writer thread
 | ||||
|     // This is done before pausing songbird, because else the writer thread
 | ||||
|     //  might hang on writing to stdout
 | ||||
|     if let Err(why) = self.stop_writer() { | ||||
|       error!("Failed to stop stdout writer: {:?}", why); | ||||
|     } else { | ||||
|       trace!("Stopped stdout writer"); | ||||
|     } | ||||
| 
 | ||||
|     // Stop songbird's playback
 | ||||
|     self.client.send(IpcPacket::StopPlayback).unwrap(); | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| 
 | ||||
|   fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { | ||||
|     use zerocopy::AsBytes; | ||||
| 
 | ||||
|     if let AudioPacket::Samples(samples) = packet { | ||||
|       let samples_f32: &[f32] = &converter.f64_to_f32(&samples); | ||||
| 
 | ||||
|       let resampled = samplerate::convert( | ||||
|         44100, | ||||
|         48000, | ||||
|         2, | ||||
|         samplerate::ConverterType::Linear, | ||||
|         &samples_f32, | ||||
|       ) | ||||
|       .unwrap(); | ||||
|       self.write_bytes(resampled.as_bytes())?; | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| impl SinkAsBytes for StdoutSink { | ||||
|   fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { | ||||
|     let get_buffer_len = || { | ||||
|       let buffer = self.buffer.lock().unwrap(); | ||||
|       buffer.len() | ||||
|     }; | ||||
| 
 | ||||
|     while get_buffer_len() > BUFFER_SIZE * 5 { | ||||
|       std::thread::sleep(Duration::from_millis(15)); | ||||
|     } | ||||
| 
 | ||||
|     let mut buffer = self.buffer.lock().unwrap(); | ||||
| 
 | ||||
|     buffer.extend_from_slice(data); | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1
									
								
								src/audio/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/audio/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| pub mod backend; | ||||
							
								
								
									
										115
									
								
								src/bot/commands/core/link.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/bot/commands/core/link.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,115 @@ | ||||
| use log::error; | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
|   Result as SerenityResult, | ||||
| }; | ||||
| 
 | ||||
| use crate::{bot::commands::CommandOutput, database::Database}; | ||||
| 
 | ||||
| pub const NAME: &str = "link"; | ||||
| 
 | ||||
| async fn respond_message( | ||||
|   ctx: &Context, | ||||
|   command: &ApplicationCommandInteraction, | ||||
|   msg: impl Into<String>, | ||||
|   ephemeral: bool, | ||||
| ) -> SerenityResult<()> { | ||||
|   command | ||||
|     .create_interaction_response(&ctx.http, |response| { | ||||
|       response | ||||
|         .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|         .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) | ||||
|     }) | ||||
|     .await | ||||
| } | ||||
| 
 | ||||
| fn check_msg(result: SerenityResult<()>) { | ||||
|   if let Err(why) = result { | ||||
|     error!("Error sending message: {:?}", why); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     let data = ctx.data.read().await; | ||||
|     let database = data.get::<Database>().unwrap(); | ||||
| 
 | ||||
|     if let Ok(_) = database.get_user_account(command.user.id.to_string()).await { | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           "You have already linked your Spotify account.", | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
| 
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if let Ok(request) = database.get_user_request(command.user.id.to_string()).await { | ||||
|       let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap(); | ||||
|       let link = format!("{}/spotify/{}", base, request.token); | ||||
| 
 | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           format!("Go to the following URL to link your account:\n{}", link), | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
| 
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     match database | ||||
|       .create_user_request(command.user.id.to_string()) | ||||
|       .await | ||||
|     { | ||||
|       Ok(request) => { | ||||
|         let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap(); | ||||
|         let link = format!("{}/spotify/{}", base, request.token); | ||||
| 
 | ||||
|         check_msg( | ||||
|           respond_message( | ||||
|             &ctx, | ||||
|             &command, | ||||
|             format!("Go to the following URL to link your account:\n{}", link), | ||||
|             true, | ||||
|           ) | ||||
|           .await, | ||||
|         ); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
|       Err(why) => { | ||||
|         error!("Error creating user request: {:?}", why); | ||||
| 
 | ||||
|         check_msg( | ||||
|           respond_message( | ||||
|             &ctx, | ||||
|             &command, | ||||
|             "An error occurred while serving your request. Please try again later.", | ||||
|             true, | ||||
|           ) | ||||
|           .await, | ||||
|         ); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
|     }; | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name(NAME) | ||||
|     .description("Link your Spotify account to Spoticord") | ||||
| } | ||||
							
								
								
									
										2
									
								
								src/bot/commands/core/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/bot/commands/core/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| pub mod link; | ||||
| pub mod unlink; | ||||
							
								
								
									
										105
									
								
								src/bot/commands/core/unlink.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/bot/commands/core/unlink.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | ||||
| use log::error; | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
|   Result as SerenityResult, | ||||
| }; | ||||
| 
 | ||||
| use crate::{ | ||||
|   bot::commands::CommandOutput, | ||||
|   database::{Database, DatabaseError}, | ||||
|   session::manager::SessionManager, | ||||
| }; | ||||
| 
 | ||||
| pub const NAME: &str = "unlink"; | ||||
| 
 | ||||
| async fn respond_message( | ||||
|   ctx: &Context, | ||||
|   command: &ApplicationCommandInteraction, | ||||
|   msg: impl Into<String>, | ||||
|   ephemeral: bool, | ||||
| ) -> SerenityResult<()> { | ||||
|   command | ||||
|     .create_interaction_response(&ctx.http, |response| { | ||||
|       response | ||||
|         .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|         .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) | ||||
|     }) | ||||
|     .await | ||||
| } | ||||
| 
 | ||||
| fn check_msg(result: SerenityResult<()>) { | ||||
|   if let Err(why) = result { | ||||
|     error!("Error sending message: {:?}", why); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     let data = ctx.data.read().await; | ||||
|     let database = data.get::<Database>().unwrap(); | ||||
|     let session_manager = data.get::<SessionManager>().unwrap(); | ||||
| 
 | ||||
|     // Disconnect session if user has any
 | ||||
|     if let Some(session) = session_manager.find(command.user.id).await { | ||||
|       if let Err(why) = session.disconnect().await { | ||||
|         error!("Error disconnecting session: {:?}", why); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Check if user exists in the first place
 | ||||
|     if let Err(why) = database | ||||
|       .delete_user_account(command.user.id.to_string()) | ||||
|       .await | ||||
|     { | ||||
|       if let DatabaseError::InvalidStatusCode(status) = why { | ||||
|         if status == 404 { | ||||
|           check_msg( | ||||
|             respond_message( | ||||
|               &ctx, | ||||
|               &command, | ||||
|               "You cannot unlink your Spotify account if you currently don't have a linked Spotify account.", | ||||
|               true, | ||||
|             ) | ||||
|             .await, | ||||
|           ); | ||||
| 
 | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       error!("Error deleting user account: {:?}", why); | ||||
| 
 | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           "An unexpected error has occured while trying to unlink your account. Please try again later.", | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
| 
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     check_msg( | ||||
|       respond_message( | ||||
|         &ctx, | ||||
|         &command, | ||||
|         "Successfully unlinked your Spotify account from Spoticord", | ||||
|         true, | ||||
|       ) | ||||
|       .await, | ||||
|     ); | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name(NAME) | ||||
|     .description("Unlink your Spotify account from Spoticord") | ||||
| } | ||||
							
								
								
									
										149
									
								
								src/bot/commands/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/bot/commands/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,149 @@ | ||||
| use std::{collections::HashMap, future::Future, pin::Pin}; | ||||
| 
 | ||||
| use log::{debug, error}; | ||||
| use serenity::{ | ||||
|   builder::{CreateApplicationCommand, CreateApplicationCommands}, | ||||
|   model::application::command::Command, | ||||
|   model::prelude::{ | ||||
|     interaction::{application_command::ApplicationCommandInteraction, InteractionResponseType}, | ||||
|     GuildId, | ||||
|   }, | ||||
|   prelude::{Context, TypeMapKey}, | ||||
| }; | ||||
| 
 | ||||
| mod core; | ||||
| mod music; | ||||
| 
 | ||||
| mod ping; | ||||
| mod token; | ||||
| 
 | ||||
| pub type CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>; | ||||
| pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput; | ||||
| 
 | ||||
| pub struct CommandManager { | ||||
|   commands: HashMap<String, CommandInfo>, | ||||
| } | ||||
| 
 | ||||
| pub struct CommandInfo { | ||||
|   pub name: String, | ||||
|   pub executor: CommandExecutor, | ||||
|   pub register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, | ||||
| } | ||||
| 
 | ||||
| impl CommandManager { | ||||
|   pub fn new() -> Self { | ||||
|     let mut instance = Self { | ||||
|       commands: HashMap::new(), | ||||
|     }; | ||||
| 
 | ||||
|     // Debug-only commands
 | ||||
|     #[cfg(debug_assertions)] | ||||
|     { | ||||
|       instance.insert_command(ping::NAME, ping::register, ping::run); | ||||
|       instance.insert_command(token::NAME, token::register, token::run); | ||||
|     } | ||||
| 
 | ||||
|     // Core commands
 | ||||
|     instance.insert_command(core::link::NAME, core::link::register, core::link::run); | ||||
|     instance.insert_command( | ||||
|       core::unlink::NAME, | ||||
|       core::unlink::register, | ||||
|       core::unlink::run, | ||||
|     ); | ||||
| 
 | ||||
|     // Music commands
 | ||||
|     instance.insert_command(music::join::NAME, music::join::register, music::join::run); | ||||
|     instance.insert_command( | ||||
|       music::leave::NAME, | ||||
|       music::leave::register, | ||||
|       music::leave::run, | ||||
|     ); | ||||
| 
 | ||||
|     instance | ||||
|   } | ||||
| 
 | ||||
|   pub fn insert_command( | ||||
|     &mut self, | ||||
|     name: impl Into<String>, | ||||
|     register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, | ||||
|     executor: CommandExecutor, | ||||
|   ) { | ||||
|     let name = name.into(); | ||||
| 
 | ||||
|     self.commands.insert( | ||||
|       name.clone(), | ||||
|       CommandInfo { | ||||
|         name, | ||||
|         register, | ||||
|         executor, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   pub async fn register_commands(&self, ctx: &Context) { | ||||
|     let cmds = &self.commands; | ||||
| 
 | ||||
|     debug!( | ||||
|       "Registering {} command{}", | ||||
|       cmds.len(), | ||||
|       if cmds.len() == 1 { "" } else { "s" } | ||||
|     ); | ||||
| 
 | ||||
|     fn _register_commands<'a>( | ||||
|       cmds: &HashMap<String, CommandInfo>, | ||||
|       mut commands: &'a mut CreateApplicationCommands, | ||||
|     ) -> &'a mut CreateApplicationCommands { | ||||
|       for cmd in cmds { | ||||
|         commands = commands.create_application_command(|command| (cmd.1.register)(command)); | ||||
|       } | ||||
| 
 | ||||
|       commands | ||||
|     } | ||||
| 
 | ||||
|     if let Ok(guild_id) = std::env::var("GUILD_ID") { | ||||
|       if let Ok(guild_id) = guild_id.parse::<u64>() { | ||||
|         let guild_id = GuildId(guild_id); | ||||
|         guild_id | ||||
|           .set_application_commands(&ctx.http, |command| _register_commands(cmds, command)) | ||||
|           .await | ||||
|           .expect("Failed to create guild commands"); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     Command::set_global_application_commands(&ctx.http, |command| { | ||||
|       _register_commands(cmds, command) | ||||
|     }) | ||||
|     .await | ||||
|     .expect("Failed to create global commands"); | ||||
|   } | ||||
| 
 | ||||
|   pub async fn execute_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) { | ||||
|     let command = self.commands.get(&interaction.data.name); | ||||
| 
 | ||||
|     if let Some(command) = command { | ||||
|       (command.executor)(ctx.clone(), interaction.clone()).await; | ||||
|     } else { | ||||
|       // Command does not exist
 | ||||
|       if let Err(why) = interaction | ||||
|         .create_interaction_response(&ctx.http, |response| { | ||||
|           response | ||||
|             .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|             .interaction_response_data(|message| { | ||||
|               message | ||||
|                 .content("Woops, that command doesn't exist") | ||||
|                 .ephemeral(true) | ||||
|             }) | ||||
|         }) | ||||
|         .await | ||||
|       { | ||||
|         error!("Failed to respond to command: {}", why); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| impl TypeMapKey for CommandManager { | ||||
|   type Value = CommandManager; | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/bot/commands/music/join.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/bot/commands/music/join.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,141 @@ | ||||
| use log::error; | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
|   Result as SerenityResult, | ||||
| }; | ||||
| 
 | ||||
| use crate::{ | ||||
|   bot::commands::CommandOutput, | ||||
|   session::manager::{SessionCreateError, SessionManager}, | ||||
| }; | ||||
| 
 | ||||
| pub const NAME: &str = "join"; | ||||
| 
 | ||||
| async fn respond_message( | ||||
|   ctx: &Context, | ||||
|   command: &ApplicationCommandInteraction, | ||||
|   msg: impl Into<String>, | ||||
|   ephemeral: bool, | ||||
| ) -> SerenityResult<()> { | ||||
|   command | ||||
|     .create_interaction_response(&ctx.http, |response| { | ||||
|       response | ||||
|         .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|         .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) | ||||
|     }) | ||||
|     .await | ||||
| } | ||||
| 
 | ||||
| fn check_msg(result: SerenityResult<()>) { | ||||
|   if let Err(why) = result { | ||||
|     error!("Error sending message: {:?}", why); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     let guild = ctx.cache.guild(command.guild_id.unwrap()).unwrap(); | ||||
| 
 | ||||
|     // Get the voice channel id of the calling user
 | ||||
|     let channel_id = match guild | ||||
|       .voice_states | ||||
|       .get(&command.user.id) | ||||
|       .and_then(|state| state.channel_id) | ||||
|     { | ||||
|       Some(channel_id) => channel_id, | ||||
|       None => { | ||||
|         check_msg( | ||||
|           respond_message( | ||||
|             &ctx, | ||||
|             &command, | ||||
|             "You need to connect to a voice channel", | ||||
|             true, | ||||
|           ) | ||||
|           .await, | ||||
|         ); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     let data = ctx.data.read().await; | ||||
|     let mut session_manager = data.get::<SessionManager>().unwrap().clone(); | ||||
| 
 | ||||
|     // Check if another session is already active in this server
 | ||||
|     if let Some(session) = session_manager.get_session(guild.id).await { | ||||
|       let msg = if session.get_owner() == command.user.id { | ||||
|         "You are already playing music in this server" | ||||
|       } else { | ||||
|         "Someone else is already playing music in this server" | ||||
|       }; | ||||
| 
 | ||||
|       check_msg(respond_message(&ctx, &command, msg, true).await); | ||||
| 
 | ||||
|       return; | ||||
|     }; | ||||
| 
 | ||||
|     // Prevent duplicate Spotify sessions
 | ||||
|     if let Some(session) = session_manager.find(command.user.id).await { | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           format!( | ||||
|             "You are already playing music in another server ({}).\nStop playing in that server first before joining this one.", | ||||
|             ctx.cache.guild(session.get_guild_id()).unwrap().name | ||||
|           ), | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
| 
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Create the session, and handle potential errors
 | ||||
|     if let Err(why) = session_manager | ||||
|       .create_session(&ctx, guild.id, channel_id, command.user.id) | ||||
|       .await | ||||
|     { | ||||
|       // Need to link first
 | ||||
|       if let SessionCreateError::NoSpotifyError = why { | ||||
|         check_msg( | ||||
|           respond_message( | ||||
|             &ctx, | ||||
|             &command, | ||||
|             "You need to link your Spotify account. Use `/link` or go to https://account.spoticord.com/ to get started.", | ||||
|             true, | ||||
|           ) | ||||
|           .await, | ||||
|         ); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // Any other error
 | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           "An error occurred while joining the channel. Please try again later.", | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
| 
 | ||||
|       return; | ||||
|     }; | ||||
| 
 | ||||
|     check_msg(respond_message(&ctx, &command, "Joined the voice channel.", false).await); | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name(NAME) | ||||
|     .description("Request the bot to join the current voice channel") | ||||
| } | ||||
							
								
								
									
										86
									
								
								src/bot/commands/music/leave.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/bot/commands/music/leave.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,86 @@ | ||||
| use log::error; | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
|   Result as SerenityResult, | ||||
| }; | ||||
| 
 | ||||
| use crate::{bot::commands::CommandOutput, session::manager::SessionManager}; | ||||
| 
 | ||||
| pub const NAME: &str = "leave"; | ||||
| 
 | ||||
| async fn respond_message( | ||||
|   ctx: &Context, | ||||
|   command: &ApplicationCommandInteraction, | ||||
|   msg: &str, | ||||
|   ephemeral: bool, | ||||
| ) -> SerenityResult<()> { | ||||
|   command | ||||
|     .create_interaction_response(&ctx.http, |response| { | ||||
|       response | ||||
|         .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|         .interaction_response_data(|message| message.content(msg).ephemeral(ephemeral)) | ||||
|     }) | ||||
|     .await | ||||
| } | ||||
| 
 | ||||
| fn check_msg(result: SerenityResult<()>) { | ||||
|   if let Err(why) = result { | ||||
|     error!("Error sending message: {:?}", why); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     let data = ctx.data.read().await; | ||||
|     let session_manager = data.get::<SessionManager>().unwrap().clone(); | ||||
| 
 | ||||
|     let session = match session_manager.get_session(command.guild_id.unwrap()).await { | ||||
|       Some(session) => session, | ||||
|       None => { | ||||
|         check_msg( | ||||
|           respond_message( | ||||
|             &ctx, | ||||
|             &command, | ||||
|             "I'm currently not connected to any voice channel", | ||||
|             true, | ||||
|           ) | ||||
|           .await, | ||||
|         ); | ||||
|         return; | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     if session.get_owner() != command.user.id { | ||||
|       // This message was generated by AI, and I love it.
 | ||||
|       check_msg(respond_message(&ctx, &command, "You are not the one who summoned me", true).await); | ||||
|       return; | ||||
|     }; | ||||
| 
 | ||||
|     if let Err(why) = session.disconnect().await { | ||||
|       error!("Error disconnecting from voice channel: {:?}", why); | ||||
| 
 | ||||
|       check_msg( | ||||
|         respond_message( | ||||
|           &ctx, | ||||
|           &command, | ||||
|           "An error occurred while disconnecting from the voice channel", | ||||
|           true, | ||||
|         ) | ||||
|         .await, | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     check_msg(respond_message(&ctx, &command, "Successfully left the voice channel", false).await); | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name(NAME) | ||||
|     .description("Request the bot to leave the current voice channel") | ||||
| } | ||||
							
								
								
									
										2
									
								
								src/bot/commands/music/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/bot/commands/music/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| pub mod join; | ||||
| pub mod leave; | ||||
							
								
								
									
										33
									
								
								src/bot/commands/ping.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/bot/commands/ping.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | ||||
| use log::info; | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
| }; | ||||
| 
 | ||||
| use super::CommandOutput; | ||||
| 
 | ||||
| pub const NAME: &str = "ping"; | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     info!("Pong!"); | ||||
| 
 | ||||
|     command | ||||
|       .create_interaction_response(&ctx.http, |response| { | ||||
|         response | ||||
|           .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|           .interaction_response_data(|message| message.content("Pong!")) | ||||
|       }) | ||||
|       .await | ||||
|       .unwrap(); | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name("ping") | ||||
|     .description("Check if the bot is alive") | ||||
| } | ||||
							
								
								
									
										42
									
								
								src/bot/commands/token.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/bot/commands/token.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | ||||
| use serenity::{ | ||||
|   builder::CreateApplicationCommand, | ||||
|   model::prelude::interaction::{ | ||||
|     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||
|   }, | ||||
|   prelude::Context, | ||||
| }; | ||||
| 
 | ||||
| use crate::database::Database; | ||||
| 
 | ||||
| use super::CommandOutput; | ||||
| 
 | ||||
| pub const NAME: &str = "token"; | ||||
| 
 | ||||
| pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||
|   Box::pin(async move { | ||||
|     let data = ctx.data.read().await; | ||||
|     let db = data.get::<Database>().unwrap(); | ||||
| 
 | ||||
|     let token = db.get_access_token(command.user.id.to_string()).await; | ||||
| 
 | ||||
|     let content = match token { | ||||
|       Ok(token) => format!("Your token is: {}", token), | ||||
|       Err(why) => format!("You don't have a token yet. (Real: {})", why), | ||||
|     }; | ||||
| 
 | ||||
|     command | ||||
|       .create_interaction_response(&ctx.http, |response| { | ||||
|         response | ||||
|           .kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|           .interaction_response_data(|message| message.content(content).ephemeral(true)) | ||||
|       }) | ||||
|       .await | ||||
|       .unwrap(); | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||
|   command | ||||
|     .name("token") | ||||
|     .description("Get your Spotify access token") | ||||
| } | ||||
							
								
								
									
										61
									
								
								src/bot/events.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/bot/events.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | ||||
| /* This file implements all events for the Discord gateway */ | ||||
| 
 | ||||
| use log::*; | ||||
| use serenity::{ | ||||
|   async_trait, | ||||
|   model::prelude::{interaction::Interaction, Ready}, | ||||
|   prelude::{Context, EventHandler}, | ||||
| }; | ||||
| 
 | ||||
| use super::commands::CommandManager; | ||||
| 
 | ||||
| // Handler struct with a command parameter, an array of dictionary which takes a string and function
 | ||||
| pub struct Handler; | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl EventHandler for Handler { | ||||
|   // READY event, emitted when the bot/shard starts up
 | ||||
|   async fn ready(&self, ctx: Context, ready: Ready) { | ||||
|     let data = ctx.data.read().await; | ||||
|     let command_manager = data.get::<CommandManager>().unwrap(); | ||||
| 
 | ||||
|     debug!("Ready received, logged in as {}", ready.user.name); | ||||
| 
 | ||||
|     command_manager.register_commands(&ctx).await; | ||||
| 
 | ||||
|     info!("{} has come online", ready.user.name); | ||||
|   } | ||||
| 
 | ||||
|   // INTERACTION_CREATE event, emitted when the bot receives an interaction (slash command, button, etc.)
 | ||||
|   async fn interaction_create(&self, ctx: Context, interaction: Interaction) { | ||||
|     if let Interaction::ApplicationCommand(command) = interaction { | ||||
|       // Commands must only be executed inside of guilds
 | ||||
|       if command.guild_id.is_none() { | ||||
|         command | ||||
|           .create_interaction_response(&ctx.http, |response| { | ||||
|             response | ||||
|               .kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource) | ||||
|               .interaction_response_data(|message| { | ||||
|                 message.content("This command can only be used in a server") | ||||
|               }) | ||||
|           }) | ||||
|           .await | ||||
|           .unwrap(); | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       trace!( | ||||
|         "Received command interaction: command={} user={} guild={}", | ||||
|         command.data.name, | ||||
|         command.user.id, | ||||
|         command.guild_id.unwrap() | ||||
|       ); | ||||
| 
 | ||||
|       let data = ctx.data.read().await; | ||||
|       let command_manager = data.get::<CommandManager>().unwrap(); | ||||
| 
 | ||||
|       command_manager.execute_command(&ctx, command).await; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										2
									
								
								src/bot/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/bot/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| pub mod commands; | ||||
| pub mod events; | ||||
							
								
								
									
										269
									
								
								src/database.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								src/database.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,269 @@ | ||||
| use thiserror::Error; | ||||
| 
 | ||||
| use log::trace; | ||||
| use reqwest::{header::HeaderMap, Client, Error, Response, StatusCode}; | ||||
| use serde::{de::DeserializeOwned, Deserialize, Serialize}; | ||||
| use serde_json::{json, Value}; | ||||
| use serenity::prelude::TypeMapKey; | ||||
| 
 | ||||
| use crate::utils; | ||||
| 
 | ||||
| #[derive(Debug, Error)] | ||||
| pub enum DatabaseError { | ||||
|   #[error("An error has occured during an I/O operation: {0}")] | ||||
|   IOError(String), | ||||
| 
 | ||||
|   #[error("An error has occured during a parsing operation: {0}")] | ||||
|   ParseError(String), | ||||
| 
 | ||||
|   #[error("An invalid status code was returned from a request: {0}")] | ||||
|   InvalidStatusCode(StatusCode), | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize)] | ||||
| struct GetAccessTokenResponse { | ||||
|   id: String, | ||||
|   access_token: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| pub struct User { | ||||
|   pub id: String, | ||||
|   pub device_name: String, | ||||
|   pub request: Option<Request>, | ||||
|   pub accounts: Vec<Account>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| pub struct Account { | ||||
|   pub user_id: String, | ||||
|   pub r#type: String, | ||||
|   pub access_token: String, | ||||
|   pub refresh_token: String, | ||||
|   pub expires: u64, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| pub struct Request { | ||||
|   pub token: String, | ||||
|   pub user_id: String, | ||||
|   pub expires: u64, | ||||
| } | ||||
| 
 | ||||
| pub struct Database { | ||||
|   base_url: String, | ||||
|   default_headers: Option<HeaderMap>, | ||||
| } | ||||
| 
 | ||||
| // Request options
 | ||||
| #[derive(Debug, Clone)] | ||||
| struct RequestOptions { | ||||
|   pub method: Method, | ||||
|   pub path: String, | ||||
|   pub body: Option<Body>, | ||||
|   pub headers: Option<HeaderMap>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[allow(dead_code)] | ||||
| enum Body { | ||||
|   Json(Value), | ||||
|   Text(String), | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[allow(dead_code)] | ||||
| enum Method { | ||||
|   Get, | ||||
|   Post, | ||||
|   Put, | ||||
|   Delete, | ||||
| } | ||||
| 
 | ||||
| impl Database { | ||||
|   pub fn new(base_url: impl Into<String>, default_headers: Option<HeaderMap>) -> Self { | ||||
|     Self { | ||||
|       base_url: base_url.into(), | ||||
|       default_headers, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async fn request(&self, options: RequestOptions) -> Result<Response, Error> { | ||||
|     let builder = Client::builder(); | ||||
|     let mut headers: HeaderMap = HeaderMap::new(); | ||||
|     let mut url = self.base_url.clone(); | ||||
| 
 | ||||
|     url.push_str(&options.path); | ||||
| 
 | ||||
|     if let Some(default_headers) = &self.default_headers { | ||||
|       headers.extend(default_headers.clone()); | ||||
|     } | ||||
| 
 | ||||
|     if let Some(request_headers) = options.headers { | ||||
|       headers.extend(request_headers); | ||||
|     } | ||||
| 
 | ||||
|     trace!("Requesting {} with headers: {:?}", url, headers); | ||||
| 
 | ||||
|     let client = builder.default_headers(headers).build()?; | ||||
| 
 | ||||
|     let mut request = match options.method { | ||||
|       Method::Get => client.get(url), | ||||
|       Method::Post => client.post(url), | ||||
|       Method::Put => client.put(url), | ||||
|       Method::Delete => client.delete(url), | ||||
|     }; | ||||
| 
 | ||||
|     request = if let Some(body) = options.body { | ||||
|       match body { | ||||
|         Body::Json(json) => request.json(&json), | ||||
|         Body::Text(text) => request.body(text), | ||||
|       } | ||||
|     } else { | ||||
|       request | ||||
|     }; | ||||
| 
 | ||||
|     let response = request.send().await?; | ||||
| 
 | ||||
|     Ok(response) | ||||
|   } | ||||
| 
 | ||||
|   async fn simple_get<T: DeserializeOwned>( | ||||
|     &self, | ||||
|     path: impl Into<String>, | ||||
|   ) -> Result<T, DatabaseError> { | ||||
|     let response = match self | ||||
|       .request(RequestOptions { | ||||
|         method: Method::Get, | ||||
|         path: path.into(), | ||||
|         body: None, | ||||
|         headers: None, | ||||
|       }) | ||||
|       .await | ||||
|     { | ||||
|       Ok(response) => response, | ||||
|       Err(error) => return Err(DatabaseError::IOError(error.to_string())), | ||||
|     }; | ||||
| 
 | ||||
|     match response.status() { | ||||
|       StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {} | ||||
|       status => return Err(DatabaseError::InvalidStatusCode(status)), | ||||
|     }; | ||||
| 
 | ||||
|     let body = match response.json::<T>().await { | ||||
|       Ok(body) => body, | ||||
|       Err(error) => return Err(DatabaseError::ParseError(error.to_string())), | ||||
|     }; | ||||
| 
 | ||||
|     Ok(body) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| impl Database { | ||||
|   pub async fn get_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> { | ||||
|     let path = format!("/user/{}", user_id.into()); | ||||
| 
 | ||||
|     self.simple_get(path).await | ||||
|   } | ||||
| 
 | ||||
|   // Get the Spotify access token for a user
 | ||||
|   pub async fn get_access_token( | ||||
|     &self, | ||||
|     user_id: impl Into<String> + Send, | ||||
|   ) -> Result<String, DatabaseError> { | ||||
|     let body: GetAccessTokenResponse = self | ||||
|       .simple_get(format!("/user/{}/spotify/access_token", user_id.into())) | ||||
|       .await?; | ||||
| 
 | ||||
|     Ok(body.access_token) | ||||
|   } | ||||
| 
 | ||||
|   // Get the Spotify account for a user
 | ||||
|   pub async fn get_user_account( | ||||
|     &self, | ||||
|     user_id: impl Into<String> + Send, | ||||
|   ) -> Result<Account, DatabaseError> { | ||||
|     let body: Account = self | ||||
|       .simple_get(format!("/account/{}/spotify", user_id.into())) | ||||
|       .await?; | ||||
| 
 | ||||
|     Ok(body) | ||||
|   } | ||||
| 
 | ||||
|   // Get the Request for a user
 | ||||
|   pub async fn get_user_request( | ||||
|     &self, | ||||
|     user_id: impl Into<String> + Send, | ||||
|   ) -> Result<Request, DatabaseError> { | ||||
|     let body: Request = self | ||||
|       .simple_get(format!("/request/by-user/{}", user_id.into())) | ||||
|       .await?; | ||||
| 
 | ||||
|     Ok(body) | ||||
|   } | ||||
| 
 | ||||
|   // Create the link Request for a user
 | ||||
|   pub async fn create_user_request( | ||||
|     &self, | ||||
|     user_id: impl Into<String> + Send, | ||||
|   ) -> Result<Request, DatabaseError> { | ||||
|     let body = json!({ | ||||
|       "user_id": user_id.into(), | ||||
|       "expires": utils::get_time() + (1000 * 60 * 60) | ||||
|     }); | ||||
| 
 | ||||
|     let response = match self | ||||
|       .request(RequestOptions { | ||||
|         method: Method::Post, | ||||
|         path: "/request".into(), | ||||
|         body: Some(Body::Json(body)), | ||||
|         headers: None, | ||||
|       }) | ||||
|       .await | ||||
|     { | ||||
|       Ok(response) => response, | ||||
|       Err(err) => return Err(DatabaseError::IOError(err.to_string())), | ||||
|     }; | ||||
| 
 | ||||
|     match response.status() { | ||||
|       StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {} | ||||
|       status => return Err(DatabaseError::InvalidStatusCode(status)), | ||||
|     }; | ||||
| 
 | ||||
|     let body = match response.json::<Request>().await { | ||||
|       Ok(body) => body, | ||||
|       Err(error) => return Err(DatabaseError::ParseError(error.to_string())), | ||||
|     }; | ||||
| 
 | ||||
|     Ok(body) | ||||
|   } | ||||
| 
 | ||||
|   pub async fn delete_user_account( | ||||
|     &self, | ||||
|     user_id: impl Into<String> + Send, | ||||
|   ) -> Result<(), DatabaseError> { | ||||
|     let response = match self | ||||
|       .request(RequestOptions { | ||||
|         method: Method::Delete, | ||||
|         path: format!("/account/{}/spotify", user_id.into()), | ||||
|         body: None, | ||||
|         headers: None, | ||||
|       }) | ||||
|       .await | ||||
|     { | ||||
|       Ok(response) => response, | ||||
|       Err(err) => return Err(DatabaseError::IOError(err.to_string())), | ||||
|     }; | ||||
| 
 | ||||
|     match response.status() { | ||||
|       StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {} | ||||
|       status => return Err(DatabaseError::InvalidStatusCode(status)), | ||||
|     }; | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| impl TypeMapKey for Database { | ||||
|   type Value = Database; | ||||
| } | ||||
							
								
								
									
										69
									
								
								src/ipc/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/ipc/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | ||||
| use std::sync::{Arc, Mutex}; | ||||
| 
 | ||||
| use ipc_channel::ipc::{self, IpcError, IpcOneShotServer, IpcReceiver, IpcSender}; | ||||
| 
 | ||||
| use self::packet::IpcPacket; | ||||
| 
 | ||||
| pub mod packet; | ||||
| 
 | ||||
| pub struct Server { | ||||
|   tx: IpcOneShotServer<IpcSender<IpcPacket>>, | ||||
|   rx: IpcOneShotServer<IpcReceiver<IpcPacket>>, | ||||
| } | ||||
| 
 | ||||
| impl Server { | ||||
|   pub fn create() -> Result<(Self, String, String), IpcError> { | ||||
|     let (tx, tx_name) = IpcOneShotServer::new().map_err(IpcError::Io)?; | ||||
|     let (rx, rx_name) = IpcOneShotServer::new().map_err(IpcError::Io)?; | ||||
| 
 | ||||
|     Ok((Self { tx, rx }, tx_name, rx_name)) | ||||
|   } | ||||
| 
 | ||||
|   pub fn accept(self) -> Result<Client, IpcError> { | ||||
|     let (_, tx) = self.tx.accept().map_err(IpcError::Bincode)?; | ||||
|     let (_, rx) = self.rx.accept().map_err(IpcError::Bincode)?; | ||||
| 
 | ||||
|     Ok(Client::new(tx, rx)) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone)] | ||||
| pub struct Client { | ||||
|   tx: Arc<Mutex<IpcSender<IpcPacket>>>, | ||||
|   rx: Arc<Mutex<IpcReceiver<IpcPacket>>>, | ||||
| } | ||||
| 
 | ||||
| impl Client { | ||||
|   pub fn new(tx: IpcSender<IpcPacket>, rx: IpcReceiver<IpcPacket>) -> Client { | ||||
|     Client { | ||||
|       tx: Arc::new(Mutex::new(tx)), | ||||
|       rx: Arc::new(Mutex::new(rx)), | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   pub fn connect(tx_name: impl Into<String>, rx_name: impl Into<String>) -> Result<Self, IpcError> { | ||||
|     let (tx, remote_rx) = ipc::channel().map_err(IpcError::Io)?; | ||||
|     let (remote_tx, rx) = ipc::channel().map_err(IpcError::Io)?; | ||||
| 
 | ||||
|     let ttx = IpcSender::connect(tx_name.into()).map_err(IpcError::Io)?; | ||||
|     let trx = IpcSender::connect(rx_name.into()).map_err(IpcError::Io)?; | ||||
| 
 | ||||
|     ttx.send(remote_tx).map_err(IpcError::Bincode)?; | ||||
|     trx.send(remote_rx).map_err(IpcError::Bincode)?; | ||||
| 
 | ||||
|     Ok(Client::new(tx, rx)) | ||||
|   } | ||||
| 
 | ||||
|   pub fn send(&self, packet: IpcPacket) -> Result<(), IpcError> { | ||||
|     self | ||||
|       .tx | ||||
|       .lock() | ||||
|       .unwrap() | ||||
|       .send(packet) | ||||
|       .map_err(IpcError::Bincode) | ||||
|   } | ||||
| 
 | ||||
|   pub fn recv(&self) -> Result<IpcPacket, IpcError> { | ||||
|     self.rx.lock().unwrap().recv() | ||||
|   } | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/ipc/packet.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/ipc/packet.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| pub enum IpcPacket { | ||||
|   Quit, | ||||
| 
 | ||||
|   Connect(String, String), | ||||
|   Disconnect, | ||||
| 
 | ||||
|   StartPlayback, | ||||
|   StopPlayback, | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/librespot_ext/discovery.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/librespot_ext/discovery.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| use librespot::discovery::Credentials; | ||||
| use librespot::protocol::authentication::AuthenticationType; | ||||
| 
 | ||||
| pub trait CredentialsExt { | ||||
|   fn with_token(username: impl Into<String>, token: impl Into<String>) -> Credentials; | ||||
| } | ||||
| 
 | ||||
| impl CredentialsExt for Credentials { | ||||
|   // Enable the use of a token to connect to Spotify
 | ||||
|   // Wouldn't want to ask users for their password would we?
 | ||||
|   fn with_token(username: impl Into<String>, token: impl Into<String>) -> Credentials { | ||||
|     Credentials { | ||||
|       username: username.into(), | ||||
|       auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, | ||||
|       auth_data: token.into().into_bytes(), | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								src/librespot_ext/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/librespot_ext/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| // Librespot extensions
 | ||||
| // =============================
 | ||||
| // Librespot is missing some key features/functionality for Spoticord to work properly.
 | ||||
| // This module contains the extensions to librespot that are required for Spoticord to work.
 | ||||
| 
 | ||||
| pub mod discovery; | ||||
							
								
								
									
										87
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,87 @@ | ||||
| use chrono::Datelike; | ||||
| use dotenv::dotenv; | ||||
| 
 | ||||
| use log::*; | ||||
| use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client}; | ||||
| use songbird::SerenityInit; | ||||
| use std::env; | ||||
| 
 | ||||
| use crate::{bot::commands::CommandManager, database::Database, session::manager::SessionManager}; | ||||
| 
 | ||||
| mod audio; | ||||
| mod bot; | ||||
| mod database; | ||||
| mod ipc; | ||||
| mod librespot_ext; | ||||
| mod player; | ||||
| mod session; | ||||
| mod utils; | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
|   env_logger::init(); | ||||
| 
 | ||||
|   let args: Vec<String> = env::args().collect(); | ||||
| 
 | ||||
|   if args.len() > 2 { | ||||
|     if &args[1] == "--player" { | ||||
|       // Woah! We're running in player mode!
 | ||||
| 
 | ||||
|       debug!("Starting Spoticord player"); | ||||
| 
 | ||||
|       player::main().await; | ||||
| 
 | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   info!("It's a good day"); | ||||
|   info!(" - Spoticord {}", chrono::Utc::now().year()); | ||||
| 
 | ||||
|   let result = dotenv(); | ||||
| 
 | ||||
|   if let Ok(path) = result { | ||||
|     debug!("Loaded environment file: {}", path.to_str().unwrap()); | ||||
|   } else { | ||||
|     warn!("No .env file found, expecting all necessary environment variables"); | ||||
|   } | ||||
| 
 | ||||
|   let token = env::var("TOKEN").expect("a token in the environment"); | ||||
|   let db_url = env::var("DATABASE_URL").expect("a database URL in the environment"); | ||||
| 
 | ||||
|   // Create client
 | ||||
|   let mut client = Client::builder( | ||||
|     token, | ||||
|     GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES, | ||||
|   ) | ||||
|   .event_handler(crate::bot::events::Handler) | ||||
|   .framework(StandardFramework::new()) | ||||
|   .register_songbird() | ||||
|   .await | ||||
|   .unwrap(); | ||||
| 
 | ||||
|   { | ||||
|     let mut data = client.data.write().await; | ||||
| 
 | ||||
|     data.insert::<Database>(Database::new(db_url, None)); | ||||
|     data.insert::<CommandManager>(CommandManager::new()); | ||||
|     data.insert::<SessionManager>(SessionManager::new()); | ||||
|   } | ||||
| 
 | ||||
|   let shard_manager = client.shard_manager.clone(); | ||||
| 
 | ||||
|   // Spawn a task to shutdown the bot when a SIGINT is received
 | ||||
|   tokio::spawn(async move { | ||||
|     tokio::signal::ctrl_c() | ||||
|       .await | ||||
|       .expect("Could not register CTRL+C handler"); | ||||
| 
 | ||||
|     info!("SIGINT Received, shutting down..."); | ||||
| 
 | ||||
|     shard_manager.lock().await.shutdown_all().await; | ||||
|   }); | ||||
| 
 | ||||
|   if let Err(why) = client.start_autosharded().await { | ||||
|     println!("Error in bot: {:?}", why); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										190
									
								
								src/player/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								src/player/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,190 @@ | ||||
| use librespot::{ | ||||
|   connect::spirc::Spirc, | ||||
|   core::{ | ||||
|     config::{ConnectConfig, SessionConfig}, | ||||
|     session::Session, | ||||
|   }, | ||||
|   discovery::Credentials, | ||||
|   playback::{ | ||||
|     config::{Bitrate, PlayerConfig}, | ||||
|     mixer::{self, MixerConfig}, | ||||
|     player::Player, | ||||
|   }, | ||||
| }; | ||||
| use log::{debug, error, info, trace, warn}; | ||||
| use serde_json::json; | ||||
| 
 | ||||
| use crate::{ | ||||
|   audio::backend::StdoutSink, | ||||
|   ipc::{self, packet::IpcPacket}, | ||||
|   librespot_ext::discovery::CredentialsExt, | ||||
|   utils, | ||||
| }; | ||||
| 
 | ||||
| pub struct SpoticordPlayer { | ||||
|   client: ipc::Client, | ||||
|   session: Option<Session>, | ||||
| } | ||||
| 
 | ||||
| impl SpoticordPlayer { | ||||
|   pub fn create(client: ipc::Client) -> Self { | ||||
|     Self { | ||||
|       client, | ||||
|       session: None, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   pub async fn start(&mut self, token: impl Into<String>, device_name: impl Into<String>) { | ||||
|     let token = token.into(); | ||||
| 
 | ||||
|     // Get the username (required for librespot)
 | ||||
|     let username = utils::spotify::get_username(&token).await.unwrap(); | ||||
| 
 | ||||
|     let session_config = SessionConfig::default(); | ||||
|     let player_config = PlayerConfig { | ||||
|       bitrate: Bitrate::Bitrate96, | ||||
|       ..PlayerConfig::default() | ||||
|     }; | ||||
| 
 | ||||
|     // Log in using the token
 | ||||
|     let credentials = Credentials::with_token(username, &token); | ||||
| 
 | ||||
|     // Connect the session
 | ||||
|     let (session, _) = match Session::connect(session_config, credentials, None, false).await { | ||||
|       Ok((session, credentials)) => (session, credentials), | ||||
|       Err(why) => panic!("Failed to connect: {}", why), | ||||
|     }; | ||||
| 
 | ||||
|     // Store session for later use
 | ||||
|     self.session = Some(session.clone()); | ||||
| 
 | ||||
|     // Volume mixer
 | ||||
|     let mixer = (mixer::find(Some("softvol")).unwrap())(MixerConfig::default()); | ||||
| 
 | ||||
|     let client = self.client.clone(); | ||||
| 
 | ||||
|     // Create the player
 | ||||
|     let (player, _) = Player::new( | ||||
|       player_config, | ||||
|       session.clone(), | ||||
|       mixer.get_soft_volume(), | ||||
|       move || Box::new(StdoutSink::new(client)), | ||||
|     ); | ||||
| 
 | ||||
|     let mut receiver = player.get_player_event_channel(); | ||||
| 
 | ||||
|     let (_, spirc_run) = Spirc::new( | ||||
|       ConnectConfig { | ||||
|         name: device_name.into(), | ||||
|         initial_volume: Some(65535), | ||||
|         ..ConnectConfig::default() | ||||
|       }, | ||||
|       session.clone(), | ||||
|       player, | ||||
|       mixer, | ||||
|     ); | ||||
| 
 | ||||
|     let device_id = session.device_id().to_owned(); | ||||
| 
 | ||||
|     // IPC Handler
 | ||||
|     tokio::spawn(async move { | ||||
|       let client = reqwest::Client::new(); | ||||
| 
 | ||||
|       // Try to switch to the device
 | ||||
|       loop { | ||||
|         match client | ||||
|           .put("https://api.spotify.com/v1/me/player") | ||||
|           .bearer_auth(token.clone()) | ||||
|           .json(&json!({ | ||||
|             "device_ids": [device_id], | ||||
|           })) | ||||
|           .send() | ||||
|           .await | ||||
|         { | ||||
|           Ok(resp) => { | ||||
|             if resp.status() == 202 { | ||||
|               info!("Successfully switched to device"); | ||||
|               break; | ||||
|             } | ||||
|           } | ||||
|           Err(why) => { | ||||
|             debug!("Failed to set device: {}", why); | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||||
|       } | ||||
| 
 | ||||
|       // TODO: Do IPC stuff with these events
 | ||||
|       loop { | ||||
|         let event = match receiver.recv().await { | ||||
|           Some(event) => event, | ||||
|           None => break, | ||||
|         }; | ||||
| 
 | ||||
|         trace!("Player event: {:?}", event); | ||||
|       } | ||||
| 
 | ||||
|       info!("Player stopped"); | ||||
|     }); | ||||
| 
 | ||||
|     tokio::spawn(spirc_run); | ||||
|   } | ||||
| 
 | ||||
|   pub fn stop(&mut self) { | ||||
|     if let Some(session) = self.session.take() { | ||||
|       session.shutdown(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| pub async fn main() { | ||||
|   let args = std::env::args().collect::<Vec<String>>(); | ||||
| 
 | ||||
|   let tx_name = args[2].clone(); | ||||
|   let rx_name = args[3].clone(); | ||||
| 
 | ||||
|   // Create IPC communication channel
 | ||||
|   let client = ipc::Client::connect(tx_name, rx_name).expect("Failed to connect to IPC"); | ||||
| 
 | ||||
|   // Create the player
 | ||||
|   let mut player = SpoticordPlayer::create(client.clone()); | ||||
| 
 | ||||
|   loop { | ||||
|     let message = match client.recv() { | ||||
|       Ok(message) => message, | ||||
|       Err(why) => { | ||||
|         error!("Failed to receive message: {}", why); | ||||
|         break; | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     match message { | ||||
|       IpcPacket::Connect(token, device_name) => { | ||||
|         info!("Connecting to Spotify with device name {}", device_name); | ||||
| 
 | ||||
|         player.start(token, device_name).await; | ||||
|       } | ||||
| 
 | ||||
|       IpcPacket::Disconnect => { | ||||
|         info!("Disconnecting from Spotify"); | ||||
| 
 | ||||
|         player.stop(); | ||||
|       } | ||||
| 
 | ||||
|       IpcPacket::Quit => { | ||||
|         debug!("Received quit packet, exiting"); | ||||
| 
 | ||||
|         player.stop(); | ||||
|         break; | ||||
|       } | ||||
| 
 | ||||
|       _ => { | ||||
|         warn!("Received unknown packet: {:?}", message); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   info!("We're done here, shutting down..."); | ||||
| } | ||||
							
								
								
									
										85
									
								
								src/session/manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/session/manager.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| use std::{collections::HashMap, sync::Arc}; | ||||
| 
 | ||||
| use serenity::{ | ||||
|   model::prelude::{ChannelId, GuildId, UserId}, | ||||
|   prelude::{Context, TypeMapKey}, | ||||
| }; | ||||
| use thiserror::Error; | ||||
| 
 | ||||
| use super::SpoticordSession; | ||||
| 
 | ||||
| #[derive(Debug, Error)] | ||||
| pub enum SessionCreateError { | ||||
|   #[error("The user has not linked their Spotify account")] | ||||
|   NoSpotifyError, | ||||
| 
 | ||||
|   #[error("An error has occured while communicating with the database")] | ||||
|   DatabaseError, | ||||
| 
 | ||||
|   #[error("Failed to join voice channel {0} ({1})")] | ||||
|   JoinError(ChannelId, GuildId), | ||||
| 
 | ||||
|   #[error("Failed to start player process")] | ||||
|   ForkError, | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone)] | ||||
| pub struct SessionManager { | ||||
|   sessions: Arc<tokio::sync::RwLock<HashMap<GuildId, Arc<SpoticordSession>>>>, | ||||
|   owner_map: Arc<tokio::sync::RwLock<HashMap<UserId, GuildId>>>, | ||||
| } | ||||
| 
 | ||||
| impl TypeMapKey for SessionManager { | ||||
|   type Value = SessionManager; | ||||
| } | ||||
| 
 | ||||
| impl SessionManager { | ||||
|   pub fn new() -> SessionManager { | ||||
|     SessionManager { | ||||
|       sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), | ||||
|       owner_map: Arc::new(tokio::sync::RwLock::new(HashMap::new())), | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// Creates a new session for the given user in the given guild.
 | ||||
|   pub async fn create_session( | ||||
|     &mut self, | ||||
|     ctx: &Context, | ||||
|     guild_id: GuildId, | ||||
|     channel_id: ChannelId, | ||||
|     owner_id: UserId, | ||||
|   ) -> Result<(), SessionCreateError> { | ||||
|     let mut sessions = self.sessions.write().await; | ||||
|     let mut owner_map = self.owner_map.write().await; | ||||
| 
 | ||||
|     let session = SpoticordSession::new(ctx, guild_id, channel_id, owner_id).await?; | ||||
| 
 | ||||
|     sessions.insert(guild_id, Arc::new(session)); | ||||
|     owner_map.insert(owner_id, guild_id); | ||||
| 
 | ||||
|     Ok(()) | ||||
|   } | ||||
| 
 | ||||
|   /// Remove (and destroy) a session
 | ||||
|   pub async fn remove_session(&mut self, guild_id: GuildId) { | ||||
|     let mut sessions = self.sessions.write().await; | ||||
|     sessions.remove(&guild_id); | ||||
|   } | ||||
| 
 | ||||
|   /// Get a session by its guild ID
 | ||||
|   pub async fn get_session(&self, guild_id: GuildId) -> Option<Arc<SpoticordSession>> { | ||||
|     let sessions = self.sessions.read().await; | ||||
| 
 | ||||
|     sessions.get(&guild_id).cloned() | ||||
|   } | ||||
| 
 | ||||
|   /// Find a Spoticord session by their current owner's ID
 | ||||
|   pub async fn find(&self, owner_id: UserId) -> Option<Arc<SpoticordSession>> { | ||||
|     let sessions = self.sessions.read().await; | ||||
|     let owner_map = self.owner_map.read().await; | ||||
| 
 | ||||
|     let guild_id = owner_map.get(&owner_id)?; | ||||
| 
 | ||||
|     sessions.get(&guild_id).cloned() | ||||
|   } | ||||
| } | ||||
							
								
								
									
										241
									
								
								src/session/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								src/session/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,241 @@ | ||||
| use self::manager::{SessionCreateError, SessionManager}; | ||||
| use crate::{ | ||||
|   database::{Database, DatabaseError}, | ||||
|   ipc::{self, packet::IpcPacket}, | ||||
| }; | ||||
| use ipc_channel::ipc::IpcError; | ||||
| use log::*; | ||||
| use serenity::{ | ||||
|   async_trait, | ||||
|   model::prelude::{ChannelId, GuildId, UserId}, | ||||
|   prelude::Context, | ||||
| }; | ||||
| use songbird::{ | ||||
|   create_player, | ||||
|   error::JoinResult, | ||||
|   input::{children_to_reader, Input}, | ||||
|   tracks::TrackHandle, | ||||
|   Call, Event, EventContext, EventHandler, | ||||
| }; | ||||
| use std::{ | ||||
|   process::{Command, Stdio}, | ||||
|   sync::Arc, | ||||
| }; | ||||
| use tokio::sync::Mutex; | ||||
| 
 | ||||
| pub mod manager; | ||||
| 
 | ||||
| #[derive(Clone)] | ||||
| pub struct SpoticordSession { | ||||
|   owner: UserId, | ||||
|   guild_id: GuildId, | ||||
|   channel_id: ChannelId, | ||||
| 
 | ||||
|   session_manager: SessionManager, | ||||
| 
 | ||||
|   call: Arc<Mutex<Call>>, | ||||
|   track: TrackHandle, | ||||
| } | ||||
| 
 | ||||
| impl SpoticordSession { | ||||
|   pub async fn new( | ||||
|     ctx: &Context, | ||||
|     guild_id: GuildId, | ||||
|     channel_id: ChannelId, | ||||
|     owner_id: UserId, | ||||
|   ) -> Result<SpoticordSession, SessionCreateError> { | ||||
|     // Get the Spotify token of the owner
 | ||||
|     let data = ctx.data.read().await; | ||||
|     let database = data.get::<Database>().unwrap(); | ||||
|     let session_manager = data.get::<SessionManager>().unwrap().clone(); | ||||
| 
 | ||||
|     let token = match database.get_access_token(owner_id.to_string()).await { | ||||
|       Ok(token) => token, | ||||
|       Err(why) => { | ||||
|         if let DatabaseError::InvalidStatusCode(code) = why { | ||||
|           if code == 404 { | ||||
|             return Err(SessionCreateError::NoSpotifyError); | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         return Err(SessionCreateError::DatabaseError); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     let user = match database.get_user(owner_id.to_string()).await { | ||||
|       Ok(user) => user, | ||||
|       Err(why) => { | ||||
|         error!("Failed to get user: {:?}", why); | ||||
|         return Err(SessionCreateError::DatabaseError); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // Create IPC oneshot server
 | ||||
|     let (server, tx_name, rx_name) = match ipc::Server::create() { | ||||
|       Ok(server) => server, | ||||
|       Err(why) => { | ||||
|         error!("Failed to create IPC server: {:?}", why); | ||||
|         return Err(SessionCreateError::ForkError); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // Join the voice channel
 | ||||
|     let songbird = songbird::get(ctx).await.unwrap().clone(); | ||||
| 
 | ||||
|     let (call, result) = songbird.join(guild_id, channel_id).await; | ||||
| 
 | ||||
|     if let Err(why) = result { | ||||
|       error!("Error joining voice channel: {:?}", why); | ||||
|       return Err(SessionCreateError::JoinError(channel_id, guild_id)); | ||||
|     } | ||||
| 
 | ||||
|     let mut call_mut = call.lock().await; | ||||
| 
 | ||||
|     // Spawn player process
 | ||||
|     let args: Vec<String> = std::env::args().collect(); | ||||
|     let child = match Command::new(&args[0]) | ||||
|       .args(["--player", &tx_name, &rx_name]) | ||||
|       .stdout(Stdio::piped()) | ||||
|       .stderr(Stdio::inherit()) | ||||
|       .spawn() | ||||
|     { | ||||
|       Ok(child) => child, | ||||
|       Err(why) => { | ||||
|         error!("Failed to start player process: {:?}", why); | ||||
|         return Err(SessionCreateError::ForkError); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // Establish bi-directional IPC channel
 | ||||
|     let client = match server.accept() { | ||||
|       Ok(client) => client, | ||||
|       Err(why) => { | ||||
|         error!("Failed to accept IPC connection: {:?}", why); | ||||
| 
 | ||||
|         return Err(SessionCreateError::ForkError); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // Pipe player audio to the voice channel
 | ||||
|     let reader = children_to_reader::<f32>(vec![child]); | ||||
| 
 | ||||
|     // Create track (paused, fixes audio glitches)
 | ||||
|     let (mut track, track_handle) = create_player(Input::float_pcm(true, reader)); | ||||
|     track.pause(); | ||||
| 
 | ||||
|     // Set call audio to track
 | ||||
|     call_mut.play_only(track); | ||||
| 
 | ||||
|     // Clone variables for use in the IPC handler
 | ||||
|     let ipc_track = track_handle.clone(); | ||||
|     let ipc_client = client.clone(); | ||||
| 
 | ||||
|     // Handle IPC packets
 | ||||
|     // This will automatically quit once the IPC connection is closed
 | ||||
|     tokio::spawn(async move { | ||||
|       let check_result = |result| { | ||||
|         if let Err(why) = result { | ||||
|           error!("Failed to issue track command: {:?}", why); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       loop { | ||||
|         let msg = match ipc_client.recv() { | ||||
|           Ok(msg) => msg, | ||||
|           Err(why) => { | ||||
|             if let IpcError::Disconnected = why { | ||||
|               break; | ||||
|             } | ||||
| 
 | ||||
|             error!("Failed to receive IPC message: {:?}", why); | ||||
|             break; | ||||
|           } | ||||
|         }; | ||||
| 
 | ||||
|         match msg { | ||||
|           IpcPacket::StartPlayback => { | ||||
|             check_result(ipc_track.play()); | ||||
|           } | ||||
| 
 | ||||
|           IpcPacket::StopPlayback => { | ||||
|             check_result(ipc_track.pause()); | ||||
|           } | ||||
| 
 | ||||
|           _ => {} | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     // Set up events
 | ||||
|     let instance = Self { | ||||
|       owner: owner_id, | ||||
|       guild_id, | ||||
|       channel_id, | ||||
|       session_manager, | ||||
|       call: call.clone(), | ||||
|       track: track_handle, | ||||
|     }; | ||||
| 
 | ||||
|     call_mut.add_global_event( | ||||
|       songbird::Event::Core(songbird::CoreEvent::DriverDisconnect), | ||||
|       instance.clone(), | ||||
|     ); | ||||
| 
 | ||||
|     call_mut.add_global_event( | ||||
|       songbird::Event::Core(songbird::CoreEvent::ClientDisconnect), | ||||
|       instance.clone(), | ||||
|     ); | ||||
| 
 | ||||
|     if let Err(why) = client.send(IpcPacket::Connect(token, user.device_name)) { | ||||
|       error!("Failed to send IpcPacket::Connect packet: {:?}", why); | ||||
|     } | ||||
| 
 | ||||
|     Ok(instance) | ||||
|   } | ||||
| 
 | ||||
|   pub async fn disconnect(&self) -> JoinResult<()> { | ||||
|     info!("Disconnecting from voice channel {}", self.channel_id); | ||||
| 
 | ||||
|     self | ||||
|       .session_manager | ||||
|       .clone() | ||||
|       .remove_session(self.guild_id) | ||||
|       .await; | ||||
| 
 | ||||
|     let mut call = self.call.lock().await; | ||||
| 
 | ||||
|     self.track.stop().unwrap_or(()); | ||||
|     call.remove_all_global_events(); | ||||
|     call.leave().await | ||||
|   } | ||||
| 
 | ||||
|   pub fn get_owner(&self) -> UserId { | ||||
|     self.owner | ||||
|   } | ||||
| 
 | ||||
|   pub fn get_guild_id(&self) -> GuildId { | ||||
|     self.guild_id | ||||
|   } | ||||
| 
 | ||||
|   pub fn get_channel_id(&self) -> ChannelId { | ||||
|     self.channel_id | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl EventHandler for SpoticordSession { | ||||
|   async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> { | ||||
|     match ctx { | ||||
|       EventContext::DriverDisconnect(_) => { | ||||
|         debug!("Driver disconnected, leaving voice channel"); | ||||
|         self.disconnect().await.ok(); | ||||
|       } | ||||
|       EventContext::ClientDisconnect(who) => { | ||||
|         debug!("Client disconnected, {}", who.user_id.to_string()); | ||||
|       } | ||||
|       _ => {} | ||||
|     } | ||||
| 
 | ||||
|     return None; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										10
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| 
 | ||||
| pub mod spotify; | ||||
| 
 | ||||
| pub fn get_time() -> u64 { | ||||
|   let now = SystemTime::now(); | ||||
|   let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); | ||||
| 
 | ||||
|   since_the_epoch.as_secs() | ||||
| } | ||||
							
								
								
									
										36
									
								
								src/utils/spotify.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/utils/spotify.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | ||||
| use log::{error, trace}; | ||||
| use serde_json::Value; | ||||
| 
 | ||||
| pub async fn get_username(token: impl Into<String>) -> Result<String, String> { | ||||
|   let token = token.into(); | ||||
|   let client = reqwest::Client::new(); | ||||
| 
 | ||||
|   let response = match client | ||||
|     .get("https://api.spotify.com/v1/me") | ||||
|     .bearer_auth(token) | ||||
|     .send() | ||||
|     .await | ||||
|   { | ||||
|     Ok(response) => response, | ||||
|     Err(why) => { | ||||
|       error!("Failed to get username: {}", why); | ||||
|       return Err(format!("{}", why)); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let body: Value = match response.json().await { | ||||
|     Ok(body) => body, | ||||
|     Err(why) => { | ||||
|       error!("Failed to parse body: {}", why); | ||||
|       return Err(format!("{}", why)); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   if let Value::String(username) = &body["id"] { | ||||
|     trace!("Got username: {}", username); | ||||
|     return Ok(username.clone()); | ||||
|   } | ||||
| 
 | ||||
|   error!("Missing 'id' field in body"); | ||||
|   Err("Failed to parse body: Invalid body received".to_string()) | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 DaXcess
						DaXcess