Uhm so yeah here's some c o d e
parent
6a77189343
commit
d530b182b3
|
@ -0,0 +1,5 @@
|
|||
target/
|
||||
.env
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
|
@ -6,4 +6,9 @@
|
|||
*.sqlite
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env
|
||||
|
||||
# Editors
|
||||
.vscode
|
||||
.vs
|
||||
.fleet
|
|
@ -306,6 +306,16 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "command_attr"
|
||||
version = "0.4.1"
|
||||
|
@ -1897,6 +1907,20 @@ dependencies = [
|
|||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513b3649f1a111c17954296e4a3b9eecb108b766c803e2b99f179ebe27005985"
|
||||
dependencies = [
|
||||
"combine",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.2.16"
|
||||
|
@ -2287,6 +2311,12 @@ dependencies = [
|
|||
"digest 0.10.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
|
||||
|
||||
[[package]]
|
||||
name = "shannon"
|
||||
version = "0.2.0"
|
||||
|
@ -2395,7 +2425,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "spoticord"
|
||||
version = "2.0.0-indev"
|
||||
version = "2.0.0-beta"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dotenv",
|
||||
|
@ -2403,12 +2433,12 @@ dependencies = [
|
|||
"ipc-channel",
|
||||
"librespot",
|
||||
"log",
|
||||
"redis",
|
||||
"reqwest",
|
||||
"samplerate",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serenity",
|
||||
"shell-words",
|
||||
"songbird",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
[package]
|
||||
name = "spoticord"
|
||||
version = "2.0.0-indev"
|
||||
version = "2.0.0-beta"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[[bin]]
|
||||
name = "spoticord"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
@ -18,12 +20,12 @@ 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"
|
||||
redis = "0.22.1"
|
||||
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"] }
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Builder
|
||||
FROM rust:1.62-buster as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Add extra build dependencies here
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
|
||||
COPY . .
|
||||
RUN cargo install --path .
|
||||
|
||||
# Runtime
|
||||
FROM debian:buster-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Add extra runtime dependencies here
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy spoticord binary from builder
|
||||
COPY --from=builder /usr/local/cargo/bin/spoticord ./spoticord
|
||||
|
||||
CMD ["./spoticord"]
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -0,0 +1,3 @@
|
|||
# Spoticord
|
||||
|
||||
Spoticord is a Discord music bot that allows you to control your music using the Spotify app.
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,2 +1,5 @@
|
|||
pub mod help;
|
||||
pub mod link;
|
||||
pub mod rename;
|
||||
pub mod unlink;
|
||||
pub mod version;
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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;
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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,2 +1,3 @@
|
|||
pub mod join;
|
||||
pub mod leave;
|
||||
pub mod playing;
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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,2 +1,3 @@
|
|||
// TODO: Check all image urls in embed responses
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
78
src/main.rs
78
src/main.rs
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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...");
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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";
|
|
@ -0,0 +1,10 @@
|
|||
pub fn escape(text: impl Into<String>) -> String {
|
||||
let text: String = text.into();
|
||||
|
||||
text
|
||||
.replace("\\", "\\\\")
|
||||
.replace("*", "\\*")
|
||||
.replace("_", "\\_")
|
||||
.replace("~", "\\~")
|
||||
.replace("`", "\\`")
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue