initial commit

main
DaXcess 2022-10-18 22:59:32 +02:00
commit 6a77189343
28 changed files with 5184 additions and 0 deletions

9
.gitignore vendored 100644
View File

@ -0,0 +1,9 @@
# Rust
/target
# SQLite database
*.db
*.sqlite
# Secrets
.env

1
.rustfmt.toml 100644
View File

@ -0,0 +1 @@
tab_spaces = 2

3215
Cargo.lock generated 100644

File diff suppressed because it is too large Load Diff

30
Cargo.toml 100644
View 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"

View 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 100644
View File

@ -0,0 +1 @@
pub mod backend;

View 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")
}

View File

@ -0,0 +1,2 @@
pub mod link;
pub mod unlink;

View 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")
}

View 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;
}

View 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")
}

View 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")
}

View File

@ -0,0 +1,2 @@
pub mod join;
pub mod leave;

View 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")
}

View 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 100644
View 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 100644
View File

@ -0,0 +1,2 @@
pub mod commands;
pub mod events;

269
src/database.rs 100644
View 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 100644
View 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 100644
View File

@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum IpcPacket {
Quit,
Connect(String, String),
Disconnect,
StartPlayback,
StopPlayback,
}

View 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(),
}
}
}

View 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 100644
View 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 100644
View 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...");
}

View 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 100644
View 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 100644
View 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()
}

View 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())
}