Uhm so yeah here's some c o d e
This commit is contained in:
		
							parent
							
								
									6a77189343
								
							
						
					
					
						commit
						d530b182b3
					
				
							
								
								
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| target/ | ||||
| .env | ||||
| .gitignore | ||||
| .dockerignore | ||||
| Dockerfile | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -6,4 +6,9 @@ | ||||
| *.sqlite | ||||
| 
 | ||||
| # Secrets | ||||
| .env | ||||
| .env | ||||
| 
 | ||||
| # Editors | ||||
| .vscode | ||||
| .vs | ||||
| .fleet | ||||
							
								
								
									
										34
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										34
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -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"] } | ||||
|  | ||||
							
								
								
									
										23
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @ -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"] | ||||
							
								
								
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							| @ -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. | ||||
							
								
								
									
										3
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @ -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)); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										34
									
								
								src/bot/commands/core/help.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/bot/commands/core/help.rs
									
									
									
									
									
										Normal 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") | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
							
								
								
									
										165
									
								
								src/bot/commands/core/rename.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/bot/commands/core/rename.rs
									
									
									
									
									
										Normal 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) | ||||
|     }) | ||||
| } | ||||
| @ -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
									
								
								src/bot/commands/core/version.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/bot/commands/core/version.rs
									
									
									
									
									
										Normal 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") | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
							
								
								
									
										170
									
								
								src/bot/commands/music/playing.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/bot/commands/music/playing.rs
									
									
									
									
									
										Normal 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") | ||||
| } | ||||
| @ -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; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       _ => {} | ||||
|     } | ||||
|  | ||||
							
								
								
									
										26
									
								
								src/stats/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/stats/mod.rs
									
									
									
									
									
										Normal 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
									
								
								src/utils/consts.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/utils/consts.rs
									
									
									
									
									
										Normal 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
									
								
								src/utils/discord.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/utils/discord.rs
									
									
									
									
									
										Normal 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
									
								
								src/utils/embed.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/utils/embed.rs
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 DaXcess
						DaXcess