9 Commits

Author SHA1 Message Date
8a0d08de4e added songbird as a submodule 2024-08-05 11:32:23 +02:00
f224ccfbdf reduced ai timeout, impl uptime 2024-08-04 21:26:45 +02:00
d0af34833b lyra nightly avatar 2024-08-04 20:11:31 +02:00
c2e653b3ea [0.9.0] new parser 2024-08-04 18:03:21 +02:00
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
33 changed files with 4319 additions and 563 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
/target
**/target
.env

6
.gitmodules vendored
View File

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

1932
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "lyra"
version = "0.7.0"
version = "0.9.0"
authors = ["Michał Czyż <mike@c2yz.com>"]
edition = "2021"
description = "A featureful Discord bot written in Rust."
@@ -10,23 +10,42 @@ homepage = "https://lyra.c2yz.com"
license-file = "LICENSE.md"
keywords = ["discord", "bot", "rust", "music", "featureful"]
[dependencies]
lib-spotify-parser = { path = "libs/spotify-parser" }
dotenv = "0.15.0"
fancy-regex = "0.13.0"
json = "0.12.4"
openssl = { version = "0.10.63", features = ["vendored"] }
openssl = { version = "0.10.66", features = ["vendored"] }
owoify = "0.1.5"
poise = "0.6.1"
rand = "0.8.5"
regex = "1.10.3"
reqwest = { version = "0.11.23", features = ["json"]}
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.114"
serenity = { version = "0.12.0", features = ["cache", "framework", "standard_framework", "voice"] }
songbird = { version = "0.4.0", features = ["builtin-queue", "serenity"] }
symphonia = { version = "0.5.3", features = ["aac", "adpcm", "alac", "flac", "mpa", "isomp4"] }
tokio = { version = "1.35.1", features = ["macros", "full", "signal"] }
regex = "1.10.6"
reqwest = { version = "0.11.27", features = ["json"] }
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.122"
songbird = { path = "./libs/songbird", version = "0.4", default-features = true, features = [
"builtin-queue",
] }
serenity = { features = [
"cache",
"framework",
"standard_framework",
"voice",
"http",
"rustls_backend",
], version = "0.12" }
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-futures = "0.2.5"
tracing-subscriber = "0.3.18"
url = "2.5.0"
url = "2.5.2"
once_cell = "1.19.0"

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)
Lyra is a music bot written in Rust.
More features coming soon!
## 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
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
```

BIN
assets/lyra-nightly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

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

1
libs/songbird Submodule

Submodule libs/songbird added at 52ccf8b328

1906
libs/spotify-parser/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[package]
name = "lib-spotify-parser"
version = "1.0.0"
edition = "2021"
[dependencies]
regex = "1.10.6"
reqwest = { version = "0.12.5", features = ["blocking", "rustls-tls"] }
scraper = "0.19.1"
serde = "1.0.204"
serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["macros"] }

View File

@@ -0,0 +1,14 @@
use std::fmt;
#[derive(Debug)]
pub enum ParseError {
InvalidUrl,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Invalid URL")
}
}
impl std::error::Error for ParseError {}

View File

@@ -0,0 +1,154 @@
mod error;
mod parser;
mod retriever;
use error::ParseError;
use parser::{parse_list, parse_single, USER_AGENT};
use regex::Regex;
use reqwest;
use retriever::{retrieve_async_track, retrieve_async_tracks, retrieve_track, retrieve_tracks};
pub fn retrieve_url(url: &str) -> Result<Vec<String>, ParseError> {
let spotify_regex: Regex = Regex::new(r"https?:\/\/(?:embed\.|open\.)spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)(?:\?si=[\w-]+)?").unwrap();
if let Some(captures) = spotify_regex.captures(url) {
let category = captures.get(1).unwrap().as_str();
let id = captures.get(2).unwrap().as_str();
match category {
"track" => {
let track = retrieve_track(category, id).unwrap();
Ok(vec![track])
}
"playlist" | "album" => {
let tracks = retrieve_tracks(category, id).unwrap();
Ok(tracks)
}
_ => Err(ParseError::InvalidUrl),
}
} else {
Err(ParseError::InvalidUrl)
}
}
pub async fn retrieve_async_url(url: &str) -> Result<Vec<String>, ParseError> {
let spotify_regex: Regex = Regex::new(r"https?:\/\/(?:embed\.|open\.)spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)(?:\?si=[\w-]+)?").unwrap();
if let Some(captures) = spotify_regex.captures(url) {
let category = captures.get(1).unwrap().as_str();
let id = captures.get(2).unwrap().as_str();
match category {
"track" => {
let track = retrieve_async_track(category, id).await.unwrap();
Ok(vec![track])
}
"playlist" | "album" => {
let tracks = retrieve_async_tracks(category, id).await.unwrap();
Ok(tracks)
}
_ => Err(ParseError::InvalidUrl),
}
} else {
Err(ParseError::InvalidUrl)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRACK: &str = "https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8?si=e0a8c8ada8284e43";
const MULTIPLE_ARTISTS_TRACK: &str =
"https://open.spotify.com/track/1O0SdrryPeGp6eSSUibdgo?si=4ae58febe9e74eae";
const PLAYLIST: &str =
"https://open.spotify.com/playlist/37i9dQZF1DZ06evO05tE88?si=e0c6f44d176f44e6";
const ALBUM: &str =
"https://open.spotify.com/album/6eUW0wxWtzkFdaEFsTJto6?si=_grLtlNySNyfJTZr8tP44Q";
#[test]
fn check_track() {
let track: Vec<&str> = vec!["Rick Astley - Never Gonna Give You Up"];
assert_eq!(retrieve_url(TRACK).unwrap(), track);
}
#[test]
fn check_multiple_artists_track() {
let track: Vec<&str> = vec!["Will o' the wisp, Rick Astley - Blood on My Tie"];
assert_eq!(retrieve_url(MULTIPLE_ARTISTS_TRACK).unwrap(), track);
}
#[test]
fn check_playlist() {
let playlist: Vec<&str> = vec![
"Rick Astley - Never Gonna Give You Up",
"Rick Astley - Take Me to Your Heart (2023 Remaster)",
"Rick Astley - Cry for Help - Single Edit",
"Rick Astley - Never Gonna Stop",
"Rick Astley - Together Forever",
"Rick Astley - Hold Me in Your Arms (7\" Version)",
"Rick Astley - Angels on My Side",
"New Kids On The Block, Salt-N-Pepa, Rick Astley, En Vogue - Bring Back The Time",
"Rick Astley - Whenever You Need Somebody",
"Rick Astley - She Wants to Dance with Me (2023 Remaster)",
"Rick Astley - Dippin My Feet",
"Rick Astley - My Arms Keep Missing You",
"Rick Astley - Don't Say Goodbye",
"Rick Astley - Dance",
"Rick Astley - Giving Up On Love (7'' Pop Version)",
"Rick Astley - Never Gonna Give You Up (Cake Mix)",
"Rick Astley - It Would Take a Strong Strong Man",
"Rick Astley - Driving Me Crazy",
"Rick Astley - Beautiful Life",
"Rick Astley - I Don't Want to Lose Her",
"Rick Astley - Keep Singing",
"Rick Astley - Forever and More",
"Rick Astley - Hopelessly",
"Rick Astley - When I Fall in Love",
"Rick Astley - Every One of Us",
"Rick Astley - High Enough",
"Rick Astley - Ain't Too Proud to Beg (2023 Remaster)",
"Rick Astley - I'll Never Let You Down",
"Trevor Horn, Rick Astley - Owner Of A Lonely Heart",
"Rick Astley - Letting Go",
"Rick Astley - Never Knew Love",
"Rick Astley - Lights Out - Radio Edit",
"Rick Astley - Try",
"Rick Astley - Dial My Number (2023 Remaster)",
"Rick Astley - Wish Away",
"Rick Astley - Giant",
"Rick Astley - Move Right Out",
"Rick Astley - Till Then (Time Stands Still) (2023 Remaster)",
"Rick Astley - Pray with Me",
"Rick Astley - I Don't Want to Be Your Lover",
"Will o' the wisp, Rick Astley - Blood on My Tie",
"Rick Astley - Can't Help Falling in Love",
"Rick Astley - I Like the Sun",
"Rick Astley - She Makes Me",
"Rick Astley - Body and Soul",
"Rick Astley - (They Long to Be) Close to You",
"Rick Astley - Unwanted (Official Song from the Podcast)",
"Rick Astley - Last Night on Earth",
"Rick Astley - Everlong - Acoustic Version",
"Rick Astley - Superman",
];
assert_eq!(retrieve_url(PLAYLIST).unwrap(), playlist);
}
#[test]
fn check_album() {
let album: Vec<&str> = vec![
"Rick Astley - Never Gonna Give You Up",
"Rick Astley - Whenever You Need Somebody",
"Rick Astley - Together Forever",
"Rick Astley - It Would Take a Strong Strong Man",
"Rick Astley - The Love Has Gone",
"Rick Astley - Don't Say Goodbye",
"Rick Astley - Slipping Away",
"Rick Astley - No More Looking for Love",
"Rick Astley - You Move Me",
"Rick Astley - When I Fall in Love",
];
assert_eq!(retrieve_url(ALBUM).unwrap(), album);
}
}

View File

@@ -0,0 +1,70 @@
use scraper::{Html, Selector};
use serde_json::Value;
use std::error::Error;
#[derive(Debug, Clone)]
pub(crate) struct SpotifyTrack {
pub title: String,
pub artist: String,
}
pub(crate) const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
pub(crate) fn parse_single(content: String) -> Result<SpotifyTrack, Box<dyn Error>> {
let document = Html::parse_document(&content);
let selector = Selector::parse("#__NEXT_DATA__").unwrap();
if let Some(script_element) = document.select(&selector).next() {
let json_str = script_element.inner_html();
let json_value: Value = serde_json::from_str(&json_str)?;
let metadata = &json_value["props"]["pageProps"]["state"]["data"]["entity"];
// println!("{metadata:?}");
let title = metadata["title"].as_str().unwrap().to_string();
let artists = metadata["artists"].as_array().unwrap();
let artists_list: Vec<String> = artists
.iter()
.map(|artist| artist["name"].as_str().unwrap().to_string())
.collect();
let artist: String = artists_list.join(", ").to_string();
let track = SpotifyTrack { title, artist };
Ok(track)
} else {
Err("Could not find element".into())
}
}
pub(crate) fn parse_list(content: String) -> Result<Vec<SpotifyTrack>, Box<dyn Error>> {
let document = Html::parse_document(&content);
let selector = Selector::parse("#__NEXT_DATA__").unwrap();
if let Some(script_element) = document.select(&selector).next() {
let json_str = script_element.inner_html();
let json_value: Value = serde_json::from_str(&json_str)?;
let metadata = &json_value["props"]["pageProps"]["state"]["data"]["entity"]["trackList"]
.as_array()
.unwrap();
// println!("{metadata:?}");
let tracks: Vec<SpotifyTrack> = metadata
.iter()
.map(|track| {
let title = track["title"].as_str().unwrap().to_string();
let artist = track["subtitle"].as_str().unwrap().to_string();
SpotifyTrack { title, artist }
})
.collect();
// println!("{tracks:?}");
Ok(tracks)
} else {
Err("Could not find element".into())
}
}

View File

@@ -0,0 +1,85 @@
use crate::*;
pub fn retrieve_track(category: &str, id: &str) -> Result<String, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::blocking::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().unwrap();
let content = response.text().unwrap();
let parsed_content = parse_single(content).unwrap();
Ok(format!(
"{} - {}",
parsed_content.artist, parsed_content.title
))
}
pub fn retrieve_tracks(category: &str, id: &str) -> Result<Vec<String>, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::blocking::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().unwrap();
let content = response.text().unwrap();
let parsed_content = parse_list(content).unwrap();
let tracks: Vec<String> = parsed_content
.iter()
.map(|track| format!("{} - {}", track.artist, track.title))
.collect();
Ok(tracks)
}
pub async fn retrieve_async_track(category: &str, id: &str) -> Result<String, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().await.unwrap();
let content = response.text().await.unwrap();
let parsed_content = parse_single(content).unwrap();
Ok(format!(
"{} - {}",
parsed_content.artist, parsed_content.title
))
}
pub async fn retrieve_async_tracks(category: &str, id: &str) -> Result<Vec<String>, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().await.unwrap();
let content = response.text().await.unwrap();
let parsed_content = parse_list(content).unwrap();
let tracks: Vec<String> = parsed_content
.iter()
.map(|track| format!("{} - {}", track.artist, track.title))
.collect();
Ok(tracks)
}

View File

@@ -1,3 +1,3 @@
#!/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 resume;
pub mod seek;
pub mod shuffle;
pub mod skip;
pub mod soundboard;
pub mod stop;
@@ -26,7 +25,6 @@ pub use queue::queue;
pub use repeat::repeat;
pub use resume::resume;
pub use seek::seek;
pub use shuffle::shuffle;
pub use skip::skip;
pub use stop::stop;
pub use volume::volume;

View File

@@ -2,6 +2,7 @@ use crate::commands::music::metadata::Metadata;
use crate::{commands::embeds::error_embed, Context, Error};
use fancy_regex::Regex;
use lib_spotify_parser;
use poise::serenity_prelude::model::Timestamp;
use poise::serenity_prelude::Colour;
use poise::serenity_prelude::CreateEmbed;
@@ -87,14 +88,7 @@ pub async fn play(
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();
let tracks: Vec<String> = lib_spotify_parser::retrieve_async_url(&song).await.unwrap();
for (index, url) in tracks.clone().iter().enumerate() {
if url.is_empty() {
@@ -161,13 +155,14 @@ pub async fn play(
}
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());
song = format!(
"ytsearch:{}",
lib_spotify_parser::retrieve_async_url(&song)
.await
.unwrap()
.first()
.unwrap()
);
}
if is_query {

View File

@@ -19,7 +19,6 @@ pub async fn resume(ctx: Context<'_>) -> Result<(), Error> {
let queue = handler.queue();
let _ = queue.resume();
ctx.say(format!("Song resumed.")).await?;
ctx.send(
CreateReply::default().embed(
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;
/// Seeks a track by provided seconds
#[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn seek(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
pub async fn seek(
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?;
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
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

@@ -12,8 +12,14 @@ use serenity::{
};
use songbird::{input::AuxMetadata, tracks::TrackHandle};
/// Skips the currently playing song
#[poise::command(prefix_command, slash_command, category = "Music")]
/// Skips the currently playing song; \
/// aliases: skip, :skipper:
#[poise::command(
prefix_command,
slash_command,
aliases("skipper:"),
category = "Music"
)]
pub async fn skip(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();

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 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
#[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn effect(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "Playing an effect", "", "").await.unwrap()))
.await?;
pub async fn effect(
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(())
}
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 poise::CreateReply;
use crate::{commands::embeds::error_embed, Context, Error};
/// Hijacks current audio output and plays selected audio
#[poise::command(prefix_command, slash_command, aliases("override"), category = "Music")]
pub async fn stream(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "Playing audio", "", "").await.unwrap()))
.await?;
use poise::serenity_prelude::model::Timestamp;
use poise::serenity_prelude::Colour;
use poise::serenity_prelude::CreateEmbed;
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;
/// 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(())
}
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;
/// Changes output volume
#[poise::command(prefix_command, slash_command, category = "Music")]
pub async fn volume(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
pub async fn volume(ctx: Context<'_>, #[description = "Volume"] volume: f32) -> 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 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?;
} else {
let msg = "I am not in a voice channel!";
ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap()))
.await?;
}
Ok(())
}

View File

@@ -10,7 +10,7 @@ pub mod posix;
pub mod qr;
pub mod register;
pub mod taf;
// pub mod uptime;
pub mod uptime;
pub mod verse;
pub mod weather;
@@ -26,6 +26,6 @@ pub use posix::posix;
pub use qr::qr;
pub use register::register;
pub use taf::taf;
// pub use uptime::uptime;
pub use uptime::uptime;
pub use verse::verse;
pub use weather::weather;

View File

@@ -35,7 +35,7 @@ pub async fn ai(
response = rng.gen_range(0..iamsorry.len());
};
sleep(Duration::from_secs(3));
sleep(Duration::from_secs(1));
ctx.send(
CreateReply::default().embed(

View File

@@ -1,12 +1,72 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use url::form_urlencoded;
use crate::{commands::embeds::embed, Context, Error};
/// Explains provided query
#[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn dictionary(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
.await?;
#[poise::command(prefix_command, slash_command, aliases("dict"), category = "Tools")]
pub async fn dictionary(
ctx: Context<'_>,
#[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(())
}
#[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 crate::{commands::embeds::embed, Context, Error};
/// Owoifies whatever you want uwu
#[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn owoify(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap()))
pub async fn owoify(
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?;
Ok(())

View File

@@ -1,35 +1,48 @@
use once_cell::sync::Lazy;
use poise::CreateReply;
use std::sync::Mutex;
use crate::{commands::embeds::embed, Context, Error};
// Currently unable to get information on how long the thread was running.
const PROCESS_UPTIME: i64 = 1000;
pub static PROCESS_UPTIME: Lazy<Mutex<std::time::SystemTime>> =
Lazy::new(|| Mutex::new(std::time::SystemTime::now()));
/// Checks how long the bot has been running
#[poise::command(prefix_command, slash_command, category = "Tools")]
pub async fn uptime(ctx: Context<'_>) -> Result<(), Error> {
let uptime = PROCESS_UPTIME;
let days = uptime / (24 * 60 * 60);
let hours = (uptime % (24 * 60 * 60)) / 3600;
let minutes = (uptime % 60 * 60) / 60;
let seconds = uptime % 60;
let start = PROCESS_UPTIME.lock().unwrap().clone();
let uptime = std::time::SystemTime::now().duration_since(start).unwrap();
ctx.send(
CreateReply::default().embed(
embed(
ctx,
"I have been up and awake for",
&format!("{} seconds", uptime),
&format!(
"{} days, {} hours, {} minutes and {} seconds",
days, hours, minutes, seconds
),
)
.await
.unwrap(),
),
)
.await?;
let (days, hours, minutes, seconds) = (
uptime.as_secs() / 86400,
(uptime.as_secs() / 3600) % 24,
(uptime.as_secs() / 60) % 60,
uptime.as_secs() % 60,
);
let mut message = format!(
"I have been awake for {} days, {} hours, {} minutes and {} seconds!",
days, hours, minutes, seconds
);
if days != 0 {
message = format!("I have been awake for {} days!", days);
}
if days == 0 && hours != 0 {
message = format!("I have been awake for {} hours!", hours);
}
if days == 0 && hours == 0 && minutes != 0 {
message = format!("I have been awake for {} minutes!", minutes);
}
if days == 0 && hours == 0 && minutes == 0 && seconds != 0 {
message = format!("I have been awake for {} seconds!", seconds);
}
ctx.send(CreateReply::default().embed(embed(ctx, "Uptime", "", &message).await.unwrap()))
.await?;
Ok(())
}

View File

@@ -1,3 +1,4 @@
use commands::tools::uptime::PROCESS_UPTIME;
use poise::serenity_prelude::{self as serenity, ActivityData};
use reqwest::Client as HttpClient;
use songbird::SerenityInit;
@@ -37,6 +38,8 @@ async fn main() {
tracing_subscriber::fmt::init();
dotenv::dotenv().expect("Failed to load .env file.");
let _ = PROCESS_UPTIME.lock().unwrap().clone();
let token =
std::env::var("DISCORD_TOKEN").expect("Environment variable `DISCORD_TOKEN` not found!");
let prefix = std::env::var("PREFIX").expect("Environment variable `PREFIX` not found!");
@@ -53,7 +56,6 @@ async fn main() {
music::repeat(),
music::resume(),
music::seek(),
music::shuffle(),
music::skip(),
music::stop(),
music::volume(),
@@ -71,7 +73,7 @@ async fn main() {
tools::qr(),
tools::register(),
tools::taf(),
// tools::uptime(),
tools::uptime(),
tools::verse(),
tools::weather(),
];
@@ -124,7 +126,7 @@ async fn main() {
};
let framework = poise::Framework::builder()
.setup(move |ctx, ready, _framework| {
.setup(move |ctx, ready, framework| {
Box::pin(async move {
info!(
"{} [{}] connected successfully!",
@@ -132,6 +134,12 @@ async fn main() {
);
ctx.set_activity(Some(ActivityData::listening(prefix + "help")));
// poise::builtins::register_globally(ctx, &framework.options().commands).await?;
poise::builtins::register_in_guild(
ctx,
&framework.options().commands,
512680330495524873.into(),
)
.await?;
Ok(Data {})
})