Compare commits

...

47 commits

Author SHA1 Message Date
23ff902e01
chore: bump version 2024-09-29 22:41:22 +02:00
84048b1b67
fix: stop bot from loading playlists and crashing itself 2024-09-29 22:41:05 +02:00
e56e83213f
fix: fixed yt-dlp not working in docker container
chore: bump rust version
2024-09-19 00:23:20 +02:00
b368a23adf
chore: bump version 2024-09-19 00:21:29 +02:00
389f61682f
chore: bump dependencies 2024-09-19 00:21:21 +02:00
0c686e5517
chore: bumped rust version in docker container 2024-07-26 16:54:31 +02:00
80dc6d05ba
chore: bumped dependencies 2024-07-26 16:50:53 +02:00
242fa52170
chore: added Cargo.lock to gitignore 2024-04-16 02:21:07 +02:00
078586dd3f
chore: upgraded dependencies 2024-04-04 10:07:21 +02:00
2b4e21736c
fix: removed warnings 2024-04-04 10:07:11 +02:00
b5888ec441
chore: bump version 2024-04-04 09:21:31 +02:00
f1b0e1e363
fix: added yt-dlp to docker container 2024-04-04 09:21:25 +02:00
cd879e8af9
feat: added docker support, added docker-compose.yml 2024-04-04 08:51:49 +02:00
72a1fe8af3 chore: updates and formatting 2024-03-10 21:40:52 +01:00
2f89b9dbb9
fix: fixed merge conflicts 2024-03-10 21:39:59 +01:00
a72f9753a2
feat: added current position to nowplaying command 2024-03-10 21:36:10 +01:00
59bf59295e chore: rename music_queue::get_head to next 2024-03-10 21:07:52 +01:00
eecb61b9f5
Merge branch 'master' of ssh://git.moonleay.net:8020/DiscordBots/Rustendo 2024-03-10 20:58:25 +01:00
d471bfcb06
feat: added nowplaying command 2024-03-10 20:58:05 +01:00
f3fec5292a chore: use VecDeque for music queue 2024-03-10 20:11:39 +01:00
05fae26549 fix: bug fixes 2024-03-10 19:46:27 +01:00
ed89386ed9 Merge branch 'fix/music-queue-improvements' 2024-03-10 19:39:01 +01:00
1879b4ee0f fix: music queue improvements 2024-03-10 19:36:28 +01:00
ba0f1fb959
feat: added skip command, reworked embed messages
fix: fixed issue with queue not working properly
2024-03-10 19:36:02 +01:00
1dcd0ab66b
added .vscode to gitignore 2024-03-10 19:35:08 +01:00
29392dc72d chore: improve checks 2024-03-10 17:46:35 +01:00
fc32017a10 fix: mutex lock 2024-03-10 16:19:19 +01:00
e79b4142de [WIP] fix: music queue bugs 2024-03-10 15:39:11 +01:00
c6af238f06
WIP: working on removing unsafe from appliction 2024-03-10 15:27:18 +01:00
9c1f6bee6d chore: clippy improvements 2024-03-09 20:09:17 +01:00
e1cf394362
WIP: continued work on queue 2024-03-09 20:06:08 +01:00
ef06cbc90b
feat: added lazy_static 2024-03-09 20:05:49 +01:00
b493209a36
chore!: removed .vscode 2024-03-09 20:05:20 +01:00
a06299fb6f
feat: started to impl queue system 2024-03-09 00:25:12 +01:00
a16d8a6b60
feat: bot now plays songs, started to work on queue system 2024-03-08 01:18:30 +01:00
edc22a91f2
Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/commands/play.rs
#	src/util/user_util.rs
2024-03-06 23:31:26 +01:00
b2ba381e44
fix: fixed not being able to get guild struct, continued work on play command 2024-03-06 23:30:39 +01:00
d72577c245 fix: improvements by clippy 2024-03-06 10:06:31 +01:00
af50d54729 fix: get_self_vc_id throws an error
The problem was that `ctx.cache.current_user()` uses an Arc and needs to get the value first before passing it to as a parameter. Assigning it to a variable first and referencing it, fixes the issue
2024-03-06 09:13:23 +01:00
cfd051be3f
feat: added getting data from discord 2024-03-05 12:08:19 +01:00
4d6e665a3b
fix: fixed import errors
feat: removed unneeded functions, added logging to user_util
2024-03-04 23:39:36 +01:00
57e9bca8f0 Merge pull request 'Several improvements' (#2) from migueldamota/Rustendo:several-improvements into master
Reviewed-on: DiscordBots/Rustendo#2
2024-03-03 22:38:59 +00:00
2c82dbb019 fix: several improvements 2024-03-03 23:30:31 +01:00
50202dfdf5
WIP: continued working on play and stop command, there are still errors and it does not work, it does compile though 2024-02-24 03:05:27 +01:00
a1a78d6598 Merge pull request 'Fixing a problem with the return type of interaction_create' (#1) from migueldamota/Rustendo:fix/add-missing-await into master
Reviewed-on: DiscordBots/Rustendo#1
2024-02-23 19:30:09 +01:00
1fd1bc893c chore: formatting 2024-02-23 19:25:19 +01:00
ba42e009f4 fix: interaction_create event 2024-02-23 19:22:19 +01:00
23 changed files with 1607 additions and 523 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
/target
/data

9
.gitignore vendored
View file

@ -1,3 +1,10 @@
/target /target
/data /data
config.json config.json
Cargo.lock
.idea/
.vscode/
.zed/
.DS_Store

View file

@ -1,6 +0,0 @@
{
"rust-analyzer.linkedProjects": [
".\\Cargo.toml"
],
"rust-analyzer.showUnlinkedFileNotification": false
}

1121
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package] [package]
name = "rustendo" name = "rustendo"
version = "0.1.0" version = "7.0.4"
authors = ["moonleay <contact@moonleay.net>"] authors = ["moonleay <contact@moonleay.net>", "migueldamota <miguel@damota.de>"]
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -9,15 +9,16 @@ edition = "2021"
[dependencies] [dependencies]
serenity = "0.12" serenity = "0.12"
tokio = { version = "1.36", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
confy = "0.6.0" confy = "0.6.0"
songbird = "0.4" songbird = "0.4"
chrono = "0.4" chrono = "0.4"
reqwest = "0.11" reqwest = "0.11" # 0.12 creates issues; don't update for now, will fix later
symphonia = "0.5" symphonia = "0.5"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
tracing-futures = "0.2.5" tracing-futures = "0.2.5"
futures = "0.3.1" futures = "0.3.1"
lazy_static = "1.4.0"

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM rust:1.81
WORKDIR /usr/src/app
COPY . .
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
RUN apt-get update
RUN apt-get install -y cmake yt-dlp/bookworm-backports
RUN cargo build --release
CMD ["/usr/src/app/target/release/rustendo"]

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
version: "3"
services:
botendo:
container_name: botendo
image: limiteddev/rustendo:0.1.0
environment:
TZ: "Europe/Berlin"
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
restart: unless-stopped
volumes:
- ./data:/usr/src/app/data

View file

@ -1,20 +1,17 @@
use chrono::Local;
use serenity::all::{CommandInteraction, Context}; use serenity::all::{CommandInteraction, Context};
use serenity::builder::{CreateCommand, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}; use serenity::builder::{CreateCommand, CreateEmbed};
use crate::util::embed::Embed;
pub async fn run(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed { pub async fn run(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = command.user.name.as_str(); let username = command.user.name.as_str();
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
CreateEmbed::new()
.author(CreateEmbedAuthor::new("Rustendo"))
.description("Botendo v7\ndeveloped by [moonleay](https://moonleay.net)\n\nCheck out the repository: https://git.moonleay.net/DiscordBots/Rustendo")
.footer(CreateEmbedFooter::new(format!(">{} | {}", current_time, username)))
}
Embed::create_success_response(username, "Botendo v7", "developed by [moonleay](https://moonleay.net)\n\nCheck out the repository: https://git.moonleay.net/DiscordBots/Rustendo")
}
pub fn register() -> CreateCommand { pub fn register() -> CreateCommand {
CreateCommand::new("info").description("Infos about the bot") CreateCommand::new("info").description("Infos about the bot")
} }
// >18/02/2024 @ 19:01:59 - bartlo // >18/02/2024 @ 19:01:59 - bartlo
// >2024-02-19 17:58:39 | moonleay // >2024-02-19 17:58:39 | moonleay

View file

@ -1,3 +1,5 @@
pub mod info; pub mod info;
pub mod now_playing;
pub mod play; pub mod play;
pub mod stop; pub mod skip;
pub mod stop;

View file

@ -0,0 +1,75 @@
use crate::music::music_queue;
use crate::util::embed::Embed;
use serenity::all::{CommandInteraction, Context};
use serenity::builder::{CreateCommand, CreateEmbed};
pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = command.user.name.as_str();
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
return Embed::create_error_respose(
username,
"guild_id not found",
"Could not find guild id.",
);
}
};
let now_plaing = match music_queue::get_now_playing(&guild_id).await {
Some(ytdl) => ytdl,
None => {
return Embed::create_error_respose(
username,
"Not playing",
"I'm not playing anything!",
);
}
};
let now_handle = match music_queue::get_now_playing_track_handle(&guild_id).await {
Some(handle) => handle,
None => {
return Embed::create_error_respose(
username,
"Cannot get TrackHandle",
"The TrackHandle is empty.",
);
}
};
let manager = songbird::get(ctx)
.await
.expect("Cannot get Songbird")
.clone();
let _ = match manager.get(*guild_id) {
Some(handler) => handler,
None => {
return Embed::create_error_respose(
username,
"Error",
"Error while getting the audio handler.",
);
}
};
let position = now_handle.get_info().await.unwrap().position;
Embed::create_yt_playing(now_plaing, username, "Currently playing")
.await
.field(
"Position",
format!(
"{}min {}sec",
position.as_secs() / 60,
position.as_secs() % 60
),
true,
)
}
pub fn register() -> CreateCommand {
CreateCommand::new("nowplaying").description("Show what is currently playing.")
}

