6 Commits

Author SHA1 Message Date
78a1937210 [0.8.2] lyra package update 2024-08-03 00:12:44 +02:00
fc42427437 updated packages and closing #2
Signed-off-by: Michael Czyż <mike@c2yz.com>
2024-05-18 23:46:25 +02:00
9474577233 Lyra is now open source! 2024-02-22 13:55:11 +01:00
b86e8b9daf soundboard and some tools 2024-02-21 12:29:30 +01:00
ef29e5319a server scripts fix 2024-02-20 22:36:40 +01:00
ee3d9a0c45 new commands, music fixes and more 2024-02-20 22:27:36 +01:00
26 changed files with 1649 additions and 546 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "src/spotify-parser"]
path = src/spotify-parser
url = https://github.com/eRgo35/spotify-parser

1254
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "lyra" name = "lyra"
version = "0.6.0" version = "0.8.2"
authors = ["Michał Czyż <mike@c2yz.com>"] authors = ["Michał Czyż <mike@c2yz.com>"]
edition = "2021" edition = "2021"
description = "A featureful Discord bot written in Rust." description = "A featureful Discord bot written in Rust."
@@ -14,15 +14,32 @@ keywords = ["discord", "bot", "rust", "music", "featureful"]
[dependencies] [dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"
fancy-regex = "0.13.0" fancy-regex = "0.13.0"
openssl = { version = "0.10.63", features = ["vendored"] } json = "0.12.4"
openssl = { version = "0.10.66", features = ["vendored"] }
owoify = "0.1.5"
poise = "0.6.1" poise = "0.6.1"
rand = "0.8.5" rand = "0.8.5"
regex = "1.10.3" regex = "1.10.6"
reqwest = "0.11.23" reqwest = { version = "0.11.27", features = ["json"] }
serenity = { version = "0.12.0", features = ["cache", "framework", "standard_framework", "voice"] } serde = { version = "1.0.204", features = ["derive"] }
songbird = { version = "0.4.0", features = ["builtin-queue", "serenity"] } serde_json = "1.0.122"
symphonia = { version = "0.5.3", features = ["aac", "adpcm", "alac", "flac", "mpa", "isomp4"] } serenity = { version = "0.12.2", features = [
tokio = { version = "1.35.1", features = ["macros", "full", "signal"] } "cache",
"framework",
"standard_framework",
"voice",
] }
songbird = { version = "0.4.3", features = ["builtin-queue", "serenity"] }
symphonia = { version = "0.5.4", features = [
"aac",
"adpcm",
"alac",
"flac",
"mpa",
"isomp4",
] }
tokio = { version = "1.39.2", features = ["macros", "full", "signal"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-futures = "0.2.5" tracing-futures = "0.2.5"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
url = "2.5.2"

View File

@@ -1,19 +0,0 @@
FROM rust:1.71-alpine
RUN apk add --update \
alpine-sdk \
ffmpeg \
youtube-dl \
pkgconfig \
cmake \
openssl-dev \
musl-dev \
openssl
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/lyra"]

View File

@@ -1,7 +1,21 @@
# License MIT License
Copyright (C) 2024 Michał Czyż Copyright (c) 2024 Michał Czyż
All Rights Reserved. Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This build is private and shall not be distributed nor modified without permission. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -3,10 +3,79 @@
![](assets/lyra-256.png) ![](assets/lyra-256.png)
Lyra is a music bot written in Rust. Lyra is a music bot written in Rust.
More features coming soon!
## Getting Started ## Getting Started
## Building Lyra is an open source, discord music bot written in Rust.
The idea behind this project is to allow a user to self-host one's own instance of the bot.
User no longer has to rely on 3rd parties to provide them an invite link.
The bot can be run even on a desktop or a phone because after compilation, it's just a simple binary.
As of now, the bot supports spotify url track recognition through a separate nodejs script. I plan to write the actual parser inside the bot iteself but as of now I postponed it into future release.
Slash commands are still work in progress! Currently bot is still heavily in development!
## Setting up
To compile the source code on your own, you need `rust` and `cargo`
To run a dev version use
```bash
$ cargo run
```
To build a production version use
```bash
$ cargo build --release
```
If you need an ARM version and just don't want to wait for ages for the program to compile, use
```bash
$ cross build -r --target aarch64-unknown-linux-gnu
```
To run a program, just type
```bash
$ ./lyra
```
if you want to disown it from the shell, I recommend using the script I provided in `scripts` folder
## Commands ## Commands
As of now, working commands are:
```
Music:
/deafen Deafens itself while in a voice channel; aliases: deafen, undeaden, shuush
/join Joins your voice channel
/leave Leaves the voice channel; aliases: leave, qa!
/mute Mutes itself while in a voice channel; aliases: mute, unmute, shhh
/pause Pauses the currently playing song
/play Plays a song; you can search by query or paste an url; aliases: play, p, enqueue
/queue Shows next tracks in queue; aliases: queue, q
/repeat Loops currently playing song provided amount of times; aliases: repeat, loop, while, for
/resume Resumes currently paused song
/seek Seeks a track by provided seconds
/skip Skips the currently playing song
/stop Stops playback and destroys the queue; aliases: stop, end
/volume Changes output volume
/effect Plays one of available audio effects
/stream Hijacks output and plays audio; search by query or paste an url; aliases: stream, override, hijack
Tools:
/ai Asks AI
/dice Rolls a dice
/owoify Owoifies whatever you want uwu
/ping Pings you backs with a response time
/posix Prints current time in POSIX format
/qr Creates a qr code from text
/verse Reference Bible by verse
Help:
/help Prints this help message; aliases: help, huh, welp
```

View File

@@ -1,6 +0,0 @@
version: '2'
services:
melvinjs:
container_name: lyra
build: .

View File

@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
nohup ../lyra > lyra.log 2> lyra.err < /dev/null & nohup ./lyra > lyra.log 2> lyra.err < /dev/null &

View File

@@ -10,7 +10,6 @@ pub mod queue;
pub mod repeat; pub mod repeat;
pub mod resume; pub mod resume;
pub mod seek; pub mod seek;
pub mod shuffle;
pub mod skip; pub mod skip;
pub mod soundboard; pub mod soundboard;
pub mod stop; pub mod stop;
@@ -26,7 +25,6 @@ pub use queue::queue;
pub use repeat::repeat; pub use repeat::repeat;
pub use resume::resume; pub use resume::resume;
pub use seek::seek; pub use seek::seek;
pub use shuffle::shuffle;
pub use skip::skip; pub use skip::skip;
pub use stop::stop; pub use stop::stop;
pub use volume::volume; pub use volume::volume;

View File

@@ -42,9 +42,12 @@ pub async fn play(
r"^((?:https?:)\/\/)?((?:www|m)\.)?((?:youtube\.com)).*(youtu.be\/|list=)([^#&?]*).*", r"^((?:https?:)\/\/)?((?:www|m)\.)?((?:youtube\.com)).*(youtu.be\/|list=)([^#&?]*).*",
) )
.unwrap(); .unwrap();
let regex_spotify_playlist = Regex::new(r"https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:(album|playlist)\/|\?uri=spotify:playlist:)((\w|-)+)(?:(?=\?)(?:[?&]foo=(\d*)(?=[&#]|$)|(?![?&]foo=)[^#])+)?(?=#|$)").unwrap();
let is_playlist = regex_youtube_playlist.is_match(&song).unwrap(); let is_playlist = regex_youtube_playlist.is_match(&song).unwrap()
let is_spotify = regex_spotify.is_match(&song).unwrap(); || regex_spotify_playlist.is_match(&song).unwrap();
let is_spotify =
regex_spotify.is_match(&song).unwrap() || regex_spotify_playlist.is_match(&song).unwrap();
let is_query = !song.starts_with("http"); let is_query = !song.starts_with("http");
let guild_id = ctx.guild_id().unwrap(); let guild_id = ctx.guild_id().unwrap();
@@ -83,6 +86,43 @@ pub async fn play(
handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier);
if is_playlist && is_spotify {
let raw_list = Command::new("node")
.args(["./src/spotify-parser", &song])
.output()
.expect("failed to execute process")
.stdout;
let list = String::from_utf8(raw_list.clone()).expect("Invalid UTF-8");
let tracks: Vec<String> = list.split("\n").map(str::to_string).collect();
for (index, url) in tracks.clone().iter().enumerate() {
if url.is_empty() {
break;
}
let src = YoutubeDl::new_ytdl_like(
"yt-dlp",
http_client.clone(),
format!("ytsearch:{}", url.to_string()),
);
let aux_metadata = src.clone().aux_metadata().await.unwrap();
let track = handler.enqueue_input(src.clone().into()).await;
let _ = track
.typemap()
.write()
.await
.insert::<Metadata>(aux_metadata);
if index == 0 {
let embed = generate_playlist_embed(ctx, track, tracks.len()).await;
let response = CreateReply::default().embed(embed.unwrap());
ctx.send(response).await?;
}
}
return Ok(());
}
if is_playlist { if is_playlist {
let raw_list = Command::new("yt-dlp") let raw_list = Command::new("yt-dlp")
.args(["-j", "--flat-playlist", &song]) .args(["-j", "--flat-playlist", &song])
@@ -98,6 +138,9 @@ pub async fn play(
.collect(); .collect();
for (index, url) in urls.clone().iter().enumerate() { for (index, url) in urls.clone().iter().enumerate() {
if url.is_empty() {
break;
}
let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client.clone(), url.to_string()); let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client.clone(), url.to_string());
let aux_metadata = src.clone().aux_metadata().await.unwrap(); let aux_metadata = src.clone().aux_metadata().await.unwrap();
let track = handler.enqueue_input(src.clone().into()).await; let track = handler.enqueue_input(src.clone().into()).await;
@@ -113,37 +156,37 @@ pub async fn play(
ctx.send(response).await?; ctx.send(response).await?;
} }
} }
} else {
if is_spotify {
let exec = format!("node ./src/spotify --url {}", song);
let query = Command::new("sh")
.arg("-c")
.arg(exec)
.output()
.expect("failed to execute process")
.stdout;
let query_str = String::from_utf8(query.clone()).expect("Invalid UTF-8");
song = format!("ytsearch:{}", query_str.to_string());
}
if is_query { return Ok(());
song = format!("ytsearch:{}", song);
}
let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song);
let embed = generate_embed(ctx, src.clone(), handler.queue()).await;
let response = CreateReply::default().embed(embed.unwrap());
ctx.send(response).await?;
let aux_metadata = src.clone().aux_metadata().await.unwrap();
let track = handler.enqueue_input(src.clone().into()).await;
let _ = track
.typemap()
.write()
.await
.insert::<Metadata>(aux_metadata);
} }
if is_spotify {
let query = Command::new("node")
.args(["./src/spotify-parser", &song])
.output()
.expect("failed to execute process")
.stdout;
let query_str = String::from_utf8(query.clone()).expect("Invalid UTF-8");
song = format!("ytsearch:{}", query_str.to_string());
}
if is_query {
song = format!("ytsearch:{}", song);
}
let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song);
let embed = generate_embed(ctx, src.clone(), handler.queue()).await;
let response = CreateReply::default().embed(embed.unwrap());
ctx.send(response).await?;
let aux_metadata = src.clone().aux_metadata().await.unwrap();
let track = handler.enqueue_input(src.clone().into()).await;
let _ = track
.typemap()
.write()
.await
.insert::<Metadata>(aux_metadata);
} }
Ok(()) Ok(())
@@ -166,7 +209,7 @@ async fn generate_embed(
let timestamp = Timestamp::now(); let timestamp = Timestamp::now();
let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60; let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60;
let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60; let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60;
let mut description = format!("Song added to queue @ {}", queue.len()); let mut description = format!("Song added to queue @ {}", queue.len() + 1);
if queue.len() == 0 { if queue.len() == 0 {
description = format!("Playing now!"); description = format!("Playing now!");

View File

@@ -10,6 +10,8 @@ use serenity::{
}; };
use songbird::input::AuxMetadata; use songbird::input::AuxMetadata;
const QUEUE_DISPLAY_LENGTH: usize = 10;
/// Shows next tracks in queue; \ /// Shows next tracks in queue; \
/// aliases: queue, q /// aliases: queue, q
#[poise::command(prefix_command, slash_command, aliases("q"), category = "Music")] #[poise::command(prefix_command, slash_command, aliases("q"), category = "Music")]
@@ -25,8 +27,9 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> {
let handler = handler_lock.lock().await; let handler = handler_lock.lock().await;
let queue = handler.queue(); let queue = handler.queue();
let mut queue_res = String::from(""); let mut queue_res = String::from("");
let mut too_long = false;
for (index, song) in queue.current_queue().iter().enumerate() { for (index, song) in queue.clone().current_queue().iter().enumerate() {
let meta_typemap = song.typemap().read().await; let meta_typemap = song.typemap().read().await;
let metadata = meta_typemap.get::<Metadata>().unwrap(); let metadata = meta_typemap.get::<Metadata>().unwrap();
let AuxMetadata { let AuxMetadata {
@@ -39,8 +42,6 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> {
let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60; let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60;
let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60; let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60;
// println!("{:?}", metadata.clone());
queue_res.push_str(&format!( queue_res.push_str(&format!(
"{}. {} - {} [{:02}:{:02}] \n", "{}. {} - {} [{:02}:{:02}] \n",
index, index,
@@ -49,6 +50,17 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> {
duration_minutes, duration_minutes,
duration_seconds duration_seconds
)); ));
if index + 1 == QUEUE_DISPLAY_LENGTH {
too_long = true;
break;
}
}
if too_long {
queue_res.push_str(&format!(
"and {} more...",
queue.len() - QUEUE_DISPLAY_LENGTH
));
} }
ctx.send(CreateReply::default().embed(embed(ctx, queue_res).await.unwrap())) ctx.send(CreateReply::default().embed(embed(ctx, queue_res).await.unwrap()))

View File

@@ -19,7 +19,6 @@ pub async fn resume(ctx: Context<'_>) -> Result<(), Error> {
let queue = handler.queue(); let queue = handler.queue();
let _ = queue.resume(); let _ = queue.resume();
ctx.say(format!("Song resumed.")).await?;
ctx.send( ctx.send(
CreateReply::default().embed( CreateReply::default().embed(
embed(ctx, "Resumed!", "Currently paused song is now resumed!", "") embed(ctx, "Resumed!", "Currently paused song is now resumed!", "")

View File

@@ -1,11 +1,51 @@
use crate::{commands::embeds::embed, Context, Error}; use std::time::Duration;
use crate::{
commands::embeds::{embed, error_embed},
Context, Error,
};
use poise::CreateReply; use poise::CreateReply;
/// Seeks a track by provided seconds /// Seeks a track by provided seconds
#[poise::command(prefix_command, slash_command, category = "Music")] #[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn seek(ctx: Context<'_>) -> Result<(), Error> { pub async fn seek(
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx: Context<'_>,
#[description = "How many seconds shall I seek"] seek: u64,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let manager = songbird::get(&ctx.serenity_context())
.await
.expect("Songbird client placed at init")
.clone();
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
let seek_duration = Duration::from_secs(seek);
let track = queue.current().unwrap();
let _ = track.seek(seek_duration);
ctx.send(
CreateReply::default().embed(
embed(
ctx,
"Track seeked!",
&format!("Track seeked by: {} seconds", seek),
"",
)
.await
.unwrap(),
),
)
.await?; .await?;
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
Ok(()) Ok(())
} }

View File

@@ -1,11 +0,0 @@
use crate::{commands::embeds::embed, Context, Error};
use poise::CreateReply;
/// Shuffles the playlist
#[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn shuffle(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
.await?;
Ok(())
}

View File

@@ -1,11 +1,25 @@
use crate::commands::music::metadata::Metadata;
use std::time::Duration;
use crate::{ use crate::{
commands::embeds::{embed, error_embed}, commands::embeds::{embed, error_embed},
Context, Error, Context, Error,
}; };
use poise::CreateReply; use poise::CreateReply;
use serenity::{
builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter},
model::{Colour, Timestamp},
};
use songbird::{input::AuxMetadata, tracks::TrackHandle};
/// Skips the currently playing song /// Skips the currently playing song; \
#[poise::command(prefix_command, slash_command, category = "Music")] /// aliases: skip, :skipper:
#[poise::command(
prefix_command,
slash_command,
aliases("skipper:"),
category = "Music"
)]
pub async fn skip(ctx: Context<'_>) -> Result<(), Error> { pub async fn skip(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap(); let guild_id = ctx.guild_id().unwrap();
@@ -17,21 +31,31 @@ pub async fn skip(ctx: Context<'_>) -> Result<(), Error> {
if let Some(handler_lock) = manager.get(guild_id) { if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await; let handler = handler_lock.lock().await;
let queue = handler.queue(); let queue = handler.queue();
let _ = queue.skip(); let _ = queue.clone().skip();
let track_raw = queue.clone().current_queue();
let track = track_raw.get(1);
let queue_length = queue.len() - 1;
ctx.send( let response;
CreateReply::default().embed(
embed( match track {
ctx, Some(track) => {
"Skipped!", response = CreateReply::default().embed(
"Next song: {song}", generate_embed(ctx, track.clone(), queue_length)
&format!("Songs left in queue: {}", queue.len()), .await
) .unwrap(),
.await );
.unwrap(), }
), None => {
) response = CreateReply::default().embed(
.await?; embed(ctx, "Skipped!", "The queue is empty!", "")
.await
.unwrap(),
);
}
};
ctx.send(response).await?;
} else { } else {
let msg = "I am not in a voice channel!"; let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap())) ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
@@ -40,3 +64,55 @@ pub async fn skip(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
async fn generate_embed(
ctx: Context<'_>,
track: TrackHandle,
queue_length: usize,
) -> Result<CreateEmbed, Error> {
let meta_typemap = track.typemap().read().await;
let metadata = meta_typemap.get::<Metadata>().unwrap();
let AuxMetadata {
title,
thumbnail,
source_url,
artist,
duration,
..
} = metadata;
let timestamp = Timestamp::now();
let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60;
let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60;
let description = format!("Song skipped! Queue length is {}", queue_length);
let embed = CreateEmbed::default()
.author(CreateEmbedAuthor::new("Skipped!").icon_url(ctx.author().clone().face()))
.colour(Colour::from_rgb(255, 58, 97))
.title(title.as_ref().unwrap())
.url(source_url.as_ref().unwrap())
.thumbnail(
thumbnail
.as_ref()
.unwrap_or(&ctx.cache().current_user().face()),
)
.field(
"Artist",
artist.as_ref().unwrap_or(&"Unknown Artist".to_string()),
true,
)
.field(
"Duration",
format!("{:02}:{:02}", duration_minutes, duration_seconds),
true,
)
.field("DJ", ctx.author().name.clone(), true)
.description(description)
.timestamp(timestamp)
.footer(
CreateEmbedFooter::new(ctx.cache().current_user().name.to_string())
.icon_url(ctx.cache().current_user().face()),
);
Ok(embed)
}

View File

@@ -1,11 +1,117 @@
use crate::{commands::embeds::embed, Context, Error}; use crate::{commands::embeds::error_embed, Context, Error};
use poise::serenity_prelude::model::Timestamp;
use poise::serenity_prelude::Colour;
use poise::serenity_prelude::CreateEmbed;
use poise::CreateReply; use poise::CreateReply;
use serenity::builder::CreateEmbedAuthor;
use serenity::builder::CreateEmbedFooter;
use songbird::events::TrackEvent;
use songbird::input::AuxMetadata;
use songbird::input::{Compose, YoutubeDl};
use std::time::Duration;
use crate::commands::music::notifier::TrackErrorNotifier;
use crate::http::HttpKey;
/// Plays one of available audio effects /// Plays one of available audio effects
#[poise::command(prefix_command, slash_command, category = "Music")] #[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn effect(ctx: Context<'_>) -> Result<(), Error> { pub async fn effect(
ctx.send(CreateReply::default().embed(embed(ctx, "Playing an effect", "", "").await.unwrap())) ctx: Context<'_>,
.await?; #[description = "Shall output pause?"]
#[flag]
pause: bool,
#[description = "Provide a query or an url"]
#[rest]
mut song: String,
) -> Result<(), Error> {
let is_query = !song.starts_with("http");
let guild_id = ctx.guild_id().unwrap();
let http_client = {
let data = ctx.serenity_context().data.read().await;
data.get::<HttpKey>()
.cloned()
.expect("Guaranteed to exist in the typemap.")
};
let manager = songbird::get(&ctx.serenity_context())
.await
.expect("Songbird Voice placed at init")
.clone();
if pause {
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
let _ = queue.pause();
}
}
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier);
if is_query {
song = format!("ytsearch:{}", song);
}
let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song);
let embed = generate_embed(ctx, src.clone()).await;
let response = CreateReply::default().embed(embed.unwrap());
ctx.send(response).await?;
let _ = handler.play_input(src.clone().into());
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
Ok(()) Ok(())
} }
async fn generate_embed(ctx: Context<'_>, src: YoutubeDl) -> Result<CreateEmbed, Error> {
let metadata = src.clone().aux_metadata().await.unwrap();
let AuxMetadata {
title,
thumbnail,
source_url,
artist,
duration,
..
} = metadata;
let timestamp = Timestamp::now();
let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60;
let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60;
let description = format!("Playing now!");
let embed = CreateEmbed::default()
.author(CreateEmbedAuthor::new("Playing an effect!").icon_url(ctx.author().clone().face()))
.colour(Colour::from_rgb(255, 58, 97))
.title(title.unwrap())
.url(source_url.unwrap())
.thumbnail(thumbnail.unwrap_or(ctx.cache().current_user().face()))
.field(
"Artist",
artist.unwrap_or("Unknown Artist".to_string()),
true,
)
.field(
"Duration",
format!("{:02}:{:02}", duration_minutes, duration_seconds),
true,
)
.field("DJ", ctx.author().name.clone(), true)
.description(description)
.timestamp(timestamp)
.footer(
CreateEmbedFooter::new(ctx.cache().current_user().name.to_string())
.icon_url(ctx.cache().current_user().face()),
);
Ok(embed)
}

View File

@@ -1,11 +1,126 @@
use crate::{commands::embeds::embed, Context, Error}; use crate::{commands::embeds::error_embed, Context, Error};
use poise::CreateReply;
/// Hijacks current audio output and plays selected audio use poise::serenity_prelude::model::Timestamp;
#[poise::command(prefix_command, slash_command, aliases("override"), category = "Music")] use poise::serenity_prelude::Colour;
pub async fn stream(ctx: Context<'_>) -> Result<(), Error> { use poise::serenity_prelude::CreateEmbed;
ctx.send(CreateReply::default().embed(embed(ctx, "Playing audio", "", "").await.unwrap())) use poise::CreateReply;
.await?; use serenity::builder::CreateEmbedAuthor;
use serenity::builder::CreateEmbedFooter;
use songbird::events::TrackEvent;
use songbird::input::AuxMetadata;
use songbird::input::{Compose, YoutubeDl};
use std::time::Duration;
use crate::commands::music::notifier::TrackErrorNotifier;
use crate::http::HttpKey;
/// Hijacks output and plays audio; \
/// search by query or paste an url; \
/// aliases: stream, override, hijack
#[poise::command(
prefix_command,
slash_command,
aliases("override", "hijack"),
category = "Music"
)]
pub async fn stream(
ctx: Context<'_>,
#[description = "Shall output pause?"]
#[flag]
pause: bool,
#[description = "Provide a query or an url"]
#[rest]
mut song: String,
) -> Result<(), Error> {
let is_query = !song.starts_with("http");
let guild_id = ctx.guild_id().unwrap();
let http_client = {
let data = ctx.serenity_context().data.read().await;
data.get::<HttpKey>()
.cloned()
.expect("Guaranteed to exist in the typemap.")
};
let manager = songbird::get(&ctx.serenity_context())
.await
.expect("Songbird Voice placed at init")
.clone();
if pause {
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
let _ = queue.pause();
}
}
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier);
if is_query {
song = format!("ytsearch:{}", song);
}
let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song);
let embed = generate_embed(ctx, src.clone()).await;
let response = CreateReply::default().embed(embed.unwrap());
ctx.send(response).await?;
let _ = handler.play_input(src.clone().into());
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
Ok(()) Ok(())
} }
async fn generate_embed(ctx: Context<'_>, src: YoutubeDl) -> Result<CreateEmbed, Error> {
let metadata = src.clone().aux_metadata().await.unwrap();
let AuxMetadata {
title,
thumbnail,
source_url,
artist,
duration,
..
} = metadata;
let timestamp = Timestamp::now();
let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60;
let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60;
let description = format!("Playing now!");
let embed = CreateEmbed::default()
.author(
CreateEmbedAuthor::new("Audio output hijacked!").icon_url(ctx.author().clone().face()),
)
.colour(Colour::from_rgb(255, 58, 97))
.title(title.unwrap())
.url(source_url.unwrap())
.thumbnail(thumbnail.unwrap_or(ctx.cache().current_user().face()))
.field(
"Artist",
artist.unwrap_or("Unknown Artist".to_string()),
true,
)
.field(
"Duration",
format!("{:02}:{:02}", duration_minutes, duration_seconds),
true,
)
.field("DJ", ctx.author().name.clone(), true)
.description(description)
.timestamp(timestamp)
.footer(
CreateEmbedFooter::new(ctx.cache().current_user().name.to_string())
.icon_url(ctx.cache().current_user().face()),
);
Ok(embed)
}

