Compare commits

...

33 Commits

Author SHA1 Message Date
jude
14913deb3a Move activity set out of Ready event 2024-07-15 16:48:40 +01:00
jude
fc3b3e08f1 Fix axum 2023-12-20 17:18:25 +00:00
jude
444c5dce33 Bump SQLx version and Axum 2023-12-20 17:12:57 +00:00
jude
6bd815fd38 Bump version 2023-12-20 17:00:09 +00:00
jude
5364e41560 Remove file-based audio streaming 2023-12-20 16:51:44 +00:00
jude
e632f55b4e Update to serenity 12 2023-12-19 19:37:41 +00:00
jude
cd5651c7f6 Add more counters 2023-10-22 12:40:17 +01:00
jude
4c17286614 Use /metrics route 2023-10-22 10:39:12 +01:00
jude
48b50f783d Add more counters 2023-10-22 09:55:36 +01:00
jude
605bc37db6 Add response to /random 2023-10-22 00:07:04 +01:00
jude
bec92177cb Remove top.gg 2023-10-21 23:52:08 +01:00
jude
cee578eaf1 Serve metrics via :31755 2023-10-21 23:45:42 +01:00
jude
6615e05196 Adding metric support 2023-10-21 21:33:50 +01:00
jude
6cfdc10a6a Add command to play at random from server 2023-10-21 19:09:01 +01:00
jude
d3e00247bd Reserve sound names beginning with '@'
Might want to use this in the future. Remove the /random command as it's dumb.
2023-10-21 17:50:48 +01:00
jude
6d324e10cb Remove broken is_text_based check 2023-10-21 17:06:41 +01:00
jude
8390bf0ec6 Fix migration. bump ver 2023-08-22 18:55:56 +01:00
jude
e6f5db1842 Fix autocompletes 2023-08-22 18:10:54 +01:00
jude
fca080253f Rename a variable.
Remove .idea
2023-08-22 17:44:00 +01:00
6482af923b Prefer selecting favorite sound over other sounds 2023-08-19 10:32:49 +01:00
e875038851 Favorite/unfavorite sounds 2023-08-16 16:47:33 +01:00
jude
92d8d077df bump version 2023-07-09 15:04:57 +01:00
jude
b861f6f093 Update dependencies 2023-07-09 13:24:39 +01:00
jude
66f45f11f2 Merge remote-tracking branch 'origin/rewrite' into rewrite 2023-07-09 13:19:26 +01:00
jude
e30a08e019 Add loop mode to soundboard 2023-07-09 13:19:18 +01:00
jude
80f45a1f5c Add script to dump sounds to files. 2023-06-23 15:27:38 +01:00
jude
1a1b1b8144 Fix typo 2023-05-07 20:53:16 +01:00
34d5fddf6c Add containerised build instructions 2023-04-09 17:35:40 +01:00
ac41dbce0c update readme 2023-04-08 22:10:09 +01:00
208d169c76 Update songbird 2023-04-08 21:37:33 +01:00
jude
9cfcb0d09c Bump ver 2023-03-29 18:18:27 +01:00
jude
cc55b3e1d1 Add user when installing 2023-03-29 18:16:04 +01:00
jude
a9a08e656f Copy config files around to prevent overwriting 2023-03-29 17:51:36 +01:00
35 changed files with 3465 additions and 1664 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target /target
.env .env
.idea

2
.idea/.gitignore vendored
View File

@ -1,2 +0,0 @@
# Default ignored files
/workspace.xml

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="CL-223.8836.42">
<data-source name="MySQL for 5.1 - soundfx@localhost" uuid="1067c1d0-1386-4a39-b3f5-6d48d6f279eb">
<database-info product="" version="" jdbc-version="" driver-name="" driver-version="" dbms="MYSQL" exact-version="0" />
<secret-storage>master_key</secret-storage>
<user-name>jude</user-name>
<schema-mapping />
</data-source>
</component>
</project>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="MySQL for 5.1 - soundfx@localhost" uuid="1067c1d0-1386-4a39-b3f5-6d48d6f279eb">
<driver-ref>mysql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.mysql.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://localhost:3306/soundfx</jdbc-url>
</data-source>
</component>
</project>

View File

@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="jude">
<words>
<w>reqwest</w>
</words>
</dictionary>
</component>

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="RsBorrowChecker" enabled="false" level="ERROR" enabled_by_default="false" />
</profile>
</component>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/soundfx-rs.iml" filepath="$PROJECT_DIR$/.idea/soundfx-rs.iml" />
</modules>
</component>
</project>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="CPP_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/benches" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

3374
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,35 +2,45 @@
name = "soundfx-rs" name = "soundfx-rs"
description = "Discord bot for custom sound effects and soundboards" description = "Discord bot for custom sound effects and soundboards"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "1.5.7" version = "1.5.18"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
songbird = { version = "0.3", features = ["builtin-queue"] } songbird = { version = "0.4", features = ["builtin-queue"] }
poise = "0.3" poise = "0.6.1-rc1"
sqlx = { version = "0.5", default-features = false, features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "migrate"] } sqlx = { version = "0.7.3", default-features = false, features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "migrate"] }
tokio = { version = "1", features = ["fs", "process", "io-util"] } tokio = { version = "1", features = ["fs", "process", "io-util", "rt-multi-thread"] }
lazy_static = "1.4" lazy_static = "1.4"
reqwest = "0.11" reqwest = "0.12"
env_logger = "0.10" env_logger = "0.11"
regex = "1.4" regex = "1.10"
log = "0.4" log = "0.4"
serde_json = "1.0" serde_json = "1.0"
dashmap = "5.3" dashmap = "6.0"
serde = "1.0" serde = "1.0"
dotenv = "0.15.0" dotenv = "0.15.0"
prometheus = { version = "0.13.3", optional = true }
axum = { version = "0.7.2", optional = true }
[patch."https://github.com/serenity-rs/serenity"] [dependencies.symphonia]
serenity = { version = "0.11.5" } version = "0.5"
features = ["ogg"]
[features]
metrics = ["dep:prometheus", "dep:axum"]
[package.metadata.deb] [package.metadata.deb]
features = ["metrics"]
depends = "$auto, ffmpeg" depends = "$auto, ffmpeg"
suggests = "mysql-server-8.0" suggests = "mysql-server-8.0"
maintainer-scripts = "debian" maintainer-scripts = "debian"
assets = [ assets = [
["target/release/soundfx-rs", "usr/bin/soundfx-rs", "755"], ["target/release/soundfx-rs", "usr/bin/soundfx-rs", "755"],
["conf/default.env", "etc/soundfx-rs/default.env", "600"] ["conf/default.env", "etc/soundfx-rs/config.env", "600"]
]
conf-files = [
"/etc/soundfx-rs/config.env",
] ]
[package.metadata.deb.systemd-units] [package.metadata.deb.systemd-units]

9
Containerfile Normal file
View File

@ -0,0 +1,9 @@
FROM ubuntu:20.04
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake ffmpeg libopus-dev pkg-config libssl-dev curl mysql-client-8.0
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal
RUN cargo install cargo-deb

View File

@ -23,11 +23,26 @@ Options:
## Building from source ## Building from source
1. Install build dependencies: `sudo apt install gcc gcc-multilib cmake ffmpeg libopus-dev` When running from source, the config options above can be configured simply as environment variables.
Two options for building are offered. The first is easier.
### Build for local platform
1. Install build dependencies: `sudo apt install gcc gcc-multilib cmake ffmpeg libopus-dev pkg-config libssl-dev`
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `soundfx` 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `soundfx`
3. Install Cargo and Rust from https://rustup.rs 3. Install Cargo and Rust from https://rustup.rs
4. Install SQLx CLI: `cargo install sqlx-cli` 4. Install SQLx CLI: `cargo install sqlx-cli`
5. From the source code directory, execute `sqlx migrate run` 5. From the source code directory, execute `sqlx migrate run`
6. Build with cargo: `cargo build --release` 6. Build with cargo: `cargo build --release`
When running from source, the config options above can be configured simply as environment variables. ### Build for other platform
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `soundfx`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t soundfx .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/soundfx" soundfx cargo deb`

9
debian/postinst vendored Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -e
id -u soundfx &>/dev/null || useradd -r -M soundfx
chown soundfx /etc/soundfx-rs/config.env
#DEBHELPER#

7
debian/postrm vendored Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
id -u soundfx &>/dev/null || userdel soundfx
#DEBHELPER#

View File

@ -0,0 +1,6 @@
CREATE TABLE favorite_sounds (
user_id BIGINT UNSIGNED NOT NULL,
sound_id INT UNSIGNED NOT NULL,
FOREIGN KEY (sound_id) REFERENCES `sounds`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (user_id, sound_id)
);

8
scripts/dump-query.sh Executable file
View File

@ -0,0 +1,8 @@
mysql -D soundfx -N -e "SELECT name, hex(src) FROM sounds" > out
split --additional-suffix=.row -l 1 out
for filename in *.row; do
name=`grep -oP '^(.+)(?=\t)' $filename`
col=`awk -F '\t' '{print $2}' "$filename"`
echo $col > "$filename.hex"
xxd -r -p "$filename.hex" "$name.opus"
done

94
src/cmds/favorite.rs Normal file
View File