View file

@ -1,77 +1,57 @@
use chrono::Local; use crate::music::music_manager;
use serenity::all::{CommandDataOption, CommandDataOptionValue, CommandInteraction, Context, ResolvedOption, ResolvedValue};
use serenity::builder::{CreateCommand, CreateCommandOption, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}; use serenity::all::{CommandDataOptionValue, CommandInteraction, Context};
use serenity::builder::{CreateCommand, CreateCommandOption, CreateEmbed};
use serenity::model::application::CommandOptionType; use serenity::model::application::CommandOptionType;
use crate::util::embed::Embed;
pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed { pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = command.user.name.as_str(); let username = command.user.name.as_str();
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
let options = &command.data.options; let options = &command.data.options;
let query = if let Some(CommandDataOption { let query = options.first().and_then(|option| {
value: CommandDataOptionValue::String(query), .. if let CommandDataOptionValue::String(query) = &option.value {
}) = &options.first() Some(query)
{ } else {
query None
} else { }
return CreateEmbed::new() });
.author(CreateEmbedAuthor::new("Rustendo"))
.title("Error 400") if query.is_none() {
.description("There is no query provied.") return Embed::create_error_respose(
.footer(CreateEmbedFooter::new(format!(">{} | {}", current_time, username))) username,
}; "400: Bad request",
"There is no query provided",
);
}
let guild_id = match &command.guild_id { let guild_id = match &command.guild_id {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
return CreateEmbed::new() return Embed::create_error_respose(
.author(CreateEmbedAuthor::new("Rustendo")) username,
.title("guildid not found") "guild_id not found",
.description("Could not find guild id.") "Could not find guild id.",
.footer(CreateEmbedFooter::new(format!("> {} | {}", current_time, username))); );
} }
}; };
let (guild_id, channel_id) = {
let guild = &ctx.cache.guild(guild_id).unwrap();
// This may be unsafe, idk not sure yet
let channel_id = guild
.voice_states
.get(&command.user.id)
.and_then(|voice_state| voice_state.channel_id);
(guild.id, channel_id)
};
let connect_to = match channel_id { music_manager::attempt_to_queue_song(
Some(channel) => channel, ctx,
None => { guild_id,
return CreateEmbed::new() &command.user.id,
.author(CreateEmbedAuthor::new("Rustendo")) &command.user.name,
.title("You are not in a VC.") query.unwrap(),
.description("Join one to start playing music.") )
.footer(CreateEmbedFooter::new(format!("> {} | {}", current_time, username))); .await
},
};
let manager = &songbird::get(ctx)
.await
.expect("")
.clone();
manager.join(guild_id, connect_to).await.expect("Cannot connect>...");
CreateEmbed::new()
.author(CreateEmbedAuthor::new("Rustendo"))
.title(format!("Searching for {}", query))
.footer(CreateEmbedFooter::new(format!(">{} | {}", current_time, username)))
} }
pub fn register() -> CreateCommand { pub fn register() -> CreateCommand {
CreateCommand::new("play") CreateCommand::new("play")
.description("Play music") .description("Play music")
.add_option( .add_option(
CreateCommandOption::new(CommandOptionType::String, "query", "Link or search term") CreateCommandOption::new(CommandOptionType::String, "query", "Link or search term")
.required(true) .required(true),
) )
} }

