initial commit
commit
6a77189343
|
@ -0,0 +1,9 @@
|
||||||
|
# Rust
|
||||||
|
/target
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
|
@ -0,0 +1 @@
|
||||||
|
tab_spaces = 2
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod backend;
|
|
@ -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")
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod link;
|
||||||
|
pub mod unlink;
|
|
@ -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")
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod join;
|
||||||
|
pub mod leave;
|
|
@ -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")
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod commands;
|
||||||
|
pub mod events;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub enum IpcPacket {
|
||||||
|
Quit,
|
||||||
|
|
||||||
|
Connect(String, String),
|
||||||
|
Disconnect,
|
||||||
|
|
||||||
|
StartPlayback,
|
||||||
|
StopPlayback,
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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...");
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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…
Reference in New Issue