30 Commits

Author SHA1 Message Date
30fda2b0ee Merge remote-tracking branch 'origin/rewrite' into rewrite 2023-03-23 19:24:53 +00:00
5fc7584100 Load environment from file 2023-03-23 19:24:43 +00:00
de0584e2f4 Update README.md 2023-03-23 15:49:10 +00:00
f7b0150688 Add services 2023-03-23 13:41:15 +00:00
31ee6b4540 Move database migrations to SQLx 2023-03-23 11:38:53 +00:00
4edcee2567 Split greet sound enabling 2023-03-22 18:31:49 +00:00
208440a7ff Restrict play channels to voice channels 2022-11-21 13:39:03 +00:00
b8b17a504d Update poise 2022-11-20 11:30:02 +00:00
6307de331d Add option for playing in separate channel
Play sounds in another channel even if not connected.
2022-11-20 10:55:16 +00:00
64e7eb4a53 order by ID descending 2022-09-15 13:16:05 +01:00
5ce9ca3923 Add IDs to /list results 2022-09-15 09:13:44 +01:00
52327b3695 Fix soundboards 2022-09-13 14:40:53 +01:00
365d1df4ce Bump version 2022-09-13 12:38:11 +01:00
189cb195a4 Add paging for list commands 2022-09-13 12:37:50 +01:00
a05d6f77db Remove unused files. Fix compiler warnings. Update help 2022-09-13 10:46:45 +01:00
f5acab7440 Join sounds per-server 2022-07-31 14:56:38 +01:00
31b6f7a0ab update serenity 2022-07-20 17:10:16 +01:00
5ade3f83a8 Dep bump 2022-07-14 17:56:36 +01:00
f3c6db036e block commands in DM 2022-05-21 17:04:04 +01:00
651ad9dffe defer upload 2022-05-21 13:54:13 +01:00
405fa08c2f defer play 2022-05-14 10:33:41 +01:00
50365c3215 fix permission checks 2022-05-13 15:20:39 +01:00
9d588e7e03 deps 2022-05-13 13:18:18 +01:00
7a1a1c637f move activity to ready 2022-05-13 12:51:02 +01:00
fe85f82a09 added activity 2022-05-07 19:42:59 +01:00
27f678b978 update readme 2022-05-05 10:19:19 +01:00
8dbf11bb68 cleaned up 2022-05-05 10:17:54 +01:00
3a70f65ec2 Merge remote-tracking branch 'origin/rewrite' into rewrite
# Conflicts:
#	README.md
2022-05-05 10:16:53 +01:00
3fef235839 cleaned up 2022-05-05 10:16:40 +01:00
a6956d9344 Merge pull request #4 from JellyWX/poise
Poise
2022-05-05 10:10:37 +01:00
35 changed files with 1510 additions and 717 deletions

View File

@ -1,2 +0,0 @@
[build]
target-dir = "/home/jude/.rust_build/soundfx-rs"

View File

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

1
.idea/sqldialects.xml generated
View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="SqlDialectMappings"> <component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/create.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="MySQL" /> <file url="PROJECT" dialect="MySQL" />
</component> </component>
</project> </project>