View File

@@ -1,11 +1,44 @@
use crate::{commands::embeds::embed, Context, Error}; use crate::{
commands::embeds::{embed, error_embed},
Context, Error,
};
use poise::CreateReply; use poise::CreateReply;
/// Changes output volume /// Changes output volume
#[poise::command(prefix_command, slash_command, category = "Music")] #[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn volume(ctx: Context<'_>) -> Result<(), Error> { pub async fn volume(ctx: Context<'_>, #[description = "Volume"] volume: f32) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) let guild_id = ctx.guild_id().unwrap();
let manager = songbird::get(&ctx.serenity_context())
.await
.expect("Songbird client placed at init")
.clone();
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
let track = queue.current().unwrap();
let _ = track.set_volume(volume / 100.0);
ctx.send(
CreateReply::default().embed(
embed(
ctx,
"Volume changed",
"",
&format!("Set volume to {}%", volume),
)
.await
.unwrap(),
),
)
.await?; .await?;
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
Ok(()) Ok(())
} }

View File

@@ -1,12 +1,72 @@
use poise::CreateReply; use poise::CreateReply;
use serde::{Deserialize, Serialize};
use url::form_urlencoded;
use crate::{commands::embeds::embed, Context, Error}; use crate::{commands::embeds::embed, Context, Error};
/// Explains provided query /// Explains provided query
#[poise::command(prefix_command, slash_command, category = "Tools")] #[poise::command(prefix_command, slash_command, aliases("dict"), category = "Tools")]
pub async fn dictionary(ctx: Context<'_>) -> Result<(), Error> { pub async fn dictionary(
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx: Context<'_>,
.await?; #[description = "Word you're looking for"]
#[rest]
word: String,
) -> Result<(), Error> {
let data: String = form_urlencoded::byte_serialize(word.as_bytes()).collect();
let client = reqwest::Client::new();
let response = client
.get(format!(
"https://api.dictionaryapi.dev/api/v2/entries/en/{}",
data
))
.send()
.await
.unwrap();
match response.status() {
reqwest::StatusCode::OK => match response.json::<Vec<Word>>().await {
Ok(parsed) => {
println!("{:?}", parsed);
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
.await?;
}
Err(err) => println!("Something is messed up! {:?}", err),
},
reqwest::StatusCode::UNAUTHORIZED => {
println!("Unauthorized.. Uoops!!");
}
error => {
println!("Something went wrong: {:?}", error);
}
}
Ok(()) Ok(())
} }
#[derive(Serialize, Deserialize, Debug)]
struct Definition {
definition: String,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
struct Meaning {
partOfSpeech: String,
definitions: Vec<Definition>,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
struct Phonetic {
text: Option<String>,
audio: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Word {
word: String,
phonetics: Vec<Phonetic>,
meanings: Vec<Meaning>,
}

View File

@@ -1,11 +1,17 @@
use owoify::OwOifiable;
use poise::CreateReply; use poise::CreateReply;
use crate::{commands::embeds::embed, Context, Error}; use crate::{commands::embeds::embed, Context, Error};
/// Owoifies whatever you want uwu /// Owoifies whatever you want uwu
#[poise::command(prefix_command, slash_command, category = "Tools")] #[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn owoify(ctx: Context<'_>) -> Result<(), Error> { pub async fn owoify(
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx: Context<'_>,
#[description = "Text to owoify w-woify OwO"]
#[rest]
text: String,
) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "OwO", &text.owoify(), "").await.unwrap()))
.await?; .await?;
Ok(()) Ok(())

