17 Commits

Author SHA1 Message Date
a2ac7050d7 Add additional indexes 2023-10-21 17:58:31 +01:00
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
6d324e10cb Remove broken is_text_based check 2023-10-21 17:06:41 +01:00
8390bf0ec6 Fix migration. bump ver 2023-08-22 18:55:56 +01:00
e6f5db1842 Fix autocompletes 2023-08-22 18:10:54 +01:00
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
92d8d077df bump version 2023-07-09 15:04:57 +01:00
b861f6f093 Update dependencies 2023-07-09 13:24:39 +01:00
66f45f11f2 Merge remote-tracking branch 'origin/rewrite' into rewrite 2023-07-09 13:19:26 +01:00
e30a08e019 Add loop mode to soundboard 2023-07-09 13:19:18 +01:00
80f45a1f5c Add script to dump sounds to files. 2023-06-23 15:27:38 +01:00
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
33 changed files with 1024 additions and 702 deletions

1
.gitignore vendored
View File

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

2
.idea/.gitignore generated 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>

11
.idea/dataSources.xml generated
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>

6
.idea/misc.xml generated
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>

8
.idea/modules.xml generated
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>

14
.idea/soundfx-rs.iml generated
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>

6
.idea/sqldialects.xml generated
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>

6
.idea/vcs.xml generated
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>

858
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,13 @@
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.8" version = "1.5.11"
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.3", features = ["builtin-queue"] }
poise = "0.3" poise = "0.5.5"
sqlx = { version = "0.5", default-features = false, features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "migrate"] } sqlx = { version = "0.5", 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"] }
lazy_static = "1.4" lazy_static = "1.4"
@ -22,7 +22,7 @@ serde = "1.0"
dotenv = "0.15.0" dotenv = "0.15.0"
[patch."https://github.com/serenity-rs/serenity"] [patch."https://github.com/serenity-rs/serenity"]
serenity = { version = "0.11.5" } serenity = { version = "0.11.6" }
[package.metadata.deb] [package.metadata.deb]
depends = "$auto, ffmpeg" depends = "$auto, ffmpeg"
@ -30,7 +30,10 @@ 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`

2
debian/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

4
debian/postinst vendored
View File

@ -4,10 +4,6 @@ set -e
id -u soundfx &>/dev/null || useradd -r -M soundfx id -u soundfx &>/dev/null || useradd -r -M soundfx
if [ ! -f /etc/soundfx-rs/config.env ]; then
cp /etc/soundfx-rs/default.env /etc/soundfx-rs/config.env
fi
chown soundfx /etc/soundfx-rs/config.env chown soundfx /etc/soundfx-rs/config.env
#DEBHELPER# #DEBHELPER#

4
debian/postrm vendored
View File

@ -4,8 +4,4 @@ set -e
id -u soundfx &>/dev/null || userdel soundfx id -u soundfx &>/dev/null || userdel soundfx
if [ -f /etc/soundfx-rs/config.env ]; then
rm /etc/soundfx/config.env
fi
#DEBHELPER# #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)
);

View File

@ -0,0 +1,6 @@
-- Add migration script here
ALTER TABLE `sounds` ADD UNIQUE INDEX `uploader_id_name` (`uploader_id`, `name`);
ALTER TABLE `sounds` ADD INDEX `name` (`name`);
ALTER TABLE `sounds` ADD INDEX `public` (`public`);
ALTER TABLE `sounds` ADD INDEX `uploader_id` (`uploader_id`);
ALTER TABLE `sounds` ADD INDEX `server_id` (`server_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

@ -33,6 +33,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
@ -57,7 +60,7 @@ __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();
ctx.send(|m| m.ephemeral(true) ctx.send(|m| m.ephemeral(true)
.embed(|e| e .embed(|e| e

View File

@ -35,7 +35,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,11 +56,10 @@ 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 =
.member(ctx.discord(), ctx.author().id) GuildId(*PATREON_GUILD).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(*PATREON_ROLE));
@ -89,9 +94,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?;
@ -123,8 +125,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

View File

@ -1,5 +1,6 @@
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;
@ -22,3 +23,19 @@ pub async fn autocomplete_sound(
}) })
.collect() .collect()
} }
pub async fn autocomplete_favorite(
ctx: Context<'_>,
partial: &str,
) -> Vec<poise::AutocompleteChoice<String>> {
ctx.data()
.autocomplete_favorite_sounds(&partial, ctx.author().id)
.await
.unwrap_or(vec![])
.iter()
.map(|s| poise::AutocompleteChoice {
name: s.name.clone(),
value: s.id.to_string(),
})
.collect()
}

