Compare commits

..

No commits in common. 'main' and 'v0.1.1' have entirely different histories.
main ... v0.1.1

@ -1,2 +0,0 @@
target
.env

@ -14,18 +14,13 @@ jobs:
- name: Set up toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
toolchain: nightly
override: true
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v3
with:
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
- name: Cache cargo builds
uses: actions/cache@v2
with:
path: |
target
~/.cargo/
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
@ -36,19 +31,13 @@ jobs:
use-cross: false
command: build
args: --release --target x86_64-unknown-linux-gnu
- name: Strip symbols
run: strip target/x86_64-unknown-linux-gnu/release/tobi-rs
- name: Move binaries
run: mv target/x86_64-unknown-linux-gnu/release/tobi-rs target/tobi-rs-linux-x86_64
- name: Sign artifact
run: gpg --batch --yes --pinentry-mode loopback --passphrase "${{ secrets.PASSPHRASE }}" --detach-sign --sign --armor --default-key steps.import_gpg.outputs.keyid --output target/tobi-rs-linux-x86_64.sig target/tobi-rs-linux-x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: tobi-rs-linux-x86_64
path: |
target/tobi-rs-linux-x86_64
target/tobi-rs-linux-x86_64.sig
path: target/tobi-rs-linux-x86_64
- name: publish release
uses: "marvinpinto/action-automatic-releases@latest"
with:
@ -56,5 +45,4 @@ jobs:
prerelease: false
files: |
LICENSE
target/tobi-rs-linux-x86_64
target/tobi-rs-linux-x86_64.sig
target/tobi-rs-linux-x86_64

@ -1,56 +0,0 @@
name: Build Container
on:
workflow_dispatch:
push:
tags:
- "v*"
schedule:
# daily builds to always include patches in the docker image
- cron: '0 4 * * *'
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /var/lib/containers/
key: ${{ runner.os }}-podman-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-podman-
- name: Build
id: build-image
uses: redhat-actions/buildah-build@v2
with:
context: .
layers: true
containerfiles: ./Containerfile
platforms: ${{github.event.inputs.platforms}}
image: trivernis/tobi
- name: Login to DockerHub
uses: redhat-actions/podman-login@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
registry: docker.io
- name: Push
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: docker.io

@ -1,4 +1,4 @@
name: Check
name: Build and Test
on:
workflow_dispatch:
@ -20,7 +20,7 @@ jobs:
- name: Set up toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
toolchain: nightly
override: true
- name: Cache build data
uses: actions/cache@v2
@ -31,42 +31,9 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Run Rustfmt
uses: actions-rust-lang/rustfmt@v1.0.0
- name: Run Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Test coreutils
run: cargo test --verbose --package bot-coreutils
- name: Test database
run: cargo test --verbose --package bot-database
- name: Test binary
run: cargo test --verbose
- name: Move binaries
run: mv target/debug/tobi-rs target/tobi-rs-linux-x86_64_debug
- name: Build
run: cargo build --verbose
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v3
with:
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
- name: Sign artifact
run: gpg --batch --yes --pinentry-mode loopback --passphrase "${{ secrets.PASSPHRASE }}" --detach-sign --sign --armor --default-key steps.import_gpg.outputs.keyid --output target/tobi-rs-linux-x86_64_debug.sig target/tobi-rs-linux-x86_64_debug
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: tobi-rs-linux_debug
path: |
target/tobi-rs-linux-x86_64_debug
target/tobi-rs-linux-x86_64_debug.sig
- name: Run tests
run: cargo test --verbose