26
src/commands/skip.rs Normal file
View file

@ -0,0 +1,26 @@
use crate::music::music_manager;
use crate::util::embed::Embed;
use serenity::all::{CommandInteraction, Context};
use serenity::builder::{CreateCommand, CreateEmbed};
pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = command.user.name.as_str();
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
return Embed::create_error_respose(
username,
"guild_id not found",
"Could not find guild id.",
);
}
};
music_manager::attempt_to_skip_current_song(ctx, guild_id, &command.user.id, &command.user.name)
.await
}
pub fn register() -> CreateCommand {
CreateCommand::new("skip").description("Skip to the next song in queue")
}

View file

@ -1,20 +1,25 @@
use chrono::Local; use crate::music::music_manager;
use crate::util::embed::Embed;
use serenity::all::{CommandInteraction, Context}; use serenity::all::{CommandInteraction, Context};
use serenity::builder::{CreateCommand, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}; use serenity::builder::{CreateCommand, CreateEmbed};
pub async fn run(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed { pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = command.user.name.as_str(); let username = command.user.name.as_str();
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
CreateEmbed::new()
.author(CreateEmbedAuthor::new("Rustendo"))
.title("I stopped and left\nJust like your girlfriend.")
.footer(CreateEmbedFooter::new(format!(">{} | {}", current_time, username)))
}
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
return Embed::create_error_respose(
username,
"guild_id not found",
"Could not find guild id.",
);
}
};
music_manager::attempt_to_stop(ctx, guild_id, &command.user.id, &command.user.name).await
}
pub fn register() -> CreateCommand { pub fn register() -> CreateCommand {
CreateCommand::new("stop").description("Stop playing and start leavin'") CreateCommand::new("stop").description("Stop playing and start leavin'")
} }
// >18/02/2024 @ 19:01:59 - bartlo
// >2024-02-19 17:58:39 | moonleay

View file

View file