View File

@@ -1,12 +1,48 @@
use poise::CreateReply; use poise::CreateReply;
use serenity::{
builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter},
model::{Colour, Timestamp},
};
use crate::{commands::embeds::embed, Context, Error}; use crate::{Context, Error};
use url::form_urlencoded;
/// Creates a qr code from text /// Creates a qr code from text
#[poise::command(prefix_command, slash_command, category = "Tools")] #[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn qr(ctx: Context<'_>) -> Result<(), Error> { pub async fn qr(
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx: Context<'_>,
.await?; #[description = "Message to encode"]
#[rest]
message: String,
) -> Result<(), Error> {
let response = CreateReply::default().embed(generate_embed(ctx, message).await.unwrap());
ctx.send(response).await?;
Ok(()) Ok(())
} }
async fn generate_embed(ctx: Context<'_>, message: String) -> Result<CreateEmbed, Error> {
let timestamp = Timestamp::now();
let data: String = form_urlencoded::byte_serialize(message.as_bytes()).collect();
let url = format!(
"http://api.qrserver.com/v1/create-qr-code/?data={}&size=1000x1000&ecc=Q&margin=8",
data
);
let embed = CreateEmbed::default()
.author(
CreateEmbedAuthor::new("Your message as a QR Code!")
.icon_url(ctx.author().clone().face()),
)
.colour(Colour::from_rgb(255, 58, 97))
.title("Your QR Code:")
.url(url.clone())
.image(url)
.timestamp(timestamp)
.footer(
CreateEmbedFooter::new(ctx.cache().current_user().name.to_string())
.icon_url(ctx.cache().current_user().face()),
);
Ok(embed)
}