@ -0,0 +1,94 @@
use log::warn;
use crate::{cmds::autocomplete_favorite, models::sound::SoundCtx, Context, Error};
#[poise::command(slash_command, rename = "favorites", guild_only = true)]
pub async fn favorites(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Add a sound as a favorite
#[poise::command(
slash_command,
rename = "add",
category = "Favorites",
guild_only = true
)]
pub async fn add_favorite(
ctx: Context<'_>,
#[description = "Name or ID of sound to favorite"] name: String,
) -> Result<(), Error> {
let sounds = ctx
.data()
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
.await;
match sounds {
Ok(sounds) => {
let sound = &sounds[0];
sound
.add_favorite(ctx.author().id, &ctx.data().database)
.await?;
ctx.say(format!(
"Sound {} (ID {}) added to favorites.",
sound.name, sound.id
))
.await?;
Ok(())
}
Err(e) => {
warn!("Couldn't fetch sounds: {:?}", e);
ctx.say("Failed to find sound.").await?;
Ok(())
}
}
}
/// Remove a sound from your favorites
#[poise::command(
slash_command,
rename = "remove",
category = "Favorites",
guild_only = true
)]
pub async fn remove_favorite(
ctx: Context<'_>,
#[description = "Name or ID of sound to favorite"]
#[autocomplete = "autocomplete_favorite"]
name: String,
) -> Result<(), Error> {
let sounds = ctx
.data()
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
.await;
match sounds {
Ok(sounds) => {
let sound = &sounds[0];
sound
.remove_favorite(ctx.author().id, &ctx.data().database)
.await?;
ctx.say(format!(
"Sound {} (ID {}) removed from favorites.",
sound.name, sound.id
))
.await?;
Ok(())
}
Err(e) => {
warn!("Couldn't fetch sounds: {:?}", e);
ctx.say("Failed to find sound.").await?;
Ok(())
}
}
}

View File

@ -1,19 +1,23 @@
use poise::{
serenity_prelude::{CreateEmbed, CreateEmbedFooter},
CreateReply,
};
use crate::{consts::THEME_COLOR, Context, Error}; use crate::{consts::THEME_COLOR, Context, Error};
/// View bot commands /// View bot commands
#[poise::command(slash_command)] #[poise::command(slash_command)]
pub async fn help(ctx: Context<'_>) -> Result<(), Error> { pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(|m| { ctx.send(
m.ephemeral(true).embed(|e| { CreateReply::default().ephemeral(true).embed(
e.title("Help") CreateEmbed::new()
.title("Help")
.color(THEME_COLOR) .color(THEME_COLOR)
.footer(|f| { .footer(CreateEmbedFooter::new(concat!(
f.text(concat!(
env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME"),
" ver ", " ver ",
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
)) )))
})
.description( .description(
"__Info Commands__ "__Info Commands__
`/help` `/info` `/help` `/info`
@ -33,6 +37,9 @@ __Library Commands__
`/public` - Set a sound as public/private `/public` - Set a sound as public/private
`/list server` - List sounds on this server `/list server` - List sounds on this server
`/list user` - List your sounds `/list user` - List your sounds
`/favorites add` - Add a favorite
`/favorites remove` - Remove a favorite
`/list favorites` - List favorites
__Search Commands__ __Search Commands__
`/search` - Search for public sounds by name `/search` - Search for public sounds by name
@ -46,9 +53,9 @@ __Setting Commands__
__Advanced Commands__ __Advanced Commands__
`/soundboard` - Create a soundboard", `/soundboard` - Create a soundboard",
),
),
) )
})
})
.await?; .await?;
Ok(()) Ok(())
@ -57,14 +64,18 @@ __Advanced Commands__
/// Get additional information about the bot /// Get additional information about the bot
#[poise::command(slash_command)] #[poise::command(slash_command)]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> { pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let current_user = ctx.discord().cache.current_user(); let current_user = ctx.serenity_context().cache.current_user().id.get();
ctx.send(|m| m.ephemeral(true) ctx.send(
.embed(|e| e CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.title("Info") .title("Info")
.color(THEME_COLOR) .color(THEME_COLOR)
.footer(|f| f .footer(CreateEmbedFooter::new(concat!(
.text(concat!(env!("CARGO_PKG_NAME"), " ver ", env!("CARGO_PKG_VERSION")))) env!("CARGO_PKG_NAME"),
" ver ",
env!("CARGO_PKG_VERSION")
)))
.description(format!("Invite me: https://discord.com/api/oauth2/authorize?client_id={}&permissions=3165184&scope=applications.commands%20bot .description(format!("Invite me: https://discord.com/api/oauth2/authorize?client_id={}&permissions=3165184&scope=applications.commands%20bot
**Welcome to SoundFX!** **Welcome to SoundFX!**
@ -73,7 +84,9 @@ Find me on https://discord.jellywx.com/ and on https://github.com/JellyWX :)
**An online dashboard is available!** Visit https://soundfx.jellywx.com/dashboard **An online dashboard is available!** Visit https://soundfx.jellywx.com/dashboard
There is a maximum sound limit per user. This can be removed by subscribing at **https://patreon.com/jellywx**", There is a maximum sound limit per user. This can be removed by subscribing at **https://patreon.com/jellywx**",
current_user.id.as_u64())))).await?; current_user)))
)
.await?;
Ok(()) Ok(())
} }

View File