@ -1,58 +1,56 @@
mod commands; mod commands;
mod music;
mod util; mod util;
mod handler;
use std::thread::current; use serenity::all::{
use chrono::Local; CommandInteraction, CreateInteractionResponseFollowup, OnlineStatus, VoiceState,
use serenity::all::{CommandInteraction, OnlineStatus}; };
use serenity::async_trait; use serenity::async_trait;
use serenity::builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage}; use serenity::builder::CreateEmbed;
use serenity::gateway::ActivityData; use serenity::gateway::ActivityData;
use serenity::model::application::{Command, Interaction}; use serenity::model::application::{Command, Interaction};
use serenity::model::gateway::Ready; use serenity::model::gateway::Ready;
use serenity::prelude::*; use serenity::prelude::*;
use util::config; use util::{config, embed::Embed, user_util};
// This trait adds the `register_songbird` and `register_songbird_with` methods // This trait adds the `register_songbird` and `register_songbird_with` methods
// to the client builder below, making it easy to install this voice client. // to the client builder below, making it easy to install this voice client.
// The voice client can be retrieved in any command using `songbird::get(ctx).await`. // The voice client can be retrieved in any command using `songbird::get(ctx).await`.
use songbird::SerenityInit; use songbird::SerenityInit;
// Event related imports to detect track creation failures.
use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler, TrackEvent};
// To turn user URLs into playable audio, we'll use yt-dlp.
use songbird::input::YoutubeDl;
// YtDl requests need an HTTP client to operate -- we'll create and store our own. // YtDl requests need an HTTP client to operate -- we'll create and store our own.
use reqwest::Client as HttpClient; use reqwest::Client as HttpClient;
// Import the `Context` to handle commands.
use serenity::client::Context;
struct HttpKey; struct HttpKey;
impl TypeMapKey for HttpKey { impl TypeMapKey for HttpKey {
type Value = HttpClient; type Value = HttpClient;
} }
// lazy static stuff. I don't like it, but it has to be here, bc it has to be @ root
#[macro_use]
extern crate lazy_static;
struct Handler; struct Handler;
#[async_trait] #[async_trait]
impl EventHandler for Handler { impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) { async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction { if let Interaction::Command(command) = interaction {
let content = match command.data.name.as_str() { let _ = &command.defer(&ctx.http()).await.expect("Cannot defer");
"info" => Some(commands::info::run(&ctx, &command)),
"play" => Some(commands::play::run(&ctx, &command)), let content = Some(match command.data.name.as_str() {
"stop" => Some(commands::stop::run(&ctx, &command)), "info" => commands::info::run(&ctx, &command).await,
_ => Some(respond_with_error(&ctx, &command)), "play" => commands::play::run(&ctx, &command).await,
}; "stop" => commands::stop::run(&ctx, &command).await,
"skip" => commands::skip::run(&ctx, &command).await,
"nowplaying" => commands::now_playing::run(&ctx, &command).await,
_ => respond_with_error(&ctx, &command).await,
});
if let Some(embed) = content { if let Some(embed) = content {
let data = CreateInteractionResponseMessage::new().embed(embed); let followup = CreateInteractionResponseFollowup::new().embed(embed);
let builder = CreateInteractionResponse::Message(data); if let Err(why) = command.create_followup(&ctx.http, followup).await {
if let Err(why) = command.create_response(&ctx.http, builder).await { println!("Cannot followup to slash command: {why}")
println!("Cannot respond to slash command: {why}");
} }
} }
} }
@ -64,32 +62,57 @@ impl EventHandler for Handler {
let _command = Command::create_global_command(&ctx.http, commands::info::register()).await; let _command = Command::create_global_command(&ctx.http, commands::info::register()).await;
let _command = Command::create_global_command(&ctx.http, commands::stop::register()).await; let _command = Command::create_global_command(&ctx.http, commands::stop::register()).await;
let _command = Command::create_global_command(&ctx.http, commands::play::register()).await; let _command = Command::create_global_command(&ctx.http, commands::play::register()).await;
let _command = Command::create_global_command(&ctx.http, commands::skip::register()).await;
let _command =
Command::create_global_command(&ctx.http, commands::now_playing::register()).await;
println!("Commands are registered and Rustendo is ready for Freddy.");
}
println!("Created all public / commands"); async fn voice_state_update(&self, ctx: Context, old: Option<VoiceState>, new: VoiceState) {
// TODO This does not work, when switching channels
if new.channel_id.is_some() {
return; // User did not leave, ignore
}
if let Some(old) = old {
if !user_util::is_self_connected_to_vc(&ctx, &old.guild_id.unwrap()).await {
return; // Bot is not connected to a VC, ignore
}
if user_util::get_amount_of_members_in_vc(
&ctx,
&old.guild_id.unwrap(),
&old.channel_id.unwrap(),
)
.await
< 2
{
let manager = songbird::get(&ctx).await.expect("Cannot get Songbird");
if let Err(e) = manager.remove(old.guild_id.unwrap()).await {
println!("Failed to remove handler: {:?}", e);
}
}
} // else: new user joined, ignore
} }
} }
pub async fn respond_with_error(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed { pub async fn respond_with_error(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed {
let username = &command.user.name.as_str(); Embed::create_error_respose(
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S"); command.user.name.as_str(),
"Command not found",
CreateEmbed::new() "Cannot find the executed command",
.author(CreateEmbedAuthor::new("Rustendo")) )
.title("Command not found")
.description("Cannot find the executed command")
.footer(CreateEmbedFooter::new(format!("> {} | {}", current_time, username)))
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
println!(r"__________ __ .___ println!(
\______ \__ __ _______/ |_ ____ ____ __| _/____ r"__________ __ .___
| _/ | | ___/\ __\_/ __ \ / \ / __ |/ _ \ \______ \__ __ _______/ |_ ____ ____ __| _/____
| _/ | | ___/\ __\_/ __ \ / \ / __ |/ _ \
| | \ | |___ \ | | \ ___/ | | | /_/ ( <_> ) | | \ | |___ \ | | \ ___/ | | | /_/ ( <_> )
|____|_ /____/____ > |__| \___ >|___| |____ |\____/ |____|_ /____/____ > |__| \___ >|___| |____ |\____/
\/ \/ \/ \/ \/ \/ \/ \/ \/ \/
"); "
);
// Load config // Load config
let config = config::load().unwrap(); let config = config::load().unwrap();
@ -104,6 +127,7 @@ async fn main() {
.activity(activity) .activity(activity)
.register_songbird() .register_songbird()
.type_map_insert::<HttpKey>(HttpClient::new()) .type_map_insert::<HttpKey>(HttpClient::new())
.intents(GatewayIntents::all())
.await .await
.expect("Error creating client"); .expect("Error creating client");
@ -111,9 +135,7 @@ async fn main() {
// //
// Shards will automatically attempt to reconnect, and will perform exponential backoff until // Shards will automatically attempt to reconnect, and will perform exponential backoff until
// it reconnects. // it reconnects.
if let Err(why) = client if let Err(why) = client.start().await {
.start()
.await {
println!("Client error: {why:?}"); println!("Client error: {why:?}");
} }
} }

3
src/music/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod music_events;
pub mod music_manager;
pub mod music_queue;

56
src/music/music_events.rs Normal file
View file

@ -0,0 +1,56 @@
use crate::music::{music_manager, music_queue};
use serenity::all::{ChannelId, GuildId, Http};
use serenity::async_trait;
use songbird::input::Compose;
use songbird::{Event, EventContext, EventHandler};
use std::sync::Arc;
pub struct TrackEndNotifier {
pub guild_id: GuildId,
pub channel_id: ChannelId,
pub http: Arc<Http>,
pub cmdctx: Arc<serenity::client::Context>,
}
#[async_trait]
impl EventHandler for TrackEndNotifier {
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
// TODO: Does this need to be unsafe?
if let EventContext::Track(..) = ctx {
println!("The track ended!");
if music_queue::is_empty(&self.guild_id).await {
// No more songs in queue, exit the vc
let stopped = match music_manager::leave(&self.cmdctx, &self.guild_id).await {
Ok(stopped) => stopped,
Err(e) => {
println!("Cannot stop: {:?}", e);
return None;
}
};
if stopped {
music_queue::delete_queue(&self.guild_id).await;
println!("Stopped playing successfully.");
} else {
println!("Failed to stop playing.");
}
return None;
}
let mut head = match music_queue::next(&self.guild_id).await {
Some(head) => head,
None => {
println!("Cannot get head of queue");
return None;
}
};
println!(
"Now playing: {}",
head.aux_metadata().await.unwrap().title.unwrap()
);
music_manager::play_song(&self.cmdctx, &self.guild_id, &head).await;
}
None
}
}

289
src/music/music_manager.rs Normal file
View file

@ -0,0 +1,289 @@
use crate::music::{music_events, music_queue};
use crate::util::embed::Embed;
use crate::util::user_util;
use crate::util::user_util::get_vc_id;
use crate::HttpKey;
use serenity::all::{Context, CreateEmbed, GuildId, UserId};
use songbird::error::JoinError;
use songbird::input::YoutubeDl;
use songbird::{Event, TrackEvent};
use std::sync::Arc;
/// Either queues the song, or start playing it instantly, depending on if there is already a song playing
pub async fn attempt_to_queue_song(
ctx: &Context,
guild_id: &GuildId,
user_id: &UserId,
username: &str,
query: &str,
) -> CreateEmbed {
if !user_util::is_user_connected_to_vc(ctx, guild_id, user_id).await {
return Embed::create_error_respose(
username,
"You are not connected to a VC",
"Connect to my vc to start controlling the music.",
);
}
let connect_to = match get_vc_id(ctx, guild_id, user_id).await {
Some(channel_id) => channel_id,
None => {
return Embed::create_error_respose(username, "Error", "Cannot find channel_id.");
}
};
let manager: &Arc<songbird::Songbird> = &songbird::get(ctx) // TODO match
.await
.expect("Cannot get Songbird.")
.clone();
let self_channel = user_util::get_self_vc_id(ctx, guild_id).await;
if !user_util::is_self_connected_to_vc(ctx, guild_id).await {
// self is connected to vc, check if user is in same vc
if self_channel.is_none() {
// Connect to VC
manager // TODO match
.join(*guild_id, connect_to)
.await
.expect("Cannot connect>...");
}
} else {
let self_channel = self_channel.expect("Cannot get self channel"); // TODO: match
// Check if user is in the same VC as the bot
if self_channel != connect_to {
return Embed::create_error_respose(
username,
"You are not in my VC",
"You have to be in my VC in order to control the music.",
);
}
}
// Get query
let do_search = !query.starts_with("http");
let http_client = {
let data = ctx.data.read().await;
data.get::<HttpKey>()
.cloned()
.expect("Guaranteed to exist in the typemap.")
};
if query.contains("youtu") && query.contains("&list=") {
return Embed::create_error_respose(
username,
"Playlists are not supported",
"I do not support playlists of any kind, please only provide links to videos"
);
}
// Create source
let src = if do_search {
YoutubeDl::new_search(http_client, query.to_string())
} else {
YoutubeDl::new(http_client, query.to_string())
};
let currently_playing = music_queue::get_now_playing(guild_id).await;
music_queue::add_to_queue(guild_id, src.clone()).await;
if currently_playing.is_some() {
// Add to queue
return Embed::create_yt_playing(src, username, "Added to queue").await;
}
let _query = music_queue::next(guild_id)
.await
.expect("Cannot get head of queue");
music_queue::set_now_playing(guild_id, Some(src.clone())).await;
let handler_lock = match manager.get(*guild_id) {
Some(handler) => handler,
None => {
return Embed::create_error_respose(
username,
"Error",
"Cannot get handler of this guild.",
);
}
};
// Start playing
let mut handler = handler_lock.lock().await;
let track_handle = handler.play_input(src.clone().into()); // TODO: Add event handlers
music_queue::set_now_playing_track_handle(guild_id, Some(track_handle)).await;
handler.add_global_event(
Event::Track(TrackEvent::End),
music_events::TrackEndNotifier {
guild_id: *guild_id,
channel_id: connect_to,
http: Arc::clone(&ctx.http),
cmdctx: Arc::new(ctx.clone()),
},
);
Embed::create_yt_playing(src, username, "Now playing").await
}
/// Play the provided song
pub async fn play_song(ctx: &Context, guild_id: &GuildId, target: &YoutubeDl) {
let manager = &songbird::get(ctx) // TODO match
.await
.expect("Cannot get Songbird.")
.clone();
if !user_util::is_self_connected_to_vc(ctx, guild_id).await {
println!("Bot is not connected to a VC, cannot play.");
return;
}
music_queue::set_now_playing(guild_id, Some(target.clone())).await;
let handler_lock = match manager.get(*guild_id) {
Some(handler) => handler,
None => return,
};
let mut handler = handler_lock.lock().await;
handler.stop(); // Stop playing the current song
let track_handle = handler.play_input(target.clone().into()); // TODO: Add event handlers
music_queue::set_now_playing_track_handle(guild_id, Some(track_handle)).await;
}
/// Attempt to skip the song, which is currently playing. Do nothing if there is no next song
pub async fn attempt_to_skip_current_song(
ctx: &Context,
guild_id: &GuildId,
user_id: &UserId,
username: &str,
) -> CreateEmbed {
if !user_util::is_user_connected_to_vc(ctx, guild_id, user_id).await {
return Embed::create_error_respose(
username,
"You are not connected to a VC",
"Connect to my vc to start controlling the music.",
);
}
let connect_to = match get_vc_id(ctx, guild_id, user_id).await {
Some(channel_id) => channel_id,
None => {
return Embed::create_error_respose(username, "Error", "Cannot find channel_id.");
}
};
let manager: &Arc<songbird::Songbird> = &songbird::get(ctx) // TODO match
.await
.expect("Cannot get Songbird.")
.clone();
let self_channel = user_util::get_self_vc_id(ctx, guild_id).await;
if !user_util::is_self_connected_to_vc(ctx, guild_id).await {
// self is connected to vc, check if user is in same vc
if self_channel.is_none() {
// Connect to VC
manager // TODO match
.join(*guild_id, connect_to)
.await
.expect("Cannot connect>...");
}
} else {
let self_channel = self_channel.expect("Cannot get self channel"); // TODO: match
// Check if user is in the same VC as the bot
if self_channel != connect_to {
return Embed::create_error_respose(
username,
"You are not in my VC",
"You have to be in my VC in order to controll the music.",
);
}
}
let head = music_queue::next(guild_id).await; // TODO match
if head.is_none() {
return Embed::create_error_respose(
username,
"Cannot find a song to play",
"The queue is empty.",
);
}
let head = head.unwrap();
play_song(ctx, guild_id, &head).await;
Embed::create_yt_playing(head, username, "Song skipped; Now playing").await
}
/// Try to clear the queue and stop playing. Also leave the vc
pub async fn attempt_to_stop(
ctx: &Context,
guild_id: &GuildId,
user_id: &UserId,
username: &str,
) -> CreateEmbed {
if !user_util::is_self_connected_to_vc(ctx, guild_id).await {
// Bot is not connectd to vc; no need to dc
return Embed::create_error_respose(
username,
"Bot is not connected",
"And therefore there is no need to do anything.",
);
}
let self_channel = user_util::get_self_vc_id(ctx, guild_id)
.await
.expect("Cannot get self channel");
let connect_to = get_vc_id(ctx, guild_id, user_id)
.await
.expect("Cannot get channel id");
// Check if user is in the same VC as the bot
if self_channel != connect_to {
return Embed::create_error_respose(
username,
"You are not in my VC.",
"Connect to my VC to controll the music.",
);
}
let stopped = match leave(ctx, guild_id).await {
Ok(stopped) => stopped,
Err(e) => {
println!("Error while stopping: {:?}", e);
return Embed::create_error_respose(
username,
"There was an error",
"Tell moonleay to check the logs.",
);
}
};
if !stopped {
Embed::create_error_respose(
username,
"Can't stop, what ain't running",
"I am not connected.\nI cant stop doing something, when I'm not doing it.",
)
} else {
music_queue::delete_queue(guild_id).await; // Clear queue
Embed::create_success_response(username, "I stopped and left", "Just like you girlfriend.")
}
}
/// Make the bot leave the voice channel. Returns Ok(true) if bot was connected, returns Ok(false) if bot was not connected. Returns Err if something went wrong.
pub async fn leave(ctx: &Context, guild_id: &GuildId) -> Result<bool, JoinError> {
let manager = songbird::get(ctx)
.await
.expect("Cannot get Songbird")
.clone();
let handler = manager.get(*guild_id);
let has_handler = handler.is_some();
if has_handler {
handler.unwrap().lock().await.stop();
manager.remove(*guild_id).await?;
return Ok(true); // Handler removed
}
Ok(false) // No handler, so it's already stopped
}

84
src/music/music_queue.rs Normal file
View file

@ -0,0 +1,84 @@
use serenity::all::GuildId;
use songbird::input::YoutubeDl;
use songbird::tracks::TrackHandle;
use tokio::sync::Mutex;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
type MusicQueueItem = Arc<Mutex<MusicQueue>>;
#[derive(Debug)]
pub struct MusicQueue {
pub queue: VecDeque<YoutubeDl>,
pub now_playing: Option<YoutubeDl>,
pub now_playing_track_handle: Option<TrackHandle>,
}
lazy_static! {
static ref HASHMAP: Mutex<HashMap<GuildId, MusicQueueItem>> = Mutex::new(HashMap::new());
}
async fn get_music_queue(guild_id: &GuildId) -> MusicQueueItem {
let mut queues = HASHMAP.lock().await;
queues
.entry(*guild_id)
.or_insert(Arc::new(Mutex::new(MusicQueue {
queue: VecDeque::new(),
now_playing: None,
now_playing_track_handle: None,
})))
.clone()
}
pub async fn with_music_queue<F, T>(guild_id: &GuildId, f: F) -> T
where
F: FnOnce(&mut MusicQueue) -> T,
T: Send,
{
let queue = get_music_queue(guild_id).await;
let mut queue = queue.lock().await;
f(&mut queue)
}
pub async fn delete_queue(guild_id: &GuildId) {
with_music_queue(guild_id, |queue| {
queue.now_playing = None;
queue.queue.clear();
})
.await;
}
pub async fn add_to_queue(guild_id: &GuildId, input: YoutubeDl) {
with_music_queue(guild_id, |queue| queue.queue.push_back(input)).await;
}
/// Get next track in queue
pub async fn next(guild_id: &GuildId) -> Option<YoutubeDl> {
with_music_queue(guild_id, |queue| queue.queue.pop_front()).await
}
pub async fn set_now_playing(guild_id: &GuildId, now_playing: Option<YoutubeDl>) {
with_music_queue(guild_id, |queue| queue.now_playing = now_playing).await;
}
pub async fn get_now_playing(guild_id: &GuildId) -> Option<YoutubeDl> {
with_music_queue(guild_id, |queue| queue.now_playing.to_owned()).await
}
pub async fn set_now_playing_track_handle(guild_id: &GuildId, track_handle: Option<TrackHandle>) {
with_music_queue(guild_id, |queue| {
queue.now_playing_track_handle = track_handle
})
.await
}
pub async fn get_now_playing_track_handle(guild_id: &GuildId) -> Option<TrackHandle> {
with_music_queue(guild_id, |queue| queue.now_playing_track_handle.to_owned()).await
}
pub async fn is_empty(guild_id: &GuildId) -> bool {
with_music_queue(guild_id, |queue| queue.queue.is_empty()).await
}

View file

@ -6,9 +6,6 @@ use std::error::Error;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct Config { pub struct Config {
pub discord_token: String, pub discord_token: String,
pub lavalink_address: String,
pub lavalink_password: String,
pub user_id: u64
} }
const CONFIG_FILE: &str = "./data/config.json"; const CONFIG_FILE: &str = "./data/config.json";
@ -17,23 +14,20 @@ pub fn load() -> Result<Config, Box<dyn Error>> {
// TODO: load config, create empty config if there is no config, stop if there is no complete config // TODO: load config, create empty config if there is no config, stop if there is no complete config
let config_file = match fs::File::open(CONFIG_FILE) { let config_file = match fs::File::open(CONFIG_FILE) {
Ok(file) => file, Ok(file) => file,
Err(_) => create_empty() Err(_) => create_empty(),
}; };
let config_file = serde_json::from_reader(config_file).unwrap(); let config_file = serde_json::from_reader(config_file).unwrap();
Ok(config_file) Ok(config_file)
} }
fn create_empty() -> fs::File{ fn create_empty() -> fs::File {
let example_config = Config { let example_config = Config {
discord_token: "paste_your_token".to_string(), discord_token: "paste_your_token".to_string(),
lavalink_address: "paste_your_lavalink_address".to_string(),
lavalink_password: "paste_your_lavalink_password".to_string(),
user_id: 1
}; };
let mut config_file = fs::File::create(CONFIG_FILE).unwrap(); let mut config_file = fs::File::create(CONFIG_FILE).unwrap();
let file_content = serde_json::to_string(&example_config).unwrap(); let file_content = serde_json::to_string(&example_config).unwrap();
config_file.write_all(&file_content.as_bytes()).unwrap(); config_file.write_all(file_content.as_bytes()).unwrap();
panic!("There is no config. But now there is a template.") panic!("There is no config. But now there is a template.")
} }

81
src/util/embed.rs Normal file
View file

@ -0,0 +1,81 @@
use std::time::Duration;
use chrono::Local;
use serenity::all::{Color, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter};
use songbird::input::{Compose, YoutubeDl};
pub struct Embed;
impl Embed {
pub fn create_success_response(username: &str, title: &str, desc: &str) -> CreateEmbed {
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
CreateEmbed::new()
.title(title)
.description(desc)
.color(Color::from_rgb(224, 49, 26))
.footer(CreateEmbedFooter::new(format!(
"> {} - {}",
current_time, username
)))
}
pub fn create_error_respose(
username: &str,
error_title: &str,
error_desc: &str,
) -> CreateEmbed {
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
CreateEmbed::new()
.author(CreateEmbedAuthor::new("Oops, something went wrong."))
.title(error_title)
.description(error_desc)
.color(Color::from_rgb(224, 49, 26))
.footer(CreateEmbedFooter::new(format!(
"> {} - {}",
current_time, username
)))
}
pub async fn create_yt_playing(
mut src: YoutubeDl,
username: &str,
show_as_author: &str,
) -> CreateEmbed {
let current_time = Local::now().format("%Y-%m-%d @ %H:%M:%S");
// Get metadata
let metadata = src.aux_metadata().await.expect("Cannot get metadata");
let title = metadata.title.unwrap_or("Unknown title".to_string());
let artist = metadata.artist.unwrap_or("Unknown artist".to_string());
let duration = metadata.duration.unwrap_or(Duration::from_millis(0));
let thumbnail = metadata
.thumbnail
.unwrap_or("https://http.cat/images/403.jpg".to_string());
let link = metadata
.source_url
.unwrap_or("https://piped.moonleay.net/403".to_string());
CreateEmbed::new()
.author(CreateEmbedAuthor::new(show_as_author))
.title(title)
.url(link)
.thumbnail(thumbnail)
.field("Artist", artist, true)
.field(
"Duration",
format!(
"{}min {}sec",
duration.as_secs() / 60,
duration.as_secs() % 60
),
true,
)
.color(Color::from_rgb(81, 224, 26))
.footer(CreateEmbedFooter::new(format!(
"> {} - {}",
current_time, username
)))
}
}

View file

@ -1 +1,3 @@
pub mod config; pub mod config;
pub mod embed;
pub mod user_util;

69
src/util/user_util.rs Normal file
View file

@ -0,0 +1,69 @@
use serenity::all::{ChannelId, Context, Guild, GuildId, PartialGuild, UserId, VoiceState};
use std::collections::HashMap;
/// Request a guild by id, get it from cache
pub fn request_guild(ctx: &Context, guild_id: &GuildId) -> Guild {
match guild_id.to_guild_cached(&ctx.cache) {
Some(guild) => guild.clone(),
None => {
panic!("Cannot get guild with id {:?}!", guild_id);
}
}
}
/// Request a guild by id, get it from Discord, not from cache, this is a partial guild
#[allow(dead_code)]
pub async fn request_partial_guild(ctx: &Context, guild_id: &GuildId) -> PartialGuild {
match ctx.http.get_guild(*guild_id).await {
Ok(guild) => guild,
Err(error) => {
panic!("error whilest getting guild from Discord {}", error);
}
}
}
/// Get the voice channel id of a user
pub async fn get_vc_id(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> Option<ChannelId> {
let guild = request_guild(ctx, guild_id);
guild
.voice_states
.get(user_id)
.and_then(|voice_state| voice_state.channel_id)
}
/// Check if the bot is connected to a voice channel
pub async fn is_self_connected_to_vc(ctx: &Context, guild_id: &GuildId) -> bool {
get_self_vc_id(ctx, guild_id).await.is_some()
}
/// Check if a user is connected to a voice channel
pub async fn is_user_connected_to_vc(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> bool {
get_vc_id(ctx, guild_id, user_id).await.is_some()
}
/// Get the voice channel id of the bot
pub async fn get_self_vc_id(ctx: &Context, guild_id: &GuildId) -> Option<ChannelId> {
let user_id = ctx.cache.current_user().id;
get_vc_id(ctx, guild_id, &user_id).await
}
/// Get all voice states of a guild
pub async fn get_voice_states(ctx: &Context, guild_id: &GuildId) -> HashMap<UserId, VoiceState> {
let guild = request_guild(ctx, guild_id);
guild.voice_states.clone()
}
/// Get the amount of members in a voice channel
pub async fn get_amount_of_members_in_vc(
ctx: &Context,
guild_id: &GuildId,
channel_id: &ChannelId,
) -> usize {
let voice_states = get_voice_states(ctx, guild_id).await;
let amount = voice_states
.iter()
.filter(|(_, voice_state)| voice_state.channel_id == Some(*channel_id))
.count();
amount
}