Uhm so yeah here's some c o d e

This commit is contained in:
DaXcess
2022-10-31 22:46:34 +01:00
parent 6a77189343
commit d530b182b3
33 changed files with 1934 additions and 298 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ impl SinkAsBytes for StdoutSink {
buffer.len()
};
while get_buffer_len() > BUFFER_SIZE * 5 {
while get_buffer_len() > BUFFER_SIZE * 2 {
std::thread::sleep(Duration::from_millis(15));
}
+34
View File
@@ -0,0 +1,34 @@
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
utils::embed::{EmbedBuilder, Status},
};
pub const NAME: &str = "help";
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Spoticord Help")
.icon_url("https://spoticord.com/img/logo-standard.webp")
.description(format!("Click **[here](https://spoticord.com/commands)** for a list of commands.\n{}",
"If you need help setting Spoticord up you can check out the **[Documentation](https://spoticord.com/documentation)** page on the Spoticord website.\n\n"))
.status(Status::Info)
.build(),
false,
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name(NAME).description("Shows the help message")
}
+58 -62
View File
@@ -1,53 +1,34 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
Result as SerenityResult,
};
use crate::{bot::commands::CommandOutput, database::Database};
use crate::{
bot::commands::{respond_message, CommandOutput},
database::Database,
utils::embed::{EmbedBuilder, Status},
};
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You have already linked your Spotify account.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
@@ -56,15 +37,22 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Link your Spotify account")
.title_url(&link)
.icon_url("https://spoticord.com/img/spotify-logo.png")
.description(format!(
"Go to [this link]({}) to connect your Spotify account.",
link
))
.status(Status::Info)
.build(),
true,
)
.await;
return;
}
@@ -77,30 +65,38 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Link your Spotify account")
.title_url(&link)
.icon_url("https://spoticord.com/img/spotify-logo.png")
.description(format!(
"Go to [this link]({}) to connect your Spotify account.",
link
))
.status(Status::Info)
.build(),
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("An error occurred while serving your request. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
+3
View File
@@ -1,2 +1,5 @@
pub mod help;
pub mod link;
pub mod rename;
pub mod unlink;
pub mod version;
+165
View File
@@ -0,0 +1,165 @@
use log::error;
use reqwest::StatusCode;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::{
command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction,
},
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
database::{Database, DatabaseError},
utils::{
self,
embed::{EmbedBuilder, Status},
},
};
pub const NAME: &str = "rename";
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let database = data.get::<Database>().unwrap();
// Check if user exists, if not, create them
if let Err(why) = database.get_user(command.user.id.to_string()).await {
match why {
DatabaseError::InvalidStatusCode(StatusCode::NOT_FOUND) => {
if let Err(why) = database.create_user(command.user.id.to_string()).await {
error!("Error creating user: {:?}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Something went wrong while trying to rename your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
_ => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Something went wrong while trying to rename your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
}
let device_name = match command.data.options.get(0) {
Some(option) => match option.value {
Some(ref value) => value.as_str().unwrap().to_string(),
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You need to provide a name for your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
},
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You need to provide a name for your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
if let Err(why) = database
.update_user_device_name(command.user.id.to_string(), &device_name)
.await
{
if let DatabaseError::InvalidInputBody(_) = why {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description(
"Your device name must not exceed 16 characters and be at least 1 character long.",
)
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
error!("Error updating user device name: {:?}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Something went wrong while trying to rename your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description(format!(
"Successfully changed the Spotify device name to **{}**",
utils::discord::escape(device_name)
))
.status(Status::Success)
.build(),
true,
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Set a new device name that is displayed in Spotify")
.create_option(|option| {
option
.name("name")
.description("The new device name")
.kind(CommandOptionType::String)
.max_length(16)
.required(true)
})
}
+30 -52
View File
@@ -1,42 +1,19 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
Result as SerenityResult,
};
use crate::{
bot::commands::CommandOutput,
bot::commands::{respond_message, CommandOutput},
database::{Database, DatabaseError},
session::manager::SessionManager,
utils::embed::{EmbedBuilder, Status},
};
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;
@@ -45,9 +22,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
// 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);
}
session.disconnect().await;
}
// Check if user exists in the first place
@@ -57,15 +32,16 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
{
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You cannot unlink your Spotify account if you haven't linked one.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
@@ -73,28 +49,30 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
error!("Error deleting user account: {:?}", why);
check_msg(
respond_message(
respond_message(
&ctx,
&command,
"An unexpected error has occured while trying to unlink your account. Please try again later.",
EmbedBuilder::new()
.description("An unexpected error has occured while trying to unlink your account. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
}
check_msg(
respond_message(
&ctx,
&command,
"Successfully unlinked your Spotify account from Spoticord",
true,
)
.await,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Successfully unlinked your Spotify account from Spoticord")
.status(Status::Success)
.build(),
true,
)
.await;
})
}
+49
View File
@@ -0,0 +1,49 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
prelude::Context,
};
use crate::{
bot::commands::CommandOutput,
utils::{consts::VERSION, embed::Status},
};
pub const NAME: &str = "version";
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message.embed(|embed| {
embed
.title("Spoticord Version")
.author(|author| {
author
.name("Maintained by: RoDaBaFilms")
.url("https://rodabafilms.com/")
.icon_url("https://rodabafilms.com/logo_2021_nobg.png")
})
.description(format!("Current version: {}\n\nSpoticord is open source, check out [our GitHub](https://github.com/SpoticordMusic)", VERSION))
.color(Status::Info as u64)
})
})
})
.await
{
error!("Error sending message: {:?}", why);
}
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Shows the current running version of Spoticord")
}
+45 -2
View File
@@ -11,12 +11,39 @@ use serenity::{
prelude::{Context, TypeMapKey},
};
use crate::utils::embed::{make_embed_message, EmbedMessageOptions};
mod core;
mod music;
#[cfg(debug_assertions)]
mod ping;
#[cfg(debug_assertions)]
mod token;
pub async fn respond_message(
ctx: &Context,
command: &ApplicationCommandInteraction,
options: EmbedMessageOptions,
ephemeral: bool,
) {
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.embed(|embed| make_embed_message(embed, options))
.ephemeral(ephemeral)
})
})
.await
{
error!("Error sending message: {:?}", why);
}
}
pub type CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>;
pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput;
@@ -44,12 +71,23 @@ impl CommandManager {
}
// Core commands
instance.insert_command(core::help::NAME, core::help::register, core::help::run);
instance.insert_command(
core::version::NAME,
core::version::register,
core::version::run,
);
instance.insert_command(core::link::NAME, core::link::register, core::link::run);
instance.insert_command(
core::unlink::NAME,
core::unlink::register,
core::unlink::run,
);
instance.insert_command(
core::rename::NAME,
core::rename::register,
core::rename::run,
);
// Music commands
instance.insert_command(music::join::NAME, music::join::register, music::join::run);
@@ -58,6 +96,11 @@ impl CommandManager {
music::leave::register,
music::leave::run,
);
instance.insert_command(
music::playing::NAME,
music::playing::register,
music::playing::run,
);
instance
}
@@ -93,8 +136,8 @@ impl CommandManager {
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));
for (_, command_info) in cmds {
commands = commands.create_application_command(|command| (command_info.register)(command));
}
commands
+121 -70
View File
@@ -1,41 +1,17 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
Result as SerenityResult,
};
use crate::{
bot::commands::CommandOutput,
bot::commands::{respond_message, CommandOutput},
session::manager::{SessionCreateError, SessionManager},
utils::embed::{EmbedBuilder, Status},
};
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();
@@ -48,15 +24,18 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
{
Some(channel_id) => channel_id,
None => {
check_msg(
respond_message(
&ctx,
&command,
"You need to connect to a voice channel",
true,
)
.await,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description("You need to connect to a voice channel")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
@@ -66,71 +45,143 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
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"
};
let session_opt = session_manager.get_session(guild.id).await;
if let Some(session) = &session_opt {
if let Some(owner) = session.get_owner().await {
let msg = if owner == command.user.id {
"You are already controlling the bot"
} else {
"The bot is currently being controlled by someone else"
};
check_msg(respond_message(&ctx, &command, msg, true).await);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description(msg)
.status(Status::Error)
.build(),
true,
)
.await;
return;
return;
}
};
// Prevent duplicate Spotify sessions
if let Some(session) = session_manager.find(command.user.id).await {
check_msg(
respond_message(
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description(
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
),
)).status(Status::Error).build(),
true,
)
.await,
);
.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(
if let Some(session) = &session_opt {
if let Err(why) = session.update_owner(&ctx, command.user.id).await {
// Need to link first
if let SessionCreateError::NoSpotifyError = why {
respond_message(
&ctx,
&command,
"You need to link your Spotify account. Use `/link` or go to https://account.spoticord.com/ to get started.",
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description("You need to link your Spotify account. Use </link:1036714850367320136> or go to https://account.spoticord.com/ to get started.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
}
return;
}
// Any other error
check_msg(
// Any other error
respond_message(
&ctx,
&command,
"An error occurred while joining the channel. Please try again later.",
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description("An error occured while joining the channel. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
};
return;
}
} else {
// 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 {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description("You need to link your Spotify account. Use </link:1036714850367320136> or go to https://account.spoticord.com/ to get started.")
.status(Status::Error)
.build(),
true,
)
.await;
check_msg(respond_message(&ctx, &command, "Joined the voice channel.", false).await);
return;
}
// Any other error
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.icon_url("https://spoticord.com/static/image/prohibited.png")
.description("An error occured while joining the channel. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
};
}
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Connected to voice channel")
.icon_url("https://spoticord.com/static/image/speaker.png")
.description(format!("Come listen along in <#{}>", channel_id))
.footer("Spotify will automatically start playing on Spoticord")
.status(Status::Success)
.build(),
false,
)
.await;
})
}
+42 -51
View File
@@ -1,38 +1,17 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
Result as SerenityResult,
};
use crate::{bot::commands::CommandOutput, session::manager::SessionManager};
use crate::{
bot::commands::{respond_message, CommandOutput},
session::manager::SessionManager,
utils::embed::{EmbedBuilder, Status},
};
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;
@@ -41,41 +20,53 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
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,
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot disconnect bot")
.icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg")
.description("I'm currently not connected to any voice channel")
.status(Status::Error)
.build(),
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(
if let Some(owner) = session.get_owner().await {
if owner != command.user.id {
// This message was generated by AI, and I love it.
respond_message(
&ctx,
&command,
"An error occurred while disconnecting from the voice channel",
EmbedBuilder::new()
.description("You are not the one who summoned me")
.status(Status::Error)
.build(),
true,
)
.await,
);
return;
.await;
return;
};
}
check_msg(respond_message(&ctx, &command, "Successfully left the voice channel", false).await);
session.disconnect().await;
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("I have left the voice channel, goodbye for now")
.status(Status::Info)
.build(),
false,
)
.await;
})
}
+1
View File
@@ -1,2 +1,3 @@
pub mod join;
pub mod leave;
pub mod playing;
+170
View File
@@ -0,0 +1,170 @@
use librespot::core::spotify_id::SpotifyAudioType;
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
session::manager::SessionManager,
utils::{embed::{EmbedBuilder, Status}, self},
};
pub const NAME: &str = "playing";
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let not_playing = async {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot get track info")
.icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg")
.description("I'm currently not playing any music in this server")
.status(Status::Error)
.build(),
true,
)
.await;
};
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 => {
not_playing.await;
return;
}
};
let owner = match session.get_owner().await {
Some(owner) => owner,
None => {
not_playing.await;
return;
}
};
// Get Playback Info from session
let pbi = match session.get_playback_info().await {
Some(pbi) => pbi,
None => {
not_playing.await;
return;
}
};
let spotify_id = match pbi.spotify_id {
Some(spotify_id) => spotify_id,
None => {
not_playing.await;
return;
}
};
// Get audio type
let audio_type = if spotify_id.audio_type == SpotifyAudioType::Track {
"track"
} else {
"episode"
};
// Create title
let title = format!("{} - {}", pbi.get_artists().unwrap(), pbi.get_name().unwrap());
// Create description
let mut description = String::new();
let position = pbi.get_position();
let spot = position * 20 / pbi.duration_ms;
description.push_str(if pbi.is_playing { "▶️ " } else { "⏸️ " });
for i in 0..20 {
if i == spot {
description.push_str("🔵");
} else {
description.push_str("▬");
}
}
description.push_str("\n:alarm_clock: ");
description.push_str(&format!("{} / {}", utils::time_to_str(position / 1000), utils::time_to_str(pbi.duration_ms / 1000)));
// Get owner of session
let owner = match ctx.cache.user(owner) {
Some(user) => user,
None => {
// This shouldn't happen
// TODO: This can happen, idk when
error!("Could not find user with id {}", owner);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("[INTERNAL ERROR] Cannot get track info")
.description(format!("Could not find user with id {}\nThis is an issue with the bot!", owner))
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
// Get the thumbnail image
let thumbnail = pbi.get_thumbnail_url().unwrap();
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.embed(|embed|
embed
.author(|author|
author
.name("Currently Playing")
.icon_url("https://www.freepnglogos.com/uploads/spotify-logo-png/file-spotify-logo-png-4.png")
)
.title(title)
.url(format!("https://open.spotify.com/{}/{}", audio_type, spotify_id.to_base62().unwrap()))
.description(description)
.footer(|footer|
footer
.text(&owner.name)
.icon_url(owner.face())
)
.thumbnail(&thumbnail)
.color(Status::Success as u64)
)
})
})
.await
{
error!("Error sending message: {:?}", why);
}
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Display which song is currently being played")
}
+6 -2
View File
@@ -3,10 +3,12 @@
use log::*;
use serenity::{
async_trait,
model::prelude::{interaction::Interaction, Ready},
model::prelude::{interaction::Interaction, Activity, Ready},
prelude::{Context, EventHandler},
};
use crate::utils::consts::MOTD;
use super::commands::CommandManager;
// Handler struct with a command parameter, an array of dictionary which takes a string and function
@@ -23,6 +25,8 @@ impl EventHandler for Handler {
command_manager.register_commands(&ctx).await;
ctx.set_activity(Activity::listening(MOTD)).await;
info!("{} has come online", ready.user.name);
}
@@ -36,7 +40,7 @@ impl EventHandler for Handler {
response
.kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message.content("This command can only be used in a server")
message.content("You can only execute commands inside of a server")
})
})
.await
+1
View File
@@ -1,2 +1,3 @@
// TODO: Check all image urls in embed responses
pub mod commands;
pub mod events;
+86
View File
@@ -18,6 +18,9 @@ pub enum DatabaseError {
#[error("An invalid status code was returned from a request: {0}")]
InvalidStatusCode(StatusCode),
#[error("An invalid input body was provided: {0}")]
InvalidInputBody(String),
}
#[derive(Serialize, Deserialize)]
@@ -78,6 +81,7 @@ enum Method {
Post,
Put,
Delete,
Patch,
}
impl Database {
@@ -112,6 +116,7 @@ impl Database {
Method::Post => client.post(url),
Method::Put => client.put(url),
Method::Delete => client.delete(url),
Method::Patch => client.patch(url),
};
request = if let Some(body) = options.body {
@@ -157,9 +162,43 @@ impl Database {
Ok(body)
}
async fn json_post<T: DeserializeOwned>(
&self,
value: impl Serialize,
path: impl Into<String>,
) -> Result<T, DatabaseError> {
let body = json!(value);
let response = match self
.request(RequestOptions {
method: Method::Post,
path: path.into(),
body: Some(Body::Json(body)),
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 {
// Get Spoticord user
pub async fn get_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
let path = format!("/user/{}", user_id.into());
@@ -202,6 +241,17 @@ impl Database {
Ok(body)
}
// Create a Spoticord user
pub async fn create_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
let body = json!({
"id": user_id.into(),
});
let user: User = self.json_post(body, "/user/new").await?;
Ok(user)
}
// Create the link Request for a user
pub async fn create_user_request(
&self,
@@ -262,6 +312,42 @@ impl Database {
Ok(())
}
pub async fn update_user_device_name(
&self,
user_id: impl Into<String>,
name: impl Into<String>,
) -> Result<(), DatabaseError> {
let device_name: String = name.into();
if device_name.len() > 16 || device_name.len() < 1 {
return Err(DatabaseError::InvalidInputBody(
"Invalid device name length".into(),
));
}
let body = json!({ "device_name": device_name });
let response = match self
.request(RequestOptions {
method: Method::Patch,
path: format!("/user/{}", user_id.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 => {
Ok(())
}
status => return Err(DatabaseError::InvalidStatusCode(status)),
}
}
}
impl TypeMapKey for Database {
+12
View File
@@ -9,4 +9,16 @@ pub enum IpcPacket {
StartPlayback,
StopPlayback,
/// The current Spotify track was changed
TrackChange(String),
/// Spotify playback was started/resumed
Playing(String, u32, u32),
/// Spotify playback was paused
Paused(String, u32, u32),
/// Sent when the user has switched their Spotify device away from Spoticord
Stopped,
}
+67 -11
View File
@@ -4,9 +4,13 @@ use dotenv::dotenv;
use log::*;
use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client};
use songbird::SerenityInit;
use std::env;
use std::{env, process::exit};
use tokio::signal::unix::SignalKind;
use crate::{bot::commands::CommandManager, database::Database, session::manager::SessionManager};
use crate::{
bot::commands::CommandManager, database::Database, session::manager::SessionManager,
stats::StatsManager,
};
mod audio;
mod bot;
@@ -15,10 +19,23 @@ mod ipc;
mod librespot_ext;
mod player;
mod session;
mod stats;
mod utils;
#[tokio::main]
#[tokio::main(flavor = "multi_thread")]
async fn main() {
if std::env::var("RUST_LOG").is_err() {
#[cfg(debug_assertions)]
{
std::env::set_var("RUST_LOG", "spoticord");
}
#[cfg(not(debug_assertions))]
{
std::env::set_var("RUST_LOG", "spoticord=info");
}
}
env_logger::init();
let args: Vec<String> = env::args().collect();
@@ -31,6 +48,8 @@ async fn main() {
player::main().await;
debug!("Player exited, shutting down");
return;
}
}
@@ -48,6 +67,10 @@ async fn main() {
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");
let kv_url = env::var("KV_URL").expect("a redis URL in the environment");
let stats_manager = StatsManager::new(kv_url).expect("Failed to connect to redis");
let session_manager = SessionManager::new();
// Create client
let mut client = Client::builder(
@@ -65,23 +88,56 @@ async fn main() {
data.insert::<Database>(Database::new(db_url, None));
data.insert::<CommandManager>(CommandManager::new());
data.insert::<SessionManager>(SessionManager::new());
data.insert::<SessionManager>(session_manager.clone());
}
let shard_manager = client.shard_manager.clone();
let cache = client.cache_and_http.cache.clone();
// Spawn a task to shutdown the bot when a SIGINT is received
let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate()).unwrap();
// Background tasks
tokio::spawn(async move {
tokio::signal::ctrl_c()
.await
.expect("Could not register CTRL+C handler");
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
let guild_count = cache.guilds().len();
let active_count = session_manager.get_active_session_count().await;
info!("SIGINT Received, shutting down...");
if let Err(why) = stats_manager.set_server_count(guild_count) {
error!("Failed to update server count: {}", why);
}
shard_manager.lock().await.shutdown_all().await;
if let Err(why) = stats_manager.set_active_count(active_count) {
error!("Failed to update active count: {}", why);
}
// Yes, I like to handle my s's when I'm working with amounts
debug!("Updated stats: {} guild{}, {} active session{}", guild_count, if guild_count == 1 { "" } else { "s" }, active_count, if active_count == 1 { "" } else { "s" });
}
_ = tokio::signal::ctrl_c() => {
info!("Received interrupt signal, shutting down...");
shard_manager.lock().await.shutdown_all().await;
break;
}
_ = sigterm.recv() => {
info!("Received terminate signal, shutting down...");
shard_manager.lock().await.shutdown_all().await;
break;
}
}
}
});
// Start the bot
if let Err(why) = client.start_autosharded().await {
println!("Error in bot: {:?}", why);
error!("FATAL Error in bot: {:?}", why);
exit(1);
}
}
+75 -19
View File
@@ -8,10 +8,10 @@ use librespot::{
playback::{
config::{Bitrate, PlayerConfig},
mixer::{self, MixerConfig},
player::Player,
player::{Player, PlayerEvent},
},
};
use log::{debug, error, info, trace, warn};
use log::{debug, error, warn};
use serde_json::json;
use crate::{
@@ -24,6 +24,7 @@ use crate::{
pub struct SpoticordPlayer {
client: ipc::Client,
session: Option<Session>,
spirc: Option<Spirc>,
}
impl SpoticordPlayer {
@@ -31,6 +32,7 @@ impl SpoticordPlayer {
Self {
client,
session: None,
spirc: None,
}
}
@@ -49,6 +51,11 @@ impl SpoticordPlayer {
// Log in using the token
let credentials = Credentials::with_token(username, &token);
// Shutdown old session (cannot be done in the stop function)
if let Some(session) = self.session.take() {
session.shutdown();
}
// Connect the session
let (session, _) = match Session::connect(session_config, credentials, None, false).await {
Ok((session, credentials)) => (session, credentials),
@@ -64,19 +71,18 @@ impl SpoticordPlayer {
let client = self.client.clone();
// Create the player
let (player, _) = Player::new(
let (player, mut receiver) = 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(
let (spirc, spirc_task) = Spirc::new(
ConnectConfig {
name: device_name.into(),
initial_volume: Some(65535),
// 75%
initial_volume: Some((65535 / 4) * 3),
..ConnectConfig::default()
},
session.clone(),
@@ -85,6 +91,7 @@ impl SpoticordPlayer {
);
let device_id = session.device_id().to_owned();
let ipc = self.client.clone();
// IPC Handler
tokio::spawn(async move {
@@ -103,12 +110,12 @@ impl SpoticordPlayer {
{
Ok(resp) => {
if resp.status() == 202 {
info!("Successfully switched to device");
debug!("Successfully switched to device");
break;
}
}
Err(why) => {
debug!("Failed to set device: {}", why);
error!("Failed to set device: {}", why);
break;
}
}
@@ -116,25 +123,76 @@ impl SpoticordPlayer {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
// TODO: Do IPC stuff with these events
// Do IPC stuff with these events
loop {
let event = match receiver.recv().await {
Some(event) => event,
None => break,
};
trace!("Player event: {:?}", event);
match event {
PlayerEvent::Playing {
play_request_id: _,
track_id,
position_ms,
duration_ms,
} => {
if let Err(why) = ipc.send(IpcPacket::Playing(
track_id.to_uri().unwrap(),
position_ms,
duration_ms,
)) {
error!("Failed to send playing packet: {}", why);
}
}
PlayerEvent::Paused {
play_request_id: _,
track_id,
position_ms,
duration_ms,
} => {
if let Err(why) = ipc.send(IpcPacket::Paused(
track_id.to_uri().unwrap(),
position_ms,
duration_ms,
)) {
error!("Failed to send paused packet: {}", why);
}
}
PlayerEvent::Changed {
old_track_id: _,
new_track_id,
} => {
if let Err(why) = ipc.send(IpcPacket::TrackChange(new_track_id.to_uri().unwrap())) {
error!("Failed to send track change packet: {}", why);
}
}
PlayerEvent::Stopped {
play_request_id: _,
track_id: _,
} => {
if let Err(why) = ipc.send(IpcPacket::Stopped) {
error!("Failed to send player stopped packet: {}", why);
}
}
_ => {}
};
}
info!("Player stopped");
debug!("Player stopped");
});
tokio::spawn(spirc_run);
self.spirc = Some(spirc);
session.spawn(spirc_task);
}
pub fn stop(&mut self) {
if let Some(session) = self.session.take() {
session.shutdown();
if let Some(spirc) = self.spirc.take() {
spirc.shutdown();
}
}
}
@@ -162,13 +220,13 @@ pub async fn main() {
match message {
IpcPacket::Connect(token, device_name) => {
info!("Connecting to Spotify with device name {}", device_name);
debug!("Connecting to Spotify with device name {}", device_name);
player.start(token, device_name).await;
}
IpcPacket::Disconnect => {
info!("Disconnecting from Spotify");
debug!("Disconnecting from Spotify");
player.stop();
}
@@ -185,6 +243,4 @@ pub async fn main() {
}
}
}
info!("We're done here, shutting down...");
}
+38 -1
View File
@@ -60,12 +60,34 @@ impl SessionManager {
Ok(())
}
/// Remove (and destroy) a session
/// Remove a session
pub async fn remove_session(&mut self, guild_id: GuildId) {
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get(&guild_id) {
if let Some(owner) = session.get_owner().await {
let mut owner_map = self.owner_map.write().await;
owner_map.remove(&owner);
}
}
sessions.remove(&guild_id);
}
/// Remove owner from owner map.
/// Used whenever a user stops playing music without leaving the bot.
pub async fn remove_owner(&mut self, owner_id: UserId) {
let mut owner_map = self.owner_map.write().await;
owner_map.remove(&owner_id);
}
/// Set the owner of a session
/// Used when a user joins a session that is already active
pub async fn set_owner(&mut self, owner_id: UserId, guild_id: GuildId) {
let mut owner_map = self.owner_map.write().await;
owner_map.insert(owner_id, 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;
@@ -82,4 +104,19 @@ impl SessionManager {
sessions.get(&guild_id).cloned()
}
/// Get the amount of sessions with an owner
pub async fn get_active_session_count(&self) -> usize {
let sessions = self.sessions.read().await;
let mut count: usize = 0;
for session in sessions.values() {
if session.owner.read().await.is_some() {
count += 1;
}
}
count
}
}
+390 -21
View File
@@ -1,18 +1,19 @@
use self::manager::{SessionCreateError, SessionManager};
use crate::{
database::{Database, DatabaseError},
ipc::{self, packet::IpcPacket},
ipc::{self, packet::IpcPacket, Client},
utils::{self, spotify},
};
use ipc_channel::ipc::IpcError;
use librespot::core::spotify_id::{SpotifyAudioType, SpotifyId};
use log::*;
use serenity::{
async_trait,
model::prelude::{ChannelId, GuildId, UserId},
prelude::Context,
prelude::{Context, RwLock},
};
use songbird::{
create_player,
error::JoinResult,
input::{children_to_reader, Input},
tracks::TrackHandle,
Call, Event, EventContext, EventHandler,
@@ -25,9 +26,111 @@ use tokio::sync::Mutex;
pub mod manager;
#[derive(Clone)]
pub struct PlaybackInfo {
last_updated: u128,
position_ms: u32,
pub track: Option<spotify::Track>,
pub episode: Option<spotify::Episode>,
pub spotify_id: Option<SpotifyId>,
pub duration_ms: u32,
pub is_playing: bool,
}
impl PlaybackInfo {
fn new(duration_ms: u32, position_ms: u32, is_playing: bool) -> Self {
Self {
last_updated: utils::get_time_ms(),
track: None,
episode: None,
spotify_id: None,
duration_ms,
position_ms,
is_playing,
}
}
// Update position, duration and playback state
async fn update_pos_dur(&mut self, position_ms: u32, duration_ms: u32, is_playing: bool) {
self.position_ms = position_ms;
self.duration_ms = duration_ms;
self.is_playing = is_playing;
self.last_updated = utils::get_time_ms();
}
// Update spotify id, track and episode
fn update_track_episode(
&mut self,
spotify_id: SpotifyId,
track: Option<spotify::Track>,
episode: Option<spotify::Episode>,
) {
self.spotify_id = Some(spotify_id);
self.track = track;
self.episode = episode;
}
pub fn get_position(&self) -> u32 {
if self.is_playing {
let now = utils::get_time_ms();
let diff = now - self.last_updated;
self.position_ms + diff as u32
} else {
self.position_ms
}
}
pub fn get_name(&self) -> Option<String> {
if let Some(track) = &self.track {
Some(track.name.clone())
} else if let Some(episode) = &self.episode {
Some(episode.name.clone())
} else {
None
}
}
pub fn get_artists(&self) -> Option<String> {
if let Some(track) = &self.track {
Some(
track
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<String>>()
.join(", "),
)
} else if let Some(episode) = &self.episode {
Some(episode.show.name.clone())
} else {
None
}
}
pub fn get_thumbnail_url(&self) -> Option<String> {
if let Some(track) = &self.track {
let mut images = track.album.images.clone();
images.sort_by(|a, b| b.width.cmp(&a.width));
Some(images.get(0).unwrap().url.clone())
} else if let Some(episode) = &self.episode {
let mut images = episode.show.images.clone();
images.sort_by(|a, b| b.width.cmp(&a.width));
Some(images.get(0).unwrap().url.clone())
} else {
None
}
}
}
#[derive(Clone)]
pub struct SpoticordSession {
owner: UserId,
owner: Arc<RwLock<Option<UserId>>>,
guild_id: GuildId,
channel_id: ChannelId,
@@ -35,6 +138,10 @@ pub struct SpoticordSession {
call: Arc<Mutex<Call>>,
track: TrackHandle,
playback_info: Arc<RwLock<Option<PlaybackInfo>>>,
client: Client,
}
impl SpoticordSession {
@@ -92,8 +199,7 @@ impl SpoticordSession {
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])
let child = match Command::new(std::env::current_exe().unwrap())
.args(["--player", &tx_name, &rx_name])
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
@@ -126,9 +232,22 @@ impl SpoticordSession {
// Set call audio to track
call_mut.play_only(track);
let instance = Self {
owner: Arc::new(RwLock::new(Some(owner_id.clone()))),
guild_id,
channel_id,
session_manager: session_manager.clone(),
call: call.clone(),
track: track_handle.clone(),
playback_info: Arc::new(RwLock::new(None)),
client: client.clone(),
};
// Clone variables for use in the IPC handler
let ipc_track = track_handle.clone();
let ipc_client = client.clone();
let ipc_context = ctx.clone();
let mut ipc_instance = instance.clone();
// Handle IPC packets
// This will automatically quit once the IPC connection is closed
@@ -140,6 +259,9 @@ impl SpoticordSession {
};
loop {
// Required for IpcPacket::TrackChange to work
tokio::task::yield_now().await;
let msg = match ipc_client.recv() {
Ok(msg) => msg,
Err(why) => {
@@ -153,29 +275,89 @@ impl SpoticordSession {
};
match msg {
// Sink requests playback to start/resume
IpcPacket::StartPlayback => {
check_result(ipc_track.play());
}
// Sink requests playback to pause
IpcPacket::StopPlayback => {
check_result(ipc_track.pause());
}
// A new track has been set by the player
IpcPacket::TrackChange(track) => {
// Convert to SpotifyId
let track_id = SpotifyId::from_uri(&track).unwrap();
let mut instance = ipc_instance.clone();
let context = ipc_context.clone();
tokio::spawn(async move {
if let Err(why) = instance.update_track(&context, &owner_id, track_id).await {
error!("Failed to update track: {:?}", why);
instance.player_stopped().await;
}
});
}
// The player has started playing a track
IpcPacket::Playing(track, position_ms, duration_ms) => {
// Convert to SpotifyId
let track_id = SpotifyId::from_uri(&track).unwrap();
let was_none = ipc_instance
.update_playback(duration_ms, position_ms, true)
.await;
if was_none {
// Stop player if update track fails
if let Err(why) = ipc_instance
.update_track(&ipc_context, &owner_id, track_id)
.await
{
error!("Failed to update track: {:?}", why);
ipc_instance.player_stopped().await;
return;
}
}
}
IpcPacket::Paused(track, position_ms, duration_ms) => {
// Convert to SpotifyId
let track_id = SpotifyId::from_uri(&track).unwrap();
let was_none = ipc_instance
.update_playback(duration_ms, position_ms, true)
.await;
if was_none {
// Stop player if update track fails
if let Err(why) = ipc_instance
.update_track(&ipc_context, &owner_id, track_id)
.await
{
error!("Failed to update track: {:?}", why);
ipc_instance.player_stopped().await;
return;
}
}
}
IpcPacket::Stopped => {
ipc_instance.player_stopped().await;
}
// Ignore other packets
_ => {}
}
}
});
// 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(),
@@ -186,6 +368,7 @@ impl SpoticordSession {
instance.clone(),
);
// Inform the player process to connect to Spotify
if let Err(why) = client.send(IpcPacket::Connect(token, user.device_name)) {
error!("Failed to send IpcPacket::Connect packet: {:?}", why);
}
@@ -193,7 +376,147 @@ impl SpoticordSession {
Ok(instance)
}
pub async fn disconnect(&self) -> JoinResult<()> {
pub async fn update_owner(
&self,
ctx: &Context,
owner_id: UserId,
) -> Result<(), SessionCreateError> {
// Get the Spotify token of the owner
let data = ctx.data.read().await;
let database = data.get::<Database>().unwrap();
let mut 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);
}
};
let mut owner = self.owner.write().await;
*owner = Some(owner_id);
session_manager.set_owner(owner_id, self.guild_id).await;
// Inform the player process to connect to Spotify
if let Err(why) = self
.client
.send(IpcPacket::Connect(token, user.device_name))
{
error!("Failed to send IpcPacket::Connect packet: {:?}", why);
}
Ok(())
}
// Update current track
async fn update_track(
&self,
ctx: &Context,
owner_id: &UserId,
spotify_id: SpotifyId,
) -> Result<(), String> {
let should_update = {
let pbi = self.playback_info.read().await;
if let Some(pbi) = &*pbi {
pbi.spotify_id.is_none() || pbi.spotify_id.unwrap() != spotify_id
} else {
false
}
};
if !should_update {
return Ok(());
}
let data = ctx.data.read().await;
let database = data.get::<Database>().unwrap();
let token = match database.get_access_token(&owner_id.to_string()).await {
Ok(token) => token,
Err(why) => {
error!("Failed to get access token: {:?}", why);
return Err("Failed to get access token".to_string());
}
};
let mut track: Option<spotify::Track> = None;
let mut episode: Option<spotify::Episode> = None;
if spotify_id.audio_type == SpotifyAudioType::Track {
let track_info = match spotify::get_track_info(&token, spotify_id).await {
Ok(track) => track,
Err(why) => {
error!("Failed to get track info: {:?}", why);
return Err("Failed to get track info".to_string());
}
};
trace!("Received track info: {:?}", track_info);
track = Some(track_info);
} else if spotify_id.audio_type == SpotifyAudioType::Podcast {
let episode_info = match spotify::get_episode_info(&token, spotify_id).await {
Ok(episode) => episode,
Err(why) => {
error!("Failed to get episode info: {:?}", why);
return Err("Failed to get episode info".to_string());
}
};
trace!("Received episode info: {:?}", episode_info);
episode = Some(episode_info);
}
let mut pbi = self.playback_info.write().await;
if let Some(pbi) = &mut *pbi {
pbi.update_track_episode(spotify_id, track, episode);
}
Ok(())
}
/// Called when the player must stop, but not leave the call
async fn player_stopped(&mut self) {
if let Err(why) = self.track.pause() {
error!("Failed to pause track: {:?}", why);
}
// Disconnect from Spotify
if let Err(why) = self.client.send(IpcPacket::Disconnect) {
error!("Failed to send disconnect packet: {:?}", why);
}
// Clear owner
let mut owner = self.owner.write().await;
if let Some(owner_id) = owner.take() {
self.session_manager.remove_owner(owner_id).await;
}
// Clear playback info
let mut playback_info = self.playback_info.write().await;
*playback_info = None;
}
// Disconnect from voice channel and remove session from manager
pub async fn disconnect(&self) {
info!("Disconnecting from voice channel {}", self.channel_id);
self
@@ -206,17 +529,55 @@ impl SpoticordSession {
self.track.stop().unwrap_or(());
call.remove_all_global_events();
call.leave().await
if let Err(why) = call.leave().await {
error!("Failed to leave voice channel: {:?}", why);
}
}
pub fn get_owner(&self) -> UserId {
self.owner
// Update playback info (duration, position, playing state)
async fn update_playback(&self, duration_ms: u32, position_ms: u32, playing: bool) -> bool {
let is_none = {
let pbi = self.playback_info.read().await;
pbi.is_none()
};
if is_none {
let mut pbi = self.playback_info.write().await;
*pbi = Some(PlaybackInfo::new(duration_ms, position_ms, playing));
} else {
let mut pbi = self.playback_info.write().await;
// Update position, duration and playback state
pbi
.as_mut()
.unwrap()
.update_pos_dur(position_ms, duration_ms, playing)
.await;
};
is_none
}
// Get the playback info for the current track
pub async fn get_playback_info(&self) -> Option<PlaybackInfo> {
self.playback_info.read().await.clone()
}
// Get the current owner of this session
pub async fn get_owner(&self) -> Option<UserId> {
let owner = self.owner.read().await;
*owner
}
// Get the server id this session is playing in
pub fn get_guild_id(&self) -> GuildId {
self.guild_id
}
// Get the channel id this session is playing in
pub fn get_channel_id(&self) -> ChannelId {
self.channel_id
}
@@ -228,10 +589,18 @@ impl EventHandler for SpoticordSession {
match ctx {
EventContext::DriverDisconnect(_) => {
debug!("Driver disconnected, leaving voice channel");
self.disconnect().await.ok();
self.disconnect().await;
}
EventContext::ClientDisconnect(who) => {
debug!("Client disconnected, {}", who.user_id.to_string());
trace!("Client disconnected, {}", who.user_id.to_string());
if let Some(session) = self.session_manager.find(UserId(who.user_id.0)).await {
if session.get_guild_id() == self.guild_id && session.get_channel_id() == self.channel_id
{
// Clone because haha immutable references
self.clone().player_stopped().await;
}
}
}
_ => {}
}
+26
View File
@@ -0,0 +1,26 @@
use redis::{Commands, RedisResult};
#[derive(Clone)]
pub struct StatsManager {
redis: redis::Client,
}
impl StatsManager {
pub fn new(url: impl Into<String>) -> RedisResult<StatsManager> {
let redis = redis::Client::open(url.into())?;
Ok(StatsManager { redis })
}
pub fn set_server_count(&self, count: usize) -> RedisResult<()> {
let mut con = self.redis.get_connection()?;
con.set("sc-bot-total-servers", count.to_string())
}
pub fn set_active_count(&self, count: usize) -> RedisResult<()> {
let mut con = self.redis.get_connection()?;
con.set("sc-bot-active-servers", count.to_string())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const MOTD: &str = "OPEN BETA (v2)";
// pub const MOTD: &str = "some good 'ol music";
+10
View File
@@ -0,0 +1,10 @@
pub fn escape(text: impl Into<String>) -> String {
let text: String = text.into();
text
.replace("\\", "\\\\")
.replace("*", "\\*")
.replace("_", "\\_")
.replace("~", "\\~")
.replace("`", "\\`")
}
+99
View File
@@ -0,0 +1,99 @@
use serenity::builder::CreateEmbed;
#[allow(dead_code)]
pub enum Status {
Info = 0x0773D6,
Success = 0x3BD65D,
Warning = 0xF0D932,
Error = 0xFC1F28,
None = 0,
}
#[derive(Default)]
pub struct EmbedMessageOptions {
pub title: Option<String>,
pub title_url: Option<String>,
pub icon_url: Option<String>,
pub description: String,
pub status: Option<Status>,
pub footer: Option<String>,
}
pub struct EmbedBuilder {
embed: EmbedMessageOptions,
}
impl EmbedBuilder {
pub fn new() -> Self {
Self {
embed: EmbedMessageOptions::default(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.embed.title = Some(title.into());
self
}
pub fn title_url(mut self, title_url: impl Into<String>) -> Self {
self.embed.title_url = Some(title_url.into());
self
}
pub fn icon_url(mut self, icon_url: impl Into<String>) -> Self {
self.embed.icon_url = Some(icon_url.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.embed.description = description.into();
self
}
pub fn status(mut self, status: Status) -> Self {
self.embed.status = Some(status);
self
}
pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.embed.footer = Some(footer.into());
self
}
/// Build the embed
pub fn build(self) -> EmbedMessageOptions {
self.embed
}
}
pub fn make_embed_message<'a>(
embed: &'a mut CreateEmbed,
options: EmbedMessageOptions,
) -> &'a mut CreateEmbed {
let status = options.status.unwrap_or(Status::None);
embed.author(|author| {
if let Some(title) = options.title {
author.name(title);
}
if let Some(title_url) = options.title_url {
author.url(title_url);
}
if let Some(icon_url) = options.icon_url {
author.icon_url(icon_url);
}
author
});
if let Some(text) = options.footer {
embed.footer(|footer| footer.text(text));
}
embed.description(options.description);
embed.color(status as u32);
embed
}
+28
View File
@@ -1,5 +1,8 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub mod consts;
pub mod discord;
pub mod embed;
pub mod spotify;
pub fn get_time() -> u64 {
@@ -8,3 +11,28 @@ pub fn get_time() -> u64 {
since_the_epoch.as_secs()
}
pub fn get_time_ms() -> u128 {
let now = SystemTime::now();
let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards");
since_the_epoch.as_millis()
}
pub fn time_to_str(time: u32) -> String {
let hour = 3600;
let min = 60;
if time / hour >= 1 {
return format!(
"{}h{}m{}s",
time / hour,
(time % hour) / min,
(time % hour) % min
);
} else if time / min >= 1 {
return format!("{}m{}s", time / min, time % min);
} else {
return format!("{}s", time);
}
}
+99
View File
@@ -1,4 +1,8 @@
use std::error::Error;
use librespot::core::spotify_id::SpotifyId;
use log::{error, trace};
use serde::Deserialize;
use serde_json::Value;
pub async fn get_username(token: impl Into<String>) -> Result<String, String> {
@@ -34,3 +38,98 @@ pub async fn get_username(token: impl Into<String>) -> Result<String, String> {
error!("Missing 'id' field in body");
Err("Failed to parse body: Invalid body received".to_string())
}
#[derive(Debug, Clone, Deserialize)]
pub struct Artist {
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Image {
pub url: String,
pub height: u32,
pub width: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Album {
pub name: String,
pub images: Vec<Image>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Track {
pub name: String,
pub artists: Vec<Artist>,
pub album: Album,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Show {
pub name: String,
pub images: Vec<Image>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Episode {
pub name: String,
pub show: Show,
}
pub async fn get_track_info(
token: impl Into<String>,
track: SpotifyId,
) -> Result<Track, Box<dyn Error>> {
let token = token.into();
let client = reqwest::Client::new();
let response = client
.get(format!(
"https://api.spotify.com/v1/tracks/{}",
track.to_base62()?
))
.bearer_auth(token)
.send()
.await?;
if response.status() != 200 {
return Err(
format!(
"Failed to get track info: Invalid status code: {}",
response.status()
)
.into(),
);
}
Ok(response.json().await?)
}
pub async fn get_episode_info(
token: impl Into<String>,
episode: SpotifyId,
) -> Result<Episode, Box<dyn Error>> {
let token = token.into();
let client = reqwest::Client::new();
let response = client
.get(format!(
"https://api.spotify.com/v1/episodes/{}",
episode.to_base62()?
))
.bearer_auth(token)
.send()
.await?;
if response.status() != 200 {
return Err(
format!(
"Failed to get episode info: Invalid status code: {}",
response.status()
)
.into(),
);
}
Ok(response.json().await?)
}