@ -1,39 +0,0 @@
version: 1
when:
- event: [pull_request]
- event: push
branch:
- ${CI_REPO_DEFAULT_BRANCH}
- release/*
- fix/*
steps:
test:
image: rust:alpine
commands:
- apk add --no-cache --force-overwrite \
build-base \
openssl-dev \
libopusenc-dev \
libpq-dev \
curl \
bash
- rustup default stable
- rustup component add clippy --toolchain stable-x86_64-unknown-linux-musl
- cargo clippy
- cargo test --verbose --package bot-coreutils
- cargo test --verbose --package bot-database
- cargo test --verbose
build:
image: rust:alpine
commands:
- apk add --no-cache --force-overwrite \
build-base \
openssl-dev \
libopusenc-dev \
libpq-dev \
curl \
bash
- cargo build
when:
- event: [pull_request]

@ -1,20 +0,0 @@
version: 1
when:
- event: [tag]
branch:
- ${CI_REPO_DEFAULT_BRANCH}
steps:
build:
image: woodpeckerci/plugin-docker-buildx
secrets: [forgejo_token]
settings:
dockerfile: Containerfile
tag: ${CI_COMMIT_TAG##v}
repo: git.trivernis.net/trivernis/2b-rs
registry: git.trivernis.net
platforms: linux/amd64
username:
from_secret: forgejo_id
password:
from_secret: forgejo_token

4735
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,55 +1,31 @@
[workspace]
members=["bot-coreutils", "bot-database", "bot-database/migration", "."]
[package]
name = "tobi-rs"
version = "0.11.4"
version = "0.1.0"
authors = ["trivernis <trivernis@protonmail.com>"]
edition = "2018"
[profile.release]
panic = 'abort'
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bot-database = {path="./bot-database"}
bot-coreutils = {path="./bot-coreutils"}
serenity = "0.11.5"
serenity = "0.10.5"
dotenv = "0.15.0"
serde_derive = "1.0.145"
serde = "1.0.145"
thiserror = "1.0.37"
minecraft-data-rs = "0.5.0"
serde_json = "1.0.86"
rand = "0.8.5"
regex = "1.6.0"
aspotify = "0.7.1"
tokio = { version = "1.4.0", features = ["macros", "rt-multi-thread"] }
serde_derive = "1.0.125"
serde = "1.0.125"
thiserror = "1.0.24"
minecraft-data-rs = "0.2.0"
songbird = "0.1.5"
serde_json = "1.0.64"
rand = "0.8.3"
regex = "1.4.5"
aspotify = "0.7.0"
lazy_static = "1.4.0"
futures = "0.3.24"
chrono = "0.4.22"
sysinfo = "0.26.4"
reqwest = "0.11.12"
chrono-tz = "0.6.3"
sauce-api = "1.0.0"
rustc_version_runtime = "0.2.1"
trigram = "0.4.4"
typemap_rev = "0.2.0"
youtube-metadata = "0.2.0"
xkcd-search = "0.1.2"
animethemes-rs = "0.4.5"
build-time = "0.1.2"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
tracing = "0.1.37"
serenity-additions = "0.3.4"
[dependencies.songbird]
version = "0.3.0"
features = ["yt-dlp"]
[dependencies.tokio]
version = "1.21.2"
features = ["macros", "rt-multi-thread"]
# [patch.crates-io]
# serenity-additions = { path = "../serenity-additions" }
futures = "0.3.13"
log = "0.4.14"
fern = "0.6.0"
chrono = "0.4.19"
colored = "2.0.0"
sysinfo = "0.16.5"
database = {path="./database"}
reqwest = "0.11.2"
chrono-tz = "0.5.3"

@ -1,45 +0,0 @@
ARG BASE_IMAGE=docker.io/alpine:edge
FROM ${BASE_IMAGE} AS build_base
RUN apk update
RUN apk add --no-cache --force-overwrite \
build-base \
openssl-dev \
libopusenc-dev \
libpq-dev \
curl \
bash
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN rm -rf /var/lib/{cache,log}/ /var/cache
FROM build_base AS builder
ENV RUSTFLAGS="-C target-feature=-crt-static"
WORKDIR /usr/src
RUN cargo new tobi
WORKDIR /usr/src/tobi
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY bot-coreutils ./bot-coreutils
COPY bot-database ./bot-database
RUN cargo build --release
RUN mkdir /tmp/tobi
RUN cp target/release/tobi-rs /tmp/tobi/
FROM ${BASE_IMAGE} AS runtime-base
RUN apk update
RUN apk add --no-cache --force-overwrite \
openssl \
libopusenc \
libpq \
python3 \
py3-pip \
qalc \
ffmpeg \
bash
RUN pip3 install yt-dlp --break-system-packages
RUN rm -rf /var/lib/{cache,log}/ /var/cache
FROM runtime-base
COPY --from=builder /tmp/tobi/tobi-rs .
ENTRYPOINT ["/tobi-rs"]

@ -38,7 +38,6 @@ The bot depends on the following APIs
- [Discord](https://discord.com/developers/applications): It's a discord bot...
- [Spotify](https://developer.spotify.com/documentation/web-api/): To fetch song names to be searched on youtube for music playback
- [lyrics.ohv](https://lyricsovh.docs.apiary.io): To fetch lyrics for playing songs
- [SauceNAO](https://saucenao.com): To fetch source information for images
## Dev Dependencies
@ -56,11 +55,10 @@ The required values are:
- `DATABASE_URL` (required): Connection uri to the postgres database in the schema `postgres://myuser:mypassword@localhost:5432/database`
- `SPOTIFY_CLIENT_ID` (required): Spotify API Client ID
- `SPOTIFY_CLIENT_SECRET` (required): Spotify API Client Secret
- `SAUCENAO_API_KEY` (required): SauceNAO API Key
- `BOT_PREFIX` (optional): The prefix of the bot. Defaults to `~` if not set.
- `LOG_DIR` (optional): Directory to store log files in. Defaults to `logs` in the cwd.
## License
See LICENSE.md
It's GPL 3.0

@ -1 +0,0 @@
target

@ -1,14 +0,0 @@
[package]
name = "bot-coreutils"
version = "0.1.1"
authors = ["trivernis <trivernis@protonmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1.21.2", features = ["process", "io-util"] }
log = "0.4.17"
url = "2.3.1"
mime_guess = "2.0.4"
rand = "0.8.5"

@ -1,10 +0,0 @@
#[cfg(test)]
mod tests;
pub mod process;
pub mod shuffle;
pub mod string;
/// Utilities to quickly check strings that represent urls
pub mod url;
pub static VERSION: &str = env!("CARGO_PKG_VERSION");

@ -1,19 +0,0 @@
use std::io;
use tokio::process::Command;
/// Asynchronously runs a given command and returns the output
pub async fn run_command_async(command: &str, args: &[&str]) -> io::Result<String> {
log::trace!("Running command '{}' with args {:?}", command, args);
let process_output: std::process::Output = Command::new(command).args(args).output().await?;
log::trace!("Reading from stderr...");
let stderr = String::from_utf8_lossy(&process_output.stderr[..]);
let stdout = String::from_utf8_lossy(&process_output.stdout[..]);
if stderr.len() != 0 {
log::trace!("STDERR of command {}: {}", command, stderr);
}
log::trace!("Command output is {}", stdout);
Ok(stdout.to_string())
}

@ -1,9 +0,0 @@
use rand::seq::SliceRandom;
/// Chooses a random value from the given iterator
/// panics when the iterator is empty
pub fn choose_unchecked<'a, I: SliceRandom<Item = &'a T>, T>(i: I) -> &'a T {
let mut rng = rand::thread_rng();
i.choose(&mut rng).unwrap()
}

@ -1,20 +0,0 @@
use rand::Rng;
use std::collections::VecDeque;
pub trait Shuffle {
fn shuffle(&mut self);
}
impl<T> Shuffle for VecDeque<T> {
/// Fisher-Yates shuffle implementation
/// for VecDeque.
fn shuffle(&mut self) {
let mut rng = rand::thread_rng();
let mut i = self.len();
while i >= 2 {
i -= 1;
self.swap(i, rng.gen_range(0..i + 1))
}
}
}

@ -1,5 +0,0 @@
/// Enquotes a string in a safe way
pub fn enquote<S: ToString>(value: S) -> String {
let value = value.to_string();
format!("\"{}\"", value.replace("\"", "\\\""))
}

@ -1,5 +0,0 @@
#[cfg(test)]
mod url_tests;
#[cfg(test)]
mod string_tests;

@ -1,8 +0,0 @@
use crate::string::enquote;
#[test]
fn test_enquote() {
assert_eq!(enquote("hello"), r#""hello""#);
assert_eq!(enquote(r#"hello "there""#), r#""hello \"there\"""#);
assert_eq!(enquote(""), r#""""#);
}

@ -1,44 +0,0 @@
use crate::url::*;
#[test]
fn it_returns_the_domain_name() {
assert_eq!(
get_domain_for_url("https://domain.com/sub/sub"),
Some("domain.com".to_string())
);
assert_eq!(
get_domain_for_url("other-domain.com"),
Some("other-domain.com".to_string())
);
assert_eq!(get_domain_for_url("Invalid URL"), None);
assert_eq!(get_domain_for_url("file:////what/a/file.txt"), None);
assert_eq!(
get_domain_for_url("https://www.domain.com/sub",),
Some("domain.com".to_string())
);
}
#[test]
fn it_checks_for_image() {
assert!(is_image("domain.com/image.png"));
assert!(is_image("https://domain.com/image.jpeg?yo=someparam"));
assert!(!is_image("https://domain.com"));
assert!(!is_image("https://domain.com/file.pdf"));
assert!(!is_image("not an url"));
}
#[test]
fn it_checks_for_video() {
assert!(is_video("domain.com/video.mp4"));
assert!(is_video("https://domain.com/video.webm?yo=someparam"));
assert!(!is_video("https://domain.com"));
assert!(!is_video("https://domain.com/file.pdf"));
assert!(!is_video("not an url"));
}
#[test]
fn it_checks_if_its_valid() {
assert!(is_valid("https://domain.com"));
assert!(!is_valid("domain.com"));
assert!(is_valid("https://url.com/sub/sub/sub.txt"))
}

@ -1,76 +0,0 @@
use mime_guess::{mime, Mime};
pub use url::*;
static PROTOCOL_HTTP: &str = "http://";
static PROTOCOL_HTTPS: &str = "https://";
static PROTOCOL_FILE: &str = "file:////";
static PROTOCOL_DATA: &str = "data:";
static PROTOCOLS: &[&str] = &[PROTOCOL_HTTP, PROTOCOL_HTTPS, PROTOCOL_FILE, PROTOCOL_DATA];
/// Adds the protocol in front of the url if it is missing from the input
fn add_missing_protocol(url_str: &str) -> String {
for protocol in PROTOCOLS {
if url_str.starts_with(protocol) {
return url_str.to_string();
}
}
format!("{}{}", PROTOCOL_HTTPS, url_str)
}
/// Parses the given url into the url representation
/// Allows for fuzzier input than the original method. If no protocol is provided,
/// it assumes https.
#[inline]
pub fn parse_url(url_str: &str) -> Result<Url, url::ParseError> {
let url_str = add_missing_protocol(url_str);
Url::parse(&url_str)
}
/// Returns the domain for a given url string
/// Example
/// ```
/// use bot_coreutils::url::get_domain_for_url;
///
/// assert_eq!(get_domain_for_url("https://reddit.com/r/anime"), Some("reddit.com".to_string()));
/// assert_eq!(get_domain_for_url("reddit.com"), Some("reddit.com".to_string()));
/// assert_eq!(get_domain_for_url("invalid url"), None);
/// ```
pub fn get_domain_for_url(url_str: &str) -> Option<String> {
let url = parse_url(url_str).ok()?;
let domain = url.domain()?;
Some(domain.trim_start_matches("www.").to_string())
}
/// Guesses the mime for a given url string
#[inline]
fn guess_mime_for_url(url_str: &str) -> Option<Mime> {
parse_url(url_str)
.ok()
.and_then(|u| mime_guess::from_path(u.path()).first())
}
/// Returns if a given url could be an image
pub fn is_image(url_str: &str) -> bool {
if let Some(guess) = guess_mime_for_url(url_str) {
guess.type_() == mime::IMAGE
} else {
false
}
}
/// Returns if a given url could be a video
pub fn is_video(url_str: &str) -> bool {
if let Some(guess) = guess_mime_for_url(url_str) {
guess.type_() == mime::VIDEO
} else {
false
}
}
/// Returns if the given url is valid
pub fn is_valid(url_str: &str) -> bool {
Url::parse(url_str).is_ok()
}

@ -1,20 +0,0 @@
[package]
name = "bot-database"
version = "0.6.0"
authors = ["trivernis <trivernis@protonmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dotenv = "0.15.0"
chrono = "0.4.22"
thiserror = "1.0.37"
tracing = "0.1.37"
[dependencies.sea-orm]
version = "0.9.3"
features = ["runtime-tokio-native-tls", "sqlx-postgres"]
[dependencies.migration]
path = "./migration"

@ -1,13 +0,0 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
sea-orm-migration = "0.9.3"
tokio = { version = "1.21.2", features = ["rt", "net", "tracing"] }

@ -1,37 +0,0 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

@ -1,16 +0,0 @@
pub use sea_orm_migration::prelude::*;
mod m20220029_164527_change_timestamp_format;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_create_table::Migration),
Box::new(m20220029_164527_change_timestamp_format::Migration),
]
}
}

@ -1,82 +0,0 @@
use crate::{DbErr, Table};
use sea_orm_migration::prelude::*;
#[derive(Iden)]
pub enum Statistics {
Table,
ExecutedAt,
}
#[derive(Iden)]
pub enum EphemeralMessages {
Table,
Timeout,
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220029_164527_change_timestamp_format"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Statistics::Table)
.modify_column(
ColumnDef::new(Statistics::ExecutedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(EphemeralMessages::Table)
.modify_column(
ColumnDef::new(EphemeralMessages::Timeout)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Statistics::Table)
.modify_column(
ColumnDef::new(Statistics::ExecutedAt)
.timestamp()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(EphemeralMessages::Table)
.modify_column(
ColumnDef::new(EphemeralMessages::Timeout)
.timestamp()
.not_null(),
)
.to_owned(),
)
.await?;
Ok(())
}
}

@ -1,287 +0,0 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
#[derive(Iden)]
pub enum EphemeralMessages {
Table,
ChannelId,
MessageId,
Timeout,
}
#[derive(Iden)]
pub enum GuildPlaylists {
Table,
GuildId,
Name,
Url,
}
#[derive(Iden)]
pub enum GuildSettings {
Table,
GuildId,
Key,
Value,
}
#[derive(Iden)]
pub enum Media {
Table,
Id,
Category,
Name,
Url,
}
#[derive(Iden)]
pub enum Statistics {
Table,
Id,
Version,
Command,
ExecutedAt,
Success,
ErrorMsg,
}
#[derive(Iden)]
pub enum YoutubeSongs {
Table,
Id,
SpotifyId,
Artist,
Title,
Album,
Url,
Score,
}
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_table"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.create_table(ephemeral_messages()).await?;
manager.create_table(guild_playlists()).await?;
manager.create_table(guild_settings()).await?;
manager.create_table(media()).await?;
manager.create_table(statistics()).await?;
manager.create_table(youtube_songs()).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(EphemeralMessages::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(GuildPlaylists::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(GuildSettings::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Media::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Statistics::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(YoutubeSongs::Table).to_owned())
.await?;
Ok(())
}
}
fn ephemeral_messages() -> TableCreateStatement {
Table::create()
.table(EphemeralMessages::Table)
.if_not_exists()
.col(
ColumnDef::new(EphemeralMessages::ChannelId)
.big_integer()
.not_null(),
)
.col(
ColumnDef::new(EphemeralMessages::MessageId)
.big_integer()
.not_null(),
)
.col(
ColumnDef::new(EphemeralMessages::Timeout)
.timestamp()
.not_null(),
)
.primary_key(
Index::create()
.col(EphemeralMessages::ChannelId)
.col(EphemeralMessages::MessageId),
)
.to_owned()
}
fn guild_playlists() -> TableCreateStatement {
Table::create()
.table(GuildPlaylists::Table)
.if_not_exists()
.col(
ColumnDef::new(GuildPlaylists::GuildId)
.big_integer()
.not_null(),
)
.col(
ColumnDef::new(GuildPlaylists::Name)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(GuildPlaylists::Url)
.string_len(1204)
.not_null(),
)
.primary_key(
Index::create()
.col(GuildPlaylists::GuildId)
.col(GuildPlaylists::Name),
)
.to_owned()
}
fn guild_settings() -> TableCreateStatement {
Table::create()
.table(GuildSettings::Table)
.if_not_exists()
.col(
ColumnDef::new(GuildSettings::GuildId)
.big_integer()
.not_null(),
)
.col(
ColumnDef::new(GuildSettings::Key)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(GuildSettings::Value)
.string_len(1024)
.not_null(),
)
.primary_key(
Index::create()
.col(GuildSettings::GuildId)
.col(GuildSettings::Key),
)
.to_owned()
}
fn media() -> TableCreateStatement {
Table::create()
.table(Media::Table)
.if_not_exists()
.col(
ColumnDef::new(Media::Id)
.big_integer()
.auto_increment()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Media::Category).string_len(128))
.col(ColumnDef::new(Media::Name).string_len(128))
.col(ColumnDef::new(Media::Url).string_len(128))
.index(
Index::create()
.unique()
.col(Media::Category)
.col(Media::Name),
)
.to_owned()
}
fn statistics() -> TableCreateStatement {
Table::create()
.table(Statistics::Table)
.if_not_exists()
.col(
ColumnDef::new(Statistics::Id)
.big_integer()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(Statistics::Version)
.string_len(32)
.not_null(),
)
.col(
ColumnDef::new(Statistics::Command)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(Statistics::ExecutedAt)
.timestamp()
.not_null(),
)
.col(
ColumnDef::new(Statistics::Success)
.boolean()
.not_null()
.default(true),
)
.col(ColumnDef::new(Statistics::ErrorMsg).string())
.to_owned()
}
fn youtube_songs() -> TableCreateStatement {
Table::create()
.table(YoutubeSongs::Table)
.if_not_exists()
.col(
ColumnDef::new(YoutubeSongs::Id)
.big_integer()
.primary_key()
.auto_increment(),
)
.col(
ColumnDef::new(YoutubeSongs::SpotifyId)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(YoutubeSongs::Artist)
.string_len(128)
.not_null(),
)
.col(
ColumnDef::new(YoutubeSongs::Title)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(YoutubeSongs::Album)
.string_len(255)
.not_null(),
)
.col(ColumnDef::new(YoutubeSongs::Url).string_len(128).not_null())
.col(
ColumnDef::new(YoutubeSongs::Score)
.integer()
.default(0)
.not_null(),
)
.index(
Index::create()
.unique()
.col(YoutubeSongs::SpotifyId)
.col(YoutubeSongs::Url),
)
.to_owned()
}

@ -1,7 +0,0 @@
use migration::Migrator;
use sea_orm_migration::prelude::*;
#[tokio::main]
async fn main() {
cli::run_cli(Migrator).await;
}

@ -1,51 +0,0 @@
use sea_orm::ActiveValue::Set;
use std::time::SystemTime;
use crate::entity::ephemeral_messages;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
impl super::BotDatabase {
/// Adds a command statistic to the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_ephemeral_message(
&self,
channel_id: u64,
message_id: u64,
timeout: SystemTime,
) -> DatabaseResult<()> {
let model = ephemeral_messages::ActiveModel {
channel_id: Set(channel_id as i64),
message_id: Set(message_id as i64),
timeout: Set(DateTimeLocal::from(timeout).into()),
..Default::default()
};
model.insert(&self.db).await?;
Ok(())
}
/// Returns a vec of all ephemeral messages
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_ephemeral_messages(&self) -> DatabaseResult<Vec<ephemeral_messages::Model>> {
let messages = ephemeral_messages::Entity::find().all(&self.db).await?;
Ok(messages)
}
/// Deletes a single ephemeral message
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_ephemeral_message(
&self,
channel_id: i64,
message_id: i64,
) -> DatabaseResult<()> {
ephemeral_messages::Entity::delete_many()
.filter(ephemeral_messages::Column::ChannelId.eq(channel_id))
.filter(ephemeral_messages::Column::MessageId.eq(message_id))
.exec(&self.db)
.await?;
Ok(())
}
}

@ -1,55 +0,0 @@
use crate::entity::guild_playlists;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
impl super::BotDatabase {
/// Returns a list of all guild playlists
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_guild_playlists(
&self,
guild_id: u64,
) -> DatabaseResult<Vec<guild_playlists::Model>> {
let playlists = guild_playlists::Entity::find()
.filter(guild_playlists::Column::GuildId.eq(guild_id))
.all(&self.db)
.await?;
Ok(playlists)
}
/// Returns a guild playlist by name
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_guild_playlist(
&self,
guild_id: u64,
name: String,
) -> DatabaseResult<Option<guild_playlists::Model>> {
let playlist = guild_playlists::Entity::find()
.filter(guild_playlists::Column::GuildId.eq(guild_id))
.filter(guild_playlists::Column::Name.eq(name))
.one(&self.db)
.await?;
Ok(playlist)
}
/// Adds a new playlist to the database overwriting the old one
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_guild_playlist(
&self,
guild_id: u64,
name: String,
url: String,
) -> DatabaseResult<()> {
let model = guild_playlists::ActiveModel {
guild_id: Set(guild_id as i64),
name: Set(name),
url: Set(url),
..Default::default()
};
model.insert(&self.db).await?;
Ok(())
}
}

@ -1,87 +0,0 @@
use sea_orm::ActiveValue::Set;
use std::any;
use std::fmt::Debug;
use std::str::FromStr;
use crate::entity::guild_settings;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
impl super::BotDatabase {
/// Returns a guild setting from the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_guild_setting<T: 'static, S: AsRef<str> + Debug>(
&self,
guild_id: u64,
key: S,
) -> DatabaseResult<Option<T>>
where
T: FromStr,
{
let setting = guild_settings::Entity::find()
.filter(guild_settings::Column::GuildId.eq(guild_id as i64))
.filter(guild_settings::Column::Key.eq(key.as_ref()))
.one(&self.db)
.await?;
if let Some(setting) = setting {
if any::TypeId::of::<T>() == any::TypeId::of::<bool>() {
Ok(setting
.value
.clone()
.unwrap_or("false".to_string())
.parse::<T>()
.ok())
} else {
Ok(setting.value.clone().and_then(|v| v.parse::<T>().ok()))
}
} else {
Ok(None)
}
}
/// Upserting a guild setting
#[tracing::instrument(level = "debug", skip(self))]
pub async fn set_guild_setting<T>(
&self,
guild_id: u64,
key: String,
value: T,
) -> DatabaseResult<()>
where
T: 'static + ToString + FromStr + Debug,
{
let model = guild_settings::ActiveModel {
guild_id: Set(guild_id as i64),
key: Set(key.clone()),
value: Set(Some(value.to_string())),
..Default::default()
};
if self
.get_guild_setting::<T, _>(guild_id, &key)
.await?
.is_some()
{
model.update(&self.db).await?;
} else {
model.insert(&self.db).await?;
}
Ok(())
}
/// Deletes a guild setting
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_guild_setting<S: AsRef<str> + Debug>(
&self,
guild_id: u64,
key: S,
) -> DatabaseResult<()> {
guild_settings::Entity::delete_many()
.filter(guild_settings::Column::GuildId.eq(guild_id))
.filter(guild_settings::Column::Key.eq(key.as_ref()))
.exec(&self.db)
.await?;
Ok(())
}
}

@ -1,48 +0,0 @@
use crate::entity::media;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use std::fmt::Debug;
impl super::BotDatabase {
/// Returns a list of all gifs in the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_all_media(&self) -> DatabaseResult<Vec<media::Model>> {
let entries = media::Entity::find().all(&self.db).await?;
Ok(entries)
}
/// Returns a list of gifs by assigned category
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_media_by_category<S: AsRef<str> + 'static + Debug>(
&self,
category: S,
) -> DatabaseResult<Vec<media::Model>> {
let entries = media::Entity::find()
.filter(media::Column::Category.eq(category.as_ref()))
.all(&self.db)
.await?;
Ok(entries)
}
/// Adds a gif to the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_media(
&self,
url: String,
category: Option<String>,
name: Option<String>,
) -> DatabaseResult<()> {
let model = media::ActiveModel {
url: Set(url),
category: Set(category),
name: Set(name),
..Default::default()
};
model.insert(&self.db).await?;
Ok(())
}
}

@ -1,25 +0,0 @@
pub use ephemeral_messages::*;
pub use guild_playlists::*;
pub use guild_playlists::*;
pub use media::*;
use sea_orm::DatabaseConnection;
pub use statistics::*;
pub use youtube_songs::*;
mod ephemeral_messages;
mod guild_playlists;
mod guild_settings;
mod media;
mod statistics;
mod youtube_songs;
#[derive(Clone)]
pub struct BotDatabase {
db: DatabaseConnection,
}
impl BotDatabase {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
}

@ -1,49 +0,0 @@
use crate::entity::statistics;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{FromQueryResult, QuerySelect};
use std::time::SystemTime;
#[derive(FromQueryResult)]
struct CommandCount {
count: i64,
}
impl super::BotDatabase {
/// Adds a command statistic to the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_statistic(
&self,
version: String,
command: String,
executed_at: SystemTime,
success: bool,
error_msg: Option<String>,
) -> DatabaseResult<()> {
let model = statistics::ActiveModel {
version: Set(version),
command: Set(command),
executed_at: Set(DateTimeLocal::from(executed_at).into()),
success: Set(success),
error_msg: Set(error_msg),
..Default::default()
};
model.insert(&self.db).await?;
Ok(())
}
/// Returns the total number of commands executed
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_total_commands_statistic(&self) -> DatabaseResult<u64> {
let total_count: Option<CommandCount> = statistics::Entity::find()
.select_only()
.column_as(statistics::Column::Id.count(), "count")
.into_model::<CommandCount>()
.one(&self.db)
.await?;
Ok(total_count.unwrap().count as u64)
}
}

@ -1,58 +0,0 @@
use crate::entity::youtube_songs;
use crate::error::DatabaseResult;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
impl super::BotDatabase {
/// Adds a song to the database or increments the score when it
/// already exists
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_song(
&self,
spotify_id: String,
artist: String,
title: String,
album: String,
url: String,
) -> DatabaseResult<()> {
if let Some(model) = self.get_song(&spotify_id).await? {
let mut active_model: youtube_songs::ActiveModel = model.into();
active_model.score = Set(active_model.score.unwrap() + 1);
active_model.update(&self.db).await?;
} else {
let model = youtube_songs::ActiveModel {
spotify_id: Set(spotify_id),
artist: Set(artist),
title: Set(title),
album: Set(album),
url: Set(url),
..Default::default()
};
model.insert(&self.db).await?;
}
Ok(())
}
/// Returns the song with the best score for the given query
#[tracing::instrument(level = "debug", skip(self))]
pub async fn get_song(&self, spotify_id: &str) -> DatabaseResult<Option<youtube_songs::Model>> {
let song = youtube_songs::Entity::find()
.filter(youtube_songs::Column::SpotifyId.eq(spotify_id))
.one(&self.db)
.await?;
Ok(song)
}
/// Deletes a song from the database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_song(&self, id: i64) -> DatabaseResult<()> {
youtube_songs::Entity::delete_many()
.filter(youtube_songs::Column::Id.eq(id))
.exec(&self.db)
.await?;
Ok(())
}
}

@ -1,24 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "ephemeral_messages")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub channel_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub message_id: i64,
pub timeout: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,24 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "guild_playlists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub guild_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub name: String,
pub url: String,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,24 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "guild_settings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub guild_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub key: String,
pub value: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,24 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "media")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub category: Option<String>,
pub name: Option<String>,
pub url: String,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,10 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
pub mod prelude;
pub mod ephemeral_messages;
pub mod guild_playlists;
pub mod guild_settings;
pub mod media;
pub mod statistics;
pub mod youtube_songs;

@ -1,8 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
pub use super::ephemeral_messages::Entity as EphemeralMessages;
pub use super::guild_playlists::Entity as GuildPlaylists;
pub use super::guild_settings::Entity as GuildSettings;
pub use super::media::Entity as Media;
pub use super::statistics::Entity as Statistics;
pub use super::youtube_songs::Entity as YoutubeSongs;

@ -1,27 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "statistics")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub version: String,
pub command: String,
pub executed_at: DateTimeWithTimeZone,
pub success: bool,
#[sea_orm(column_type = "Text", nullable)]
pub error_msg: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,27 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.7.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "youtube_songs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub spotify_id: String,
pub artist: String,
pub title: String,
pub album: String,
pub url: String,
pub score: i32,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

@ -1,21 +0,0 @@
use thiserror::Error;
pub type DatabaseResult<T> = Result<T, DatabaseError>;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("DotEnv Error: {0}")]
DotEnv(#[from] dotenv::Error),
#[error("{0}")]
SeaOrm(#[from] sea_orm::error::DbErr),
#[error("{0}")]
Msg(String),
}
impl From<&str> for DatabaseError {
fn from(s: &str) -> Self {
Self::Msg(s.to_string())
}
}

@ -1,31 +0,0 @@
use crate::error::DatabaseResult;
use std::env;
pub mod database;
pub mod entity;
pub mod error;
pub mod models;
pub static VERSION: &str = env!("CARGO_PKG_VERSION");
pub use database::BotDatabase as Database;
use migration::MigratorTrait;
use sea_orm::{ConnectOptions, Database as SeaDatabase, DatabaseConnection};
#[tracing::instrument]
async fn get_connection() -> DatabaseResult<DatabaseConnection> {
let database_url = env::var("DATABASE_URL").expect("No DATABASE_URL in path");
tracing::debug!("Establishing database connection...");
let opt = ConnectOptions::new(database_url);
let db = SeaDatabase::connect(opt).await?;
tracing::debug!("Running migrations...");
migration::Migrator::up(&db, None).await?;
tracing::debug!("Migrations finished");
tracing::info!("Database connection initialized");
Ok(db)
}
pub async fn get_database() -> DatabaseResult<Database> {
let conn = get_connection().await?;
Ok(Database::new(conn))
}

@ -1,8 +0,0 @@
use super::entity;
pub use entity::ephemeral_messages::Model as EphemeralMessage;
pub use entity::guild_playlists::Model as GuildPlaylist;
pub use entity::guild_settings::Model as GuildSetting;
pub use entity::media::Model as Media;
pub use entity::statistics::Model as Statistic;
pub use entity::youtube_songs::Model as YoutubeSong;

467
database/Cargo.lock generated

@ -0,0 +1,467 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "async-trait"
version = "0.1.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36ea56748e10732c49404c153638a15ec3d6211ec5ff35d9bb20e13b93576adf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "bitflags"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"libc",
"num-integer",
"num-traits",
"time",
"winapi",
]
[[package]]
name = "database"
version = "0.1.0"
dependencies = [
"chrono",
"diesel",
"diesel_migrations",
"dotenv",
"log",
"r2d2",
"thiserror",
"tokio-diesel",
]
[[package]]
name = "diesel"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "047bfc4d5c3bd2ef6ca6f981941046113524b9a9f9a7cbdfdd7ff40f58e6f542"
dependencies = [
"bitflags",
"byteorder",
"chrono",
"diesel_derives",
"pq-sys",
"r2d2",
]
[[package]]
name = "diesel_derives"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45f5098f628d02a7a0f68ddba586fb61e80edec3bdc1be3b921f4ceec60858d3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "diesel_migrations"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3cde8413353dc7f5d72fa8ce0b99a560a359d2c5ef1e5817ca731cd9008f4c"
dependencies = [
"migrations_internals",
"migrations_macros",
]
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "futures"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d5813545e459ad3ca1bff9915e9ad7f1a47dc6a91b627ce321d5863b7dd253"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce79c6a52a299137a6013061e0cf0e688fce5d7f1bc60125f520912fdb29ec25"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "098cd1c6dda6ca01650f1a37a794245eb73181d0d4d4e955e2f3c37db7af1815"
[[package]]
name = "futures-io"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "365a1a1fb30ea1c03a830fdb2158f5236833ac81fa0ad12fe35b29cddc35cb04"
[[package]]
name = "futures-sink"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5629433c555de3d82861a7a4e3794a4c40040390907cfbfd7143a92a426c23"
[[package]]
name = "futures-task"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba7aa51095076f3ba6d9a1f702f74bd05ec65f555d70d2033d55ba8d69f581bc"
[[package]]
name = "futures-util"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c144ad54d60f23927f0a6b6d816e4271278b64f005ad65e4e35291d2de9c025"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"pin-utils",
]
[[package]]
name = "hermit-abi"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec"
dependencies = [
"cfg-if",
]
[[package]]
name = "libc"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
[[package]]
name = "lock_api"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3c91c24eae6777794bb1997ad98bbb87daf92890acab859f7eaa4320333176"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "migrations_internals"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4fc84e4af020b837029e017966f86a1c2d5e83e64b589963d5047525995860"
dependencies = [
"diesel",
]
[[package]]
name = "migrations_macros"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9753f12909fd8d923f75ae5c3258cae1ed3c8ec052e1b38c93c21a6d157f789c"
dependencies = [
"migrations_internals",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-integer"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "parking_lot"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb"
dependencies = [
"instant",
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall",
"smallvec",
"winapi",
]
[[package]]
name = "pin-project-lite"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pq-sys"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ac25eee5a0582f45a67e837e350d784e7003bd29a5f460796772061ca49ffda"
dependencies = [
"vcpkg",
]
[[package]]
name = "proc-macro2"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r2d2"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "redox_syscall"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
dependencies = [
"bitflags",
]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "smallvec"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e"
[[package]]
name = "syn"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "thiserror"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi",
"winapi",
]
[[package]]
name = "tokio"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134af885d758d645f0f0505c9a8b3f9bf8a348fd822e112ab5248138348f1722"
dependencies = [
"autocfg",
"num_cpus",
"pin-project-lite",
]
[[package]]
name = "tokio-diesel"
version = "0.3.0"
source = "git+https://github.com/Trivernis/tokio-diesel#f4af42558246ab323600622ba8d08803d3c18842"
dependencies = [
"async-trait",
"diesel",
"futures",
"r2d2",
"tokio",
]
[[package]]
name = "unicode-xid"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "vcpkg"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb"
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

@ -0,0 +1,17 @@
[package]
name = "database"
version = "0.1.0"
authors = ["trivernis <trivernis@protonmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dotenv = "0.15.0"
chrono = "0.4.19"
thiserror = "1.0.24"
diesel = {version="1.4.6", features=["postgres", "r2d2", "chrono"]}
log = "0.4.14"
diesel_migrations = "1.4.0"
r2d2 = "0.8.9"
tokio-diesel = {git = "https://github.com/Trivernis/tokio-diesel"}

@ -0,0 +1,5 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE guild_settings;

@ -0,0 +1,6 @@
CREATE TABLE guild_settings (
guild_id BIGINT NOT NULL,
key VARCHAR(255) NOT NULL,
value VARCHAR(1024),
PRIMARY KEY (guild_id, key)
);

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE guild_playlists;

@ -0,0 +1,7 @@
-- Your SQL goes here
CREATE TABLE guild_playlists (
guild_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
url VARCHAR(1024) NOT NULL,
PRIMARY KEY (guild_id, name)
)

@ -0,0 +1,155 @@
use crate::error::DatabaseResult;
use crate::models::*;
use crate::schema::*;
use crate::PoolConnection;
use diesel::prelude::*;
use diesel::{delete, insert_into};
use std::any;
use std::fmt::Debug;
use std::str::FromStr;
use tokio_diesel::*;
#[derive(Clone)]
pub struct Database {
pool: PoolConnection,
}
unsafe impl Send for Database {}
unsafe impl Sync for Database {}
impl Database {
pub fn new(pool: PoolConnection) -> Self {
Self { pool }
}
/// Returns a guild setting from the database
pub async fn get_guild_setting<T: 'static>(
&self,
guild_id: u64,
key: String,
) -> DatabaseResult<Option<T>>
where
T: FromStr,
{
use guild_settings::dsl;
log::debug!("Retrieving setting '{}' for guild {}", key, guild_id);
let entries: Vec<GuildSetting> = dsl::guild_settings
.filter(dsl::guild_id.eq(guild_id as i64))
.filter(dsl::key.eq(key))
.load_async::<GuildSetting>(&self.pool)
.await?;
log::trace!("Result is {:?}", entries);
if let Some(first) = entries.first() {
if any::TypeId::of::<T>() == any::TypeId::of::<bool>() {
Ok(first
.value
.clone()
.unwrap_or("false".to_string())
.parse::<T>()
.ok())
} else {
Ok(first.value.clone().and_then(|v| v.parse::<T>().ok()))
}
} else {
return Ok(None);
}
}
/// Upserting a guild setting
pub async fn set_guild_setting<T>(
&self,
guild_id: u64,
key: String,
value: T,
) -> DatabaseResult<()>
where
T: ToString + Debug,
{
use guild_settings::dsl;
log::debug!("Setting '{}' to '{:?}' for guild {}", key, value, guild_id);
insert_into(dsl::guild_settings)
.values(GuildSettingInsert {
guild_id: guild_id as i64,
key: key.to_string(),
value: value.to_string(),
})
.on_conflict((dsl::guild_id, dsl::key))
.do_update()
.set(dsl::value.eq(value.to_string()))
.execute_async(&self.pool)
.await?;
Ok(())
}
/// Deletes a guild setting
pub async fn delete_guild_setting(&self, guild_id: u64, key: String) -> DatabaseResult<()> {
use guild_settings::dsl;
delete(dsl::guild_settings)
.filter(dsl::guild_id.eq(guild_id as i64))
.filter(dsl::key.eq(key))
.execute_async(&self.pool)
.await?;
Ok(())
}
/// Returns a list of all guild playlists
pub async fn get_guild_playlists(&self, guild_id: u64) -> DatabaseResult<Vec<GuildPlaylist>> {
use guild_playlists::dsl;
log::debug!("Retrieving guild playlists for guild {}", guild_id);
let playlists: Vec<GuildPlaylist> = dsl::guild_playlists
.filter(dsl::guild_id.eq(guild_id as i64))
.load_async::<GuildPlaylist>(&self.pool)
.await?;
Ok(playlists)
}
/// Returns a guild playlist by name
pub async fn get_guild_playlist(
&self,
guild_id: u64,
name: String,
) -> DatabaseResult<Option<GuildPlaylist>> {
use guild_playlists::dsl;
log::debug!("Retriving guild playlist '{}' for guild {}", name, guild_id);
let playlists: Vec<GuildPlaylist> = dsl::guild_playlists
.filter(dsl::guild_id.eq(guild_id as i64))
.filter(dsl::name.eq(name))
.load_async::<GuildPlaylist>(&self.pool)
.await?;
Ok(playlists.into_iter().next())
}
/// Adds a new playlist to the database overwriting the old one
pub async fn add_guild_playlist(
&self,
guild_id: u64,
name: String,
url: String,
) -> DatabaseResult<()> {
use guild_playlists::dsl;
log::debug!("Inserting guild playlist '{}' for guild {}", name, guild_id);
insert_into(dsl::guild_playlists)
.values(GuildPlaylistInsert {
guild_id: guild_id as i64,
name: name.clone(),
url: url.clone(),
})
.on_conflict((dsl::guild_id, dsl::name))
.do_update()
.set(dsl::url.eq(url))
.execute_async(&self.pool)
.await?;
Ok(())
}
}

@ -0,0 +1,24 @@
use thiserror::Error;
pub type DatabaseResult<T> = Result<T, DatabaseError>;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("DotEnv Error: {0}")]
DotEnv(#[from] dotenv::Error),
#[error("Connection Error: {0}")]
ConnectionError(#[from] diesel::prelude::ConnectionError),
#[error("Pool Connection Error: {0}")]
PoolConnectionError(#[from] r2d2::Error),
#[error("Migration Error: {0}")]
MigrationError(#[from] diesel_migrations::RunMigrationsError),
#[error("Result Error: {0}")]
ResultError(#[from] diesel::result::Error),
#[error("AsyncError: {0}")]
AsyncError(#[from] tokio_diesel::AsyncError),
}

@ -0,0 +1,41 @@
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
use crate::error::DatabaseResult;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use std::env;
pub mod database;
pub mod error;
pub mod models;
pub mod schema;
pub use database::Database;
type PoolConnection = Pool<ConnectionManager<PgConnection>>;
embed_migrations!("../database/migrations");
fn get_connection() -> DatabaseResult<PoolConnection> {
dotenv::dotenv()?;
let database_url = env::var("DATABASE_URL").expect("No DATABASE_URL in path");
log::debug!("Establishing database connection...");
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = Pool::builder().max_size(16).build(manager)?;
let connection = pool.get()?;
log::debug!("Running migrations...");
embedded_migrations::run(&connection)?;
log::debug!("Migrations finished");
log::info!("Database connection initialized");
Ok(pool)
}
pub fn get_database() -> DatabaseResult<Database> {
let conn = get_connection()?;
Ok(Database::new(conn))
}

@ -0,0 +1,31 @@
use crate::schema::*;
#[derive(Queryable, Debug)]
pub struct GuildSetting {
pub guild_id: i64,
pub key: String,
pub value: Option<String>,
}
#[derive(Insertable, Debug)]
#[table_name = "guild_settings"]
pub struct GuildSettingInsert {
pub guild_id: i64,
pub key: String,
pub value: String,
}
#[derive(Queryable, Debug)]
pub struct GuildPlaylist {
pub guild_id: i64,
pub name: String,
pub url: String,
}
#[derive(Insertable, Debug)]
#[table_name = "guild_playlists"]
pub struct GuildPlaylistInsert {
pub guild_id: i64,
pub name: String,
pub url: String,
}

@ -0,0 +1,20 @@
table! {
guild_playlists (guild_id, name) {
guild_id -> Int8,
name -> Varchar,
url -> Varchar,
}
}
table! {
guild_settings (guild_id, key) {
guild_id -> Int8,
key -> Varchar,
value -> Nullable<Varchar>,
}
}
allow_tables_to_appear_in_same_query!(
guild_playlists,
guild_settings,
);

@ -1,5 +1,5 @@
[toolchain]
channel = "stable"
channel = "nightly"
targets = [
"x86_64-unknown-linux-gnu",
]

@ -1,44 +1,41 @@
use std::collections::{HashMap, HashSet};
use std::env;
use std::time::SystemTime;
use bot_database::get_database;
use serenity::client::Context;
use serenity::framework::standard::buckets::LimitedFor;
use serenity::framework::standard::macros::hook;
use serenity::framework::standard::{CommandResult, DispatchError};
use serenity::framework::StandardFramework;
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use serenity::prelude::GatewayIntents;
use serenity::Client;
use serenity_additions::RegisterAdditions;
use songbird::SerenityInit;
use crate::commands::*;
use crate::handler::{get_raw_event_handler, Handler};
use crate::handler::Handler;
use crate::utils::context_data::{
get_database_from_context, DatabaseContainer, MusicPlayers, Store, StoreData,
DatabaseContainer, EventDrivenMessageContainer, Store, StoreData,
};
use crate::utils::error::{BotError, BotResult};
use database::get_database;
use serenity::model::id::UserId;
use std::collections::{HashMap, HashSet};
pub async fn get_client() -> BotResult<Client> {
let token = env::var("BOT_TOKEN").map_err(|_| BotError::MissingToken)?;
let database = get_database().await?;
let client = Client::builder(token, GatewayIntents::all())
.register_serenity_additions_with(get_raw_event_handler())
let token = dotenv::var("BOT_TOKEN").map_err(|_| BotError::MissingToken)?;
let database = get_database()?;
let client = Client::builder(token)
.event_handler(Handler)
.framework(get_framework().await)
.framework(get_framework())
.register_songbird()
.type_map_insert::<Store>(StoreData::create().await)
.type_map_insert::<DatabaseContainer>(database)
.type_map_insert::<MusicPlayers>(HashMap::new())
.await?;
{
let mut data = client.data.write().await;
data.insert::<Store>(StoreData::new());
data.insert::<DatabaseContainer>(database);
data.insert::<EventDrivenMessageContainer>(HashMap::new());
}
Ok(client)
}
pub async fn get_framework() -> StandardFramework {
pub fn get_framework() -> StandardFramework {
let mut owners = HashSet::new();
if let Some(owner) = dotenv::var("BOT_OWNER").ok().and_then(|o| o.parse().ok()) {
owners.insert(UserId(owner));
@ -58,64 +55,35 @@ pub async fn get_framework() -> StandardFramework {
.group(&MISC_GROUP)
.group(&MUSIC_GROUP)
.group(&SETTINGS_GROUP)
.group(&WEEB_GROUP)
.after(after_hook)
.before(before_hook)
.on_dispatch_error(dispatch_error)
.help(&HELP)
.bucket("music_api", |b| {
b.delay(1)
.time_span(60)
.limit(30)
.limit_for(LimitedFor::User)
})
.await
.bucket("sauce_api", |b| {
b.delay(1)
.time_span(60)
.limit(10)
.limit_for(LimitedFor::User)
})
.await
.bucket("general", |b| b.time_span(10).limit(5))
.await
}
#[hook]
async fn after_hook(ctx: &Context, msg: &Message, cmd_name: &str, error: CommandResult) {
// Print out an error if it happened
let mut error_msg = None;
if let Err(why) = error {
error_msg = Some(why.to_string());
let _ = msg
.channel_id
.send_message(ctx, |m| {
m.embed(|e| e.title("Error occurred").description(format!("{}", why)))
})
.await;
tracing::warn!("Error in {}: {:?}", cmd_name, why);
log::warn!("Error in {}: {:?}", cmd_name, why);
}
let database = get_database_from_context(ctx).await;
let _ = database
.add_statistic(
crate::VERSION.to_string(),
cmd_name.to_string(),
SystemTime::now(),
error_msg.is_none(),
error_msg,
)
.await;
}
#[hook]
async fn before_hook(ctx: &Context, msg: &Message, _: &str) -> bool {
tracing::trace!("Got command message {}", msg.content);
log::trace!("Got command message {}", msg.content);
let _ = msg.channel_id.broadcast_typing(ctx).await;
true
}
#[hook]
async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError, command_name: &str) {
async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError) {
match error {
DispatchError::Ratelimited(info) => {
if info.is_first_try {
@ -131,19 +99,13 @@ async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError, comm
DispatchError::OnlyForDM => {
let _ = msg
.channel_id
.say(
&ctx.http,
format!("The command {command_name} only works via DM"),
)
.say(&ctx.http, "This command only works via DM")
.await;
}
DispatchError::OnlyForGuilds => {
let _ = msg
.channel_id
.say(
&ctx.http,
format!("The command {command_name} only works on servers"),
)
.say(&ctx.http, "This command only works on servers")
.await;
}
DispatchError::NotEnoughArguments { min, given } => {

@ -1,8 +1,7 @@
use serenity::model::channel::Message;
use serenity::prelude::*;
use crate::providers::settings::{get_setting, Setting};
use crate::utils::error::BotResult;
use serenity::model::channel::Message;
use serenity::prelude::*;
/// Deletes a message automatically if configured that way
pub async fn handle_autodelete(ctx: &Context, msg: &Message) -> BotResult<()> {

@ -11,12 +11,11 @@ use crate::utils::context_data::Store;
#[example("unbreaking")]
#[min_args(1)]
#[aliases("ench")]
#[bucket("general")]
pub(crate) async fn enchantment(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let data = ctx.data.read().await;
let store = data.get::<Store>().expect("Failed to get store");
let enchantment_name = args.message().to_lowercase();
tracing::debug!("Searching for enchantment {}", enchantment_name);
log::debug!("Searching for enchantment {}", enchantment_name);
let enchantments_by_name = store
.minecraft_data_api
@ -29,7 +28,7 @@ pub(crate) async fn enchantment(ctx: &Context, msg: &Message, args: Args) -> Com
enchantment_name
)))?
.clone();
tracing::trace!("Enchantment is {:?}", enchantment);
log::trace!("Enchantment is {:?}", enchantment);
msg.channel_id
.send_message(ctx, |m| {

@ -1,10 +1,8 @@
use serenity::client::Context;
use serenity::framework::standard::{macros::command, Args, CommandResult};
use serenity::framework::standard::{macros::command, Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::messages::minecraft::item::create_item_message;
use crate::providers::minecraft::get_item_full_information;
use crate::utils::context_data::Store;
#[command]
@ -13,17 +11,56 @@ use crate::utils::context_data::Store;
#[example("bread")]
#[min_args(1)]
#[aliases("i")]
#[bucket("general")]
pub(crate) async fn item(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let data = ctx.data.read().await;
let store = data.get::<Store>().expect("Failed to get store");
let item_name = args.message().to_lowercase();
tracing::debug!("Searching for item '{}'", item_name);
let information = get_item_full_information(&item_name, &store.minecraft_data_api)?;
tracing::trace!("Item full information is {:?}", information);
create_item_message(ctx, msg.channel_id, information).await?;
log::debug!("Searching for item '{}'", item_name);
let items_by_name = store.minecraft_data_api.items.items_by_name()?;
let item = items_by_name
.get(&item_name)
.ok_or(CommandError::from(format!(
"The item `{}` could not be found",
item_name
)))?;
let enchantments_by_category = store
.minecraft_data_api
.enchantments
.enchantments_by_category()?;
log::trace!("Item is {:?}", item);
msg.channel_id
.send_message(ctx, |m| {
m.embed(|mut e| {
e = e
.title(&*item.display_name)
.thumbnail(format!(
"https://minecraftitemids.com/item/128/{}.png",
item.name
))
.field("Name", &*item.name, false)
.field("Stack Size", item.stack_size, false);
if let Some(durability) = item.durability {
e = e.field("Durability", durability, true);
}
if let Some(variations) = &item.variations {
e = e.field("Variations", format!("{:?}", variations), false);
}
if let Some(enchant_categories) = &item.enchant_categories {
let item_enchantments = enchant_categories
.into_iter()
.filter_map(|c| enchantments_by_category.get(c))
.flatten()
.map(|e| e.display_name.clone())
.collect::<Vec<String>>();
e = e.field("Enchantments", item_enchantments.join(", "), false);
}
e
})
})
.await?;
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,25 +0,0 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
#[command]
#[description("Displays information about the bot")]
#[bucket("general")]
async fn about(ctx: &Context, msg: &Message) -> CommandResult {
msg.channel_id
.send_message(ctx, |m| {
m.embed(|e| {
e.title("About").description(format!(
"\
I'm a general purpose discord bot written in rusty Rust. \
My main focus is providing information about all kinds of stuff and playing music.\
Use `{}help` to get an overview of the commands I provide.",
std::env::var("BOT_PREFIX").unwrap()
))
})
})
.await?;
Ok(())
}

@ -1,37 +0,0 @@
use crate::utils::context_data::get_database_from_context;
use bot_coreutils::url;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
#[command]
#[description("Adds media to the database")]
#[usage("<url> [<category>] [<name>]")]
#[bucket("general")]
#[aliases("add_gif", "add-gif", "addgif", "add-media", "addmedia")]
#[min_args(1)]
#[max_args(3)]
#[owners_only]
async fn add_media(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let url = args.single::<String>()?;
if !url::is_valid(&url) {
msg.reply(ctx, "Invalid url").await?;
return Ok(());
}
let category = args.single_quoted::<String>().ok();
let name = args.single_quoted::<String>().ok();
let database = get_database_from_context(&ctx).await;
database.add_media(url, category, name).await?;
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |c| {
c.reference_message(msg)
.content("Media entry added to the database.")
})
.await?;
Ok(())
}

@ -1,39 +0,0 @@
use futures::future::BoxFuture;
use futures::FutureExt;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use serenity::Result as SerenityResult;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
#[command]
#[description("Clears the chat (maximum 100 messages)")]
#[usage("[<number>]")]
#[example("20")]
#[min_args(0)]
#[max_args(1)]
#[bucket("general")]
#[required_permissions("MANAGE_MESSAGES")]
async fn clear(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let limit = args.single::<u64>().unwrap_or(20);
tracing::debug!("Deleting messages for channel {}", msg.channel_id);
let messages = msg.channel_id.messages(ctx, |b| b.limit(limit)).await?;
tracing::debug!("Deleting {} messages", messages.len());
let futures: Vec<BoxFuture<SerenityResult<()>>> = messages
.into_iter()
.map(|m| async move { ctx.http.delete_message(m.channel_id.0, m.id.0).await }.boxed())
.collect();
tracing::debug!("Waiting for all messages to be deleted");
let deleted = futures::future::join_all(futures).await;
let deleted_count = deleted.into_iter().filter(|d| d.is_ok()).count();
tracing::debug!("{} Messages deleted", deleted_count);
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |f| {
f.content(format!("Deleted {} messages", deleted_count))
})
.await?;
Ok(())
}

@ -1,44 +0,0 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use std::time::Duration;
#[command]
#[description("Fuck this person in particular")]
#[usage("<person> [<amount>] [<verbosity>]")]
#[min_args(1)]
#[max_args(3)]
#[bucket("general")]
#[aliases("frick", "fock")]
async fn fuck(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut amount = args.single::<usize>().unwrap_or(3);
if amount > 3 {
msg.reply(&ctx.http, "Don't you think that's a bit much?")
.await?;
tokio::time::sleep(Duration::from_secs(2)).await;
amount = 3;
} else {
msg.reply(&ctx.http, "no").await?;
tokio::time::sleep(Duration::from_secs(1)).await;
}
let mut verbosity = args.single::<usize>().unwrap_or(1);
if verbosity == 0 {
verbosity = 1
}
let fuck_word = match verbosity {
1 => "frick",
2 => "flock",
3 => "fock",
4 => "fck",
_ => "fuck",
};
for _ in 0..amount {
msg.channel_id
.say(&ctx, format!("{} <@{}>", fuck_word, msg.author.id))
.await?;
}
Ok(())
}

@ -1,5 +1,6 @@
use std::collections::HashSet;
use crate::commands::common::handle_autodelete;
use serenity::client::Context;
use serenity::framework::standard::macros::help;
use serenity::framework::standard::{help_commands, Args};
@ -7,8 +8,6 @@ use serenity::framework::standard::{CommandGroup, CommandResult, HelpOptions};
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use crate::commands::common::handle_autodelete;
#[help]
#[max_levenshtein_distance(2)]
pub async fn help(
@ -19,7 +18,6 @@ pub async fn help(
groups: &[&'static CommandGroup],
owners: HashSet<UserId>,
) -> CommandResult {
tracing::debug!("Help");
let _ = help_commands::with_embeds(ctx, msg, args, help_options, groups, owners).await;
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,16 +0,0 @@
use crate::messages::inspirobot::create_inspirobot_menu;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
#[command]
#[description("Get an inspiring quote")]
#[usage("")]
#[aliases("inspireme", "inspire-me", "inspiro")]
#[bucket("general")]
async fn inspirobot(ctx: &Context, msg: &Message) -> CommandResult {
create_inspirobot_menu(ctx, msg.channel_id, msg.author.id).await?;
Ok(())
}

@ -1,18 +0,0 @@
use crate::messages::gifs::create_media_menu;
use crate::utils::context_data::get_database_from_context;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
#[command]
#[description("Displays a list of all gifs used by the bot")]
#[bucket("general")]
#[only_in(guilds)]
async fn media(ctx: &Context, msg: &Message) -> CommandResult {
let database = get_database_from_context(ctx).await;
let gifs = database.get_all_media().await?;
create_media_menu(ctx, msg.channel_id, gifs, msg.author.id).await?;
Ok(())
}

@ -1,41 +1,22 @@
use serenity::framework::standard::macros::group;
use about::ABOUT_COMMAND;
use add_media::ADD_MEDIA_COMMAND;
use clear::CLEAR_COMMAND;
use fuck::FUCK_COMMAND;
use inspirobot::INSPIROBOT_COMMAND;
use media::MEDIA_COMMAND;
use pain::PAIN_COMMAND;
use party::PARTY_COMMAND;
use pekofy::PEKOFY_COMMAND;
use ping::PING_COMMAND;
use qalc::QALC_COMMAND;
use shutdown::SHUTDOWN_COMMAND;
use stats::STATS_COMMAND;
use time::TIME_COMMAND;
use timezones::TIMEZONES_COMMAND;
use xkcd::XKCD_COMMAND;
mod about;
mod add_media;
mod clear;
mod fuck;
pub(crate) mod help;
mod inspirobot;
mod media;
mod pain;
mod party;
mod pekofy;
mod ping;
mod qalc;
mod shutdown;
mod stats;
mod time;
mod timezones;
mod xkcd;
#[group]
#[commands(
ping, stats, shutdown, time, timezones, qalc, about, add_media, media, pain, clear, xkcd, fuck,
party, inspirobot
)]
#[commands(ping, stats, shutdown, pekofy, time, timezones, qalc)]
pub struct Misc;

@ -1,42 +0,0 @@
use crate::utils::context_data::get_database_from_context;
use crate::utils::error::BotError;
use rand::prelude::IteratorRandom;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
static CATEGORY_PREFIX: &str = "pain-";
static NOT_FOUND_PAIN: &str = "404";
#[command]
#[description("Various types of pain (pain-peko)")]
#[usage("<pain-type>")]
#[example("peko")]
#[min_args(1)]
#[max_args(1)]
#[bucket("general")]
async fn pain(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
tracing::debug!("Got pain command");
let pain_type = args.message().to_lowercase();
let database = get_database_from_context(ctx).await;
let mut media = database
.get_media_by_category(format!("{}{}", CATEGORY_PREFIX, pain_type))
.await?;
if media.is_empty() {
tracing::debug!("No media found for pain {}. Using 404", pain_type);
media = database
.get_media_by_category(format!("{}{}", CATEGORY_PREFIX, NOT_FOUND_PAIN))
.await?;
}
let entry = media
.into_iter()
.choose(&mut rand::thread_rng())
.ok_or(BotError::from("No gifs found."))?;
tracing::trace!("Gif for pain is {:?}", entry);
msg.reply(ctx, entry.url).await?;
Ok(())
}

@ -1,43 +0,0 @@
use crate::utils::context_data::get_database_from_context;
use crate::utils::error::BotError;
use bot_database::models::Media;
use rand::prelude::SliceRandom;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
#[command]
#[description("Party command")]
#[max_args(1)]
#[usage("(<amount>)")]
#[bucket("general")]
async fn party(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut amount = args.single::<u32>().unwrap_or(1);
if amount > 5 {
amount = 5;
}
if amount == 0 {
return Ok(());
}
let database = get_database_from_context(ctx).await;
let mut media = database.get_media_by_category("party").await?;
media.shuffle(&mut rand::thread_rng());
let mut chosen_gifs = Vec::new();
for _ in 0..amount {
chosen_gifs.push(media.pop());
}
let chosen_gifs: Vec<Media> = chosen_gifs.into_iter().filter_map(|g| g).collect();
if chosen_gifs.is_empty() {
return Err(BotError::from("No media found.").into());
}
for gif in chosen_gifs {
msg.channel_id
.send_message(&ctx.http, |m| m.content(gif.url))
.await?;
}
Ok(())
}

@ -4,11 +4,6 @@ use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use serenity::{framework::standard::macros::command, prelude::*};
use crate::utils::context_data::get_database_from_context;
use crate::utils::error::{BotError, BotResult};
use crate::utils::get_previous_message_or_reply;
use bot_database::models::Media;
// return a normal peko in most cases
static PEKOS: &[&str] = &[
"peko",
@ -18,37 +13,44 @@ static PEKOS: &[&str] = &[
"🇵 🇪 🇰 🇴",
"p3k0",
];
static MEDIA_CATEGORY: &str = "pain-peko";
#[command]
#[description("Pekofy messages")]
#[usage("[<content>...]")]
#[usage("(<content>)")]
#[example("Hello")]
#[aliases("peko")]
#[bucket("general")]
async fn pekofy(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let mut reference_message = msg.id;
let mut content = args.message().to_string();
if args.is_empty() {
let reference = get_previous_message_or_reply(ctx, msg)
.await?
.ok_or(CommandError::from("No message to pekofy"))?;
reference_message = reference.id;
content = reference.content;
if let Some(reference) = &msg.referenced_message {
reference_message = reference.id;
content = reference.content.clone();
} else {
let messages = msg
.channel_id
.messages(ctx, |ret| ret.before(&msg.id).limit(1))
.await?;
let reference = messages
.first()
.ok_or(CommandError::from("No message to pekofy"))?;
reference_message = reference.id;
content = reference.content.clone();
};
let _ = msg.delete(ctx).await;
}
if content.is_empty() {
return Err(CommandError::from("Can't pekofy empty message"));
}
tracing::debug!("Pekofying message '{}'", content);
log::debug!("Pekofying message '{}'", content);
let mut alpha_lowercase = content.to_lowercase();
alpha_lowercase.retain(|c| c.is_alphanumeric());
let pekofied: String = if alpha_lowercase == "pain" {
random_pain_media(ctx).await?.url
"https://tenor.com/view/pekora-usada-peko-hololive-died-gif-18114577".to_string()
} else if PEKOS.contains(&&*alpha_lowercase) {
random_peko()
} else {
@ -64,7 +66,7 @@ async fn pekofy(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
.http
.get_message(msg.channel_id.0, reference_message.0)
.await?;
tracing::debug!("Pekofied message is '{}'", pekofied);
log::debug!("Pekofied message is '{}'", pekofied);
message.reply(ctx, pekofied).await?;
Ok(())
@ -73,7 +75,7 @@ async fn pekofy(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
/// Pekofies a single line
fn pekofy_line(mut line: &str) -> String {
lazy_static::lazy_static! { static ref FORMATTING_REGEX: Regex = Regex::new(r"^(.*?)((<:\w+:\d+>|\W)*)$").unwrap(); }
tracing::debug!("Pekofying line '{}'", line);
log::debug!("Pekofying line '{}'", line);
let original = line;
let mut md = "";
@ -84,7 +86,7 @@ fn pekofy_line(mut line: &str) -> String {
for peko in PEKOS {
if line.to_lowercase().ends_with(peko) {
tracing::debug!("Peko already found in message. Returning original");
log::debug!("Peko already found in message. Returning original");
return original.to_string();
}
}
@ -96,7 +98,7 @@ fn pekofy_line(mut line: &str) -> String {
.filter(|c| c.is_alphabetic())
.all(char::is_uppercase)
{
tracing::debug!("Message is all uppercase. Peko will also be uppercase");
log::debug!("Message is all uppercase. Peko will also be uppercase");
peko = peko.to_uppercase();
}
@ -112,12 +114,3 @@ fn random_peko() -> String {
"peko".to_string()
}
}
/// Chooses a random pain peko gif
async fn random_pain_media(ctx: &Context) -> BotResult<Media> {
let database = get_database_from_context(ctx).await;
let gifs = database.get_media_by_category(MEDIA_CATEGORY).await?;
gifs.into_iter()
.choose(&mut rand::thread_rng())
.ok_or(BotError::from("No media found"))
}

@ -6,7 +6,6 @@ use serenity::model::channel::Message;
#[command]
#[description("Simple ping test command")]
#[usage("")]
#[bucket("general")]
async fn ping(ctx: &Context, msg: &Message) -> CommandResult {
msg.reply(ctx, "Pong!").await?;

@ -1,11 +1,10 @@
use crate::providers::qalc;
use regex::Regex;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use crate::providers::qalc;
static QALC_HELP: &[&str] = &["help", "--help", "-h", "h"];
#[command]
@ -13,8 +12,6 @@ static QALC_HELP: &[&str] = &["help", "--help", "-h", "h"];
#[min_args(1)]
#[usage("<expression>")]
#[example("1 * 1 + 1 / sqrt(2)")]
#[aliases("calc", "calculate", "qalculate")]
#[bucket("general")]
async fn qalc(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let expression = args.message();
lazy_static::lazy_static! {
@ -33,10 +30,6 @@ async fn qalc(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
description +=
"\nRead the [Qalculate! Manual](https://qalculate.github.io/manual/index.html)";
}
if &result == "aborted\n" {
description =
"Calculation aborted after timeout. Try a less complex calculation.".to_string();
}
msg.channel_id
.send_message(ctx, |f| {

@ -1,27 +1,19 @@
use std::process;
use crate::commands::common::handle_autodelete;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use serenity::prelude::*;
use crate::commands::common::handle_autodelete;
use std::process;
#[command]
#[description("Shuts down the bot with the specified exit code")]
#[min_args(0)]
#[max_args(1)]
#[usage("[<code>]")]
#[description("Shutdown")]
#[usage("")]
#[owners_only]
async fn shutdown(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let code = args.single::<i32>().unwrap_or(0);
tracing::info!("Shutting down with code {}...", code);
async fn shutdown(ctx: &Context, msg: &Message) -> CommandResult {
log::info!("Shutting down...");
msg.channel_id
.say(
ctx,
format!(":night_with_stars: Good night (code: {})...", code),
)
.say(ctx, ":night_with_stars: Good night ....")
.await?;
handle_autodelete(ctx, msg).await?;
process::exit(code);
process::exit(0);
}

@ -1,28 +1,22 @@
use std::process;
use std::time::Duration;
use crate::commands::common::handle_autodelete;
use chrono::Duration as ChronoDuration;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use serenity::prelude::*;
use sysinfo::{Pid, PidExt, ProcessExt, SystemExt};
use crate::commands::common::handle_autodelete;
use crate::utils::context_data::{get_database_from_context, MusicPlayers};
use std::process;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::{ProcessExt, SystemExt};
#[command]
#[description("Shows some statistics about the bot")]
#[usage("")]
#[bucket("general")]
async fn stats(ctx: &Context, msg: &Message) -> CommandResult {
tracing::debug!("Reading system stats");
let database = get_database_from_context(ctx).await;
log::debug!("Reading system stats");
let mut system = sysinfo::System::new_all();
system.refresh_all();
let kernel_version = system.kernel_version().unwrap_or("n/a".to_string());
let own_process = system.process(Pid::from_u32(process::id())).unwrap();
let kernel_version = system.get_kernel_version().unwrap_or("n/a".to_string());
let own_process = system.get_process(process::id() as i32).unwrap();
let memory_usage = own_process.memory();
let cpu_usage = own_process.cpu_usage();
let thread_count = own_process.tasks.len();
@ -30,33 +24,19 @@ async fn stats(ctx: &Context, msg: &Message) -> CommandResult {
let guild_count: usize = current_user.guilds(ctx).await?.len();
let bot_info = ctx.http.get_current_application_info().await?;
let uptime = own_process.run_time();
let uptime = ChronoDuration::from_std(Duration::from_secs(uptime)).unwrap();
let total_commands_executed = database.get_total_commands_statistic().await?;
let shard_count = ctx.cache.shard_count();
let current_time_seconds = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let uptime = current_time_seconds - Duration::from_secs(own_process.start_time());
let uptime = ChronoDuration::from_std(uptime).unwrap();
let discord_info = format!(
r#"
Version: {}
Compiled with: rustc {}
Build at: {}
Owner: <@{}>
Guilds: {}
Shards: {}
Voice Connections: {}
Times Used: {}
"#,
crate::VERSION,
rustc_version_runtime::version(),
build_time::build_time_utc!("%Y-%m-%dT%H:%M:%S"),
bot_info.owner.id,
guild_count,
shard_count,
get_queue_count(ctx).await,
total_commands_executed
bot_info.owner.id, guild_count
);
tracing::trace!("Discord info {}", discord_info);
log::trace!("Discord info {}", discord_info);
let system_info = format!(
r#"
@ -74,7 +54,7 @@ async fn stats(ctx: &Context, msg: &Message) -> CommandResult {
uptime.num_hours() % 24,
uptime.num_minutes() % 60
);
tracing::trace!("System info {}", system_info);
log::trace!("System info {}", system_info);
msg.channel_id
.send_message(ctx, |m| {
@ -89,11 +69,3 @@ async fn stats(ctx: &Context, msg: &Message) -> CommandResult {
Ok(())
}
/// Returns the total number of queues that are not
/// flagged to leave
async fn get_queue_count(ctx: &Context) -> usize {
let data = ctx.data.read().await;
let players = data.get::<MusicPlayers>().unwrap();
players.len()
}

@ -9,21 +9,20 @@ use serenity::model::channel::Message;
#[description("Converts a time into a different timezone")]
#[min_args(1)]
#[max_args(3)]
#[usage("(now | <%H:%M>) [<from-timezone>] [<to-timezone>]")]
#[bucket("general")]
#[usage("<%H:%M/now> (<from-timezone>) (<to-timezone>)")]
async fn time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let when = args.single::<String>().unwrap_or("now".to_string());
let first_timezone = args.single::<String>().ok();
let second_timezone = args.single::<String>().ok();
let from_timezone: Tz = if let Some(first) = &first_timezone {
crate::forward_error!(ctx, msg.channel_id, first.parse::<Tz>())
first.parse::<Tz>()?
} else {
Tz::UTC
};
let to_timezone = if let Some(second) = &second_timezone {
crate::forward_error!(ctx, msg.channel_id, second.parse::<Tz>())
second.parse::<Tz>()?
} else {
Tz::UTC
};
@ -33,25 +32,18 @@ async fn time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
} else {
let now = Utc::now();
if second_timezone.is_some() {
crate::forward_error!(
ctx,
msg.channel_id,
from_timezone.datetime_from_str(
&format!("{} {}:00", now.format("%Y-%m-%d"), &*when),
"%Y-%m-%d %H:%M:%S",
)
)
from_timezone.datetime_from_str(
&format!("{} {}:00", now.format("%Y-%m-%d"), &*when),
"%Y-%m-%d %H:%M:%S",
)?
} else {
let timezone: Tz = "UTC".parse().unwrap();
crate::forward_error!(
ctx,
msg.channel_id,
timezone.datetime_from_str(
timezone
.datetime_from_str(
&format!("{} {}:00", now.format("%Y-%m-%d"), &*when),
"%Y-%m-%d %H:%M:%S",
)
)
.with_timezone(&from_timezone)
)?
.with_timezone(&from_timezone)
}
};

@ -8,7 +8,6 @@ use serenity::model::channel::Message;
#[min_args(1)]
#[usage("<query...>")]
#[example("Europe Berlin")]
#[bucket("general")]
async fn timezones(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let query = args
.iter::<String>()

@ -1,35 +0,0 @@
use crate::messages::xkcd::create_xkcd_menu;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use xkcd_search::get_comic;
#[command]
#[description("Retrieves xkcd comics")]
#[usage("[(<id>|<query..>)]")]
#[bucket("general")]
async fn xkcd(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let comics = if let Ok(id) = args.single::<u32>() {
if let Ok(comic) = xkcd_search::get_comic(id).await {
vec![comic]
} else {
vec![]
}
} else if !args.is_empty() {
let query = args.message();
let results = xkcd_search::search(query).await?;
let comics =
futures::future::join_all(results.into_iter().map(|(_, id)| get_comic(id))).await;
comics
.into_iter()
.filter_map(|result| result.ok())
.collect()
} else {
vec![xkcd_search::get_latest_comic().await?]
};
create_xkcd_menu(ctx, msg.channel_id, comics, msg.author.id).await?;
Ok(())
}

@ -3,11 +3,9 @@ pub use misc::help::HELP;
pub use misc::MISC_GROUP;
pub use music::MUSIC_GROUP;
pub use settings::SETTINGS_GROUP;
pub use weeb::WEEB_GROUP;
mod common;
pub(crate) mod minecraft;
pub(crate) mod misc;
pub(crate) mod music;
pub(crate) mod settings;
pub(crate) mod weeb;
mod common;

@ -1,41 +1,34 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{CommandError, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::{get_music_player_for_guild, DJ_CHECK};
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
use crate::commands::music::{get_queue_for_guild, is_dj};
#[command]
#[only_in(guilds)]
#[description("Clears the queue")]
#[usage("")]
#[aliases("cq", "clear-queue", "clearqueue")]
#[bucket("general")]
#[checks(DJ)]
#[aliases("cl")]
async fn clear_queue(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Clearing queue for guild {}", guild.id);
let guild = msg.guild(&ctx.cache).await.unwrap();
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
if !is_dj(ctx, guild.id, &msg.author).await? {
msg.channel_id.say(ctx, "Requires DJ permissions").await?;
return Ok(());
}
log::debug!("Clearing queue for guild {}", guild.id);
let queue = get_queue_for_guild(ctx, &guild.id).await?;
{
let mut player = player.lock().await;
player.queue().clear();
let mut queue_lock = queue.lock().await;
queue_lock.clear();
}
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("🧹 The queue has been cleared")
})
.await?;
msg.channel_id
.say(ctx, "The queue has been cleared")
.await?;
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,39 +1,34 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{CommandError, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::get_music_player_for_guild;
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use crate::messages::music::now_playing::create_now_playing_msg;
use crate::commands::music::get_queue_for_guild;
use crate::messages::music::NowPlayingMessage;
use std::mem;
#[command]
#[only_in(guilds)]
#[description("Displays the currently playing song")]
#[usage("")]
#[aliases("nowplaying", "np")]
#[bucket("general")]
async fn current(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
tracing::debug!("Displaying current song for queue in {}", guild.id);
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
let current = {
let mut player = player.lock().await;
player.queue().current().clone()
};
log::debug!("Displaying current song for queue in {}", guild.id);
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let mut queue_lock = queue.lock().await;
if let Some(_) = current {
let np_msg = create_now_playing_msg(ctx, player.clone(), msg.channel_id).await?;
let mut player = player.lock().await;
player.set_now_playing(np_msg).await;
if let Some(current) = queue_lock.current() {
let metadata = current.metadata().clone();
log::trace!("Metadata is {:?}", metadata);
let np_msg =
NowPlayingMessage::create(ctx.http.clone(), &msg.channel_id, &metadata).await?;
if let Some(old_np) = mem::replace(&mut queue_lock.now_playing_msg, Some(np_msg)) {
let _ = old_np.inner().delete().await;
}
}
handle_autodelete(ctx, msg).await?;

@ -1,52 +1,20 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::{get_channel_for_author, get_music_player_for_guild, is_dj};
use crate::providers::music::player::MusicPlayer;
use serenity::model::id::ChannelId;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
use crate::commands::music::{get_channel_for_author, join_channel};
#[command]
#[only_in(guilds)]
#[description("Joins a voice channel")]
#[usage("")]
#[bucket("general")]
async fn join(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
let channel_id = if let Ok(arg) = args.single::<u64>() {
if is_dj(ctx, guild.id, &msg.author).await? {
ChannelId(arg)
} else {
crate::forward_error!(
ctx,
msg.channel_id,
get_channel_for_author(&msg.author.id, &guild)
)
}
} else {
crate::forward_error!(
ctx,
msg.channel_id,
get_channel_for_author(&msg.author.id, &guild)
)
};
if get_music_player_for_guild(ctx, guild.id).await.is_some() {
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("‼️ I'm already in a Voice Channel")
})
.await?;
return Ok(());
}
tracing::debug!("Joining channel {} for guild {}", channel_id, guild.id);
MusicPlayer::join(ctx, guild.id, channel_id, msg.channel_id).await?;
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("🎤 Joined the Voice Channel")
})
.await?;
async fn join(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).await.unwrap();
let channel_id = get_channel_for_author(&msg.author.id, &guild)?;
log::debug!("Joining channel {} for guild {}", channel_id, guild.id);
join_channel(ctx, channel_id, guild.id).await;
handle_autodelete(ctx, msg).await?;
Ok(())

@ -4,46 +4,42 @@ use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::DJ_CHECK;
use crate::utils::context_data::MusicPlayers;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
use crate::commands::music::{get_queue_for_guild, get_voice_manager, is_dj};
#[command]
#[only_in(guilds)]
#[description("Leaves a voice channel")]
#[usage("")]
#[aliases("stop")]
#[bucket("general")]
#[checks(DJ)]
async fn leave(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Leave request received for guild {}", guild.id);
let manager = songbird::get(ctx).await.unwrap();
if let Some(handler) = manager.get(guild.id) {
let mut handler_lock = handler.lock().await;
handler_lock.leave().await?;
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Leave request received for guild {}", guild.id);
if !is_dj(ctx, guild.id, &msg.author).await? {
msg.channel_id.say(ctx, "Requires DJ permissions").await?;
return Ok(());
}
let mut data = ctx.data.write().await;
let players = data.get_mut::<MusicPlayers>().unwrap();
let manager = get_voice_manager(ctx).await;
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let queue_lock = queue.lock().await;
let handler = manager.get(guild.id);
match players.remove(&guild.id.0) {
None => {
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("‼️ I'm not in a Voice Channel")
})
.await?;
}
Some(player) => {
let mut player = player.lock().await;
player.stop().await?;
player.delete_now_playing().await?;
}
if let Some(handler) = handler {
let mut handler_lock = handler.lock().await;
handler_lock.remove_all_global_events();
}
if let Some(current) = queue_lock.current() {
current.stop()?;
}
manager.remove(guild.id).await?;
if manager.get(guild.id).is_some() {
manager.remove(guild.id).await?;
msg.channel_id.say(ctx, "Left the voice channel").await?;
log::debug!("Left the voice channel");
} else {
msg.channel_id.say(ctx, "Not in a voice channel").await?;
log::debug!("Not in a voice channel");
}
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,55 +1,50 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{CommandError, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::get_music_player_for_guild;
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use crate::commands::music::get_queue_for_guild;
use crate::providers::music::lyrics::get_lyrics;
#[command]
#[only_in(guilds)]
#[description("Shows the lyrics for the currently playing song")]
#[usage("")]
#[bucket("general")]
async fn lyrics(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Fetching lyrics for song playing in {}", guild.id);
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Fetching lyrics for song playing in {}", guild.id);
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let queue_lock = queue.lock().await;
let (lyrics, current) = {
let mut player = player.lock().await;
let current = player.queue().current().clone();
(player.lyrics().await?, current)
};
if let Some(current) = queue_lock.current() {
log::debug!("Playing music. Fetching lyrics for currently playing song...");
let metadata = current.metadata();
let title = metadata.title.clone().unwrap();
let author = metadata.artist.clone().unwrap();
if let Some(lyrics) = lyrics {
let current = current.unwrap();
msg.channel_id
.send_message(ctx, |m| {
m.embed(|e| {
e.title(format!(
"Lyrics for {} by {}",
current.title(),
current.author()
))
.description(lyrics)
.footer(|f| f.text("Powered by lyricsovh"))
if let Some(lyrics) = get_lyrics(&*author, &*title).await? {
log::trace!("Lyrics for '{}' are {}", title, lyrics);
msg.channel_id
.send_message(ctx, |m| {
m.embed(|e| {
e.title(format!("Lyrics for {} by {}", title, author))
.description(lyrics)
.footer(|f| f.text("Powered by lyricsovh"))
})
})
})
.await?;
.await?;
} else {
log::debug!("No lyrics found");
msg.channel_id.say(ctx, "No lyrics found").await?;
}
} else {
tracing::debug!("No lyrics found");
msg.channel_id.say(ctx, "No lyrics found").await?;
msg.channel_id
.say(ctx, "I'm not playing music right now")
.await?;
}
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,60 +1,55 @@
use std::sync::Arc;
use aspotify::Track;
use bot_database::Database;
use futures::future::BoxFuture;
use futures::FutureExt;
use crate::providers::music::queue::{MusicQueue, Song};
use crate::providers::music::youtube_dl;
use crate::utils::context_data::{DatabaseContainer, Store};
use crate::utils::error::{BotError, BotResult};
use regex::Regex;
use serenity::async_trait;
use serenity::client::Context;
use serenity::framework::standard::macros::{check, group};
use serenity::framework::standard::{Args, CommandOptions, Reason};
use serenity::framework::standard::macros::group;
use serenity::http::Http;
use serenity::model::channel::Message;
use serenity::model::guild::Guild;
use serenity::model::id::{ChannelId, GuildId, UserId};
use serenity::model::user::User;
use songbird::Songbird;
use songbird::{
Call, Event, EventContext, EventHandler as VoiceEventHandler, Songbird, TrackEvent,
};
use std::mem;
use std::sync::atomic::{AtomicIsize, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use youtube_metadata::get_video_information;
use clear_queue::CLEAR_QUEUE_COMMAND;
use current::CURRENT_COMMAND;
use join::JOIN_COMMAND;
use leave::LEAVE_COMMAND;
use lyrics::LYRICS_COMMAND;
use move_song::MOVE_SONG_COMMAND;
use pause::PAUSE_COMMAND;
use play::PLAY_COMMAND;
use play_next::PLAY_NEXT_COMMAND;
use playlists::PLAYLISTS_COMMAND;
use queue::QUEUE_COMMAND;
use remove_song::REMOVE_SONG_COMMAND;
use save_playlist::SAVE_PLAYLIST_COMMAND;
use shuffle::SHUFFLE_COMMAND;
use skip::SKIP_COMMAND;
use crate::providers::music::player::MusicPlayer;
use crate::providers::music::queue::Song;
use crate::providers::music::{add_youtube_song_to_database, youtube_dl};
use crate::providers::settings::{get_setting, Setting};
use crate::utils::context_data::{DatabaseContainer, MusicPlayers, Store};
use crate::utils::error::{BotError, BotResult};
mod clear_queue;
mod current;
mod join;
mod leave;
mod lyrics;
mod move_song;
mod pause;
mod play;
mod play_next;
mod playlists;
mod queue;
mod remove_song;
mod save_playlist;
mod shuffle;
mod skip;
use crate::providers::settings::{get_setting, Setting};
use clear_queue::CLEAR_QUEUE_COMMAND;
use current::CURRENT_COMMAND;
use join::JOIN_COMMAND;
use leave::LEAVE_COMMAND;
use lyrics::LYRICS_COMMAND;
use pause::PAUSE_COMMAND;
use play::PLAY_COMMAND;
use play_next::PLAY_NEXT_COMMAND;
use playlists::PLAYLISTS_COMMAND;
use queue::QUEUE_COMMAND;
use save_playlist::SAVE_PLAYLIST_COMMAND;
use serenity::model::user::User;
use shuffle::SHUFFLE_COMMAND;
use skip::SKIP_COMMAND;
#[group]
#[commands(
join,
@ -69,18 +64,120 @@ mod skip;
pause,
save_playlist,
playlists,
lyrics,
move_song,
remove_song
lyrics
)]
pub struct Music;
/// Returns the voice manager from the context
pub async fn get_voice_manager(ctx: &Context) -> Arc<Songbird> {
songbird::get(ctx)
struct SongEndNotifier {
channel_id: ChannelId,
http: Arc<Http>,
queue: Arc<Mutex<MusicQueue>>,
handler: Arc<Mutex<Call>>,
}
#[async_trait]
impl VoiceEventHandler for SongEndNotifier {
async fn act(&self, _ctx: &EventContext<'_>) -> Option<Event> {
log::debug!("Song ended in {}. Playing next one", self.channel_id);
while !play_next_in_queue(&self.http, &self.channel_id, &self.queue, &self.handler).await {
tokio::time::sleep(Duration::from_millis(100)).await;
}
None
}
}
struct ChannelDurationNotifier {
channel_id: ChannelId,
guild_id: GuildId,
count: Arc<AtomicUsize>,
queue: Arc<Mutex<MusicQueue>>,
leave_in: Arc<AtomicIsize>,
handler: Arc<Mutex<Call>>,
manager: Arc<Songbird>,
}
#[async_trait]
impl VoiceEventHandler for ChannelDurationNotifier {
async fn act(&self, _ctx: &EventContext<'_>) -> Option<Event> {
let count_before = self.count.fetch_add(1, Ordering::Relaxed);
log::debug!(
"Playing in channel {} for {} minutes",
self.channel_id,
count_before
);
let queue_lock = self.queue.lock().await;
if queue_lock.leave_flag {
log::debug!("Waiting to leave");
if self.leave_in.fetch_sub(1, Ordering::Relaxed) <= 0 {
log::debug!("Leaving voice channel");
{
let mut handler_lock = self.handler.lock().await;
handler_lock.remove_all_global_events();
}
if let Some(current) = queue_lock.current() {
let _ = current.stop();
}
let _ = self.manager.remove(self.guild_id).await;
log::debug!("Left the voice channel");
}
} else {
log::debug!("Resetting leave value");
self.leave_in.store(5, Ordering::Relaxed)
}
None
}
}
/// Joins a voice channel
async fn join_channel(ctx: &Context, channel_id: ChannelId, guild_id: GuildId) -> Arc<Mutex<Call>> {
log::debug!(
"Attempting to join channel {} in guild {}",
channel_id,
guild_id
);
let manager = songbird::get(ctx)
.await
.expect("Songbird Voice client placed in at initialisation.")
.clone()
.clone();
let (handler, _) = manager.join(guild_id, channel_id).await;
let mut data = ctx.data.write().await;
let store = data.get_mut::<Store>().unwrap();
log::debug!("Creating new queue");
let queue = Arc::new(Mutex::new(MusicQueue::new()));
store.music_queues.insert(guild_id, queue.clone());
{
let mut handler_lock = handler.lock().await;
log::debug!("Registering track end handler");
handler_lock.add_global_event(
Event::Track(TrackEvent::End),
SongEndNotifier {
channel_id: channel_id.clone(),
http: ctx.http.clone(),
queue: Arc::clone(&queue),
handler: handler.clone(),
},
);
handler_lock.add_global_event(
Event::Periodic(Duration::from_secs(60), None),
ChannelDurationNotifier {
channel_id,
guild_id,
count: Default::default(),
queue: Arc::clone(&queue),
handler: handler.clone(),
leave_in: Arc::new(AtomicIsize::new(5)),
manager: manager.clone(),
},
);
}
handler
}
/// Returns the voice channel the author is in
@ -89,18 +186,80 @@ fn get_channel_for_author(author_id: &UserId, guild: &Guild) -> BotResult<Channe
.voice_states
.get(author_id)
.and_then(|voice_state| voice_state.channel_id)
.ok_or(BotError::from("You're not in a Voice Channel"))
.ok_or(BotError::from("Not in a voice channel."))
}
/// Returns the voice manager from the context
async fn get_voice_manager(ctx: &Context) -> Arc<Songbird> {
songbird::get(ctx)
.await
.expect("Songbird Voice client placed in at initialisation.")
.clone()
}
/// Returns the music player for a given guild
pub async fn get_music_player_for_guild(
/// Returns a reference to a guilds music queue
pub(crate) async fn get_queue_for_guild(
ctx: &Context,
guild_id: GuildId,
) -> Option<Arc<Mutex<MusicPlayer>>> {
guild_id: &GuildId,
) -> BotResult<Arc<Mutex<MusicQueue>>> {
let data = ctx.data.read().await;
let players = data.get::<MusicPlayers>().unwrap();
let store = data.get::<Store>().unwrap();
let queue = store
.music_queues
.get(guild_id)
.ok_or(BotError::from("No queue for server"))?
.clone();
Ok(queue)
}
/// Plays the next song in the queue
async fn play_next_in_queue(
http: &Arc<Http>,
channel_id: &ChannelId,
queue: &Arc<Mutex<MusicQueue>>,
handler: &Arc<Mutex<Call>>,
) -> bool {
let mut queue_lock = queue.lock().await;
if let Some(mut next) = queue_lock.next() {
let url = match next.url().await {
Some(url) => url,
None => {
let _ = channel_id
.say(&http, format!("'{}' not found", next.title()))
.await;
return false;
}
};
log::debug!("Getting source for song '{}'", url);
let source = match songbird::ytdl(&url).await {
Ok(s) => s,
Err(e) => {
let _ = channel_id
.say(
&http,
format!("Failed to enqueue {}: {:?}", next.title(), e),
)
.await;
return false;
}
};
let mut handler_lock = handler.lock().await;
let track = handler_lock.play_only_source(source);
log::trace!("Track is {:?}", track);
players.get(&guild_id.0).cloned()
if let Some(np) = &mut queue_lock.now_playing_msg {
let _ = np.refresh(track.metadata()).await;
}
queue_lock.set_current(track);
} else {
if let Some(np) = mem::take(&mut queue_lock.now_playing_msg) {
let _ = np.inner().delete().await;
}
queue_lock.clear_current();
}
true
}
/// Returns the list of songs for a given url
@ -120,23 +279,23 @@ async fn get_songs_for_query(ctx: &Context, msg: &Message, query: &str) -> BotRe
let store = data.get::<Store>().unwrap();
let database = data.get::<DatabaseContainer>().unwrap();
tracing::debug!("Querying play input {}", query);
log::debug!("Querying play input {}", query);
if let Some(captures) = PLAYLIST_NAME_REGEX.captures(&query) {
tracing::debug!("Query is a saved playlist");
log::debug!("Query is a saved playlist");
let pl_name: &str = captures.get(1).unwrap().as_str();
tracing::trace!("Playlist name is {}", pl_name);
log::trace!("Playlist name is {}", pl_name);
let playlist_opt = database
.get_guild_playlist(guild_id.0, pl_name.to_string())
.await?;
tracing::trace!("Playlist is {:?}", playlist_opt);
log::trace!("Playlist is {:?}", playlist_opt);
if let Some(playlist) = playlist_opt {
tracing::debug!("Assigning url for saved playlist to query");
log::debug!("Assigning url for saved playlist to query");
query = playlist.url;
}
}
if YOUTUBE_URL_REGEX.is_match(&query) {
tracing::debug!("Query is youtube video or playlist");
log::debug!("Query is youtube video or playlist");
// try fetching the url as a playlist
songs = youtube_dl::get_videos_for_playlist(&query)
.await?
@ -146,65 +305,37 @@ async fn get_songs_for_query(ctx: &Context, msg: &Message, query: &str) -> BotRe
// if no songs were found fetch the song as a video
if songs.len() == 0 {
tracing::debug!("Query is youtube video");
let mut song: Song = get_video_information(&query).await?.into();
log::debug!("Query is youtube video");
let mut song: Song = youtube_dl::get_video_information(&query).await?.into();
added_one_msg(&ctx, msg, &mut song).await?;
add_youtube_song_to_database(&store, &database, &mut song).await?;
songs.push(song);
} else {
tracing::debug!("Query is playlist with {} songs", songs.len());
log::debug!("Query is playlist with {} songs", songs.len());
added_multiple_msg(&ctx, msg, &mut songs).await?;
}
} else if SPOTIFY_PLAYLIST_REGEX.is_match(&query) {
// search for all songs in the playlist and search for them on youtube
tracing::debug!("Query is spotify playlist");
let tracks = store.spotify_api.get_songs_in_playlist(&query).await?;
let futures: Vec<BoxFuture<Song>> = tracks
.into_iter()
.map(|track| {
async {
get_youtube_song_for_track(&database, track.clone())
.await
.unwrap_or(None)
.unwrap_or(track.into())
}
.boxed()
})
.collect();
songs = futures::future::join_all(futures).await;
log::debug!("Query is spotify playlist");
songs = store.spotify_api.get_songs_in_playlist(&query).await?;
added_multiple_msg(&ctx, msg, &mut songs).await?;
} else if SPOTIFY_ALBUM_REGEX.is_match(&query) {
// fetch all songs in the album and search for them on youtube
tracing::debug!("Query is spotify album");
let tracks = store.spotify_api.get_songs_in_album(&query).await?;
for track in tracks {
songs.push(
get_youtube_song_for_track(&database, track.clone())
.await?
.unwrap_or(track.into()),
)
}
log::debug!("Query is spotify album");
songs = store.spotify_api.get_songs_in_album(&query).await?;
added_multiple_msg(&ctx, msg, &mut songs).await?;
} else if SPOTIFY_SONG_REGEX.is_match(&query) {
// fetch the song name and search it on youtube
tracing::debug!("Query is a spotify song");
let track = store.spotify_api.get_track_for_url(&query).await?;
let mut song = get_youtube_song_for_track(&database, track.clone())
.await?
.unwrap_or(track.into());
log::debug!("Query is a spotify song");
let mut song = store.spotify_api.get_song_name(&query).await?;
added_one_msg(ctx, msg, &mut song).await?;
songs.push(song);
} else {
tracing::debug!("Query is a youtube search");
log::debug!("Query is a youtube search");
let mut song: Song = youtube_dl::search_video_information(query.clone())
.await?
.ok_or(BotError::Msg(format!("Noting found for {}", query)))?
.into();
tracing::trace!("Search result is {:?}", song);
log::trace!("Search result is {:?}", song);
added_one_msg(&ctx, msg, &mut song).await?;
songs.push(song);
@ -241,31 +372,9 @@ async fn added_multiple_msg(ctx: &Context, msg: &Message, songs: &mut Vec<Song>)
Ok(())
}
#[check]
#[name = "DJ"]
pub async fn check_dj(
ctx: &Context,
msg: &Message,
_: &mut Args,
_: &CommandOptions,
) -> Result<(), Reason> {
let guild = msg
.guild(&ctx.cache)
.ok_or(Reason::Log("Not in a guild".to_string()))?;
if is_dj(ctx, guild.id, &msg.author)
.await
.map_err(|e| Reason::Log(format!("{:?}", e)))?
{
Ok(())
} else {
Err(Reason::User("Lacking DJ role".to_string()))
}
}
/// Returns if the given user is a dj in the given guild based on the
/// setting for the name of the dj role
pub async fn is_dj(ctx: &Context, guild: GuildId, user: &User) -> BotResult<bool> {
async fn is_dj(ctx: &Context, guild: GuildId, user: &User) -> BotResult<bool> {
let dj_role = get_setting::<String>(ctx, guild, Setting::MusicDjRole).await?;
if let Some(role_name) = dj_role {
@ -281,27 +390,3 @@ pub async fn is_dj(ctx: &Context, guild: GuildId, user: &User) -> BotResult<bool
Ok(true)
}
}
/// Searches for a matching youtube song for the given track in the local database
async fn get_youtube_song_for_track(database: &Database, track: Track) -> BotResult<Option<Song>> {
tracing::debug!("Trying to find track in database.");
if let Some(id) = track.id {
let entry = database.get_song(&id).await?;
if let Some(song) = entry {
// check if the video is still available
tracing::trace!("Found entry is {:?}", song);
if let Ok(info) = get_video_information(&song.url).await {
return Ok(Some(info.into()));
} else {
tracing::debug!("Video '{}' is not available. Deleting entry", song.url);
database.delete_song(song.id).await?;
return Ok(None);
}
}
Ok(None)
} else {
tracing::debug!("Track has no ID");
Ok(None)
}
}

@ -1,48 +0,0 @@
use crate::commands::common::handle_autodelete;
use crate::commands::music::{get_music_player_for_guild, DJ_CHECK};
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
#[command]
#[description("Moves a song in the queue from one position to a new one")]
#[usage("<old-pos> <new-pos>")]
#[example("102 2")]
#[num_args(2)]
#[bucket("general")]
#[only_in(guilds)]
#[aliases("mvs", "movesong", "move-song")]
#[checks(DJ)]
async fn move_song(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Moving song for guild {}", guild.id);
let pos1 = args.single::<usize>()?;
let pos2 = args.single::<usize>()?;
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
{
let mut player = player.lock().await;
player.queue().move_position(pos1, pos2);
}
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content(format!(
"↕ Moved Song `{}` to new position `{}`",
pos1, pos2
))
})
.await?;
handle_autodelete(ctx, msg).await?;
Ok(())
}

@ -1,51 +1,34 @@
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{CommandError, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use serenity::prelude::*;
use crate::commands::common::handle_autodelete;
use crate::commands::music::{get_music_player_for_guild, DJ_CHECK};
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
use crate::commands::music::{get_queue_for_guild, is_dj};
#[command]
#[only_in(guilds)]
#[description("Pauses playback")]
#[usage("")]
#[bucket("general")]
#[checks(DJ)]
async fn pause(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Pausing playback for guild {}", guild.id);
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
let mut player = player.lock().await;
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Pausing playback for guild {}", guild.id);
if !is_dj(ctx, guild.id, &msg.author).await? {
msg.channel_id.say(ctx, "Requires DJ permissions").await?;
return Ok(());
}
if let Some(_) = player.queue().current() {
player.toggle_paused().await?;
let is_paused = player.is_paused();
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let mut queue_lock = queue.lock().await;
if is_paused {
tracing::debug!("Paused");
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("⏸️ Paused playback")
})
.await?;
player.update_now_playing().await?;
if let Some(_) = queue_lock.current() {
queue_lock.pause();
if queue_lock.paused() {
log::debug!("Paused");
msg.channel_id.say(ctx, "Paused playback").await?;
} else {
tracing::debug!("Resumed");
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content("▶ Resumed playback")
})
.await?;
player.update_now_playing().await?;
log::debug!("Resumed");
msg.channel_id.say(ctx, "Resumed playback").await?;
}
} else {
msg.channel_id.say(ctx, "Nothing to pause").await?;

@ -1,70 +1,64 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::{
get_channel_for_author, get_music_player_for_guild, get_songs_for_query,
get_channel_for_author, get_queue_for_guild, get_songs_for_query, get_voice_manager,
join_channel, play_next_in_queue,
};
use crate::messages::music::now_playing::create_now_playing_msg;
use crate::providers::music::player::MusicPlayer;
use crate::commands::common::handle_autodelete;
use crate::providers::settings::{get_setting, Setting};
use std::sync::Arc;
#[command]
#[only_in(guilds)]
#[description("Plays a song in a voice channel")]
#[usage("(<spotify_ur>|<youtube_url>|<query>|pl:<saved_playlist>)")]
#[usage("(<spotify_url,youtube_url,query>)")]
#[min_args(1)]
#[aliases("p")]
#[bucket("music_api")]
async fn play(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let query = args.message();
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Play request received for guild {}", guild.id);
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Play request received for guild {}", guild.id);
let mut player = get_music_player_for_guild(ctx, guild.id).await;
let manager = get_voice_manager(ctx).await;
let mut handler = manager.get(guild.id);
if player.is_none() {
tracing::debug!("Not in a channel. Joining authors channel...");
if handler.is_none() {
log::debug!("Not in a channel. Joining authors channel...");
msg.guild(&ctx.cache).await.unwrap();
let channel_id = get_channel_for_author(&msg.author.id, &guild)?;
let music_player = MusicPlayer::join(ctx, guild.id, channel_id, msg.channel_id).await?;
player = Some(music_player);
handler = Some(join_channel(ctx, channel_id, guild.id).await);
}
let player = player.unwrap();
let handler_lock = handler.ok_or(CommandError::from("Not in a voice channel"))?;
let songs = get_songs_for_query(&ctx, msg, query).await?;
let (play_first, create_now_playing) = {
tracing::debug!("Adding song to queue");
let mut player_lock = player.lock().await;
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let play_first = {
log::debug!("Adding song to queue");
let mut queue_lock = queue.lock().await;
for song in songs {
player_lock.queue().add(song);
queue_lock.add(song);
}
let autoshuffle = get_setting(ctx, guild.id, Setting::MusicAutoShuffle)
.await?
.unwrap_or(false);
if autoshuffle {
tracing::debug!("Autoshuffeling");
player_lock.queue().shuffle();
log::debug!("Autoshuffeling");
queue_lock.shuffle();
}
(
player_lock.queue().current().is_none(),
player_lock.now_playing_message().is_none(),
)
queue_lock.current().is_none()
};
if play_first {
tracing::debug!("Playing first song in queue");
let mut player_lock = player.lock().await;
player_lock.play_next().await?;
}
if create_now_playing {
let handle = create_now_playing_msg(ctx, Arc::clone(&player), msg.channel_id).await?;
let mut player_lock = player.lock().await;
player_lock.set_now_playing(handle).await;
log::debug!("Playing first song in queue");
while !play_next_in_queue(&ctx.http, &msg.channel_id, &queue, &handler_lock).await {}
}
handle_autodelete(ctx, msg).await?;

@ -1,64 +1,57 @@
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::{
get_channel_for_author, get_music_player_for_guild, get_songs_for_query, DJ_CHECK,
get_channel_for_author, get_queue_for_guild, get_songs_for_query, get_voice_manager, is_dj,
join_channel, play_next_in_queue,
};
use crate::messages::music::now_playing::create_now_playing_msg;
use crate::providers::music::player::MusicPlayer;
use std::sync::Arc;
#[command]
#[only_in(guilds)]
#[description("Puts a song as the next to play in the queue")]
#[usage("(<spotify_ur>|<youtube_url>|<query>|pl:<saved_playlist>)")]
#[usage("<song-url>")]
#[min_args(1)]
#[aliases("pn", "play-next", "playnext")]
#[bucket("music_api")]
#[checks(DJ)]
#[aliases("pn", "play-next")]
async fn play_next(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let query = args.message();
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Playing song as next song for guild {}", guild.id);
let mut player = get_music_player_for_guild(ctx, guild.id).await;
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Playing song as next song for guild {}", guild.id);
if !is_dj(ctx, guild.id, &msg.author).await? {
msg.channel_id.say(ctx, "Requires DJ permissions").await?;
return Ok(());
}
let manager = get_voice_manager(ctx).await;
let mut handler = manager.get(guild.id);
if player.is_none() {
tracing::debug!("Not in a channel. Joining authors channel...");
if handler.is_none() {
log::debug!("Not in a voice channel. Joining authors channel");
msg.guild(&ctx.cache).await.unwrap();
let channel_id = get_channel_for_author(&msg.author.id, &guild)?;
let music_player = MusicPlayer::join(ctx, guild.id, channel_id, msg.channel_id).await?;
player = Some(music_player);
handler = Some(join_channel(ctx, channel_id, guild.id).await);
}
let player = player.unwrap();
let handler = handler.ok_or(CommandError::from("Not in a voice channel"))?;
let mut songs = get_songs_for_query(&ctx, msg, query).await?;
let (play_first, create_now_playing) = {
let mut player_lock = player.lock().await;
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let play_first = {
let mut queue_lock = queue.lock().await;
songs.reverse();
tracing::debug!("Enqueueing songs as next songs in the queue");
log::debug!("Enqueueing songs as next songs in the queue");
for song in songs {
player_lock.queue().add_next(song);
queue_lock.add_next(song);
}
(
player_lock.queue().current().is_none(),
player_lock.now_playing_message().is_none(),
)
queue_lock.current().is_none()
};
if play_first {
let mut player_lock = player.lock().await;
player_lock.play_next().await?;
}
if create_now_playing {
let handle = create_now_playing_msg(ctx, Arc::clone(&player), msg.channel_id).await?;
let mut player_lock = player.lock().await;
player_lock.set_now_playing(handle).await;
while !play_next_in_queue(&ctx.http, &msg.channel_id, &queue, &handler).await {}
}
handle_autodelete(ctx, msg).await?;

@ -1,19 +1,17 @@
use crate::commands::common::handle_autodelete;
use crate::utils::context_data::get_database_from_context;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::utils::context_data::get_database_from_context;
#[command]
#[only_in(guilds)]
#[description("Displays a list of all saved playlists")]
#[usage("")]
#[bucket("general")]
async fn playlists(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Displaying playlists for guild {}", guild.id);
let guild = msg.guild(&ctx.cache).await.unwrap();
log::debug!("Displaying playlists for guild {}", guild.id);
let database = get_database_from_context(ctx).await;
let playlists = database.get_guild_playlists(guild.id.0).await?;

@ -1,59 +1,31 @@
use std::cmp::min;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::framework::standard::CommandResult;
use serenity::model::channel::Message;
use crate::commands::common::handle_autodelete;
use crate::commands::music::get_music_player_for_guild;
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use crate::messages::music::queue::create_queue_menu;
use crate::providers::music::queue::Song;
use crate::commands::music::get_queue_for_guild;
#[command]
#[only_in(guilds)]
#[description("Shows the song queue")]
#[usage("(<query...>)")]
#[usage("")]
#[aliases("q")]
#[bucket("general")]
async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::trace!("Displaying queue for guild {}", guild.id);
let query = args
.iter::<String>()
.map(|s| s.unwrap().to_lowercase())
.collect::<Vec<String>>();
async fn queue(ctx: &Context, msg: &Message) -> CommandResult {
let guild = msg.guild(&ctx.cache).await.unwrap();
log::trace!("Displaying queue for guild {}", guild.id);
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
let mut player = player.lock().await;
let songs: Vec<(usize, Song)> = player
.queue()
let queue = get_queue_for_guild(ctx, &guild.id).await?;
let queue_lock = queue.lock().await;
let songs: Vec<(usize, String)> = queue_lock
.entries()
.into_iter()
.map(|s| s.title().clone())
.enumerate()
.filter(|(i, s)| {
if query.is_empty() {
return true;
}
for kw in &query {
if s.title().to_lowercase().contains(kw)
|| s.author().to_lowercase().contains(kw)
|| &i.to_string() == kw
{
return true;
}
}
false
})
.map(|(i, s)| (i, s.clone()))
.collect();
tracing::trace!("Songs are {:?}", songs);
log::trace!("Songs are {:?}", songs);
if songs.len() == 0 {
msg.channel_id
@ -64,8 +36,26 @@ async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
return Ok(());
}
create_queue_menu(ctx, msg.channel_id, songs).await?;
let mut song_list = Vec::new();
for i in 0..min(10, songs.len() - 1) {
song_list.push(format!("{:0>3} - {}", songs[i].0 + 1, songs[i].1))
}
if songs.len() > 10 {
song_list.push("...".to_string());
let last = songs.last().unwrap();
song_list.push(format!("{:0>3} - {}", last.0 + 1, last.1))
}
log::trace!("Song list is {:?}", song_list);
msg.channel_id
.send_message(ctx, |m| {
m.embed(|e| {
e.title("Queue")
.description(format!("```\n{}\n```", song_list.join("\n")))
})
})
.await?;
handle_autodelete(ctx, msg).await?;
Ok(())

@ -1,45 +0,0 @@
use crate::commands::common::handle_autodelete;
use crate::commands::music::{get_music_player_for_guild, DJ_CHECK};
use crate::messages::music::no_voicechannel::create_no_voicechannel_message;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandError, CommandResult};
use serenity::model::channel::Message;
use serenity_additions::core::SHORT_TIMEOUT;
use serenity_additions::ephemeral_message::EphemeralMessage;
#[command]
#[description("Removes a song from the queue")]
#[usage("<pos>")]
#[example("102")]
#[num_args(1)]
#[bucket("general")]
#[only_in(guilds)]
#[aliases("rms", "removesong", "remove-song")]
#[checks(DJ)]
async fn remove_song(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
tracing::debug!("Moving song for guild {}", guild.id);
let pos = args.single::<usize>()?;
let player = if let Some(player) = get_music_player_for_guild(ctx, guild.id).await {
player
} else {
return create_no_voicechannel_message(&ctx.http, msg.channel_id)
.await
.map_err(CommandError::from);
};
{
let mut player = player.lock().await;
player.queue().remove(pos);
}
EphemeralMessage::create(&ctx.http, msg.channel_id, SHORT_TIMEOUT, |m| {
m.content(format!("🗑️ Removed Song at `{}`", pos))
})
.await?;
handle_autodelete(ctx, msg).await?;
Ok(())
}

@ -1,26 +1,27 @@
use crate::commands::music::is_dj;
use crate::utils::context_data::get_database_from_context;
use serenity::client::Context;
use serenity::framework::standard::macros::command;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use crate::commands::music::DJ_CHECK;
use crate::utils::context_data::get_database_from_context;
#[command]
#[only_in(guilds)]
#[description("Adds a playlist to the guilds saved playlists")]
#[usage("<name> (<url>|<query>")]
#[usage("<name> <url/query>")]
#[example("anime https://www.youtube.com/playlist?list=PLqaM77H_o5hykROCe3uluvZEaPo6bZj-C")]
#[min_args(2)]
#[aliases("add-playlist", "save-playlist", "saveplaylist", "savepl")]
#[bucket("general")]
#[checks(DJ)]
#[aliases("add-playlist", "save-playlist")]
async fn save_playlist(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
if !is_dj(ctx, guild.id, &msg.author).await? {
msg.channel_id.say(ctx, "Requires DJ permissions").await?;
return Ok(());
}
let name: String = args.single().unwrap();
let url: &str = args.remains().unwrap();
tracing::debug!(
log::debug!(
"Adding playlist '{}' with url '{}' to guild {}",
name,
url,

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save