1146
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,38 @@
[package] [package]
name = "soundfx-rs" name = "soundfx-rs"
version = "1.5.0" description = "Discord bot for custom sound effects and soundboards"
license = "AGPL-3.0-only"
version = "1.5.7"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
songbird = { git = "https://github.com/serenity-rs/songbird", branch = "next", features = ["builtin-queue"] } songbird = { version = "0.3", features = ["builtin-queue"] }
poise = { git = "https://github.com/jellywx/poise", branch = "jellywx-pv2" } poise = "0.3"
sqlx = { version = "0.5", default-features = false, features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal"] } sqlx = { version = "0.5", default-features = false, features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "migrate"] }
dotenv = "0.15"
tokio = { version = "1", features = ["fs", "process", "io-util"] } tokio = { version = "1", features = ["fs", "process", "io-util"] }
lazy_static = "1.4" lazy_static = "1.4"
reqwest = "0.11" reqwest = "0.11"
env_logger = "0.8" env_logger = "0.10"
regex = "1.4" regex = "1.4"
log = "0.4" log = "0.4"
serde_json = "1.0" serde_json = "1.0"
dashmap = "4.0" dashmap = "5.3"
serde = "1.0"
dotenv = "0.15.0"
[patch."https://github.com/serenity-rs/serenity"] [patch."https://github.com/serenity-rs/serenity"]
serenity = { git = "https://github.com//serenity-rs/serenity", branch = "current" } serenity = { version = "0.11.5" }
[package.metadata.deb]
depends = "$auto, ffmpeg"
suggests = "mysql-server-8.0"
maintainer-scripts = "debian"
assets = [
["target/release/soundfx-rs", "usr/bin/soundfx-rs", "755"],
["conf/default.env", "etc/soundfx-rs/default.env", "600"]
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

View File

@ -1,26 +1,33 @@
# SoundFX 2 # SoundFX
## The complete (second) Rust rewrite of SoundFX
SoundFX 2 is the Rust rewrite of SoundFX. SoundFX 2 attempts to retain all functionality of the original bot, in a more A bot for managing sound effects in Discord.
efficient and robust package. SoundFX 2 is as asynchronous as it can get, and runs on the Tokio runtime.
### Building ## Installing
Run the migrations in the `migrations` directory to set up the database. Download a .deb file from the releases and install with `sudo apt install ./soundfx_rs-a.b.c_arm64.deb`. You will also need a database set up. Install MySQL 8.
Use Cargo to build the executable. ## Running & config
### Running & Config The bot is installed as a systemd service `soundfx-rs`. Use `systemctl start soundfx-rs` and `systemctl stop soundfx-rs` to respectively start and stop the bot.
The bot connects to the MySQL server URL defined in the environment. Config options are provided in a file `/etc/soundfx-rs/default.env`
Environment variables read: Options:
* `DISCORD_TOKEN`- your token (required) * `DISCORD_TOKEN`- your token (required)
* `DATABASE_URL`- your database URL (required) * `DATABASE_URL`- your database URL (required)
* `UPLOAD_MAX_SIZE`- specifies the maximum file size to allow in bytes (defaults to 2097152 (2MB)) * `MAX_SOUNDS`- specifies how many sounds a user should be allowed without having the `PATREON_ROLE` specified below
* `MAX_SOUNDS`- specifies how many sounds a user should be allowed without Patreon
* `PATREON_GUILD`- specifies the ID of the guild being used for Patreon benefits * `PATREON_GUILD`- specifies the ID of the guild being used for Patreon benefits
* `PATREON_ROLE`- specifies the role being checked for Patreon benefits * `PATREON_ROLE`- specifies the role being checked for Patreon benefits
* `CACHING_LOCATION`- specifies the location in which to cache the audio files (defaults to `/tmp/`) * `CACHING_LOCATION`- specifies the location in which to cache the audio files (defaults to `/tmp/`)
* `UPLOAD_MAX_SIZE`- specifies the maximum upload size to permit in bytes. Defaults to 2MB
The bot will also consider variables in a `.env` file in the working directory. ## Building from source
1. Install build dependencies: `sudo apt install gcc gcc-multilib cmake ffmpeg libopus-dev`
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
4. Install SQLx CLI: `cargo install sqlx-cli`
5. From the source code directory, execute `sqlx migrate run`
6. Build with cargo: `cargo build --release`
When running from source, the config options above can be configured simply as environment variables.

View File

@ -1,7 +0,0 @@
{
"heavy rain": "243627__lebaston100__heavy-rain.wav",
"rain on window": "rain-on-windows-cropped.wav",
"rain on tent": "531947__straget__the-rain-falls-against-the-parasol.wav",
"waves": "400632__inspectorj__ambience-seaside-waves-close-a.wav",
"river": "459407__pfannkuchn__small-river-1-fast-close.wav"
}

Binary file not shown.

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

7
conf/default.env Normal file
View File

@ -0,0 +1,7 @@
DISCORD_TOKEN=
DATABASE_URL=mysql://localhost/soundfx
UPLOAD_MAX_SIZE=2097152
MAX_SOUNDS=8
CACHING_LOCATION=/tmp
PATREON_GUILD=
PATREON_ROLE=

2
debian/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,10 @@
CREATE TABLE join_sounds (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user` BIGINT UNSIGNED NOT NULL,
`join_sound_id` INT UNSIGNED NOT NULL,
`guild` BIGINT UNSIGNED,
FOREIGN KEY (`join_sound_id`) REFERENCES sounds(id) ON DELETE CASCADE,
PRIMARY KEY (`id`)
);
INSERT INTO join_sounds (`user`, `join_sound_id`) SELECT `user`, `join_sound_id` FROM `users` WHERE `join_sound_id` is not null;

View File

@ -0,0 +1 @@
ALTER TABLE servers MODIFY COLUMN allow_greets INT NOT NULL DEFAULT 1;

View File

@ -4,7 +4,7 @@ use crate::{consts::THEME_COLOR, Context, Error};
#[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| {
m.embed(|e| { m.ephemeral(true).embed(|e| {
e.title("Help") e.title("Help")
.color(THEME_COLOR) .color(THEME_COLOR)
.footer(|f| { .footer(|f| {
@ -21,6 +21,7 @@ pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
__Play Commands__ __Play Commands__
`/play` - Play a sound by name or ID `/play` - Play a sound by name or ID
`/queue` - Play sounds on queue instead of instantly
`/loop` - Play a sound on loop `/loop` - Play a sound on loop
`/disconnect` - Disconnect the bot `/disconnect` - Disconnect the bot
`/stop` - Stop playback `/stop` - Stop playback
@ -38,7 +39,8 @@ __Search Commands__
`/random` - View random public sounds `/random` - View random public sounds
__Setting Commands__ __Setting Commands__
`/greet set/unset` - Set or unset a join sound `/greet server set/unset` - Set or unset a join sound for just this server
`/greet user set/unset` - Set or unset a join sound across all servers
`/greet enable/disable` - Enable or disable join sounds on this server `/greet enable/disable` - Enable or disable join sounds on this server
`/volume` - Change the volume `/volume` - Change the volume
@ -57,7 +59,7 @@ __Advanced Commands__
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.discord().cache.current_user();
ctx.send(|m| m ctx.send(|m| m.ephemeral(true)
.embed(|e| e .embed(|e| e
.title("Info") .title("Info")
.color(THEME_COLOR) .color(THEME_COLOR)

View File

@ -13,13 +13,16 @@ use crate::{
slash_command, slash_command,
rename = "upload", rename = "upload",
category = "Manage", category = "Manage",
required_permissions = "MANAGE_GUILD" default_member_permissions = "MANAGE_GUILD",
guild_only = true
)] )]
pub async fn upload_new_sound( pub async fn upload_new_sound(
ctx: Context<'_>, ctx: Context<'_>,
#[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> {
ctx.defer().await?;
fn is_numeric(s: &String) -> bool { fn is_numeric(s: &String) -> bool {
for char in s.chars() { for char in s.chars() {
if char.is_digit(10) { if char.is_digit(10) {
@ -98,7 +101,7 @@ pub async fn upload_new_sound(
} }
/// Delete a sound you have uploaded /// Delete a sound you have uploaded
#[poise::command(slash_command, rename = "delete", category = "Manage")] #[poise::command(slash_command, rename = "delete", guild_only = true)]
pub async fn delete_sound( pub async fn delete_sound(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to delete"] #[description = "Name or ID of sound to delete"]
@ -151,7 +154,7 @@ pub async fn delete_sound(
} }
/// Change a sound between public and private /// Change a sound between public and private
#[poise::command(slash_command, rename = "public", category = "Manage")] #[poise::command(slash_command, rename = "public", guild_only = true)]
pub async fn change_public( pub async fn change_public(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to change privacy setting of"] #[description = "Name or ID of sound to change privacy setting of"]
@ -194,7 +197,7 @@ pub async fn change_public(
} }
/// Download a sound file from the bot /// Download a sound file from the bot
#[poise::command(slash_command, rename = "download", category = "Manage")] #[poise::command(slash_command, rename = "download", guild_only = true)]
pub async fn download_file( pub async fn download_file(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to download"] #[description = "Name or ID of sound to download"]

View File

@ -9,7 +9,7 @@ pub mod stop;
pub async fn autocomplete_sound( pub async fn autocomplete_sound(
ctx: Context<'_>, ctx: Context<'_>,
partial: String, partial: &str,
) -> Vec<poise::AutocompleteChoice<String>> { ) -> 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())

View File

@ -1,5 +1,5 @@
use poise::serenity::{ use poise::serenity_prelude::{
builder::CreateActionRow, model::interactions::message_component::ButtonStyle, builder::CreateActionRow, model::application::component::ButtonStyle, GuildChannel,
}; };
use crate::{ use crate::{
@ -10,33 +10,49 @@ use crate::{
}; };
/// Play a sound in your current voice channel /// Play a sound in your current voice channel
#[poise::command(slash_command, required_permissions = "SPEAK")] #[poise::command(slash_command, default_member_permissions = "SPEAK", guild_only = true)]
pub async fn play( pub async fn play(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to play"] #[description = "Name or ID of sound to play"]
#[autocomplete = "autocomplete_sound"] #[autocomplete = "autocomplete_sound"]
name: String, name: String,
#[description = "Channel to play in (default: your current voice channel)"]
#[channel_types("Voice")]
channel: Option<GuildChannel>,
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?;
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
ctx.say( if channel.as_ref().map_or(false, |c| c.is_text_based()) {
play_from_query( ctx.say("The channel specified is not a voice channel.")
&ctx.discord(), .await?;
&ctx.data(), } else {
guild, ctx.say(
ctx.author().id, play_from_query(
&name, &ctx.discord(),
false, &ctx.data(),
guild,
ctx.author().id,
channel.map(|c| c.id),
&name,
false,
)
.await,
) )
.await, .await?;
) }
.await?;
Ok(()) Ok(())
} }
/// Play up to 25 sounds on queue /// Play up to 25 sounds on queue
#[poise::command(slash_command, rename = "queue", required_permissions = "SPEAK")] #[poise::command(
slash_command,
rename = "queue",
default_member_permissions = "SPEAK",
guild_only = true
)]
pub async fn queue_play( pub async fn queue_play(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID for queue position 1"] #[description = "Name or ID for queue position 1"]
@ -115,7 +131,7 @@ pub async fn queue_play(
#[autocomplete = "autocomplete_sound"] #[autocomplete = "autocomplete_sound"]
sound_25: Option<String>, sound_25: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let _ = ctx.defer().await; ctx.defer().await?;
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
@ -197,13 +213,20 @@ pub async fn queue_play(
} }
/// Loop a sound in your current voice channel /// Loop a sound in your current voice channel
#[poise::command(slash_command, rename = "loop", required_permissions = "SPEAK")] #[poise::command(
slash_command,
rename = "loop",
default_member_permissions = "SPEAK",
guild_only = true
)]
pub async fn loop_play( pub async fn loop_play(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to loop"] #[description = "Name or ID of sound to loop"]
#[autocomplete = "autocomplete_sound"] #[autocomplete = "autocomplete_sound"]
name: String, name: String,
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?;
let guild = ctx.guild().unwrap(); let guild = ctx.guild().unwrap();
ctx.say( ctx.say(
@ -212,6 +235,7 @@ pub async fn loop_play(
&ctx.data(), &ctx.data(),
guild, guild,
ctx.author().id, ctx.author().id,
None,
&name, &name,
true, true,
) )
@ -227,7 +251,8 @@ pub async fn loop_play(
slash_command, slash_command,
rename = "soundboard", rename = "soundboard",
category = "Play", category = "Play",
required_permissions = "SPEAK" default_member_permissions = "SPEAK",
guild_only = true
)] )]
pub async fn soundboard( pub async fn soundboard(
ctx: Context<'_>, ctx: Context<'_>,

View File

@ -1,8 +1,19 @@
use poise::{serenity::constants::MESSAGE_CODE_LIMIT, CreateReply}; use poise::{
serenity_prelude,
serenity_prelude::{
application::component::ButtonStyle,
constants::MESSAGE_CODE_LIMIT,
interaction::{message_component::MessageComponentInteraction, InteractionResponseType},
CreateActionRow, CreateEmbed, GuildId, UserId,
},
CreateReply,
};
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
consts::THEME_COLOR,
models::sound::{Sound, SoundCtx}, models::sound::{Sound, SoundCtx},
Context, Error, Context, Data, Error,
}; };
fn format_search_results<'a>(search_results: Vec<Sound>) -> CreateReply<'a> { fn format_search_results<'a>(search_results: Vec<Sound>) -> CreateReply<'a> {
@ -27,83 +38,224 @@ fn format_search_results<'a>(search_results: Vec<Sound>) -> CreateReply<'a> {
} }
/// Show uploaded sounds /// Show uploaded sounds
#[poise::command(slash_command, rename = "list")] #[poise::command(slash_command, rename = "list", guild_only = true)]
pub async fn list_sounds(_ctx: Context<'_>) -> Result<(), Error> { pub async fn list_sounds(_ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Show the sounds uploaded to this server #[derive(Serialize, Deserialize, Clone, Copy)]
#[poise::command(slash_command, rename = "server")] enum ListContext {
pub async fn list_guild_sounds(ctx: Context<'_>) -> Result<(), Error> { User = 0,
let sounds; Guild = 1,
let mut message_buffer; }
sounds = ctx.data().guild_sounds(ctx.guild_id().unwrap()).await?; impl ListContext {
pub fn title(&self) -> &'static str {
message_buffer = "Sounds on this server: ".to_string(); match self {
ListContext::User => "Your sounds",
// todo change this to iterator ListContext::Guild => "Server sounds",
for sound in sounds {
message_buffer.push_str(
format!(
"**{}** ({}), ",
sound.name,
if sound.public { "🔓" } else { "🔒" }
)
.as_str(),
);
if message_buffer.len() > 2000 {
ctx.say(message_buffer).await?;
message_buffer = "".to_string();
} }
} }
}
if message_buffer.len() > 0 { /// Show the sounds uploaded to this server
ctx.say(message_buffer).await?; #[poise::command(slash_command, rename = "server", guild_only = true)]
} pub async fn list_guild_sounds(ctx: Context<'_>) -> Result<(), Error> {
let pager = SoundPager {
nonce: 0,
page: 0,
context: ListContext::Guild,
};
pager.reply(ctx).await?;
Ok(()) Ok(())
} }
/// Show all sounds you have uploaded /// Show all sounds you have uploaded
#[poise::command(slash_command, rename = "user")] #[poise::command(slash_command, rename = "user", guild_only = true)]
pub async fn list_user_sounds(ctx: Context<'_>) -> Result<(), Error> { pub async fn list_user_sounds(ctx: Context<'_>) -> Result<(), Error> {
let sounds; let pager = SoundPager {
let mut message_buffer; nonce: 0,
page: 0,
context: ListContext::User,
};
sounds = ctx.data().user_sounds(ctx.author().id).await?; pager.reply(ctx).await?;
message_buffer = "Sounds on this server: ".to_string();
// todo change this to iterator
for sound in sounds {
message_buffer.push_str(
format!(
"**{}** ({}), ",
sound.name,
if sound.public { "🔓" } else { "🔒" }
)
.as_str(),
);
if message_buffer.len() > 2000 {
ctx.say(message_buffer).await?;
message_buffer = "".to_string();
}
}
if message_buffer.len() > 0 {
ctx.say(message_buffer).await?;
}
Ok(()) Ok(())
} }
#[derive(Serialize, Deserialize)]
pub struct SoundPager {
nonce: u64,
page: u64,
context: ListContext,
}
impl SoundPager {
async fn get_page(
&self,
data: &Data,
user_id: UserId,
guild_id: GuildId,
) -> Result<Vec<Sound>, sqlx::Error> {
match self.context {
ListContext::User => data.user_sounds(user_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 {
let mut row = CreateActionRow::default();
row.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager {
nonce: 0,
page: 0,
context: self.context,
})
.unwrap(),
)
.style(ButtonStyle::Primary)
.label("")
.disabled(self.page == 0)
})
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager {
nonce: 1,
page: self.page.saturating_sub(1),
context: self.context,
})
.unwrap(),
)
.style(ButtonStyle::Secondary)
.label("◀️")
.disabled(self.page == 0)
})
.create_button(|b| {
b.custom_id("pid")
.style(ButtonStyle::Success)
.label(format!("Page {}", self.page + 1))
.disabled(true)
})
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager {
nonce: 2,
page: self.page.saturating_add(1),
context: self.context,
})
.unwrap(),
)
.style(ButtonStyle::Secondary)
.label("▶️")
.disabled(self.page == max_page)
})
.create_button(|b| {
b.custom_id(
serde_json::to_string(&SoundPager {
nonce: 3,
page: max_page,
context: self.context,
})
.unwrap(),
)
.style(ButtonStyle::Primary)
.label("")
.disabled(self.page == max_page)
});
row
}
fn embed(&self, sounds: &[Sound], count: u64) -> CreateEmbed {
let mut embed = CreateEmbed::default();
embed
.color(THEME_COLOR)
.title(self.context.title())
.description(format!("**{}** sounds:", count))
.fields(sounds.iter().map(|s| {
(
s.name.as_str(),
format!(
"ID: `{}`\n{}",
s.id,
if s.public { "*Public*" } else { "*Private*" }
),
true,
)
}));
embed
}
pub async fn handle_interaction(
ctx: &serenity_prelude::Context,
data: &Data,
interaction: &MessageComponentInteraction,
) -> Result<(), Error> {
let user_id = interaction.user.id;
let guild_id = interaction.guild_id.unwrap();
let pager = serde_json::from_str::<Self>(&interaction.data.custom_id)?;
let sounds = pager.get_page(data, user_id, guild_id).await?;
let count = match pager.context {
ListContext::User => data.count_user_sounds(user_id).await?,
ListContext::Guild => data.count_guild_sounds(guild_id).await?,
};
interaction
.create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
d.ephemeral(true)
.add_embed(pager.embed(&sounds, count))
.components(|c| c.add_action_row(pager.create_action_row(count / 25)))
})
})
.await?;
Ok(())
}
async fn reply(&self, ctx: Context<'_>) -> Result<(), Error> {
let sounds = self
.get_page(ctx.data(), ctx.author().id, ctx.guild_id().unwrap())
.await?;
let count = match self.context {
ListContext::User => ctx.data().count_user_sounds(ctx.author().id).await?,
ListContext::Guild => {
ctx.data()
.count_guild_sounds(ctx.guild_id().unwrap())
.await?
}
};
ctx.send(|r| {
r.ephemeral(true)
.embed(|e| {
*e = self.embed(&sounds, count);
e
})
.components(|c| c.add_action_row(self.create_action_row(count / 25)))
})
.await?;
Ok(())
}
}
/// Search for sounds /// Search for sounds
#[poise::command(slash_command, rename = "search", category = "Search")] #[poise::command(
slash_command,
rename = "search",
category = "Search",
guild_only = true
)]
pub async fn search_sounds( pub async fn search_sounds(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Sound name to search for"] query: String, #[description = "Sound name to search for"] query: String,
@ -123,7 +275,7 @@ pub async fn search_sounds(
} }
/// Show a page of random sounds /// Show a page of random sounds
#[poise::command(slash_command, rename = "random")] #[poise::command(slash_command, rename = "random", guild_only = true)]
pub async fn show_random_sounds(ctx: Context<'_>) -> Result<(), Error> { pub async fn show_random_sounds(ctx: Context<'_>) -> Result<(), Error> {
let search_results = sqlx::query_as_unchecked!( let search_results = sqlx::query_as_unchecked!(
Sound, Sound,

View File

@ -1,10 +1,17 @@
use poise::serenity_prelude::{GuildId, User};
use crate::{ use crate::{
models::{guild_data::CtxGuildData, join_sound::JoinSoundCtx, sound::SoundCtx}, cmds::autocomplete_sound,
models::{
guild_data::{AllowGreet, CtxGuildData},
join_sound::JoinSoundCtx,
sound::SoundCtx,
},
Context, Error, Context, Error,
}; };
/// Change the bot's volume in this server /// Change the bot's volume in this server
#[poise::command(slash_command, rename = "volume")] #[poise::command(slash_command, rename = "volume", guild_only = true)]
pub async fn change_volume( pub async fn change_volume(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "New volume as a percentage"] volume: Option<usize>, #[description = "New volume as a percentage"] volume: Option<usize>,
@ -31,18 +38,44 @@ pub async fn change_volume(
Ok(()) Ok(())
} }
/// Manage greet sounds on this server /// Manage greet sounds
#[poise::command(slash_command, rename = "greet")] #[poise::command(slash_command, rename = "greet", guild_only = true)]
pub async fn greet_sound(_ctx: Context<'_>) -> Result<(), Error> { pub async fn greet_sound(_ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Set a join sound /// Manage greet sounds in this server
#[poise::command(slash_command, rename = "server")]
pub async fn guild_greet_sound(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set a user's server-specific join sound
#[poise::command(slash_command, rename = "set")] #[poise::command(slash_command, rename = "set")]
pub async fn set_greet_sound( pub async fn set_guild_greet_sound(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name or ID of sound to set as your join sound"] name: String, #[description = "Name or ID of sound to set as join sound"]
#[autocomplete = "autocomplete_sound"]
name: String,
#[description = "User to set join sound for"] user: User,
) -> Result<(), Error> { ) -> Result<(), Error> {
if user.id != ctx.author().id {
let guild = ctx.guild().unwrap();
let permissions = guild
.member_permissions(&ctx.discord(), ctx.author().id)
.await;
if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(|b| {
b.ephemeral(true)
.content("Only admins can change other user's greet sounds.")
})
.await?;
return Ok(());
}
}
let sound_vec = ctx let sound_vec = ctx
.data() .data()
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true) .search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
@ -51,8 +84,8 @@ pub async fn set_greet_sound(
match sound_vec.first() { match sound_vec.first() {
Some(sound) => { Some(sound) => {
ctx.data() ctx.data()
.update_join_sound(ctx.author().id, Some(sound.id)) .update_join_sound(user.id, ctx.guild_id(), Some(sound.id))
.await; .await?;
ctx.say(format!( ctx.say(format!(
"Greet sound has been set to {} (ID {})", "Greet sound has been set to {} (ID {})",
@ -69,23 +102,109 @@ pub async fn set_greet_sound(
Ok(()) Ok(())
} }
/// Set a join sound /// Unset a user's server-specific join sound
#[poise::command(slash_command, rename = "unset")] #[poise::command(slash_command, rename = "unset", guild_only = true)]
pub async fn unset_greet_sound(ctx: Context<'_>) -> Result<(), Error> { pub async fn unset_guild_greet_sound(
ctx.data().update_join_sound(ctx.author().id, None).await; ctx: Context<'_>,
#[description = "User to set join sound for"] user: User,
) -> Result<(), Error> {
if user.id != ctx.author().id {
let guild = ctx.guild().unwrap();
let permissions = guild
.member_permissions(&ctx.discord(), ctx.author().id)
.await;
if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(|b| {
b.ephemeral(true)
.content("Only admins can change other user's greet sounds.")
})
.await?;
return Ok(());
}
}
ctx.data()
.update_join_sound(user.id, ctx.guild_id(), None)
.await?;
ctx.say("Greet sound has been unset").await?; ctx.say("Greet sound has been unset").await?;
Ok(()) Ok(())
} }
/// Disable greet sounds on this server /// Manage your own greet sound
#[poise::command(slash_command, rename = "disable")] #[poise::command(slash_command, rename = "user")]
pub async fn user_greet_sound(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set your global join sound
#[poise::command(slash_command, rename = "set")]
pub async fn set_user_greet_sound(
ctx: Context<'_>,
#[description = "Name or ID of sound to set as your join sound"]
#[autocomplete = "autocomplete_sound"]
name: String,
) -> Result<(), Error> {
let sound_vec = ctx
.data()
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
.await?;
match sound_vec.first() {
Some(sound) => {
ctx.data()
.update_join_sound(ctx.author().id, None::<GuildId>, Some(sound.id))
.await?;
ctx.send(|b| {
b.ephemeral(true).content(format!(
"Greet sound has been set to {} (ID {})",
sound.name, sound.id
))
})
.await?;
}
None => {
ctx.send(|b| {
b.ephemeral(true)
.content("Could not find a sound by that name.")
})
.await?;
}
}
Ok(())
}
/// Unset your global join sound
#[poise::command(slash_command, rename = "unset", guild_only = true)]
pub async fn unset_user_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
ctx.data()
.update_join_sound(ctx.author().id, None::<GuildId>, None)
.await?;
ctx.send(|b| b.ephemeral(true).content("Greet sound has been unset"))
.await?;
Ok(())
}
/// Disable all greet sounds on this server
#[poise::command(
slash_command,
rename = "disable",
guild_only = true,
required_permissions = "MANAGE_GUILD"
)]
pub async fn disable_greet_sound(ctx: Context<'_>) -> Result<(), Error> { pub async fn disable_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await; let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await;
if let Ok(guild_data) = guild_data_opt { if let Ok(guild_data) = guild_data_opt {
guild_data.write().await.allow_greets = false; guild_data.write().await.allow_greets = AllowGreet::Disabled;
guild_data.read().await.commit(&ctx.data().database).await?; guild_data.read().await.commit(&ctx.data().database).await?;
} }
@ -96,13 +215,40 @@ pub async fn disable_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Enable greet sounds on this server /// Enable only server greet sounds on this server
#[poise::command(slash_command, rename = "enable")] #[poise::command(
slash_command,
rename = "enable",
guild_only = true,
required_permissions = "MANAGE_GUILD"
)]
pub async fn enable_guild_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await;
if let Ok(guild_data) = guild_data_opt {
guild_data.write().await.allow_greets = AllowGreet::GuildOnly;
guild_data.read().await.commit(&ctx.data().database).await?;
}
ctx.say("Greet sounds have been partially enable in this server. Use \"/greet server set\" to configure server greet sounds.")
.await?;
Ok(())
}
/// Enable all greet sounds on this server
#[poise::command(
slash_command,
rename = "enable",
guild_only = true,
required_permissions = "MANAGE_GUILD"
)]
pub async fn enable_greet_sound(ctx: Context<'_>) -> Result<(), Error> { pub async fn enable_greet_sound(ctx: Context<'_>) -> Result<(), Error> {
let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await; let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await;
if let Ok(guild_data) = guild_data_opt { if let Ok(guild_data) = guild_data_opt {
guild_data.write().await.allow_greets = true; guild_data.write().await.allow_greets = AllowGreet::Enabled;
guild_data.read().await.commit(&ctx.data().database).await?; guild_data.read().await.commit(&ctx.data().database).await?;
} }

View File

@ -3,7 +3,12 @@ use songbird;
use crate::{Context, Error}; use crate::{Context, Error};
/// Stop the bot from playing and clear the play queue /// Stop the bot from playing and clear the play queue
#[poise::command(slash_command, rename = "stop", required_permissions = "MANAGE_GUILD")] #[poise::command(
slash_command,
rename = "stop",
default_member_permissions = "SPEAK",
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.discord()).await.unwrap();
let call_opt = songbird.get(ctx.guild_id().unwrap()); let call_opt = songbird.get(ctx.guild_id().unwrap());
@ -20,7 +25,7 @@ pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> {
} }
/// Disconnect the bot /// Disconnect the bot
#[poise::command(slash_command, required_permissions = "SPEAK")] #[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.discord()).await.unwrap();
let _ = songbird.leave(ctx.guild_id().unwrap()).await; let _ = songbird.leave(ctx.guild_id().unwrap()).await;

View File

@ -7,7 +7,10 @@ lazy_static! {
.unwrap_or_else(|_| "2097152".to_string()) .unwrap_or_else(|_| "2097152".to_string())
.parse::<u64>() .parse::<u64>()
.unwrap(); .unwrap();
pub static ref MAX_SOUNDS: u32 = env::var("MAX_SOUNDS").unwrap().parse::<u32>().unwrap(); pub static ref MAX_SOUNDS: u32 = env::var("MAX_SOUNDS")
.unwrap_or_else(|_| "8".to_string())
.parse::<u32>()
.unwrap();
pub static ref PATREON_GUILD: u64 = env::var("PATREON_GUILD").unwrap().parse::<u64>().unwrap(); pub static ref PATREON_GUILD: u64 = env::var("PATREON_GUILD").unwrap().parse::<u64>().unwrap();
pub static ref PATREON_ROLE: u64 = env::var("PATREON_ROLE").unwrap().parse::<u64>().unwrap(); pub static ref PATREON_ROLE: u64 = env::var("PATREON_ROLE").unwrap().parse::<u64>().unwrap();
} }

View File

@ -1,22 +1,30 @@
use std::{collections::HashMap, env}; use std::{collections::HashMap, env};
use poise::serenity::{ use poise::serenity_prelude::{
model::{ model::{
application::interaction::{Interaction, InteractionResponseType},
channel::Channel, channel::Channel,
interactions::{Interaction, InteractionResponseType},
}, },
prelude::Context,
utils::shard_id, utils::shard_id,
Activity, Context,
}; };
use crate::{ use crate::{
models::{guild_data::CtxGuildData, join_sound::JoinSoundCtx, sound::Sound}, cmds::search::SoundPager,
models::{
guild_data::{AllowGreet, CtxGuildData},
join_sound::JoinSoundCtx,
sound::Sound,
},
utils::{join_channel, play_audio, play_from_query}, utils::{join_channel, play_audio, play_from_query},
Data, Error, Data, Error,
}; };
pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> { pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> {
match event { match event {
poise::Event::Ready { .. } => {
ctx.set_activity(Activity::watching("for /play")).await;
}
poise::Event::GuildCreate { guild, is_new, .. } => { poise::Event::GuildCreate { guild, is_new, .. } => {
if *is_new { if *is_new {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
@ -85,8 +93,15 @@ pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> R
allowed_greets = read.allow_greets; allowed_greets = read.allow_greets;
} }
if allowed_greets { if allowed_greets != AllowGreet::Disabled {
if let Some(join_id) = data.join_sound(new.user_id).await { if let Some(join_id) = data
.join_sound(
new.user_id,
new.guild_id,
allowed_greets == AllowGreet::GuildOnly,
)
.await
{
let mut sound = sqlx::query_as_unchecked!( let mut sound = sqlx::query_as_unchecked!(
Sound, Sound,
" "
@ -119,23 +134,27 @@ SELECT name, id, public, server_id, uploader_id
} }
poise::Event::InteractionCreate { interaction } => match interaction { poise::Event::InteractionCreate { interaction } => match interaction {
Interaction::MessageComponent(component) => { Interaction::MessageComponent(component) => {
if component.guild_id.is_some() { if let Some(guild_id) = component.guild_id {
play_from_query( if let Ok(()) = SoundPager::handle_interaction(ctx, &data, component).await {
&ctx, } else {
&data, component
component.guild_id.unwrap().to_guild_cached(&ctx).unwrap(), .create_interaction_response(ctx, |r| {
component.user.id, r.kind(InteractionResponseType::DeferredUpdateMessage)
&component.data.custom_id, })
false, .await
) .unwrap();
.await;
component play_from_query(
.create_interaction_response(ctx, |r| { &ctx,
r.kind(InteractionResponseType::DeferredUpdateMessage) &data,
}) guild_id.to_guild_cached(&ctx).unwrap(),
.await component.user.id,
.unwrap(); None,
&component.data.custom_id,
false,
)
.await;
}
} }
} }
_ => {} _ => {}

View File

@ -8,14 +8,13 @@ mod event_handlers;
mod models; mod models;
mod utils; mod utils;
use std::{env, sync::Arc}; use std::{env, path::Path, sync::Arc};
use dashmap::DashMap; use dashmap::DashMap;
use dotenv::dotenv; use poise::serenity_prelude::{
use poise::serenity::{
builder::CreateApplicationCommands, builder::CreateApplicationCommands,
model::{ model::{
gateway::{Activity, GatewayIntents}, gateway::GatewayIntents,
id::{GuildId, UserId}, id::{GuildId, UserId},
}, },
}; };
@ -25,24 +24,23 @@ use tokio::sync::RwLock;
use crate::{event_handlers::listener, models::guild_data::GuildData}; use crate::{event_handlers::listener, models::guild_data::GuildData};
// Which database driver are we using?
type Database = MySql; type Database = MySql;
pub struct Data { pub struct Data {
database: Pool<Database>, database: Pool<Database>,
http: reqwest::Client, http: reqwest::Client,
guild_data_cache: DashMap<GuildId, Arc<RwLock<GuildData>>>, guild_data_cache: DashMap<GuildId, Arc<RwLock<GuildData>>>,
join_sound_cache: DashMap<UserId, Option<u32>>, join_sound_cache: DashMap<UserId, DashMap<Option<GuildId>, Option<u32>>>,
} }
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( pub async fn register_application_commands(
ctx: &poise::serenity::client::Context, ctx: &poise::serenity_prelude::Context,
framework: &poise::Framework<Data, Error>, framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> { ) -> Result<(), poise::serenity_prelude::Error> {
let mut commands_builder = CreateApplicationCommands::default(); let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands; let commands = &framework.options().commands;
for command in commands { for command in commands {
@ -53,7 +51,7 @@ pub async fn register_application_commands(
commands_builder.add_application_command(context_menu_command); commands_builder.add_application_command(context_menu_command);
} }
} }
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id { if let Some(guild_id) = guild_id {
ctx.http ctx.http
@ -68,12 +66,13 @@ pub async fn register_application_commands(
Ok(()) Ok(())
} }
// entry point
#[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>> {
env_logger::init(); if Path::new("/etc/soundfx-rs/default.env").exists() {
dotenv::from_path("/etc/soundfx-rs/default.env").unwrap();
}
dotenv()?; env_logger::init();
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
@ -103,10 +102,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
cmds::settings::change_volume(), cmds::settings::change_volume(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
poise::Command {
subcommands: vec![
cmds::settings::set_guild_greet_sound(),
cmds::settings::unset_guild_greet_sound(),
cmds::settings::enable_guild_greet_sound(),
],
..cmds::settings::guild_greet_sound()
},
poise::Command {
subcommands: vec![
cmds::settings::set_user_greet_sound(),
cmds::settings::unset_user_greet_sound(),
],
..cmds::settings::user_greet_sound()
},
cmds::settings::disable_greet_sound(), cmds::settings::disable_greet_sound(),
cmds::settings::enable_greet_sound(), cmds::settings::enable_greet_sound(),
cmds::settings::set_greet_sound(),
cmds::settings::unset_greet_sound(),
], ],
..cmds::settings::greet_sound() ..cmds::settings::greet_sound()
}, },
@ -120,21 +132,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.await .await
.unwrap(); .unwrap();
poise::Framework::build() sqlx::migrate!().run(&database).await?;
poise::Framework::builder()
.token(discord_token) .token(discord_token)
.user_data_setup(move |ctx, _bot, framework| { .user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
ctx.set_activity(Activity::watching("for /play")).await; register_application_commands(ctx, framework, None)
.await
register_application_commands( .unwrap();
ctx,
framework,
env::var("DEBUG_GUILD")
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(),
)
.await
.unwrap();
Ok(Data { Ok(Data {
http: reqwest::Client::new(), http: reqwest::Client::new(),

View File

@ -1,17 +1,25 @@
use std::sync::Arc; use std::sync::Arc;
use poise::serenity::{async_trait, model::id::GuildId}; use poise::serenity_prelude::{async_trait, model::id::GuildId};
use sqlx::Executor; use sqlx::{Executor, Type};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{Context, Data, Database}; use crate::{Context, Data, Database};
#[derive(Copy, Clone, Type, PartialEq)]
#[repr(i32)]
pub enum AllowGreet {
Enabled = 1,
GuildOnly = 0,
Disabled = -1,
}
#[derive(Clone)] #[derive(Clone)]
pub struct GuildData { pub struct GuildData {
pub id: u64, pub id: u64,
pub prefix: String, pub prefix: String,
pub volume: u8, pub volume: u8,
pub allow_greets: bool, pub allow_greets: AllowGreet,
pub allowed_role: Option<u64>, pub allowed_role: Option<u64>,
} }
@ -109,7 +117,7 @@ INSERT INTO servers (id)
id: guild_id.as_u64().to_owned(), id: guild_id.as_u64().to_owned(),
prefix: String::from("?"), prefix: String::from("?"),
volume: 100, volume: 100,
allow_greets: true, allow_greets: AllowGreet::Enabled,
allowed_role: None, allowed_role: None,
}) })
} }

View File

@ -1,45 +1,90 @@
use poise::serenity::{async_trait, model::id::UserId}; use poise::serenity_prelude::{async_trait, model::id::UserId, GuildId};
use crate::Data; use crate::Data;
#[async_trait] #[async_trait]
pub trait JoinSoundCtx { pub trait JoinSoundCtx {
async fn join_sound<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Option<u32>; async fn join_sound<U: Into<UserId> + Send + Sync, G: Into<GuildId> + Send + Sync>(
async fn update_join_sound<U: Into<UserId> + Send + Sync>(
&self, &self,
user_id: U, user_id: U,
guild_id: Option<G>,
guild_only: bool,
) -> Option<u32>;
async fn update_join_sound<U: Into<UserId> + Send + Sync, G: Into<GuildId> + Send + Sync>(
&self,
user_id: U,
guild_id: Option<G>,
join_id: Option<u32>, join_id: Option<u32>,
); ) -> Result<(), sqlx::Error>;
}
struct JoinSound {
join_sound_id: u32,
} }
#[async_trait] #[async_trait]
impl JoinSoundCtx for Data { impl JoinSoundCtx for Data {
async fn join_sound<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Option<u32> { async fn join_sound<U: Into<UserId> + Send + Sync, G: Into<GuildId> + Send + Sync>(
&self,
user_id: U,
guild_id: Option<G>,
guild_only: bool,
) -> Option<u32> {
let user_id = user_id.into(); let user_id = user_id.into();
let guild_id = guild_id.map(|g| g.into());
let x = if let Some(join_sound_id) = self.join_sound_cache.get(&user_id) { let cached_join_id = self
join_sound_id.value().clone() .join_sound_cache
.get(&user_id)
.map(|d| d.get(&guild_id).map(|i| i.value().clone()))
.flatten();
let x = if let Some(join_sound_id) = cached_join_id {
join_sound_id
} else { } else {
let join_sound_id = { let join_sound_id = {
let join_id_res = sqlx::query!( let join_id_res = if guild_only {
" sqlx::query_as!(
JoinSound,
"
SELECT join_sound_id SELECT join_sound_id
FROM users FROM join_sounds
WHERE user = ? WHERE user = ?
AND guild = ?
ORDER BY guild IS NULL
", ",
user_id.as_u64() user_id.as_u64(),
) guild_id.map(|g| g.0)
.fetch_one(&self.database) )
.await; .fetch_one(&self.database)
.await
} else {
sqlx::query_as!(
JoinSound,
"
SELECT join_sound_id
FROM join_sounds
WHERE user = ?
AND (guild IS NULL OR guild = ?)
ORDER BY guild IS NULL
",
user_id.as_u64(),
guild_id.map(|g| g.0)
)
.fetch_one(&self.database)
.await
};
if let Ok(row) = join_id_res { if let Ok(row) = join_id_res {
row.join_sound_id Some(row.join_sound_id)
} else { } else {
None None
} }
}; };
self.join_sound_cache.insert(user_id, join_sound_id); self.join_sound_cache.entry(user_id).and_modify(|d| {
d.insert(guild_id, join_sound_id);
});
join_sound_id join_sound_id
}; };
@ -47,39 +92,54 @@ SELECT join_sound_id
x x
} }
async fn update_join_sound<U: Into<UserId> + Send + Sync>( async fn update_join_sound<U: Into<UserId> + Send + Sync, G: Into<GuildId> + Send + Sync>(
&self, &self,
user_id: U, user_id: U,
guild_id: Option<G>,
join_id: Option<u32>, join_id: Option<u32>,
) { ) -> Result<(), sqlx::Error> {
let user_id = user_id.into(); let user_id = user_id.into();
let guild_id = guild_id.map(|g| g.into());
self.join_sound_cache.insert(user_id, join_id); self.join_sound_cache.entry(user_id).and_modify(|d| {
d.insert(guild_id, join_id);
});
let pool = self.database.clone(); let mut transaction = self.database.begin().await?;
let _ = sqlx::query!( match join_id {
" Some(join_id) => {
INSERT IGNORE INTO users (user) sqlx::query!(
VALUES (?) "DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
", user_id.0,
user_id.as_u64() guild_id.map(|g| g.0)
) )
.execute(&pool) .execute(&mut transaction)
.await; .await?;
let _ = sqlx::query!( sqlx::query!(
" "INSERT INTO join_sounds (user, join_sound_id, guild) VALUES (?, ?, ?)",
UPDATE users user_id.0,
SET join_id,
join_sound_id = ? guild_id.map(|g| g.0)
WHERE )
user = ? .execute(&mut transaction)
", .await?;
join_id, }
user_id.as_u64()
) None => {
.execute(&pool) sqlx::query!(
.await; "DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
user_id.0,
guild_id.map(|g| g.0)
)
.execute(&mut transaction)
.await?;
}
}
transaction.commit().await?;
Ok(())
} }
} }

View File

@ -1,8 +1,8 @@
use std::{env, path::Path}; use std::{env, path::Path};
use poise::serenity::async_trait; use poise::serenity_prelude::async_trait;
use songbird::input::restartable::Restartable; use songbird::input::restartable::Restartable;
use sqlx::{Error, Executor}; use sqlx::Executor;
use tokio::{fs::File, io::AsyncWriteExt, process::Command}; use tokio::{fs::File, io::AsyncWriteExt, process::Command};
use crate::{consts::UPLOAD_MAX_SIZE, error::ErrorTypes, Data, Database}; use crate::{consts::UPLOAD_MAX_SIZE, error::ErrorTypes, Data, Database};
@ -37,12 +37,21 @@ 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 user_sounds<U: Into<u64> + Send>(&self, user_id: U) async fn user_sounds<U: Into<u64> + Send>(
-> Result<Vec<Sound>, sqlx::Error>; &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>,
) -> 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_guild_sounds<G: Into<u64> + Send>(
&self,
guild_id: G,
) -> Result<u64, sqlx::Error>;
} }
#[async_trait] #[async_trait]
@ -149,7 +158,7 @@ SELECT name, id, public, server_id, uploader_id
query: &str, query: &str,
user_id: U, user_id: U,
guild_id: G, guild_id: G,
) -> Result<Vec<Sound>, Error> { ) -> Result<Vec<Sound>, sqlx::Error> {
let db_pool = self.database.clone(); let db_pool = self.database.clone();
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
@ -171,18 +180,41 @@ LIMIT 25
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>,
) -> Result<Vec<Sound>, sqlx::Error> { ) -> Result<Vec<Sound>, sqlx::Error> {
let sounds = sqlx::query_as_unchecked!( let sounds = match page {
Sound, Some(page) => {
" sqlx::query_as_unchecked!(
Sound,
"
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
LIMIT ?, ?
", ",
user_id.into() user_id.into(),
) page * 25,
.fetch_all(&self.database) (page + 1) * 25
.await?; )
.fetch_all(&self.database)
.await?
}
None => {
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE uploader_id = ?
ORDER BY id DESC
",
user_id.into()
)
.fetch_all(&self.database)
.await?
}
};
Ok(sounds) Ok(sounds)
} }
@ -190,21 +222,68 @@ SELECT name, id, public, server_id, uploader_id
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>,
) -> Result<Vec<Sound>, sqlx::Error> { ) -> Result<Vec<Sound>, sqlx::Error> {
let sounds = sqlx::query_as_unchecked!( let sounds = match page {
Sound, Some(page) => {
" sqlx::query_as_unchecked!(
Sound,
"
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
LIMIT ?, ?
", ",
guild_id.into() guild_id.into(),
) page * 25,
.fetch_all(&self.database) (page + 1) * 25
.await?; )
.fetch_all(&self.database)
.await?
}
None => {
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE server_id = ?
ORDER BY id DESC
",
guild_id.into()
)
.fetch_all(&self.database)
.await?
}
};
Ok(sounds) Ok(sounds)
} }
async fn count_user_sounds<U: Into<u64> + Send>(&self, user_id: U) -> Result<u64, sqlx::Error> {
Ok(sqlx::query!(
"SELECT COUNT(1) as count FROM sounds WHERE uploader_id = ?",
user_id.into()
)
.fetch_one(&self.database)
.await?
.count as u64)
}
async fn count_guild_sounds<G: Into<u64> + Send>(
&self,
guild_id: G,
) -> Result<u64, sqlx::Error> {
Ok(sqlx::query!(
"SELECT COUNT(1) as count FROM sounds WHERE server_id = ?",
guild_id.into()
)
.fetch_one(&self.database)
.await?
.count as u64)
}
} }
impl Sound { impl Sound {
@ -262,7 +341,7 @@ SELECT src
pub async fn count_user_sounds<U: Into<u64>>( pub async fn count_user_sounds<U: Into<u64>>(
user_id: U, user_id: U,
db_pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
) -> Result<u32, sqlx::error::Error> { ) -> Result<u32, sqlx::Error> {
let user_id = user_id.into(); let user_id = user_id.into();
let c = sqlx::query!( let c = sqlx::query!(
@ -284,7 +363,7 @@ SELECT COUNT(1) as count
user_id: U, user_id: U,
name: &String, name: &String,
db_pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
) -> Result<u32, sqlx::error::Error> { ) -> Result<u32, sqlx::Error> {
let user_id = user_id.into(); let user_id = user_id.into();
let c = sqlx::query!( let c = sqlx::query!(

View File

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use poise::serenity::model::{ use poise::serenity_prelude::model::{
channel::Channel, channel::Channel,
guild::Guild, guild::Guild,
id::{ChannelId, UserId}, id::{ChannelId, UserId},
@ -104,15 +104,18 @@ pub async fn play_from_query(
data: &Data, data: &Data,
guild: Guild, guild: Guild,
user_id: UserId, user_id: UserId,
channel: Option<ChannelId>,
query: &str, query: &str,
loop_: bool, loop_: bool,
) -> String { ) -> String {
let guild_id = guild.id; let guild_id = guild.id;
let channel_to_join = guild let channel_to_join = channel.or_else(|| {
.voice_states guild
.get(&user_id) .voice_states
.and_then(|voice_state| voice_state.channel_id); .get(&user_id)
.and_then(|voice_state| voice_state.channel_id)
});
match channel_to_join { match channel_to_join {
Some(user_channel) => { Some(user_channel) => {

View File

@ -0,0 +1,14 @@
[Unit]
Description=Discord bot for custom sound effects and soundboards
[Service]
Type=simple
ExecStart=/usr/bin/soundfx-rs
WorkingDirectory=/etc/soundfx-rs
Restart=always
RestartSec=4
# Environment="RUST_LOG=warn,soundfx_rs=info"
# Environment="RUST_BACKTRACE=full"
[Install]
WantedBy=multi-user.target