View File

@ -1,5 +1,6 @@
use poise::serenity_prelude::{ use poise::serenity_prelude::{
builder::CreateActionRow, model::application::component::ButtonStyle, GuildChannel, builder::CreateActionRow, model::application::component::ButtonStyle, GuildChannel,
ReactionType,
}; };
use crate::{ use crate::{
@ -24,13 +25,9 @@ pub async fn play(
let guild = ctx.guild().unwrap(); let guild = ctx.guild().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,
@ -41,7 +38,6 @@ pub async fn play(
.await, .await,
) )
.await?; .await?;
}
Ok(()) Ok(())
} }
@ -142,7 +138,8 @@ pub async fn queue_play(
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_handler, _) =
join_channel(ctx.serenity_context(), guild.clone(), user_channel).await;
let guild_data = ctx let guild_data = ctx
.data() .data()
@ -231,7 +228,7 @@ pub async fn loop_play(
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,
@ -316,21 +313,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 +337,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![];
@ -392,7 +369,33 @@ pub async fn soundboard(
c.add_action_row(action_row); c.add_action_row(action_row);
} }
c c.create_action_row(|r| {
r.create_button(|b| {
b.label("Stop")
.emoji(ReactionType::Unicode("".to_string()))
.style(ButtonStyle::Danger)
.custom_id("#stop")
})
.create_button(|b| {
b.label("Mode:")
.style(ButtonStyle::Secondary)
.disabled(true)
.custom_id("#mode")
})
.create_button(|b| {
b.label("Instant")
.emoji(ReactionType::Unicode("".to_string()))
.style(ButtonStyle::Secondary)
.disabled(true)
.custom_id("#instant")
})
.create_button(|b| {
b.label("Loop")
.emoji(ReactionType::Unicode("🔁".to_string()))
.style(ButtonStyle::Secondary)
.custom_id("#loop")
})
})
}) })
}) })
.await?; .await?;

View File

@ -47,12 +47,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 +88,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,6 +118,7 @@ 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,
} }
} }
@ -205,6 +222,7 @@ 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?,
}; };
@ -228,6 +246,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())
@ -273,28 +292,3 @@ pub async fn search_sounds(
Ok(()) 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(())
}

View File