@ -1,6 +1,10 @@
use poise::serenity_prelude::{Attachment, GuildId, RoleId}; use poise::{
use tokio::fs::File; serenity_prelude::{Attachment, CreateAttachment, GuildId, RoleId},
CreateReply,
};
#[cfg(feature = "metrics")]
use crate::metrics::{DELETE_COUNTER, UPLOAD_COUNTER};
use crate::{ use crate::{
cmds::autocomplete_sound, cmds::autocomplete_sound,
consts::{MAX_SOUNDS, PATREON_GUILD, PATREON_ROLE}, consts::{MAX_SOUNDS, PATREON_GUILD, PATREON_ROLE},
@ -21,6 +25,9 @@ pub async fn upload_new_sound(
#[description = "Name to upload sound to"] name: String, #[description = "Name to upload sound to"] name: String,
#[description = "Sound file (max. 2MB)"] file: Attachment, #[description = "Sound file (max. 2MB)"] file: Attachment,
) -> Result<(), Error> { ) -> Result<(), Error> {
#[cfg(feature = "metrics")]
UPLOAD_COUNTER.inc();
ctx.defer().await?; ctx.defer().await?;
fn is_numeric(s: &String) -> bool { fn is_numeric(s: &String) -> bool {
@ -35,7 +42,13 @@ pub async fn upload_new_sound(
} }
if !name.is_empty() && name.len() <= 20 { if !name.is_empty() && name.len() <= 20 {
if !is_numeric(&name) { if name.starts_with("@") {
ctx.say("Sound names cannot start with an @ symbol. Please choose another name")
.await?;
} else if is_numeric(&name) {
ctx.say("Please ensure the sound name contains a non-numerical character")
.await?;
} else {
// need to check the name is not currently in use by the user // need to check the name is not currently in use by the user
let count_name = let count_name =
Sound::count_named_user_sounds(ctx.author().id, &name, &ctx.data().database) Sound::count_named_user_sounds(ctx.author().id, &name, &ctx.data().database)
@ -50,14 +63,14 @@ pub async fn upload_new_sound(
let count = Sound::count_user_sounds(ctx.author().id, &ctx.data().database).await?; let count = Sound::count_user_sounds(ctx.author().id, &ctx.data().database).await?;
let mut permit_upload = true; let mut permit_upload = true;
// need to check if user is patreon or nah // need to check if user is Patreon or not
if count >= *MAX_SOUNDS { if count >= *MAX_SOUNDS {
let patreon_guild_member = GuildId(*PATREON_GUILD) let patreon_guild_member = GuildId::from(*PATREON_GUILD)
.member(ctx.discord(), ctx.author().id) .member(ctx, ctx.author().id)
.await; .await;
if let Ok(member) = patreon_guild_member { if let Ok(member) = patreon_guild_member {
permit_upload = member.roles.contains(&RoleId(*PATREON_ROLE)); permit_upload = member.roles.contains(&RoleId::from(*PATREON_ROLE));
} else { } else {
permit_upload = false; permit_upload = false;
} }
@ -89,9 +102,6 @@ pub async fn upload_new_sound(
)).await?; )).await?;
} }
} }
} else {
ctx.say("Please ensure the sound name contains a non-numerical character")
.await?;
} }
} else { } else {
ctx.say("Usage: `/upload <name>`. Please ensure the name provided is less than 20 characters in length").await?; ctx.say("Usage: `/upload <name>`. Please ensure the name provided is less than 20 characters in length").await?;
@ -108,10 +118,13 @@ pub async fn delete_sound(
#[autocomplete = "autocomplete_sound"] #[autocomplete = "autocomplete_sound"]
name: String, name: String,
) -> Result<(), Error> { ) -> Result<(), Error> {
#[cfg(feature = "metrics")]
DELETE_COUNTER.inc();
let pool = ctx.data().database.clone(); let pool = ctx.data().database.clone();
let uid = ctx.author().id.0; let uid = ctx.author().id.get();
let gid = ctx.guild_id().unwrap().0; let gid = ctx.guild_id().unwrap().get();
let sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?; let sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?;
let sound_result = sound_vec.first(); let sound_result = sound_vec.first();
@ -123,8 +136,8 @@ pub async fn delete_sound(
.await?; .await?;
} else { } else {
let has_perms = { let has_perms = {
if let Ok(member) = ctx.guild_id().unwrap().member(&ctx.discord(), uid).await { if let Ok(member) = ctx.guild_id().unwrap().member(&ctx, uid).await {
if let Ok(perms) = member.permissions(&ctx.discord()) { if let Ok(perms) = member.permissions(&ctx) {
perms.manage_guild() perms.manage_guild()
} else { } else {
false false
@ -163,8 +176,8 @@ pub async fn change_public(
) -> Result<(), Error> { ) -> Result<(), Error> {
let pool = ctx.data().database.clone(); let pool = ctx.data().database.clone();
let uid = ctx.author().id.0; let uid = ctx.author().id.get();
let gid = ctx.guild_id().unwrap().0; let gid = ctx.guild_id().unwrap().get();
let mut sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?; let mut sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?;
let sound_result = sound_vec.first_mut(); let sound_result = sound_vec.first_mut();
@ -213,12 +226,12 @@ pub async fn download_file(
match sound.first() { match sound.first() {
Some(sound) => { Some(sound) => {
let source = sound.store_sound_source(&ctx.data().database).await?;
let file = File::open(&source).await?;
let name = format!("{}-{}.opus", sound.id, sound.name); let name = format!("{}-{}.opus", sound.id, sound.name);
ctx.send(|m| m.attachment((&file, name.as_str()).into())) ctx.send(CreateReply::default().attachment(CreateAttachment::bytes(
sound.src(&ctx.data().database).await,
name.as_str(),
)))
.await?; .await?;
} }

View File

@ -1,5 +1,8 @@
use poise::serenity_prelude::AutocompleteChoice;
use crate::{models::sound::SoundCtx, Context}; use crate::{models::sound::SoundCtx, Context};
pub mod favorite;
pub mod info; pub mod info;
pub mod manage; pub mod manage;
pub mod play; pub mod play;
@ -7,18 +10,22 @@ pub mod search;
pub mod settings; pub mod settings;
pub mod stop; pub mod stop;
pub async fn autocomplete_sound( pub async fn autocomplete_sound(ctx: Context<'_>, partial: &str) -> Vec<AutocompleteChoice> {
ctx: Context<'_>,
partial: &str,
) -> Vec<poise::AutocompleteChoice<String>> {
ctx.data() ctx.data()
.autocomplete_user_sounds(&partial, ctx.author().id, ctx.guild_id().unwrap()) .autocomplete_user_sounds(&partial, ctx.author().id, ctx.guild_id().unwrap())
.await .await
.unwrap_or(vec![]) .unwrap_or(vec![])
.iter() .iter()
.map(|s| poise::AutocompleteChoice { .map(|s| AutocompleteChoice::new(s.name.clone(), s.id.to_string()))
name: s.name.clone(), .collect()
value: s.id.to_string(), }
})
pub async fn autocomplete_favorite(ctx: Context<'_>, partial: &str) -> Vec<AutocompleteChoice> {
ctx.data()
.autocomplete_favorite_sounds(&partial, ctx.author().id)
.await
.unwrap_or(vec![])
.iter()
.map(|s| AutocompleteChoice::new(s.name.clone(), s.id.to_string()))
.collect() .collect()
} }

View File

@ -1,11 +1,18 @@
use poise::serenity_prelude::{ use std::time::{SystemTime, UNIX_EPOCH};
builder::CreateActionRow, model::application::component::ButtonStyle, GuildChannel,
use poise::{
serenity_prelude::{
builder::CreateActionRow, ButtonStyle, CreateButton, GuildChannel, ReactionType,
},
CreateReply,
}; };
#[cfg(feature = "metrics")]
use crate::metrics::PLAY_COUNTER;
use crate::{ use crate::{
cmds::autocomplete_sound, cmds::autocomplete_sound,
models::{guild_data::CtxGuildData, sound::SoundCtx}, models::{guild_data::CtxGuildData, sound::SoundCtx},
utils::{join_channel, play_from_query, queue_audio}, utils::{join_channel, play_audio, play_from_query, queue_audio},
Context, Error, Context, Error,
}; };
@ -20,19 +27,18 @@ pub async fn play(
#[channel_types("Voice")] #[channel_types("Voice")]
channel: Option<GuildChannel>, channel: Option<GuildChannel>,
) -> Result<(), Error> { ) -> Result<(), Error> {
#[cfg(feature = "metrics")]
PLAY_COUNTER.inc();
ctx.defer().await?; ctx.defer().await?;
let guild = ctx.guild().unwrap(); let guild = ctx.guild().map(|g| g.clone()).unwrap();
if channel.as_ref().map_or(false, |c| c.is_text_based()) {
ctx.say("The channel specified is not a voice channel.")
.await?;
} else {
ctx.say( ctx.say(
play_from_query( play_from_query(
&ctx.discord(), &ctx.serenity_context(),
&ctx.data(), &ctx.data(),
guild, &guild,
ctx.author().id, ctx.author().id,
channel.map(|c| c.id), channel.map(|c| c.id),
&name, &name,
@ -41,6 +47,83 @@ pub async fn play(
.await, .await,
) )
.await?; .await?;
Ok(())
}
/// Play a random sound from this server
#[poise::command(
slash_command,
rename = "random",
default_member_permissions = "SPEAK",
guild_only = true
)]
pub async fn play_random(
ctx: Context<'_>,
#[description = "Channel to play in (default: your current voice channel)"]
#[channel_types("Voice")]
channel: Option<GuildChannel>,
) -> Result<(), Error> {
ctx.defer().await?;
let (channel_to_join, guild_id) = {
let guild = ctx.guild().unwrap();
(
channel.map(|c| c.id).or_else(|| {
guild
.voice_states
.get(&ctx.author().id)
.and_then(|voice_state| voice_state.channel_id)
}),
guild.id,
)
};
match channel_to_join {
Some(channel) => {
let call = join_channel(ctx.serenity_context(), guild_id, channel).await?;
let sounds = ctx.data().guild_sounds(guild_id, None).await?;
if sounds.len() == 0 {
ctx.say("No sounds in this server!").await?;
return Ok(());
}
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
println!("{}", ts.subsec_micros());
// This is far cheaper and easier than using an RNG. No reason to use a full RNG here
// anyway.
match sounds.get(ts.subsec_micros() as usize % sounds.len()) {
Some(sound) => {
let guild_data = ctx.data().guild_data(guild_id).await.unwrap();
let mut lock = call.lock().await;
play_audio(
sound,
guild_data.read().await.volume,
&mut lock,
&ctx.data().database,
false,
)
.await
.unwrap();
ctx.say(format!("Playing {} (ID {})", sound.name, sound.id))
.await?;
}
None => {
ctx.say("No sounds in this server!").await?;
}
}
}
None => {
ctx.say("You are not in a voice chat!").await?;
}
} }
Ok(()) Ok(())
@ -133,24 +216,23 @@ pub async fn queue_play(
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
let (channel_to_join, guild_id) = {
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
let channel_to_join = guild (
guild
.voice_states .voice_states
.get(&ctx.author().id) .get(&ctx.author().id)
.and_then(|voice_state| voice_state.channel_id); .and_then(|voice_state| voice_state.channel_id),
guild.id,
)
};
match channel_to_join { match channel_to_join {
Some(user_channel) => { Some(user_channel) => {
let (call_handler, _) = join_channel(ctx.discord(), guild.clone(), user_channel).await; let call = join_channel(ctx.serenity_context(), guild_id, user_channel).await?;
let guild_data = ctx let guild_data = ctx.data().guild_data(guild_id).await.unwrap();
.data()
.guild_data(ctx.guild_id().unwrap())
.await
.unwrap();
let mut lock = call_handler.lock().await;
let query_terms = [ let query_terms = [
Some(sound_1), Some(sound_1),
@ -193,6 +275,9 @@ pub async fn queue_play(
} }
} }
{
let mut lock = call.lock().await;
queue_audio( queue_audio(
&sounds, &sounds,
guild_data.read().await.volume, guild_data.read().await.volume,
@ -201,6 +286,7 @@ pub async fn queue_play(
) )
.await .await
.unwrap(); .unwrap();
}
ctx.say(format!("Queued {} sounds!", sounds.len())).await?; ctx.say(format!("Queued {} sounds!", sounds.len())).await?;
} }
@ -227,13 +313,13 @@ pub async fn loop_play(
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
let guild = ctx.guild().unwrap(); let guild = ctx.guild().map(|g| g.clone()).unwrap();
ctx.say( ctx.say(
play_from_query( play_from_query(
&ctx.discord(), &ctx.serenity_context(),
&ctx.data(), &ctx.data(),
guild, &guild,
ctx.author().id, ctx.author().id,
None, None,
&name, &name,
@ -316,21 +402,6 @@ pub async fn soundboard(
#[description = "Name or ID of sound for button 20"] #[description = "Name or ID of sound for button 20"]
#[autocomplete = "autocomplete_sound"] #[autocomplete = "autocomplete_sound"]
sound_20: Option<String>, sound_20: Option<String>,
#[description = "Name or ID of sound for button 21"]
#[autocomplete = "autocomplete_sound"]
sound_21: Option<String>,
#[description = "Name or ID of sound for button 22"]
#[autocomplete = "autocomplete_sound"]
sound_22: Option<String>,
#[description = "Name or ID of sound for button 23"]
#[autocomplete = "autocomplete_sound"]
sound_23: Option<String>,
#[description = "Name or ID of sound for button 24"]
#[autocomplete = "autocomplete_sound"]
sound_24: Option<String>,
#[description = "Name or ID of sound for button 25"]
#[autocomplete = "autocomplete_sound"]
sound_25: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
@ -355,11 +426,6 @@ pub async fn soundboard(
sound_18, sound_18,
sound_19, sound_19,
sound_20, sound_20,
sound_21,
sound_22,
sound_23,
sound_24,
sound_25,
]; ];
let mut sounds = vec![]; let mut sounds = vec![];
@ -377,24 +443,49 @@ pub async fn soundboard(
} }
} }
ctx.send(|m| { let components = {
m.content("**Play a sound:**").components(|c| { let mut c = vec![];
for row in sounds.as_slice().chunks(5) { for row in sounds.as_slice().chunks(5) {
let mut action_row: CreateActionRow = Default::default(); let mut action_row = vec![];
for sound in row { for sound in row {
action_row.create_button(|b| { action_row.push(
b.style(ButtonStyle::Primary) CreateButton::new(sound.id.to_string())
.label(&sound.name) .style(ButtonStyle::Primary)
.custom_id(sound.id) .label(&sound.name),
}); );
} }
c.add_action_row(action_row); c.push(CreateActionRow::Buttons(action_row));
} }
c.push(CreateActionRow::Buttons(vec![
CreateButton::new("#stop")
.label("Stop")
.emoji(ReactionType::Unicode("".to_string()))
.style(ButtonStyle::Danger),
CreateButton::new("#mode")
.label("Mode:")
.style(ButtonStyle::Secondary)
.disabled(true),
CreateButton::new("#instant")
.label("Instant")
.emoji(ReactionType::Unicode("".to_string()))
.style(ButtonStyle::Secondary)
.disabled(true),
CreateButton::new("#loop")
.label("Loop")
.emoji(ReactionType::Unicode("🔁".to_string()))
.style(ButtonStyle::Secondary),
]));
c c
}) };
})
ctx.send(
CreateReply::default()
.content("**Play a sound:**")
.components(components),
)
.await?; .await?;
Ok(()) Ok(())

View File

@ -1,10 +1,8 @@
use poise::{ use poise::{
serenity_prelude, serenity_prelude,
serenity_prelude::{ serenity_prelude::{
application::component::ButtonStyle, constants::MESSAGE_CODE_LIMIT, ButtonStyle, ComponentInteraction, CreateActionRow,
constants::MESSAGE_CODE_LIMIT, CreateButton, CreateEmbed, EditInteractionResponse, GuildId, UserId,
interaction::{message_component::MessageComponentInteraction, InteractionResponseType},
CreateActionRow, CreateEmbed, GuildId, UserId,
}, },
CreateReply, CreateReply,
}; };
@ -16,8 +14,8 @@ use crate::{
Context, Data, Error, Context, Data, Error,
}; };
fn format_search_results<'a>(search_results: Vec<Sound>) -> CreateReply<'a> { fn format_search_results(search_results: Vec<Sound>) -> CreateReply {
let mut builder = CreateReply::default(); let builder = CreateReply::default();
let mut current_character_count = 0; let mut current_character_count = 0;
let title = "Public sounds matching filter:"; let title = "Public sounds matching filter:";
@ -32,9 +30,7 @@ fn format_search_results<'a>(search_results: Vec<Sound>) -> CreateReply<'a> {
current_character_count <= MESSAGE_CODE_LIMIT - title.len() current_character_count <= MESSAGE_CODE_LIMIT - title.len()
}); });
builder.embed(|e| e.title(title).fields(field_iter)); builder.embed(CreateEmbed::default().title(title).fields(field_iter))
builder
} }
/// Show uploaded sounds /// Show uploaded sounds
@ -47,12 +43,14 @@ pub async fn list_sounds(_ctx: Context<'_>) -> Result<(), Error> {
enum ListContext { enum ListContext {
User = 0, User = 0,
Guild = 1, Guild = 1,
Favorite = 2,
} }
impl ListContext { impl ListContext {
pub fn title(&self) -> &'static str { pub fn title(&self) -> &'static str {
match self { match self {
ListContext::User => "Your sounds", ListContext::User => "Your sounds",
ListContext::Favorite => "Your favorite sounds",
ListContext::Guild => "Server sounds", ListContext::Guild => "Server sounds",
} }
} }
@ -86,6 +84,20 @@ pub async fn list_user_sounds(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Show sounds you have favorited
#[poise::command(slash_command, rename = "favorite", guild_only = true)]
pub async fn list_favorite_sounds(ctx: Context<'_>) -> Result<(), Error> {
let pager = SoundPager {
nonce: 0,
page: 0,
context: ListContext::Favorite,
};
pager.reply(ctx).await?;
Ok(())
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct SoundPager { pub struct SoundPager {
nonce: u64, nonce: u64,
@ -102,15 +114,14 @@ impl SoundPager {
) -> Result<Vec<Sound>, sqlx::Error> { ) -> Result<Vec<Sound>, sqlx::Error> {
match self.context { match self.context {
ListContext::User => data.user_sounds(user_id, Some(self.page)).await, ListContext::User => data.user_sounds(user_id, Some(self.page)).await,
ListContext::Favorite => data.favorite_sounds(user_id, Some(self.page)).await,
ListContext::Guild => data.guild_sounds(guild_id, Some(self.page)).await, ListContext::Guild => data.guild_sounds(guild_id, Some(self.page)).await,
} }
} }
fn create_action_row(&self, max_page: u64) -> CreateActionRow { fn create_action_row(&self, max_page: u64) -> CreateActionRow {
let mut row = CreateActionRow::default(); let row = CreateActionRow::Buttons(vec![
CreateButton::new(
row.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager { serde_json::to_string(&SoundPager {
nonce: 0, nonce: 0,
page: 0, page: 0,
@ -120,10 +131,8 @@ impl SoundPager {
) )
.style(ButtonStyle::Primary) .style(ButtonStyle::Primary)
.label("") .label("")
.disabled(self.page == 0) .disabled(self.page == 0),
}) CreateButton::new(
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager { serde_json::to_string(&SoundPager {
nonce: 1, nonce: 1,
page: self.page.saturating_sub(1), page: self.page.saturating_sub(1),
@ -133,16 +142,12 @@ impl SoundPager {
) )
.style(ButtonStyle::Secondary) .style(ButtonStyle::Secondary)
.label("◀️") .label("◀️")
.disabled(self.page == 0) .disabled(self.page == 0),
}) CreateButton::new("pid")
.create_button(|b| {
b.custom_id("pid")
.style(ButtonStyle::Success) .style(ButtonStyle::Success)
.label(format!("Page {}", self.page + 1)) .label(format!("Page {}", self.page + 1))
.disabled(true) .disabled(true),
}) CreateButton::new(
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager { serde_json::to_string(&SoundPager {
nonce: 2, nonce: 2,
page: self.page.saturating_add(1), page: self.page.saturating_add(1),
@ -152,10 +157,8 @@ impl SoundPager {
) )
.style(ButtonStyle::Secondary) .style(ButtonStyle::Secondary)
.label("▶️") .label("▶️")
.disabled(self.page == max_page) .disabled(self.page == max_page),
}) CreateButton::new(
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager { serde_json::to_string(&SoundPager {
nonce: 3, nonce: 3,
page: max_page, page: max_page,
@ -165,16 +168,14 @@ impl SoundPager {
) )
.style(ButtonStyle::Primary) .style(ButtonStyle::Primary)
.label("") .label("")
.disabled(self.page == max_page) .disabled(self.page == max_page),
}); ]);
row row
} }
fn embed(&self, sounds: &[Sound], count: u64) -> CreateEmbed { fn embed(&self, sounds: &[Sound], count: u64) -> CreateEmbed {
let mut embed = CreateEmbed::default(); CreateEmbed::default()
embed
.color(THEME_COLOR) .color(THEME_COLOR)
.title(self.context.title()) .title(self.context.title())
.description(format!("**{}** sounds:", count)) .description(format!("**{}** sounds:", count))
@ -188,15 +189,13 @@ impl SoundPager {
), ),
true, true,
) )
})); }))
embed
} }
pub async fn handle_interaction( pub async fn handle_interaction(
ctx: &serenity_prelude::Context, ctx: &serenity_prelude::Context,
data: &Data, data: &Data,
interaction: &MessageComponentInteraction, interaction: &ComponentInteraction,
) -> Result<(), Error> { ) -> Result<(), Error> {
let user_id = interaction.user.id; let user_id = interaction.user.id;
let guild_id = interaction.guild_id.unwrap(); let guild_id = interaction.guild_id.unwrap();
@ -205,18 +204,17 @@ impl SoundPager {
let sounds = pager.get_page(data, user_id, guild_id).await?; let sounds = pager.get_page(data, user_id, guild_id).await?;
let count = match pager.context { let count = match pager.context {
ListContext::User => data.count_user_sounds(user_id).await?, ListContext::User => data.count_user_sounds(user_id).await?,
ListContext::Favorite => data.count_favorite_sounds(user_id).await?,
ListContext::Guild => data.count_guild_sounds(guild_id).await?, ListContext::Guild => data.count_guild_sounds(guild_id).await?,
}; };
interaction interaction
.create_interaction_response(&ctx, |r| { .edit_response(
r.kind(InteractionResponseType::UpdateMessage) &ctx,
.interaction_response_data(|d| { EditInteractionResponse::default()
d.ephemeral(true)
.add_embed(pager.embed(&sounds, count)) .add_embed(pager.embed(&sounds, count))
.components(|c| c.add_action_row(pager.create_action_row(count / 25))) .components(vec![pager.create_action_row(count / 25)]),
}) )
})
.await?; .await?;
Ok(()) Ok(())
@ -228,6 +226,7 @@ impl SoundPager {
.await?; .await?;
let count = match self.context { let count = match self.context {
ListContext::User => ctx.data().count_user_sounds(ctx.author().id).await?, ListContext::User => ctx.data().count_user_sounds(ctx.author().id).await?,
ListContext::Favorite => ctx.data().count_favorite_sounds(ctx.author().id).await?,
ListContext::Guild => { ListContext::Guild => {
ctx.data() ctx.data()
.count_guild_sounds(ctx.guild_id().unwrap()) .count_guild_sounds(ctx.guild_id().unwrap())
@ -235,14 +234,12 @@ impl SoundPager {
} }
}; };
ctx.send(|r| { ctx.send(
r.ephemeral(true) CreateReply::default()
.embed(|e| { .ephemeral(true)
*e = self.embed(&sounds, count); .embed(self.embed(&sounds, count))
e .components(vec![self.create_action_row(count / 25)]),
}) )
.components(|c| c.add_action_row(self.create_action_row(count / 25)))
})
.await?; .await?;
Ok(()) Ok(())
@ -265,36 +262,7 @@ pub async fn search_sounds(
.search_for_sound(&query, ctx.guild_id().unwrap(), ctx.author().id, false) .search_for_sound(&query, ctx.guild_id().unwrap(), ctx.author().id, false)
.await?; .await?;
ctx.send(|m| { ctx.send(format_search_results(search_results)).await?;
*m = format_search_results(search_results);
m
})
.await?;
Ok(())
}
/// Show a page of random sounds
#[poise::command(slash_command, rename = "random", guild_only = true)]
pub async fn show_random_sounds(ctx: Context<'_>) -> Result<(), Error> {
let search_results = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE public = 1
ORDER BY rand()
LIMIT 25
"
)
.fetch_all(&ctx.data().database)
.await?;
ctx.send(|m| {
*m = format_search_results(search_results);
m
})
.await?;
Ok(()) Ok(())
} }

View File

@ -1,4 +1,7 @@
use poise::serenity_prelude::{GuildId, User}; use poise::{
serenity_prelude::{GuildId, User},
CreateReply,
};
use crate::{ use crate::{
cmds::autocomplete_sound, cmds::autocomplete_sound,
@ -60,16 +63,14 @@ pub async fn set_guild_greet_sound(
#[description = "User to set join sound for"] user: User, #[description = "User to set join sound for"] user: User,
) -> Result<(), Error> { ) -> Result<(), Error> {
if user.id != ctx.author().id { if user.id != ctx.author().id {
let guild = ctx.guild().unwrap(); let permissions = ctx.author_member().await.unwrap().permissions(&ctx.cache());
let permissions = guild
.member_permissions(&ctx.discord(), ctx.author().id)
.await;
if permissions.map_or(true, |p| !p.manage_guild()) { if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(|b| { ctx.send(
b.ephemeral(true) CreateReply::default()
.content("Only admins can change other user's greet sounds.") .ephemeral(true)
}) .content("Only admins can change other user's greet sounds."),
)
.await?; .await?;
return Ok(()); return Ok(());
@ -109,16 +110,14 @@ pub async fn unset_guild_greet_sound(
#[description = "User to set join sound for"] user: User, #[description = "User to set join sound for"] user: User,
) -> Result<(), Error> { ) -> Result<(), Error> {
if user.id != ctx.author().id { if user.id != ctx.author().id {
let guild = ctx.guild().unwrap(); let permissions = ctx.author_member().await.unwrap().permissions(&ctx.cache());
let permissions = guild
.member_permissions(&ctx.discord(), ctx.author().id)
.await;
if permissions.map_or(true, |p| !p.manage_guild()) { if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(|b| { ctx.send(
b.ephemeral(true) CreateReply::default()
.content("Only admins can change other user's greet sounds.") .ephemeral(true)
}) .content("Only admins can change other user's greet sounds."),
)
.await?; .await?;
return Ok(()); return Ok(());
@ -159,20 +158,19 @@ pub async fn set_user_greet_sound(
.update_join_sound(ctx.author().id, None::<GuildId>, Some(sound.id)) .update_join_sound(ctx.author().id, None::<GuildId>, Some(sound.id))
.await?; .await?;
ctx.send(|b| { ctx.send(CreateReply::default().ephemeral(true).content(format!(
b.ephemeral(true).content(format!(
"Greet sound has been set to {} (ID {})", "Greet sound has been set to {} (ID {})",
sound.name, sound.id sound.name, sound.id
)) )))
})
.await?; .await?;
} }
None => { None => {
ctx.send(|b| { ctx.send(
b.ephemeral(true) CreateReply::default()
.content("Could not find a sound by that name.") .ephemeral(true)
}) .content("Could not find a sound by that name."),
)
.await?; .await?;
} }
} }
@ -187,7 +185,11 @@ pub async fn unset_user_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
.update_join_sound(ctx.author().id, None::<GuildId>, None) .update_join_sound(ctx.author().id, None::<GuildId>, None)
.await?; .await?;
ctx.send(|b| b.ephemeral(true).content("Greet sound has been unset")) ctx.send(
CreateReply::default()
.ephemeral(true)
.content("Greet sound has been unset"),
)
.await?; .await?;
Ok(()) Ok(())

View File

@ -10,7 +10,7 @@ use crate::{Context, Error};
guild_only = true guild_only = true
)] )]
pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> { pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> {
let songbird = songbird::get(ctx.discord()).await.unwrap(); let songbird = songbird::get(ctx.serenity_context()).await.unwrap();
let call_opt = songbird.get(ctx.guild_id().unwrap()); let call_opt = songbird.get(ctx.guild_id().unwrap());
if let Some(call) = call_opt { if let Some(call) = call_opt {
@ -27,7 +27,7 @@ pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> {
/// Disconnect the bot /// Disconnect the bot
#[poise::command(slash_command, default_member_permissions = "SPEAK", guild_only = true)] #[poise::command(slash_command, default_member_permissions = "SPEAK", guild_only = true)]
pub async fn disconnect(ctx: Context<'_>) -> Result<(), Error> { pub async fn disconnect(ctx: Context<'_>) -> Result<(), Error> {
let songbird = songbird::get(ctx.discord()).await.unwrap(); let songbird = songbird::get(ctx.serenity_context()).await.unwrap();
let _ = songbird.leave(ctx.guild_id().unwrap()).await; let _ = songbird.leave(ctx.guild_id().unwrap()).await;
ctx.say("👍").await?; ctx.say("👍").await?;

View File

@ -1,14 +1,10 @@
use std::{collections::HashMap, env};
use poise::serenity_prelude::{ use poise::serenity_prelude::{
model::{ ActionRowComponent, ButtonKind, Context, CreateActionRow, CreateButton,
application::interaction::{Interaction, InteractionResponseType}, EditInteractionResponse, FullEvent, Interaction,
channel::Channel,
},
utils::shard_id,
Activity, Context,
}; };
#[cfg(feature = "metrics")]
use crate::metrics::GREET_COUNTER;
use crate::{ use crate::{
cmds::search::SoundPager, cmds::search::SoundPager,
models::{ models::{
@ -20,67 +16,29 @@ use crate::{
Data, Error, Data, Error,
}; };
pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> { pub async fn listener(ctx: &Context, event: &FullEvent, data: &Data) -> Result<(), Error> {
match event { match event {
poise::Event::Ready { .. } => { FullEvent::VoiceStateUpdate { old, new, .. } => {
ctx.set_activity(Activity::watching("for /play")).await;
}
poise::Event::GuildCreate { guild, is_new, .. } => {
if *is_new {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild.id.as_u64().to_owned(), shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
})
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
poise::Event::VoiceStateUpdate { old, new, .. } => {
if let Some(past_state) = old { if let Some(past_state) = old {
if let (Some(guild_id), None) = (past_state.guild_id, new.channel_id) { if let (Some(guild_id), None) = (past_state.guild_id, new.channel_id) {
if let Some(channel_id) = past_state.channel_id { if let Some(channel_id) = past_state.channel_id {
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { let is_okay = ctx
if channel.members(&ctx).await.map(|m| m.len()).unwrap_or(0) <= 1 { .cache
.channel(channel_id)
.map(|c| c.members(&ctx).ok().map(|m| m.len()))
.flatten()
.unwrap_or(0)
<= 1;
if is_okay {
let songbird = songbird::get(ctx).await.unwrap(); let songbird = songbird::get(ctx).await.unwrap();
let _ = songbird.remove(guild_id).await; songbird.remove(guild_id).await?;
}
} }
} }
} }
} else if let (Some(guild_id), Some(user_channel)) = (new.guild_id, new.channel_id) { } else if let (Some(guild_id), Some(user_channel)) = (new.guild_id, new.channel_id) {
if let Some(guild) = ctx.cache.guild(guild_id) { let guild_data_opt = data.guild_data(guild_id).await;
let guild_data_opt = data.guild_data(guild.id).await;
if let Ok(guild_data) = guild_data_opt { if let Ok(guild_data) = guild_data_opt {
let volume; let volume;
@ -107,20 +65,22 @@ pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> R
" "
SELECT name, id, public, server_id, uploader_id SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE id = ? WHERE id = ?",
",
join_id join_id
) )
.fetch_one(&data.database) .fetch_one(&data.database)
.await .await
.unwrap(); .unwrap();
let (handler, _) = join_channel(&ctx, guild, user_channel).await; let call = join_channel(&ctx, guild_id, user_channel).await?;
#[cfg(feature = "metrics")]
GREET_COUNTER.inc();
play_audio( play_audio(
&mut sound, &mut sound,
volume, volume,
&mut handler.lock().await, &mut call.lock().await,
&data.database, &data.database,
false, false,
) )
@ -131,32 +91,104 @@ SELECT name, id, public, server_id, uploader_id
} }
} }
} }
} FullEvent::InteractionCreate { interaction } => match interaction {
poise::Event::InteractionCreate { interaction } => match interaction { Interaction::Component(component) => {
Interaction::MessageComponent(component) => {
if let Some(guild_id) = component.guild_id { if let Some(guild_id) = component.guild_id {
if let Ok(()) = SoundPager::handle_interaction(ctx, &data, component).await { if let Ok(()) = SoundPager::handle_interaction(ctx, &data, component).await {
} else { } else {
component let mode = component.data.custom_id.as_str();
.create_interaction_response(ctx, |r| { match mode {
r.kind(InteractionResponseType::DeferredUpdateMessage) "#stop" => {
}) component.defer(&ctx).await.unwrap();
.await
.unwrap(); let songbird = songbird::get(ctx).await.unwrap();
let call_opt = songbird.get(guild_id);
if let Some(call) = call_opt {
let mut lock = call.lock().await;
lock.stop();
}
}
"#loop" | "#queue" | "#instant" => {
let components = {
let mut c = vec![];
for action_row in &component.message.components {
let mut row = vec![];
// These are always buttons
for component in &action_row.components {
match component {
ActionRowComponent::Button(button) => match &button
.data
{
ButtonKind::Link { .. } => {}
ButtonKind::NonLink { custom_id, style } => row
.push(
CreateButton::new(
if custom_id.starts_with('#') {
custom_id.to_string()
} else {
format!(
"{}{}",
custom_id
.split('#')
.next()
.unwrap(),
mode
)
},
)
.label(button.label.clone().unwrap())
.emoji(button.emoji.clone().unwrap())
.disabled(
custom_id == "#mode"
|| custom_id == mode,
)
.style(*style),
),
},
_ => {}
}
}
c.push(CreateActionRow::Buttons(row));
}
c
};
let response =
EditInteractionResponse::default().components(components);
component.edit_response(&ctx, response).await.unwrap();
}
id_mode => {
component.defer(&ctx).await.unwrap();
let mut it = id_mode.split('#');
let id = it.next().unwrap();
let mode = it.next().unwrap_or("instant");
let guild =
guild_id.to_guild_cached(&ctx).map(|g| g.clone()).unwrap();
play_from_query( play_from_query(
&ctx, &ctx,
&data, &data,
guild_id.to_guild_cached(&ctx).unwrap(), &guild,
component.user.id, component.user.id,
None, None,
&component.data.custom_id, id.split('#').next().unwrap(),
false, mode == "loop",
) )
.await; .await;
} }
} }
} }
}
}
_ => {} _ => {}
}, },
_ => {} _ => {}

View File

@ -5,6 +5,8 @@ mod cmds;
mod consts; mod consts;
mod error; mod error;
mod event_handlers; mod event_handlers;
#[cfg(feature = "metrics")]
mod metrics;
mod models; mod models;
mod utils; mod utils;
@ -12,11 +14,11 @@ use std::{env, path::Path, sync::Arc};
use dashmap::DashMap; use dashmap::DashMap;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
builder::CreateApplicationCommands,
model::{ model::{
gateway::GatewayIntents, gateway::GatewayIntents,
id::{GuildId, UserId}, id::{GuildId, UserId},
}, },
ActivityData, ClientBuilder,
}; };
use songbird::SerenityInit; use songbird::SerenityInit;
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
@ -28,7 +30,6 @@ type Database = MySql;
pub struct Data { pub struct Data {
database: Pool<Database>, database: Pool<Database>,
http: reqwest::Client,
guild_data_cache: DashMap<GuildId, Arc<RwLock<GuildData>>>, guild_data_cache: DashMap<GuildId, Arc<RwLock<GuildData>>>,
join_sound_cache: DashMap<UserId, DashMap<Option<GuildId>, Option<u32>>>, join_sound_cache: DashMap<UserId, DashMap<Option<GuildId>, Option<u32>>>,
} }
@ -36,40 +37,10 @@ pub struct Data {
type Error = Box<dyn std::error::Error + Send + Sync>; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
pub async fn register_application_commands(
ctx: &poise::serenity_prelude::Context,
framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>,
) -> Result<(), poise::serenity_prelude::Error> {
let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands;
for command in commands {
if let Some(slash_command) = command.create_as_slash_command() {
commands_builder.add_application_command(slash_command);
}
if let Some(context_menu_command) = command.create_as_context_menu_command() {
commands_builder.add_application_command(context_menu_command);
}
}
let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id {
ctx.http
.create_guild_application_commands(guild_id.0, &commands_builder)
.await?;
} else {
ctx.http
.create_global_application_commands(&commands_builder)
.await?;
}
Ok(())
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if Path::new("/etc/soundfx-rs/default.env").exists() { if Path::new("/etc/soundfx-rs/config.env").exists() {
dotenv::from_path("/etc/soundfx-rs/default.env").unwrap(); dotenv::from_path("/etc/soundfx-rs/config.env").unwrap();
} }
env_logger::init(); env_logger::init();
@ -85,6 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
cmds::manage::download_file(), cmds::manage::download_file(),
cmds::manage::delete_sound(), cmds::manage::delete_sound(),
cmds::play::play(), cmds::play::play(),
cmds::play::play_random(),
cmds::play::queue_play(), cmds::play::queue_play(),
cmds::play::loop_play(), cmds::play::loop_play(),
cmds::play::soundboard(), cmds::play::soundboard(),
@ -92,10 +64,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
subcommands: vec![ subcommands: vec![
cmds::search::list_guild_sounds(), cmds::search::list_guild_sounds(),
cmds::search::list_user_sounds(), cmds::search::list_user_sounds(),
cmds::search::list_favorite_sounds(),
], ],
..cmds::search::list_sounds() ..cmds::search::list_sounds()
}, },
cmds::search::show_random_sounds(), poise::Command {
subcommands: vec![
cmds::favorite::add_favorite(),
cmds::favorite::remove_favorite(),
],
..cmds::favorite::favorites()
},
cmds::search::search_sounds(), cmds::search::search_sounds(),
cmds::stop::stop_playing(), cmds::stop::stop_playing(),
cmds::stop::disconnect(), cmds::stop::disconnect(),
@ -124,7 +103,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
}, },
], ],
allowed_mentions: None, allowed_mentions: None,
listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
..Default::default() ..Default::default()
}; };
@ -134,16 +113,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::migrate!().run(&database).await?; sqlx::migrate!().run(&database).await?;
poise::Framework::builder() #[cfg(feature = "metrics")]
.token(discord_token) {
.user_data_setup(move |ctx, _bot, framework| { metrics::init_metrics();
tokio::spawn(async { metrics::serve().await });
}
let framework = poise::Framework::builder()
.setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
register_application_commands(ctx, framework, None) poise::builtins::register_globally(ctx, &framework.options().commands).await?;
.await
.unwrap();
Ok(Data { Ok(Data {
http: reqwest::Client::new(),
database, database,
guild_data_cache: Default::default(), guild_data_cache: Default::default(),
join_sound_cache: Default::default(), join_sound_cache: Default::default(),
@ -151,10 +132,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
}) })
}) })
.options(options) .options(options)
.client_settings(move |client_builder| client_builder.register_songbird()) .build();
.intents(GatewayIntents::GUILD_VOICE_STATES | GatewayIntents::GUILDS)
.run_autosharded() let mut client = ClientBuilder::new(
&discord_token,
GatewayIntents::GUILD_VOICE_STATES | GatewayIntents::GUILDS,
)
.activity(ActivityData::watching("for /play"))
.framework(framework)
.register_songbird()
.await?; .await?;
client.start_autosharded().await.unwrap();
Ok(()) Ok(())
} }

46
src/metrics.rs Normal file
View File

@ -0,0 +1,46 @@
use axum::{routing::get, Router};
use lazy_static;
use log::warn;
use prometheus::{register_int_counter, IntCounter, Registry};
lazy_static! {
static ref REGISTRY: Registry = Registry::new();
pub static ref PLAY_COUNTER: IntCounter =
register_int_counter!("play_cmd", "Number of calls to /play").unwrap();
pub static ref UPLOAD_COUNTER: IntCounter =
register_int_counter!("upload_cmd", "Number of calls to /upload").unwrap();
pub static ref DELETE_COUNTER: IntCounter =
register_int_counter!("delete_cmd", "Number of calls to /delete").unwrap();
pub static ref GREET_COUNTER: IntCounter =
register_int_counter!("greet_invoke", "Number of greet sounds played").unwrap();
}
pub fn init_metrics() {
REGISTRY.register(Box::new(PLAY_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(UPLOAD_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(DELETE_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(GREET_COUNTER.clone())).unwrap();
}
pub async fn serve() {
let app = Router::new().route("/metrics", get(metrics));
let listener = tokio::net::TcpListener::bind("localhost:31755")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn metrics() -> String {
let encoder = prometheus::TextEncoder::new();
let res_custom = encoder.encode_to_string(&REGISTRY.gather());
match res_custom {
Ok(s) => s,
Err(e) => {
warn!("Error encoding metrics: {:?}", e);
String::new()
}
}
}

View File

@ -78,12 +78,10 @@ impl GuildData {
let guild_data = sqlx::query_as_unchecked!( let guild_data = sqlx::query_as_unchecked!(
GuildData, GuildData,
" "SELECT id, prefix, volume, allow_greets, allowed_role
SELECT id, prefix, volume, allow_greets, allowed_role
FROM servers FROM servers
WHERE id = ? WHERE id = ?",
", guild_id.get()
guild_id.as_u64()
) )
.fetch_one(db_pool) .fetch_one(db_pool)
.await; .await;
@ -104,17 +102,15 @@ SELECT id, prefix, volume, allow_greets, allowed_role
let guild_id = guild_id.into(); let guild_id = guild_id.into();
sqlx::query!( sqlx::query!(
" "INSERT INTO servers (id)
INSERT INTO servers (id) VALUES (?)",
VALUES (?) guild_id.get()
",
guild_id.as_u64()
) )
.execute(db_pool) .execute(db_pool)
.await?; .await?;
Ok(GuildData { Ok(GuildData {
id: guild_id.as_u64().to_owned(), id: guild_id.get(),
prefix: String::from("?"), prefix: String::from("?"),
volume: 100, volume: 100,
allow_greets: AllowGreet::Enabled, allow_greets: AllowGreet::Enabled,

View File

@ -1,4 +1,5 @@
use poise::serenity_prelude::{async_trait, model::id::UserId, GuildId}; use poise::serenity_prelude::{async_trait, model::id::UserId, GuildId};
use sqlx::Acquire;
use crate::Data; use crate::Data;
@ -51,10 +52,9 @@ SELECT join_sound_id
FROM join_sounds FROM join_sounds
WHERE user = ? WHERE user = ?
AND guild = ? AND guild = ?
ORDER BY guild IS NULL ORDER BY guild IS NULL",
", user_id.get(),
user_id.as_u64(), guild_id.map(|g| g.get())
guild_id.map(|g| g.0)
) )
.fetch_one(&self.database) .fetch_one(&self.database)
.await .await
@ -66,10 +66,9 @@ SELECT join_sound_id
FROM join_sounds FROM join_sounds
WHERE user = ? WHERE user = ?
AND (guild IS NULL OR guild = ?) AND (guild IS NULL OR guild = ?)
ORDER BY guild IS NULL ORDER BY guild IS NULL",
", user_id.get(),
user_id.as_u64(), guild_id.map(|g| g.get())
guild_id.map(|g| g.0)
) )
.fetch_one(&self.database) .fetch_one(&self.database)
.await .await
@ -111,29 +110,29 @@ SELECT join_sound_id
Some(join_id) => { Some(join_id) => {
sqlx::query!( sqlx::query!(
"DELETE FROM join_sounds WHERE user = ? AND guild <=> ?", "DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
user_id.0, user_id.get(),
guild_id.map(|g| g.0) guild_id.map(|g| g.get())
) )
.execute(&mut transaction) .execute(transaction.acquire().await?)
.await?; .await?;
sqlx::query!( sqlx::query!(
"INSERT INTO join_sounds (user, join_sound_id, guild) VALUES (?, ?, ?)", "INSERT INTO join_sounds (user, join_sound_id, guild) VALUES (?, ?, ?)",
user_id.0, user_id.get(),
join_id, join_id,
guild_id.map(|g| g.0) guild_id.map(|g| g.get())
) )
.execute(&mut transaction) .execute(transaction.acquire().await?)
.await?; .await?;
} }
None => { None => {
sqlx::query!( sqlx::query!(
"DELETE FROM join_sounds WHERE user = ? AND guild <=> ?", "DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
user_id.0, user_id.get(),
guild_id.map(|g| g.0) guild_id.map(|g| g.get())
) )
.execute(&mut transaction) .execute(transaction.acquire().await?)
.await?; .await?;
} }
} }

View File

@ -1,9 +1,7 @@
use std::{env, path::Path};
use poise::serenity_prelude::async_trait; use poise::serenity_prelude::async_trait;
use songbird::input::restartable::Restartable; use songbird::input::Input;
use sqlx::Executor; use sqlx::Executor;
use tokio::{fs::File, io::AsyncWriteExt, process::Command}; use tokio::process::Command;
use crate::{consts::UPLOAD_MAX_SIZE, error::ErrorTypes, Data, Database}; use crate::{consts::UPLOAD_MAX_SIZE, error::ErrorTypes, Data, Database};
@ -37,17 +35,31 @@ pub trait SoundCtx {
user_id: U, user_id: U,
guild_id: G, guild_id: G,
) -> Result<Vec<Sound>, sqlx::Error>; ) -> Result<Vec<Sound>, sqlx::Error>;
async fn autocomplete_favorite_sounds<U: Into<u64> + Send>(
&self,
query: &str,
user_id: U,
) -> Result<Vec<Sound>, sqlx::Error>;
async fn user_sounds<U: Into<u64> + Send>( async fn user_sounds<U: Into<u64> + Send>(
&self, &self,
user_id: U, user_id: U,
page: Option<u64>, page: Option<u64>,
) -> Result<Vec<Sound>, sqlx::Error>; ) -> Result<Vec<Sound>, sqlx::Error>;
async fn favorite_sounds<U: Into<u64> + Send>(
&self,
user_id: U,
page: Option<u64>,
) -> Result<Vec<Sound>, sqlx::Error>;
async fn guild_sounds<G: Into<u64> + Send>( async fn guild_sounds<G: Into<u64> + Send>(
&self, &self,
guild_id: G, guild_id: G,
page: Option<u64>, page: Option<u64>,
) -> Result<Vec<Sound>, sqlx::Error>; ) -> Result<Vec<Sound>, sqlx::Error>;
async fn count_user_sounds<U: Into<u64> + Send>(&self, user_id: U) -> Result<u64, sqlx::Error>; async fn count_user_sounds<U: Into<u64> + Send>(&self, user_id: U) -> Result<u64, sqlx::Error>;
async fn count_favorite_sounds<U: Into<u64> + Send>(
&self,
user_id: U,
) -> Result<u64, sqlx::Error>;
async fn count_guild_sounds<G: Into<u64> + Send>( async fn count_guild_sounds<G: Into<u64> + Send>(
&self, &self,
guild_id: G, guild_id: G,
@ -91,8 +103,7 @@ SELECT name, id, public, server_id, uploader_id
public = 1 OR public = 1 OR
uploader_id = ? OR uploader_id = ? OR
server_id = ? server_id = ?
) )",
",
id, id,
user_id, user_id,
guild_id guild_id
@ -116,12 +127,21 @@ SELECT name, id, public, server_id, uploader_id
uploader_id = ? OR uploader_id = ? OR
server_id = ? server_id = ?
) )
ORDER BY uploader_id = ? DESC, server_id = ? DESC, public = 1 DESC, rand() ORDER BY
", uploader_id = ? DESC,
EXISTS(
SELECT 1
FROM favorite_sounds
WHERE sound_id = id AND user_id = ?
) DESC,
server_id = ? DESC,
public = 1 DESC,
rand()",
name, name,
user_id, user_id,
guild_id, guild_id,
user_id, user_id,
user_id,
guild_id guild_id
) )
.fetch_all(&db_pool) .fetch_all(&db_pool)
@ -137,12 +157,21 @@ SELECT name, id, public, server_id, uploader_id
uploader_id = ? OR uploader_id = ? OR
server_id = ? server_id = ?
) )
ORDER BY uploader_id = ? DESC, server_id = ? DESC, public = 1 DESC, rand() ORDER BY
", uploader_id = ? DESC,
EXISTS(
SELECT 1
FROM favorite_sounds
WHERE sound_id = id AND user_id = ?
) DESC,
server_id = ? DESC,
public = 1 DESC,
rand()",
name, name,
user_id, user_id,
guild_id, guild_id,
user_id, user_id,
user_id,
guild_id guild_id
) )
.fetch_all(&db_pool) .fetch_all(&db_pool)
@ -160,18 +189,48 @@ SELECT name, id, public, server_id, uploader_id
guild_id: G, guild_id: G,
) -> Result<Vec<Sound>, sqlx::Error> { ) -> Result<Vec<Sound>, sqlx::Error> {
let db_pool = self.database.clone(); let db_pool = self.database.clone();
let user_id = user_id.into();
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Sound, Sound,
" "
SELECT name, id, public, server_id, uploader_id SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE name LIKE CONCAT(?, '%') AND (uploader_id = ? OR server_id = ?) WHERE name LIKE CONCAT(?, '%') AND (uploader_id = ? OR server_id = ? OR EXISTS(
LIMIT 25 SELECT 1
", FROM favorite_sounds
WHERE sound_id = id AND user_id = ?
))
LIMIT 25",
query,
user_id,
guild_id.into(),
user_id,
)
.fetch_all(&db_pool)
.await
}
async fn autocomplete_favorite_sounds<U: Into<u64> + Send>(
&self,
query: &str,
user_id: U,
) -> Result<Vec<Sound>, sqlx::Error> {
let db_pool = self.database.clone();
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name LIKE CONCAT(?, '%') AND EXISTS(
SELECT 1
FROM favorite_sounds
WHERE sound_id = id AND user_id = ?
)
LIMIT 25",
query, query,
user_id.into(), user_id.into(),
guild_id.into(),
) )
.fetch_all(&db_pool) .fetch_all(&db_pool)
.await .await
@ -191,8 +250,7 @@ SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE uploader_id = ? WHERE uploader_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ?, ? LIMIT ?, ?",
",
user_id.into(), user_id.into(),
page * 25, page * 25,
(page + 1) * 25 (page + 1) * 25
@ -207,8 +265,49 @@ SELECT name, id, public, server_id, uploader_id
SELECT name, id, public, server_id, uploader_id SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE uploader_id = ? WHERE uploader_id = ?
ORDER BY id DESC",
user_id.into()
)
.fetch_all(&self.database)
.await?
}
};
Ok(sounds)
}
async fn favorite_sounds<U: Into<u64> + Send>(
&self,
user_id: U,
page: Option<u64>,
) -> Result<Vec<Sound>, sqlx::Error> {
let sounds = match page {
Some(page) => {
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
INNER JOIN favorite_sounds f ON sounds.id = f.sound_id
WHERE f.user_id = ?
ORDER BY id DESC ORDER BY id DESC
", LIMIT ?, ?",
user_id.into(),
page * 25,
(page + 1) * 25
)
.fetch_all(&self.database)
.await?
}
None => {
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
INNER JOIN favorite_sounds f ON sounds.id = f.sound_id
WHERE f.user_id = ?
ORDER BY id DESC",
user_id.into() user_id.into()
) )
.fetch_all(&self.database) .fetch_all(&self.database)
@ -233,8 +332,7 @@ SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE server_id = ? WHERE server_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ?, ? LIMIT ?, ?",
",
guild_id.into(), guild_id.into(),
page * 25, page * 25,
(page + 1) * 25 (page + 1) * 25
@ -250,8 +348,7 @@ SELECT name, id, public, server_id, uploader_id
SELECT name, id, public, server_id, uploader_id SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE server_id = ? WHERE server_id = ?
ORDER BY id DESC ORDER BY id DESC",
",
guild_id.into() guild_id.into()
) )
.fetch_all(&self.database) .fetch_all(&self.database)
@ -272,6 +369,19 @@ SELECT name, id, public, server_id, uploader_id
.count as u64) .count as u64)
} }
async fn count_favorite_sounds<U: Into<u64> + Send>(
&self,
user_id: U,
) -> Result<u64, sqlx::Error> {
Ok(sqlx::query!(
"SELECT COUNT(1) as count FROM favorite_sounds WHERE user_id = ?",
user_id.into()
)
.fetch_one(&self.database)
.await?
.count as u64)
}
async fn count_guild_sounds<G: Into<u64> + Send>( async fn count_guild_sounds<G: Into<u64> + Send>(
&self, &self,
guild_id: G, guild_id: G,
@ -287,7 +397,7 @@ SELECT name, id, public, server_id, uploader_id
} }
impl Sound { impl Sound {
async fn src(&self, db_pool: impl Executor<'_, Database = Database>) -> Vec<u8> { pub(crate) async fn src(&self, db_pool: impl Executor<'_, Database = Database>) -> Vec<u8> {
struct Src { struct Src {
src: Vec<u8>, src: Vec<u8>,
} }
@ -298,8 +408,7 @@ impl Sound {
SELECT src SELECT src
FROM sounds FROM sounds
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1",
",
self.id self.id
) )
.fetch_one(db_pool) .fetch_one(db_pool)
@ -309,33 +418,11 @@ SELECT src
record.src record.src
} }
pub async fn store_sound_source(
&self,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let caching_location = env::var("CACHING_LOCATION").unwrap_or(String::from("/tmp"));
let path_name = format!("{}/sound-{}", caching_location, self.id);
let path = Path::new(&path_name);
if !path.exists() {
let mut file = File::create(&path).await?;
file.write_all(&self.src(db_pool).await).await?;
}
Ok(path_name)
}
pub async fn playable( pub async fn playable(
&self, &self,
db_pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
) -> Result<Restartable, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<Input, Box<dyn std::error::Error + Send + Sync>> {
let path_name = self.store_sound_source(db_pool).await?; Ok(Input::from(self.src(db_pool).await))
Ok(Restartable::ffmpeg(path_name, false)
.await
.expect("FFMPEG ERROR!"))
} }
pub async fn count_user_sounds<U: Into<u64>>( pub async fn count_user_sounds<U: Into<u64>>(
@ -348,8 +435,7 @@ SELECT src
" "
SELECT COUNT(1) as count SELECT COUNT(1) as count
FROM sounds FROM sounds
WHERE uploader_id = ? WHERE uploader_id = ?",
",
user_id user_id
) )
.fetch_one(db_pool) .fetch_one(db_pool)
@ -372,8 +458,7 @@ SELECT COUNT(1) as count
FROM sounds FROM sounds
WHERE WHERE
uploader_id = ? AND uploader_id = ? AND
name = ? name = ?",
",
user_id, user_id,
name name
) )
@ -394,8 +479,7 @@ UPDATE sounds
SET SET
public = ? public = ?
WHERE WHERE
id = ? id = ?",
",
self.public, self.public,
self.id self.id
) )
@ -409,12 +493,41 @@ WHERE
&self, &self,
db_pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!("DELETE FROM sounds WHERE id = ?", self.id)
.execute(db_pool)
.await?;
Ok(())
}
pub async fn add_favorite<U: Into<u64>>(
&self,
user_id: U,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + Send>> {
let user_id = user_id.into();
sqlx::query!( sqlx::query!(
" "INSERT INTO favorite_sounds (user_id, sound_id) VALUES (?, ?)",
DELETE user_id,
FROM sounds self.id
WHERE id = ? )
", .execute(db_pool)
.await?;
Ok(())
}
pub async fn remove_favorite<U: Into<u64>>(
&self,
user_id: U,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + Send>> {
let user_id = user_id.into();
sqlx::query!(
"DELETE FROM favorite_sounds WHERE user_id = ? AND sound_id = ?",
user_id,
self.id self.id
) )
.execute(db_pool) .execute(db_pool)
@ -468,8 +581,7 @@ DELETE
match sqlx::query!( match sqlx::query!(
" "
INSERT INTO sounds (name, server_id, uploader_id, public, src) INSERT INTO sounds (name, server_id, uploader_id, public, src)
VALUES (?, ?, ?, 1, ?) VALUES (?, ?, ?, 1, ?)",
",
name, name,
server_id, server_id,
user_id, user_id,

View File

@ -1,11 +1,13 @@
use std::sync::Arc; use std::{ops::Deref, sync::Arc};
use poise::serenity_prelude::model::{ use poise::serenity_prelude::{
channel::Channel, model::{
guild::Guild, guild::Guild,
id::{ChannelId, UserId}, id::{ChannelId, UserId},
},
ChannelType, EditVoiceState, GuildId,
}; };
use songbird::{create_player, error::JoinResult, tracks::TrackHandle, Call}; use songbird::{tracks::TrackHandle, Call};
use sqlx::Executor; use sqlx::Executor;
use tokio::sync::{Mutex, MutexGuard}; use tokio::sync::{Mutex, MutexGuard};
@ -22,21 +24,20 @@ pub async fn play_audio(
volume: u8, volume: u8,
call_handler: &mut MutexGuard<'_, Call>, call_handler: &mut MutexGuard<'_, Call>,
db_pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
loop_: bool, r#loop: bool,
) -> Result<TrackHandle, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<TrackHandle, Box<dyn std::error::Error + Send + Sync>> {
let (track, track_handler) = create_player(sound.playable(db_pool).await?.into()); let track = sound.playable(db_pool).await?;
let handle = call_handler.play_input(track);
let _ = track_handler.set_volume(volume as f32 / 100.0); handle.set_volume(volume as f32 / 100.0)?;
if loop_ { if r#loop {
let _ = track_handler.enable_loop(); handle.enable_loop()?;
} else { } else {
let _ = track_handler.disable_loop(); handle.disable_loop()?;
} }
call_handler.play(track); Ok(handle)
Ok(track_handler)
} }
pub async fn queue_audio( pub async fn queue_audio(
@ -46,11 +47,10 @@ pub async fn queue_audio(
db_pool: impl Executor<'_, Database = Database> + Copy, db_pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for sound in sounds { for sound in sounds {
let (a, b) = create_player(sound.playable(db_pool).await?.into()); let track = sound.playable(db_pool).await?;
let handle = call_handler.enqueue_input(track).await;
let _ = b.set_volume(volume as f32 / 100.0); handle.set_volume(volume as f32 / 100.0)?;
call_handler.enqueue(a);
} }
Ok(()) Ok(())
@ -58,60 +58,65 @@ pub async fn queue_audio(
pub async fn join_channel( pub async fn join_channel(
ctx: &poise::serenity_prelude::Context, ctx: &poise::serenity_prelude::Context,
guild: Guild, guild_id: GuildId,
channel_id: ChannelId, channel_id: ChannelId,
) -> (Arc<Mutex<Call>>, JoinResult<()>) { ) -> Result<Arc<Mutex<Call>>, Box<dyn std::error::Error + Send + Sync>> {
let songbird = songbird::get(ctx).await.unwrap(); let songbird = songbird::get(ctx).await.unwrap();
let current_user = ctx.cache.current_user_id(); let current_user = ctx.cache.current_user().id;
let current_voice_state = guild let current_voice_state = ctx
.voice_states .cache
.guild(guild_id)
.map(|g| {
g.voice_states
.get(&current_user) .get(&current_user)
.and_then(|voice_state| voice_state.channel_id); .and_then(|voice_state| voice_state.channel_id)
})
.flatten();
let (call, res) = if current_voice_state == Some(channel_id) { let call = if current_voice_state == Some(channel_id) {
let call_opt = songbird.get(guild.id); let call_opt = songbird.get(guild_id);
if let Some(call) = call_opt { if let Some(call) = call_opt {
(call, Ok(())) Ok(call)
} else { } else {
let (call, res) = songbird.join(guild.id, channel_id).await; songbird.join(guild_id, channel_id).await
(call, res)
} }
} else { } else {
let (call, res) = songbird.join(guild.id, channel_id).await; songbird.join(guild_id, channel_id).await
}?;
(call, res)
};
{ {
// set call to deafen call.lock().await.deafen(true).await?;
let _ = call.lock().await.deafen(true).await;
} }
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { if let Some(channel) = ctx.cache.channel(channel_id).map(|c| c.clone()) {
let _ = channel if channel.kind == ChannelType::Stage {
.edit_voice_state(&ctx, ctx.cache.current_user(), |v| v.suppress(false)) let user_id = ctx.cache.current_user().id.clone();
.await;
channel
.edit_voice_state(&ctx, user_id, EditVoiceState::new().suppress(true))
.await?;
}
} }
(call, res) Ok(call)
} }
pub async fn play_from_query( pub async fn play_from_query(
ctx: &poise::serenity_prelude::Context, ctx: &poise::serenity_prelude::Context,
data: &Data, data: &Data,
guild: Guild, guild: impl Deref<Target = Guild> + Send + Sync,
user_id: UserId, user_id: UserId,
channel: Option<ChannelId>, channel: Option<ChannelId>,
query: &str, query: &str,
loop_: bool, r#loop: bool,
) -> String { ) -> String {
let guild_id = guild.id; let guild_id = guild.deref().id;
let channel_to_join = channel.or_else(|| { let channel_to_join = channel.or_else(|| {
guild guild
.deref()
.voice_states .voice_states
.get(&user_id) .get(&user_id)
.and_then(|voice_state| voice_state.channel_id) .and_then(|voice_state| voice_state.channel_id)
@ -129,8 +134,7 @@ pub async fn play_from_query(
match sound_res { match sound_res {
Some(sound) => { Some(sound) => {
{ {
let (call_handler, _) = let call_handler = join_channel(ctx, guild_id, user_channel).await.unwrap();
join_channel(ctx, guild.clone(), user_channel).await;
let guild_data = data.guild_data(guild_id).await.unwrap(); let guild_data = data.guild_data(guild_id).await.unwrap();
@ -141,7 +145,7 @@ pub async fn play_from_query(
guild_data.read().await.volume, guild_data.read().await.volume,
&mut lock, &mut lock,
&data.database, &data.database,
loop_, r#loop,
) )
.await .await
.unwrap(); .unwrap();

View File

@ -2,6 +2,7 @@
Description=Discord bot for custom sound effects and soundboards Description=Discord bot for custom sound effects and soundboards
[Service] [Service]
User=soundfx
Type=simple Type=simple
ExecStart=/usr/bin/soundfx-rs ExecStart=/usr/bin/soundfx-rs
WorkingDirectory=/etc/soundfx-rs WorkingDirectory=/etc/soundfx-rs