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