View File

@@ -1,12 +1,78 @@
use crate::{
commands::embeds::{embed, error_embed},
Context, Error,
};
use poise::CreateReply; use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{commands::embeds::embed, Context, Error}; use url::form_urlencoded;
/// Reference Bible by verse /// Reference Bible by verse
#[poise::command(prefix_command, slash_command, category = "Tools")] #[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn verse(ctx: Context<'_>) -> Result<(), Error> { pub async fn verse(
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx: Context<'_>,
.await?; #[description = "Latin?"]
#[flag]
latin: bool,
#[description = "BOOK+CHAPTER:VERSE"]
#[rest]
verse: String,
) -> Result<(), Error> {
let data: String = form_urlencoded::byte_serialize(verse.as_bytes()).collect();
let translation = if latin { "clementine" } else { "web" };
let client = reqwest::Client::new();
let response = client
.get(format!(
"https://bible-api.com/{}?translation={}",
data, translation
))
.send()
.await
.unwrap();
match response.status() {
reqwest::StatusCode::OK => {
match response.json::<APIResponse>().await {
Ok(parsed) => {
if parsed.text.len() > 4000 {
ctx.send(
CreateReply::default()
.embed(error_embed(ctx, "Quoted text is too long!").await.unwrap()),
)
.await?;
return Ok(());
}
ctx.send(
CreateReply::default().embed(
embed(
ctx,
&parsed.translation_name,
&parsed.text,
&parsed.reference,
)
.await
.unwrap(),
),
)
.await?;
}
Err(err) => println!("Something is messed up! {:?}", err),
};
}
reqwest::StatusCode::UNAUTHORIZED => {
println!("Unauthorized.. Uoops!!");
}
error => {
println!("Something went wrong: {:?}", error);
}
}
Ok(()) Ok(())
} }
#[derive(Serialize, Deserialize, Debug)]
struct APIResponse {
reference: String,
text: String,
translation_name: String,
translation_note: String,
}

View File

@@ -4,7 +4,12 @@ use crate::{commands::embeds::embed, Context, Error};
/// Shows weather for provided location /// Shows weather for provided location
#[poise::command(prefix_command, slash_command, category = "Tools")] #[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn weather(ctx: Context<'_>) -> Result<(), Error> { pub async fn weather(
ctx: Context<'_>,
#[description = "Provide a city name"]
#[rest]
_location: String,
) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
.await?; .await?;

View File

@@ -53,7 +53,6 @@ async fn main() {
music::repeat(), music::repeat(),
music::resume(), music::resume(),
music::seek(), music::seek(),
music::shuffle(),
music::skip(), music::skip(),
music::stop(), music::stop(),
music::volume(), music::volume(),

Submodule src/spotify deleted from ea246e9ed2

1
src/spotify-parser Submodule

Submodule src/spotify-parser added at e3b3c0fb6e