@ -61,9 +61,7 @@ pub async fn set_guild_greet_sound(
) -> Result<(), Error> { ) -> Result<(), Error> {
if user.id != ctx.author().id { if user.id != ctx.author().id {
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
let permissions = guild let permissions = guild.member_permissions(&ctx, ctx.author().id).await;
.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| {
@ -110,9 +108,7 @@ pub async fn unset_guild_greet_sound(
) -> Result<(), Error> { ) -> Result<(), Error> {
if user.id != ctx.author().id { if user.id != ctx.author().id {
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
let permissions = guild let permissions = guild.member_permissions(&ctx, ctx.author().id).await;
.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| {

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

@ -6,7 +6,7 @@ use poise::serenity_prelude::{
channel::Channel, channel::Channel,
}, },
utils::shard_id, utils::shard_id,
Activity, Context, ActionRowComponent, Activity, Context, CreateActionRow, CreateComponents,
}; };
use crate::{ use crate::{
@ -137,6 +137,9 @@ SELECT name, id, public, server_id, uploader_id
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 {
let mode = component.data.custom_id.as_str();
match mode {
"#stop" => {
component component
.create_interaction_response(ctx, |r| { .create_interaction_response(ctx, |r| {
r.kind(InteractionResponseType::DeferredUpdateMessage) r.kind(InteractionResponseType::DeferredUpdateMessage)
@ -144,19 +147,111 @@ SELECT name, id, public, server_id, uploader_id
.await .await
.unwrap(); .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" => {
component
.create_interaction_response(ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
let mut c: CreateComponents = Default::default();
for action_row in &component.message.components {
let mut a: CreateActionRow = Default::default();
// These are always buttons
for component in &action_row.components {
match component {
ActionRowComponent::Button(button) => {
a.create_button(|b| {
if let Some(label) =
&button.label
{
b.label(label);
}
if let Some(emoji) =
&button.emoji
{
b.emoji(emoji.clone());
}
if let Some(custom_id) =
&button.custom_id
{
if custom_id
.starts_with('#')
{
b.custom_id(custom_id)
.disabled(
custom_id
== "#mode"
|| custom_id
== mode,
);
} else {
b.custom_id(format!(
"{}{}",
custom_id
.split('#')
.next()
.unwrap(),
mode
));
}
}
b.style(button.style);
b
});
}
_ => {}
}
}
c.add_action_row(a);
}
d.set_components(c)
})
})
.await
.unwrap();
}
id_mode => {
component
.create_interaction_response(ctx, |r| {
r.kind(InteractionResponseType::DeferredUpdateMessage)
})
.await
.unwrap();
let mut it = id_mode.split('#');
let id = it.next().unwrap();
let mode = it.next().unwrap_or("instant");
play_from_query( play_from_query(
&ctx, &ctx,
&data, &data,
guild_id.to_guild_cached(&ctx).unwrap(), guild_id.to_guild_cached(&ctx).unwrap(),
component.user.id, component.user.id,
None, None,
&component.data.custom_id, id.split('#').next().unwrap(),
false, mode == "loop",
) )
.await; .await;
} }
} }
} }
}
}
_ => {} _ => {}
}, },
_ => {} _ => {}

View File

@ -92,9 +92,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()
}, },
poise::Command {
subcommands: vec![
cmds::favorite::add_favorite(),
cmds::favorite::remove_favorite(),
],
..cmds::favorite::favorites()
},
cmds::search::show_random_sounds(), cmds::search::show_random_sounds(),
cmds::search::search_sounds(), cmds::search::search_sounds(),
cmds::stop::stop_playing(), cmds::stop::stop_playing(),
@ -124,7 +132,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()
}; };
@ -136,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
poise::Framework::builder() poise::Framework::builder()
.token(discord_token) .token(discord_token)
.user_data_setup(move |ctx, _bot, framework| { .setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
register_application_commands(ctx, framework, None) register_application_commands(ctx, framework, None)
.await .await

View File

@ -37,17 +37,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 +105,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 +129,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 +159,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 +191,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 +252,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 +267,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 +334,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 +350,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 +371,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,
@ -298,8 +410,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)
@ -348,8 +459,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 +482,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 +503,7 @@ UPDATE sounds
SET SET
public = ? public = ?
WHERE WHERE
id = ? id = ?",
",
self.public, self.public,
self.id self.id
) )
@ -416,6 +524,42 @@ WHERE
Ok(()) 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!(
"INSERT INTO favorite_sounds (user_id, sound_id) VALUES (?, ?)",
user_id,
self.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
)
.execute(db_pool)
.await?;
Ok(())
}
pub async fn create_anon<G: Into<u64>, U: Into<u64>>( pub async fn create_anon<G: Into<u64>, U: Into<u64>>(
name: &str, name: &str,
src_url: &str, src_url: &str,
@ -461,8 +605,7 @@ WHERE
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

@ -22,13 +22,13 @@ 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, track_handler) = create_player(sound.playable(db_pool).await?.into());
let _ = track_handler.set_volume(volume as f32 / 100.0); let _ = track_handler.set_volume(volume as f32 / 100.0);
if loop_ { if r#loop {
let _ = track_handler.enable_loop(); let _ = track_handler.enable_loop();
} else { } else {
let _ = track_handler.disable_loop(); let _ = track_handler.disable_loop();