Compare commits

..

No commits in common. "rewrite" and "soundboard" have entirely different histories.

60 changed files with 5079 additions and 5517 deletions

2
.cargo/config Normal file
View File

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

1
.gitignore vendored
View File

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

2
.idea/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="CL-211.7442.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 Normal file
View File

@ -0,0 +1,11 @@
<?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

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

View File

@ -0,0 +1,6 @@
<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 Normal file
View File

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

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?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 Normal file
View File

@ -0,0 +1,14 @@
<?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>

7
.idea/sqldialects.xml Normal file
View File

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

6
.idea/vcs.xml Normal file
View File

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

3681
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,22 @@
[package] [package]
name = "soundfx-rs" name = "soundfx-rs"
description = "Discord bot for custom sound effects and soundboards" version = "1.4.3"
license = "AGPL-3.0-only"
version = "1.5.18"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
songbird = { version = "0.4", features = ["builtin-queue"] } songbird = { git = "https://github.com/serenity-rs/songbird", branch = "next" }
poise = "0.6.1-rc1" serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", features = ["voice", "collector", "unstable_discord_api"] }
sqlx = { version = "0.7.3", 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"] }
tokio = { version = "1", features = ["fs", "process", "io-util", "rt-multi-thread"] } dotenv = "0.15"
tokio = { version = "1", features = ["fs", "process", "io-util"] }
lazy_static = "1.4" lazy_static = "1.4"
reqwest = "0.12" reqwest = "0.11"
env_logger = "0.11" env_logger = "0.8"
regex = "1.10" regex = "1.4"
log = "0.4" log = "0.4"
serde_json = "1.0" serde_json = "1.0"
dashmap = "6.0" dashmap = "4.0"
serde = "1.0"
dotenv = "0.15.0"
prometheus = { version = "0.13.3", optional = true }
axum = { version = "0.7.2", optional = true }
[dependencies.symphonia] [dependencies.regex_command_attr]
version = "0.5" path = "./regex_command_attr"
features = ["ogg"]
[features]
metrics = ["dep:prometheus", "dep:axum"]
[package.metadata.deb]
features = ["metrics"]
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/config.env", "600"]
]
conf-files = [
"/etc/soundfx-rs/config.env",
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

View File

@ -1,9 +0,0 @@
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

@ -1,48 +1,24 @@
# SoundFX # SoundFX 2
## The complete (second) Rust rewrite of SoundFX
A bot for managing sound effects in Discord. SoundFX 2 is the Rust rewrite of SoundFX. SoundFX 2 attempts to retain all functionality of the original bot, in a more
efficient and robust package. SoundFX 2 is as asynchronous as it can get, and runs on the Tokio runtime.
## Installing ### Building
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 the Cargo.toml file to build it. Simple as. Don't need anything like MySQL libs and stuff because SQLx includes its
own pure Rust one. Needs Rust 1.43+
## 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 a `.env` file in the working directory of the program.
Config options are provided in a file `/etc/soundfx-rs/default.env` Config options:
Options:
* `DISCORD_TOKEN`- your token (required) * `DISCORD_TOKEN`- your token (required)
* `DATABASE_URL`- your database URL (required) * `DATABASE_URL`- your database URL (required)
* `MAX_SOUNDS`- specifies how many sounds a user should be allowed without having the `PATREON_ROLE` specified below * `DISCONNECT_CYCLES`- specifies the number of inactivity cycles before the bot should disconnect itself from a voice channel
* `DISCONNECT_CYCLE_DELAY`- specifies the delay between cleanup cycles
* `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
## Building from source
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`
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`
### 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`

Binary file not shown.

Binary file not shown.

7
audio/audio.json Normal file
View File

@ -0,0 +1,7 @@
{
"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.

View File

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

View File

@ -1,7 +0,0 @@
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
View File

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

9
debian/postinst vendored
View File

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

7
debian/postrm vendored
View File

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

View File

@ -1,10 +0,0 @@
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

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

View File

@ -1,6 +0,0 @@
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,15 @@
[package]
name = "regex_command_attr"
version = "0.3.6"
authors = ["acdenisSK <acdenissk69@gmail.com>", "jellywx <judesouthworth@pm.me>"]
edition = "2018"
description = "Procedural macros for command creation for the Serenity library."
license = "ISC"
[lib]
proc-macro = true
[dependencies]
quote = "^1.0"
syn = { version = "^1.0", features = ["full", "derive", "extra-traits"] }
proc-macro2 = "1.0"

View File

@ -0,0 +1,408 @@
use std::fmt::{self, Write};
use proc_macro2::Span;
use syn::parse::{Error, Result};
use syn::spanned::Spanned;
use syn::{Attribute, Ident, Lit, LitStr, Meta, NestedMeta, Path};
use crate::structures::{ApplicationCommandOptionType, Arg, CommandKind, PermissionLevel};
use crate::util::{AsOption, LitExt};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ValueKind {
// #[<name>]
Name,
// #[<name> = <value>]
Equals,
// #[<name>([<value>, <value>, <value>, ...])]
List,
// #[<name>([<prop> = <value>, <prop> = <value>, ...])]
EqualsList,
// #[<name>(<value>)]
SingleList,
}
impl fmt::Display for ValueKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValueKind::Name => f.pad("`#[<name>]`"),
ValueKind::Equals => f.pad("`#[<name> = <value>]`"),
ValueKind::List => f.pad("`#[<name>([<value>, <value>, <value>, ...])]`"),
ValueKind::EqualsList => {
f.pad("`#[<name>([<prop> = <value>, <prop> = <value>, ...])]`")
}
ValueKind::SingleList => f.pad("`#[<name>(<value>)]`"),
}
}
}
fn to_ident(p: Path) -> Result<Ident> {
if p.segments.is_empty() {
return Err(Error::new(
p.span(),
"cannot convert an empty path to an identifier",
));
}
if p.segments.len() > 1 {
return Err(Error::new(
p.span(),
"the path must not have more than one segment",
));
}
if !p.segments[0].arguments.is_empty() {
return Err(Error::new(
p.span(),
"the singular path segment must not have any arguments",
));
}
Ok(p.segments[0].ident.clone())
}
#[derive(Debug)]
pub struct Values {
pub name: Ident,
pub literals: Vec<(Option<String>, Lit)>,
pub kind: ValueKind,
pub span: Span,
}
impl Values {
#[inline]
pub fn new(
name: Ident,
kind: ValueKind,
literals: Vec<(Option<String>, Lit)>,
span: Span,
) -> Self {
Values {
name,
literals,
kind,
span,
}
}
}
pub fn parse_values(attr: &Attribute) -> Result<Values> {
fn is_list_or_named_list(meta: &NestedMeta) -> ValueKind {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(_) => ValueKind::List,
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
// path => some quoted value
Meta::Path(_) => ValueKind::List,
Meta::List(_) | Meta::NameValue(_) => ValueKind::EqualsList,
},
}
}
let meta = attr.parse_meta()?;
match meta {
Meta::Path(path) => {
let name = to_ident(path)?;
Ok(Values::new(name, ValueKind::Name, Vec::new(), attr.span()))
}
Meta::List(meta) => {
let name = to_ident(meta.path)?;
let nested = meta.nested;
if nested.is_empty() {
return Err(Error::new(attr.span(), "list cannot be empty"));
}
if is_list_or_named_list(nested.first().unwrap()) == ValueKind::List {
let mut lits = Vec::with_capacity(nested.len());
for meta in nested {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(l) => lits.push((None, l)),
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
// path => some quoted value
Meta::Path(path) => {
let i = to_ident(path)?;
lits.push((None, Lit::Str(LitStr::new(&i.to_string(), i.span()))))
}
Meta::List(_) | Meta::NameValue(_) => {
return Err(Error::new(attr.span(), "cannot nest a list; only accept literals and identifiers at this level"))
}
},
}
}
let kind = if lits.len() == 1 {
ValueKind::SingleList
} else {
ValueKind::List
};
Ok(Values::new(name, kind, lits, attr.span()))
} else {
let mut lits = Vec::with_capacity(nested.len());
for meta in nested {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(_) => {
return Err(Error::new(attr.span(), "key-value pairs expected"))
}
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
Meta::NameValue(n) => {
let name = to_ident(n.path)?.to_string();
let value = n.lit;
lits.push((Some(name), value));
}
Meta::List(_) | Meta::Path(_) => {
return Err(Error::new(attr.span(), "key-value pairs expected"))
}
},
}
}
Ok(Values::new(name, ValueKind::EqualsList, lits, attr.span()))
}
}
Meta::NameValue(meta) => {
let name = to_ident(meta.path)?;
let lit = meta.lit;
Ok(Values::new(
name,
ValueKind::Equals,
vec![(None, lit)],
attr.span(),
))
}
}
}
#[derive(Debug, Clone)]
struct DisplaySlice<'a, T>(&'a [T]);
impl<'a, T: fmt::Display> fmt::Display for DisplaySlice<'a, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut iter = self.0.iter().enumerate();
match iter.next() {
None => f.write_str("nothing")?,
Some((idx, elem)) => {
write!(f, "{}: {}", idx, elem)?;
for (idx, elem) in iter {
f.write_char('\n')?;
write!(f, "{}: {}", idx, elem)?;
}
}
}
Ok(())
}
}
#[inline]
fn is_form_acceptable(expect: &[ValueKind], kind: ValueKind) -> bool {
if expect.contains(&ValueKind::List) && kind == ValueKind::SingleList {
true
} else {
expect.contains(&kind)
}
}
#[inline]
fn validate(values: &Values, forms: &[ValueKind]) -> Result<()> {
if !is_form_acceptable(forms, values.kind) {
return Err(Error::new(
values.span,
// Using the `_args` version here to avoid an allocation.
format_args!(
"the attribute must be in of these forms:\n{}",
DisplaySlice(forms)
),
));
}
Ok(())
}
#[inline]
pub fn parse<T: AttributeOption>(values: Values) -> Result<T> {
T::parse(values)
}
pub trait AttributeOption: Sized {
fn parse(values: Values) -> Result<Self>;
}
impl AttributeOption for Vec<String> {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::List])?;
Ok(values
.literals
.into_iter()
.map(|(_, l)| l.to_str())
.collect())
}
}
impl AttributeOption for String {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::Equals, ValueKind::SingleList])?;
Ok(values.literals[0].1.to_str())
}
}
impl AttributeOption for bool {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::Name, ValueKind::SingleList])?;
Ok(values.literals.get(0).map_or(true, |(_, l)| l.to_bool()))
}
}
impl AttributeOption for Ident {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(values.literals[0].1.to_ident())
}
}
impl AttributeOption for Vec<Ident> {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::List])?;
Ok(values
.literals
.into_iter()
.map(|(_, l)| l.to_ident())
.collect())
}
}
impl AttributeOption for Option<String> {
fn parse(values: Values) -> Result<Self> {
validate(
&values,
&[ValueKind::Name, ValueKind::Equals, ValueKind::SingleList],
)?;
Ok(values.literals.get(0).map(|(_, l)| l.to_str()))
}
}
impl AttributeOption for PermissionLevel {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(values
.literals
.get(0)
.map(|(_, l)| PermissionLevel::from_str(&*l.to_str()).unwrap())
.unwrap())
}
}
impl AttributeOption for CommandKind {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(values
.literals
.get(0)
.map(|(_, l)| CommandKind::from_str(&*l.to_str()).unwrap())
.unwrap())
}
}
impl AttributeOption for Arg {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::EqualsList])?;
let mut arg: Arg = Default::default();
for (key, value) in &values.literals {
match key {
Some(s) => match s.as_str() {
"name" => {
arg.name = value.to_str();
}
"description" => {
arg.description = value.to_str();
}
"required" => {
arg.required = value.to_bool();
}
"kind" => arg.kind = ApplicationCommandOptionType::from_str(value.to_str()),
_ => {
return Err(Error::new(key.span(), "unexpected attribute"));
}
},
_ => {
return Err(Error::new(key.span(), "unnamed attribute"));
}
}
}
Ok(arg)
}
}
impl<T: AttributeOption> AttributeOption for AsOption<T> {
#[inline]
fn parse(values: Values) -> Result<Self> {
Ok(AsOption(Some(T::parse(values)?)))
}
}
macro_rules! attr_option_num {
($($n:ty),*) => {
$(
impl AttributeOption for $n {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(match &values.literals[0].1 {
Lit::Int(l) => l.base10_parse::<$n>()?,
l => {
let s = l.to_str();
// Use `as_str` to guide the compiler to use `&str`'s parse method.
// We don't want to use our `parse` method here (`impl AttributeOption for String`).
match s.as_str().parse::<$n>() {
Ok(n) => n,
Err(_) => return Err(Error::new(l.span(), "invalid integer")),
}
}
})
}
}
impl AttributeOption for Option<$n> {
#[inline]
fn parse(values: Values) -> Result<Self> {
<$n as AttributeOption>::parse(values).map(Some)
}
}
)*
}
}
attr_option_num!(u16, u32, usize);

View File

@ -0,0 +1,6 @@
pub mod suffixes {
pub const COMMAND: &str = "COMMAND";
pub const ARG: &str = "ARG";
}
pub use self::suffixes::*;

View File

@ -0,0 +1,173 @@
#![deny(rust_2018_idioms)]
#![deny(broken_intra_doc_links)]
use proc_macro::TokenStream;
use proc_macro2::Ident;
use quote::quote;
use syn::{parse::Error, parse_macro_input, parse_quote, spanned::Spanned, Lit};
pub(crate) mod attributes;
pub(crate) mod consts;
pub(crate) mod structures;
#[macro_use]
pub(crate) mod util;
use attributes::*;
use consts::*;
use structures::*;
use util::*;
macro_rules! match_options {
($v:expr, $values:ident, $options:ident, $span:expr => [$($name:ident);*]) => {
match $v {
$(
stringify!($name) => $options.$name = propagate_err!($crate::attributes::parse($values)),
)*
_ => {
return Error::new($span, format_args!("invalid attribute: {:?}", $v))
.to_compile_error()
.into();
},
}
};
}
#[proc_macro_attribute]
pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream {
let mut fun = parse_macro_input!(input as CommandFun);
let _name = if !attr.is_empty() {
parse_macro_input!(attr as Lit).to_str()
} else {
fun.name.to_string()
};
let mut options = Options::new();
for attribute in &fun.attributes {
let span = attribute.span();
let values = propagate_err!(parse_values(attribute));
let name = values.name.to_string();
let name = &name[..];
match name {
"arg" => options
.cmd_args
.push(propagate_err!(attributes::parse(values))),
"example" => {
options
.examples
.push(propagate_err!(attributes::parse(values)));
}
"description" => {
let line: String = propagate_err!(attributes::parse(values));
util::append_line(&mut options.description, line);
}
_ => {
match_options!(name, values, options, span => [
aliases;
group;
required_permissions;
kind
]);
}
}
}
let Options {
aliases,
description,
group,
examples,
required_permissions,
kind,
mut cmd_args,
} = options;
propagate_err!(create_declaration_validations(&mut fun));
let res = parse_quote!(serenity::framework::standard::CommandResult);
create_return_type_validation(&mut fun, res);
let visibility = fun.visibility;
let name = fun.name.clone();
let body = fun.body;
let ret = fun.ret;
let n = name.with_suffix(COMMAND);
let cooked = fun.cooked.clone();
let command_path = quote!(crate::framework::Command);
let arg_path = quote!(crate::framework::Arg);
populate_fut_lifetimes_on_refs(&mut fun.args);
let args = fun.args;
let arg_idents = cmd_args
.iter()
.map(|arg| {
n.with_suffix(arg.name.replace(" ", "_").replace("-", "_").as_str())
.with_suffix(ARG)
})
.collect::<Vec<Ident>>();
let mut tokens = cmd_args
.iter_mut()
.map(|arg| {
let Arg {
name,
description,
kind,
required,
} = arg;
let an = n.with_suffix(name.as_str()).with_suffix(ARG);
quote! {
#(#cooked)*
#[allow(missing_docs)]
pub static #an: #arg_path = #arg_path {
name: #name,
description: #description,
kind: #kind,
required: #required,
};
}
})
.fold(quote! {}, |mut a, b| {
a.extend(b);
a
});
tokens.extend(quote! {
#(#cooked)*
#[allow(missing_docs)]
pub static #n: #command_path = #command_path {
fun: #name,
names: &[#_name, #(#aliases),*],
desc: #description,
group: #group,
examples: &[#(#examples),*],
required_permissions: #required_permissions,
kind: #kind,
args: &[#(&#arg_idents),*],
};
#(#cooked)*
#[allow(missing_docs)]
#visibility fn #name<'fut> (#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, #ret> {
use ::serenity::futures::future::FutureExt;
async move {
let _output: #ret = { #(#body)* };
#[allow(unreachable_code)]
_output
}.boxed()
}
});
tokens.into()
}

View File

@ -0,0 +1,359 @@
use proc_macro2::TokenStream as TokenStream2;
use quote::{quote, ToTokens};
use syn::{
braced,
parse::{Error, Parse, ParseStream, Result},
spanned::Spanned,
Attribute, Block, FnArg, Ident, Pat, ReturnType, Stmt, Token, Type, Visibility,
};
use crate::util::{self, Argument, Parenthesised};
fn parse_argument(arg: FnArg) -> Result<Argument> {
match arg {
FnArg::Typed(typed) => {
let pat = typed.pat;
let kind = typed.ty;
match *pat {
Pat::Ident(id) => {
let name = id.ident;
let mutable = id.mutability;
Ok(Argument {
mutable,
name,
kind: *kind,
})
}
Pat::Wild(wild) => {
let token = wild.underscore_token;
let name = Ident::new("_", token.spans[0]);
Ok(Argument {
mutable: None,
name,
kind: *kind,
})
}
_ => Err(Error::new(
pat.span(),
format_args!("unsupported pattern: {:?}", pat),
)),
}
}
FnArg::Receiver(_) => Err(Error::new(
arg.span(),
format_args!("`self` arguments are prohibited: {:?}", arg),
)),
}
}
/// Test if the attribute is cooked.
fn is_cooked(attr: &Attribute) -> bool {
const COOKED_ATTRIBUTE_NAMES: &[&str] = &[
"cfg", "cfg_attr", "derive", "inline", "allow", "warn", "deny", "forbid",
];
COOKED_ATTRIBUTE_NAMES.iter().any(|n| attr.path.is_ident(n))
}
/// Removes cooked attributes from a vector of attributes. Uncooked attributes are left in the vector.
///
/// # Return
///
/// Returns a vector of cooked attributes that have been removed from the input vector.
fn remove_cooked(attrs: &mut Vec<Attribute>) -> Vec<Attribute> {
let mut cooked = Vec::new();
// FIXME: Replace with `Vec::drain_filter` once it is stable.
let mut i = 0;
while i < attrs.len() {
if !is_cooked(&attrs[i]) {
i += 1;
continue;
}
cooked.push(attrs.remove(i));
}
cooked
}
#[derive(Debug)]
pub struct CommandFun {
/// `#[...]`-style attributes.
pub attributes: Vec<Attribute>,
/// Populated cooked attributes. These are attributes outside of the realm of this crate's procedural macros
/// and will appear in generated output.
pub cooked: Vec<Attribute>,
pub visibility: Visibility,
pub name: Ident,
pub args: Vec<Argument>,
pub ret: Type,
pub body: Vec<Stmt>,
}
impl Parse for CommandFun {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let mut attributes = input.call(Attribute::parse_outer)?;
// Rename documentation comment attributes (`#[doc = "..."]`) to `#[description = "..."]`.
util::rename_attributes(&mut attributes, "doc", "description");
let cooked = remove_cooked(&mut attributes);
let visibility = input.parse::<Visibility>()?;
input.parse::<Token![async]>()?;
input.parse::<Token![fn]>()?;
let name = input.parse()?;
// (...)
let Parenthesised(args) = input.parse::<Parenthesised<FnArg>>()?;
let ret = match input.parse::<ReturnType>()? {
ReturnType::Type(_, t) => (*t).clone(),
ReturnType::Default => {
return Err(input
.error("expected a result type of either `CommandResult` or `CheckResult`"))
}
};
// { ... }
let bcont;
braced!(bcont in input);
let body = bcont.call(Block::parse_within)?;
let args = args
.into_iter()
.map(parse_argument)
.collect::<Result<Vec<_>>>()?;
Ok(Self {
attributes,
cooked,
visibility,
name,
args,
ret,
body,
})
}
}
impl ToTokens for CommandFun {
fn to_tokens(&self, stream: &mut TokenStream2) {
let Self {
attributes: _,
cooked,
visibility,
name,
args,
ret,
body,
} = self;
stream.extend(quote! {
#(#cooked)*
#visibility async fn #name (#(#args),*) -> #ret {
#(#body)*
}
});
}
}
#[derive(Debug)]
pub enum PermissionLevel {
Unrestricted,
Managed,
Restricted,
}
impl Default for PermissionLevel {
fn default() -> Self {
Self::Unrestricted
}
}
impl PermissionLevel {
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.to_uppercase().as_str() {
"UNRESTRICTED" => Self::Unrestricted,
"MANAGED" => Self::Managed,
"RESTRICTED" => Self::Restricted,
_ => return None,
})
}
}
impl ToTokens for PermissionLevel {
fn to_tokens(&self, stream: &mut TokenStream2) {
let path = quote!(crate::framework::PermissionLevel);
let variant;
match self {
Self::Unrestricted => {
variant = quote!(Unrestricted);
}
Self::Managed => {
variant = quote!(Managed);
}
Self::Restricted => {
variant = quote!(Restricted);
}
}
stream.extend(quote! {
#path::#variant
});
}
}
#[derive(Debug)]
pub enum CommandKind {
Slash,
Both,
Text,
}
impl Default for CommandKind {
fn default() -> Self {
Self::Both
}
}
impl CommandKind {
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.to_uppercase().as_str() {
"SLASH" => Self::Slash,
"BOTH" => Self::Both,
"TEXT" => Self::Text,
_ => return None,
})
}
}
impl ToTokens for CommandKind {
fn to_tokens(&self, stream: &mut TokenStream2) {
let path = quote!(crate::framework::CommandKind);
let variant;
match self {
Self::Slash => {
variant = quote!(Slash);
}
Self::Both => {
variant = quote!(Both);
}
Self::Text => {
variant = quote!(Text);
}
}
stream.extend(quote! {
#path::#variant
});
}
}
#[derive(Debug)]
pub(crate) enum ApplicationCommandOptionType {
SubCommand,
SubCommandGroup,
String,
Integer,
Boolean,
User,
Channel,
Role,
Mentionable,
Unknown,
}
impl ApplicationCommandOptionType {
pub fn from_str(s: String) -> Self {
match s.as_str() {
"SubCommand" => Self::SubCommand,
"SubCommandGroup" => Self::SubCommandGroup,
"String" => Self::String,
"Integer" => Self::Integer,
"Boolean" => Self::Boolean,
"User" => Self::User,
"Channel" => Self::Channel,
"Role" => Self::Role,
"Mentionable" => Self::Mentionable,
_ => Self::Unknown,
}
}
}
impl ToTokens for ApplicationCommandOptionType {
fn to_tokens(&self, stream: &mut TokenStream2) {
let path = quote!(
serenity::model::interactions::application_command::ApplicationCommandOptionType
);
let variant = match self {
ApplicationCommandOptionType::SubCommand => quote!(SubCommand),
ApplicationCommandOptionType::SubCommandGroup => quote!(SubCommandGroup),
ApplicationCommandOptionType::String => quote!(String),
ApplicationCommandOptionType::Integer => quote!(Integer),
ApplicationCommandOptionType::Boolean => quote!(Boolean),
ApplicationCommandOptionType::User => quote!(User),
ApplicationCommandOptionType::Channel => quote!(Channel),
ApplicationCommandOptionType::Role => quote!(Role),
ApplicationCommandOptionType::Mentionable => quote!(Mentionable),
ApplicationCommandOptionType::Unknown => quote!(Unknown),
};
stream.extend(quote! {
#path::#variant
});
}
}
#[derive(Debug)]
pub(crate) struct Arg {
pub name: String,
pub description: String,
pub kind: ApplicationCommandOptionType,
pub required: bool,
}
impl Default for Arg {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
kind: ApplicationCommandOptionType::String,
required: false,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct Options {
pub aliases: Vec<String>,
pub description: String,
pub group: String,
pub examples: Vec<String>,
pub required_permissions: PermissionLevel,
pub kind: CommandKind,
pub cmd_args: Vec<Arg>,
}
impl Options {
#[inline]
pub fn new() -> Self {
Self {
group: "Other".to_string(),
..Default::default()
}
}
}

View File

@ -0,0 +1,239 @@
use proc_macro::TokenStream;
use proc_macro2::Span;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote, ToTokens};
use syn::{
braced, bracketed, parenthesized,
parse::{Error, Parse, ParseStream, Result as SynResult},
parse_quote,
punctuated::Punctuated,
spanned::Spanned,
token::{Comma, Mut},
Attribute, Ident, Lifetime, Lit, Path, PathSegment, Type,
};
use crate::structures::CommandFun;
pub trait LitExt {
fn to_str(&self) -> String;
fn to_bool(&self) -> bool;
fn to_ident(&self) -> Ident;
}
impl LitExt for Lit {
fn to_str(&self) -> String {
match self {
Lit::Str(s) => s.value(),
Lit::ByteStr(s) => unsafe { String::from_utf8_unchecked(s.value()) },
Lit::Char(c) => c.value().to_string(),
Lit::Byte(b) => (b.value() as char).to_string(),
_ => panic!("values must be a (byte)string or a char"),
}
}
fn to_bool(&self) -> bool {
if let Lit::Bool(b) = self {
b.value
} else {
self.to_str()
.parse()
.unwrap_or_else(|_| panic!("expected bool from {:?}", self))
}
}
#[inline]
fn to_ident(&self) -> Ident {
Ident::new(&self.to_str(), self.span())
}
}
pub trait IdentExt2: Sized {
fn to_uppercase(&self) -> Self;
fn with_suffix(&self, suf: &str) -> Ident;
}
impl IdentExt2 for Ident {
#[inline]
fn to_uppercase(&self) -> Self {
format_ident!("{}", self.to_string().to_uppercase())
}
#[inline]
fn with_suffix(&self, suffix: &str) -> Ident {
format_ident!("{}_{}", self.to_string().to_uppercase(), suffix)
}
}
#[inline]
pub fn into_stream(e: Error) -> TokenStream {
e.to_compile_error().into()
}
macro_rules! propagate_err {
($res:expr) => {{
match $res {
Ok(v) => v,
Err(e) => return $crate::util::into_stream(e),
}
}};
}
#[derive(Debug)]
pub struct Bracketed<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Bracketed<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
bracketed!(content in input);
Ok(Bracketed(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct Braced<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Braced<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
braced!(content in input);
Ok(Braced(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct Parenthesised<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Parenthesised<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
parenthesized!(content in input);
Ok(Parenthesised(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct AsOption<T>(pub Option<T>);
impl<T: ToTokens> ToTokens for AsOption<T> {
fn to_tokens(&self, stream: &mut TokenStream2) {
match &self.0 {
Some(o) => stream.extend(quote!(Some(#o))),
None => stream.extend(quote!(None)),
}
}
}
impl<T> Default for AsOption<T> {
#[inline]
fn default() -> Self {
AsOption(None)
}
}
#[derive(Debug)]
pub struct Argument {
pub mutable: Option<Mut>,
pub name: Ident,
pub kind: Type,
}
impl ToTokens for Argument {
fn to_tokens(&self, stream: &mut TokenStream2) {
let Argument {
mutable,
name,
kind,
} = self;
stream.extend(quote! {
#mutable #name: #kind
});
}
}
#[inline]
pub fn generate_type_validation(have: Type, expect: Type) -> syn::Stmt {
parse_quote! {
serenity::static_assertions::assert_type_eq_all!(#have, #expect);
}
}
pub fn create_declaration_validations(fun: &mut CommandFun) -> SynResult<()> {
if fun.args.len() > 3 {
return Err(Error::new(
fun.args.last().unwrap().span(),
format_args!("function's arity exceeds more than 3 arguments"),
));
}
let context: Type = parse_quote!(&serenity::client::Context);
let message: Type = parse_quote!(&(dyn crate::framework::CommandInvoke + Sync + Send));
let args: Type = parse_quote!(crate::framework::Args);
let mut index = 0;
let mut spoof_or_check = |kind: Type, name: &str| {
match fun.args.get(index) {
Some(x) => fun
.body
.insert(0, generate_type_validation(x.kind.clone(), kind)),
None => fun.args.push(Argument {
mutable: None,
name: Ident::new(name, Span::call_site()),
kind,
}),
}
index += 1;
};
spoof_or_check(context, "_ctx");
spoof_or_check(message, "_msg");
spoof_or_check(args, "_args");
Ok(())
}
#[inline]
pub fn create_return_type_validation(r#fn: &mut CommandFun, expect: Type) {
let stmt = generate_type_validation(r#fn.ret.clone(), expect);
r#fn.body.insert(0, stmt);
}
#[inline]
pub fn populate_fut_lifetimes_on_refs(args: &mut Vec<Argument>) {
for arg in args {
if let Type::Reference(reference) = &mut arg.kind {
reference.lifetime = Some(Lifetime::new("'fut", Span::call_site()));
}
}
}
/// Renames all attributes that have a specific `name` to the `target`.
pub fn rename_attributes(attributes: &mut Vec<Attribute>, name: &str, target: &str) {
for attr in attributes {
if attr.path.is_ident(name) {
attr.path = Path::from(PathSegment::from(Ident::new(target, Span::call_site())));
}
}
}
pub fn append_line(desc: &mut String, mut line: String) {
if line.starts_with(' ') {
line.remove(0);
}
match line.rfind("\\$") {
Some(i) => {
desc.push_str(line[..i].trim_end());
desc.push(' ');
}
None => {
desc.push_str(&line);
desc.push('\n');
}
}
}

View File

@ -1,8 +0,0 @@
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

View File

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

View File

@ -1,92 +1,225 @@
use poise::{ use std::{collections::HashMap, sync::Arc};
serenity_prelude::{CreateEmbed, CreateEmbedFooter},
CreateReply, use regex_command_attr::command;
use serenity::{client::Context, framework::standard::CommandResult};
use crate::{
framework::{Args, CommandInvoke, CommandKind, CreateGenericResponse, RegexFramework},
THEME_COLOR,
}; };
use crate::{consts::THEME_COLOR, Context, Error}; #[command]
#[group("Information")]
#[description("Get information on the commands of the bot")]
#[arg(
name = "command",
description = "Get help for a specific command",
kind = "String",
required = false
)]
#[example("`/help` - see all commands")]
#[example("`/help play` - get help about the `play` command")]
pub async fn help(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
args: Args,
) -> CommandResult {
fn get_groups(framework: Arc<RegexFramework>) -> HashMap<&'static str, Vec<&'static str>> {
let mut groups = HashMap::new();
/// View bot commands for command in &framework.commands_ {
#[poise::command(slash_command)] let entry = groups.entry(command.group).or_insert(vec![]);
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(
CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.title("Help")
.color(THEME_COLOR)
.footer(CreateEmbedFooter::new(concat!(
env!("CARGO_PKG_NAME"),
" ver ",
env!("CARGO_PKG_VERSION")
)))
.description(
"__Info Commands__
`/help` `/info`
*run these commands with no options*
__Play Commands__ entry.push(command.names[0]);
`/play` - Play a sound by name or ID }
`/queue` - Play sounds on queue instead of instantly
`/loop` - Play a sound on loop
`/disconnect` - Disconnect the bot
`/stop` - Stop playback
__Library Commands__ groups
`/upload` - Upload a sound file }
`/delete` - Delete a sound file
`/download` - Download a sound file
`/public` - Set a sound as public/private
`/list server` - List sounds on this server
`/list user` - List your sounds
`/favorites add` - Add a favorite
`/favorites remove` - Remove a favorite
`/list favorites` - List favorites
__Search Commands__ let framework = ctx
`/search` - Search for public sounds by name .data
`/random` - View random public sounds .read()
.await
.get::<RegexFramework>()
.cloned()
.unwrap();
__Setting Commands__ if let Some(command_name) = args.named("command") {
`/greet server set/unset` - Set or unset a join sound for just this server if let Some(command) = framework.commands.get(command_name) {
`/greet user set/unset` - Set or unset a join sound across all servers let examples = if command.examples.is_empty() {
`/greet enable/disable` - Enable or disable join sounds on this server "".to_string()
`/volume` - Change the volume } else {
format!(
"**Examples**
{}",
command
.examples
.iter()
.map(|e| format!("{}", e))
.collect::<Vec<String>>()
.join("\n")
)
};
__Advanced Commands__ let args = if command.args.is_empty() {
`/soundboard` - Create a soundboard", "**Arguments**
), *This command has no arguments*"
), .to_string()
) } else {
.await?; format!(
"**Arguments**
{}",
command
.args
.iter()
.map(|a| format!(
" • `{}` {} - {}",
a.name,
if a.required { "" } else { "[optional]" },
a.description
))
.collect::<Vec<String>>()
.join("\n")
)
};
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title(format!("{} Help", command_name))
.color(THEME_COLOR)
.description(format!(
"**Available In**
`Slash Commands` {}
` Text Commands` {}
**Aliases**
{}
**Overview**
{}
{}
{}",
if command.kind != CommandKind::Text {
""
} else {
""
},
if command.kind != CommandKind::Slash {
""
} else {
""
},
command
.names
.iter()
.map(|n| format!("`{}`", n))
.collect::<Vec<String>>()
.join(" "),
command.desc,
args,
examples
))
}),
)
.await?;
} else {
let groups = get_groups(framework);
let groups_iter = groups.iter().map(|(name, commands)| {
(
name,
commands
.iter()
.map(|c| format!("`{}`", c))
.collect::<Vec<String>>()
.join(" "),
true,
)
});
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Invalid Command")
.color(THEME_COLOR)
.description("Type `/help command` to view more about a command below:")
.fields(groups_iter)
}),
)
.await?;
}
} else {
let groups = get_groups(framework);
let groups_iter = groups.iter().map(|(name, commands)| {
(
name,
commands
.iter()
.map(|c| format!("`{}`", c))
.collect::<Vec<String>>()
.join(" "),
true,
)
});
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Help")
.color(THEME_COLOR)
.description("**Welcome to SoundFX!**
To get started, upload a sound with `/upload`, or use `/search` and `/play` to look at some of the public sounds
Type `/help command` to view help about a command below:")
.fields(groups_iter)
}),
)
.await?;
}
Ok(()) Ok(())
} }
/// Get additional information about the bot #[command]
#[poise::command(slash_command)] #[group("Information")]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> { #[aliases("invite")]
let current_user = ctx.serenity_context().cache.current_user().id.get(); #[description("Get additional information on the bot")]
async fn info(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
_args: Args,
) -> CommandResult {
let current_user = ctx.cache.current_user();
ctx.send( invoke.respond(ctx.http.clone(), CreateGenericResponse::new()
CreateReply::default().ephemeral(true).embed( .embed(|e| e
CreateEmbed::new() .title("Info")
.title("Info") .color(THEME_COLOR)
.color(THEME_COLOR) .footer(|f| f
.footer(CreateEmbedFooter::new(concat!( .text(concat!(env!("CARGO_PKG_NAME"), " ver ", env!("CARGO_PKG_VERSION"))))
env!("CARGO_PKG_NAME"), .description(format!("Default prefix: `?`
" ver ",
env!("CARGO_PKG_VERSION") Reset prefix: `@{0} prefix ?`
)))
.description(format!("Invite me: https://discord.com/api/oauth2/authorize?client_id={}&permissions=3165184&scope=applications.commands%20bot Invite me: https://discord.com/api/oauth2/authorize?client_id={1}&permissions=3165184&scope=applications.commands%20bot
**Welcome to SoundFX!** **Welcome to SoundFX!**
Developer: <@203532103185465344> Developer: <@203532103185465344>
Find me on https://discord.jellywx.com/ and on https://github.com/JellyWX :) Find me on https://discord.jellywx.com/ and on https://github.com/JellyWX :)
**Sound Credits**
\"The rain falls against the parasol\" https://freesound.org/people/straget/
\"Heavy Rain\" https://freesound.org/people/lebaston100/
\"Rain on Windows, Interior, A\" https://freesound.org/people/InspectorJ/
\"Seaside Waves, Close, A\" https://freesound.org/people/InspectorJ/
\"Small River 1 - Fast - Close\" https://freesound.org/people/Pfannkuchn/
**An online dashboard is available!** Visit https://soundfx.jellywx.com/dashboard **An online dashboard is available!** Visit https://soundfx.jellywx.com/dashboard
There is a maximum sound limit per user. This can be removed by subscribing at **https://patreon.com/jellywx**", There is a maximum sound limit per user. This can be removed by subscribing at **https://patreon.com/jellywx**", current_user.name, current_user.id.as_u64())))).await?;
current_user)))
)
.await?;
Ok(()) Ok(())
} }

View File

@ -1,35 +1,32 @@
use poise::{ use std::time::Duration;
serenity_prelude::{Attachment, CreateAttachment, GuildId, RoleId},
CreateReply, use regex_command_attr::command;
use serenity::{
client::Context,
framework::standard::CommandResult,
model::id::{GuildId, RoleId},
}; };
#[cfg(feature = "metrics")]
use crate::metrics::{DELETE_COUNTER, UPLOAD_COUNTER};
use crate::{ use crate::{
cmds::autocomplete_sound, framework::{Args, CommandInvoke, CreateGenericResponse},
consts::{MAX_SOUNDS, PATREON_GUILD, PATREON_ROLE}, sound::Sound,
models::sound::{Sound, SoundCtx}, MySQL, MAX_SOUNDS, PATREON_GUILD, PATREON_ROLE,
Context, Error,
}; };
/// Upload a new sound to the bot #[command("upload")]
#[poise::command( #[group("Manage")]
slash_command, #[description("Upload a new sound to the bot")]
rename = "upload", #[arg(
category = "Manage", name = "name",
default_member_permissions = "MANAGE_GUILD", description = "Name to upload sound to",
guild_only = true kind = "String",
required = 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, invoke: &(dyn CommandInvoke + Sync + Send),
#[description = "Sound file (max. 2MB)"] file: Attachment, args: Args,
) -> Result<(), Error> { ) -> CommandResult {
#[cfg(feature = "metrics")]
UPLOAD_COUNTER.inc();
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) {
@ -41,102 +38,192 @@ pub async fn upload_new_sound(
true true
} }
if !name.is_empty() && name.len() <= 20 { let new_name = args
if name.starts_with("@") { .named("name")
ctx.say("Sound names cannot start with an @ symbol. Please choose another name") .map(|n| n.to_string())
.await?; .unwrap_or(String::new());
} else if is_numeric(&name) {
ctx.say("Please ensure the sound name contains a non-numerical character") if !new_name.is_empty() && new_name.len() <= 20 {
.await?; if !is_numeric(&new_name) {
} else { let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
// 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(invoke.author_id().0, &new_name, pool.clone())
.await?; .await?;
if count_name > 0 { if count_name > 0 {
ctx.say( invoke.respond(ctx.http.clone(), CreateGenericResponse::new().content("You are already using that name. Please choose a unique name for your upload.")).await?;
"You are already using that name. Please choose a unique name for your upload.",
)
.await?;
} else { } else {
// need to check how many sounds user currently has // need to check how many sounds user currently has
let count = Sound::count_user_sounds(ctx.author().id, &ctx.data().database).await?; let count = Sound::count_user_sounds(invoke.author_id().0, pool.clone()).await?;
let mut permit_upload = true; let mut permit_upload = true;
// need to check if user is Patreon or not // need to check if user is patreon or nah
if count >= *MAX_SOUNDS { if count >= *MAX_SOUNDS {
let patreon_guild_member = GuildId::from(*PATREON_GUILD) let patreon_guild_member = GuildId(*PATREON_GUILD)
.member(ctx, ctx.author().id) .member(ctx, invoke.author_id())
.await; .await;
if let Ok(member) = patreon_guild_member { if let Ok(member) = patreon_guild_member {
permit_upload = member.roles.contains(&RoleId::from(*PATREON_ROLE)); permit_upload = member.roles.contains(&RoleId(*PATREON_ROLE));
} else { } else {
permit_upload = false; permit_upload = false;
} }
} }
if permit_upload { if permit_upload {
match Sound::create_anon( let attachment = if let Some(attachment) = invoke
&name, .msg()
file.url.as_str(), .map(|m| m.attachments.get(0).map(|a| a.url.clone()))
ctx.guild_id().unwrap(), .flatten()
ctx.author().id,
&ctx.data().database,
)
.await
{ {
Ok(_) => { Some(attachment)
ctx.say("Sound has been uploaded").await?; } else {
} invoke.respond(ctx.http.clone(), CreateGenericResponse::new().content("Please now upload an audio file under 1MB in size (larger files will be automatically trimmed):")).await?;
Err(e) => { let reply = invoke
println!("Error occurred during upload: {:?}", e); .channel_id()
ctx.say("Sound failed to upload.").await?; .await_reply(&ctx)
.author_id(invoke.author_id())
.timeout(Duration::from_secs(120))
.await;
match reply {
Some(reply_msg) => {
if let Some(attachment) = reply_msg.attachments.get(0) {
Some(attachment.url.clone())
} else {
invoke.followup(ctx.http.clone(), CreateGenericResponse::new().content("Please upload 1 attachment following your upload command. Aborted")).await?;
None
}
}
None => {
invoke
.followup(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Upload timed out. Please redo the command"),
)
.await?;
None
}
}
};
if let Some(url) = attachment {
match Sound::create_anon(
&new_name,
url.as_str(),
invoke.guild_id().unwrap().0,
invoke.author_id().0,
pool,
)
.await
{
Ok(_) => {
invoke
.followup(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Sound has been uploaded"),
)
.await?;
}
Err(e) => {
println!("Error occurred during upload: {:?}", e);
invoke
.followup(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Sound failed to upload."),
)
.await?;
}
} }
} }
} else { } else {
ctx.say(format!( invoke.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!(
"You have reached the maximum number of sounds ({}). Either delete some with `/delete` or join our Patreon for unlimited uploads at **https://patreon.com/jellywx**", "You have reached the maximum number of sounds ({}). Either delete some with `/delete` or join our Patreon for unlimited uploads at **https://patreon.com/jellywx**",
*MAX_SOUNDS, *MAX_SOUNDS,
)).await?; ))).await?;
} }
} }
} else {
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("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?; invoke.respond(ctx.http.clone(), CreateGenericResponse::new().content("Usage: `/upload <name>`. Please ensure the name provided is less than 20 characters in length")).await?;
} }
Ok(()) Ok(())
} }
/// Delete a sound you have uploaded #[command("delete")]
#[poise::command(slash_command, rename = "delete", guild_only = true)] #[group("Manage")]
#[description("Delete a sound you have uploaded")]
#[arg(
name = "query",
description = "Delete sound with the specified name or ID",
kind = "String",
required = true
)]
#[example("`/delete beep` - delete the sound with the name \"beep\"")]
pub async fn delete_sound( pub async fn delete_sound(
ctx: Context<'_>, ctx: &Context,
#[description = "Name or ID of sound to delete"] invoke: &(dyn CommandInvoke + Sync + Send),
#[autocomplete = "autocomplete_sound"] args: Args,
name: String, ) -> CommandResult {
) -> Result<(), Error> { let pool = ctx
#[cfg(feature = "metrics")] .data
DELETE_COUNTER.inc(); .read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let pool = ctx.data().database.clone(); let uid = invoke.author_id().0;
let gid = invoke.guild_id().unwrap().0;
let uid = ctx.author().id.get(); let name = args
let gid = ctx.guild_id().unwrap().get(); .named("query")
.map(|s| s.to_owned())
.unwrap_or(String::new());
let sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?; let sound_vec = Sound::search_for_sound(&name, gid, uid, pool.clone(), true).await?;
let sound_result = sound_vec.first(); let sound_result = sound_vec.first();
match sound_result { match sound_result {
Some(sound) => { Some(sound) => {
if sound.uploader_id != Some(uid) && sound.server_id != gid { if sound.uploader_id != Some(uid) && sound.server_id != gid {
ctx.say("You can only delete sounds from this guild or that you have uploaded.") invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(
"You can only delete sounds from this guild or that you have uploaded.",
),
)
.await?; .await?;
} else { } else {
let has_perms = { let has_perms = {
if let Ok(member) = ctx.guild_id().unwrap().member(&ctx, uid).await { if let Ok(member) = invoke.member(&ctx).await {
if let Ok(perms) = member.permissions(&ctx) { if let Ok(perms) = member.permissions(&ctx) {
perms.manage_guild() perms.manage_guild()
} else { } else {
@ -148,95 +235,109 @@ pub async fn delete_sound(
}; };
if sound.uploader_id == Some(uid) || has_perms { if sound.uploader_id == Some(uid) || has_perms {
sound.delete(&pool).await?; sound.delete(pool).await?;
ctx.say("Sound has been deleted").await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Sound has been deleted"),
)
.await?;
} else { } else {
ctx.say("Only server admins can delete sounds uploaded by other users.") invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(
"Only server admins can delete sounds uploaded by other users.",
),
)
.await?; .await?;
} }
} }
} }
None => { None => {
ctx.say("Sound could not be found by that name.").await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Sound could not be found by that name."),
)
.await?;
} }
} }
Ok(()) Ok(())
} }
/// Change a sound between public and private #[command("public")]
#[poise::command(slash_command, rename = "public", guild_only = true)] #[group("Manage")]
#[description("Change a sound between public and private")]
#[arg(
name = "query",
kind = "String",
description = "Sound name or ID to change the privacy setting of",
required = true
)]
#[example("`/public 12` - change sound with ID 12 to private")]
#[example("`/public 12` - change sound with ID 12 back to public")]
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"] invoke: &(dyn CommandInvoke + Sync + Send),
#[autocomplete = "autocomplete_sound"] args: Args,
name: String, ) -> CommandResult {
) -> Result<(), Error> { let pool = ctx
let pool = ctx.data().database.clone(); .data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let uid = ctx.author().id.get(); let uid = invoke.author_id().as_u64().to_owned();
let gid = ctx.guild_id().unwrap().get();
let mut sound_vec = ctx.data().search_for_sound(&name, gid, uid, true).await?; let name = args.named("query").unwrap();
let gid = *invoke.guild_id().unwrap().as_u64();
let mut sound_vec = Sound::search_for_sound(name, gid, uid, pool.clone(), true).await?;
let sound_result = sound_vec.first_mut(); let sound_result = sound_vec.first_mut();
match sound_result { match sound_result {
Some(sound) => { Some(sound) => {
if sound.uploader_id != Some(uid) { if sound.uploader_id != Some(uid) {
ctx.say("You can only change the visibility of sounds you have uploaded. Use `/list` to view your sounds").await?; invoke.respond(ctx.http.clone(), CreateGenericResponse::new().content("You can only change the visibility of sounds you have uploaded. Use `?list me` to view your sounds")).await?;
} else { } else {
if sound.public { if sound.public {
sound.public = false; sound.public = false;
ctx.say("Sound has been set to private 🔒").await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Sound has been set to private 🔒"),
)
.await?;
} else { } else {
sound.public = true; sound.public = true;
ctx.say("Sound has been set to public 🔓").await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Sound has been set to public 🔓"),
)
.await?;
} }
sound.commit(&pool).await? sound.commit(pool).await?
} }
} }
None => { None => {
ctx.say("Sound could not be found by that name.").await?; invoke
} .respond(
} ctx.http.clone(),
CreateGenericResponse::new().content("Sound could not be found by that name."),
Ok(()) )
} .await?;
/// Download a sound file from the bot
#[poise::command(slash_command, rename = "download", guild_only = true)]
pub async fn download_file(
ctx: Context<'_>,
#[description = "Name or ID of sound to download"]
#[autocomplete = "autocomplete_sound"]
name: String,
) -> Result<(), Error> {
ctx.defer().await?;
let sound = ctx
.data()
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
.await?;
match sound.first() {
Some(sound) => {
let name = format!("{}-{}.opus", sound.id, sound.name);
ctx.send(CreateReply::default().attachment(CreateAttachment::bytes(
sound.src(&ctx.data().database).await,
name.as_str(),
)))
.await?;
}
None => {
ctx.say("No sound found by specified name/ID").await?;
} }
} }

View File

@ -1,31 +1,6 @@
use poise::serenity_prelude::AutocompleteChoice;
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;
pub mod search; pub mod search;
pub mod settings; pub mod settings;
pub mod stop; pub mod stop;
pub async fn autocomplete_sound(ctx: Context<'_>, partial: &str) -> Vec<AutocompleteChoice> {
ctx.data()
.autocomplete_user_sounds(&partial, ctx.author().id, ctx.guild_id().unwrap())
.await
.unwrap_or(vec![])
.iter()
.map(|s| AutocompleteChoice::new(s.name.clone(), s.id.to_string()))
.collect()
}
pub async fn autocomplete_favorite(ctx: Context<'_>, partial: &str) -> Vec<AutocompleteChoice> {
ctx.data()
.autocomplete_favorite_sounds(&partial, ctx.author().id)
.await
.unwrap_or(vec![])
.iter()
.map(|s| AutocompleteChoice::new(s.name.clone(), s.id.to_string()))
.collect()
}

View File

@ -1,440 +1,393 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::{convert::TryFrom, time::Duration};
use poise::{ use regex_command_attr::command;
serenity_prelude::{ use serenity::{
builder::CreateActionRow, ButtonStyle, CreateButton, GuildChannel, ReactionType, builder::CreateActionRow,
}, client::Context,
CreateReply, framework::standard::CommandResult,
model::interactions::{message_component::ButtonStyle, InteractionResponseType},
};
use songbird::{
create_player, ffmpeg,
input::{cached::Memory, Input},
Event,
}; };
#[cfg(feature = "metrics")]
use crate::metrics::PLAY_COUNTER;
use crate::{ use crate::{
cmds::autocomplete_sound, event_handlers::RestartTrack,
models::{guild_data::CtxGuildData, sound::SoundCtx}, framework::{Args, CommandInvoke, CreateGenericResponse},
utils::{join_channel, play_audio, play_from_query, queue_audio}, guild_data::CtxGuildData,
Context, Error, join_channel, play_from_query,
sound::Sound,
AudioIndex, MySQL,
}; };
/// Play a sound in your current voice channel #[command]
#[poise::command(slash_command, default_member_permissions = "SPEAK", guild_only = true)] #[aliases("p")]
#[required_permissions(Managed)]
#[group("Play")]
#[description("Play a sound in your current voice channel")]
#[arg(
name = "query",
description = "Play sound with the specified name or ID",
kind = "String",
required = true
)]
#[example("`/play ubercharge` - play sound with name \"ubercharge\" ")]
#[example("`/play 13002` - play sound with ID 13002")]
pub async fn play( pub async fn play(
ctx: Context<'_>, ctx: &Context,
#[description = "Name or ID of sound to play"] invoke: &(dyn CommandInvoke + Sync + Send),
#[autocomplete = "autocomplete_sound"] args: Args,
name: String, ) -> CommandResult {
#[description = "Channel to play in (default: your current voice channel)"] let guild = invoke.guild(ctx.cache.clone()).unwrap();
#[channel_types("Voice")]
channel: Option<GuildChannel>,
) -> Result<(), Error> {
#[cfg(feature = "metrics")]
PLAY_COUNTER.inc();
ctx.defer().await?; invoke
.respond(
let guild = ctx.guild().map(|g| g.clone()).unwrap(); ctx.http.clone(),
CreateGenericResponse::new()
ctx.say( .content(play_from_query(ctx, guild, invoke.author_id(), args, false).await),
play_from_query(
&ctx.serenity_context(),
&ctx.data(),
&guild,
ctx.author().id,
channel.map(|c| c.id),
&name,
false,
) )
.await, .await?;
)
.await?;
Ok(()) Ok(())
} }
/// Play a random sound from this server #[command("loop")]
#[poise::command( #[required_permissions(Managed)]
slash_command, #[group("Play")]
rename = "random", #[description("Play a sound on loop in your current voice channel")]
default_member_permissions = "SPEAK", #[arg(
guild_only = true name = "query",
description = "Play sound with the specified name or ID",
kind = "String",
required = true
)] )]
pub async fn play_random( #[example("`/loop rain` - loop sound with name \"rain\" ")]
ctx: Context<'_>, #[example("`/loop 13002` - play sound with ID 13002")]
#[description = "Channel to play in (default: your current voice channel)"] pub async fn loop_play(
#[channel_types("Voice")] ctx: &Context,
channel: Option<GuildChannel>, invoke: &(dyn CommandInvoke + Sync + Send),
) -> Result<(), Error> { args: Args,
ctx.defer().await?; ) -> CommandResult {
let guild = invoke.guild(ctx.cache.clone()).unwrap();
let (channel_to_join, guild_id) = { invoke
let guild = ctx.guild().unwrap(); .respond(
ctx.http.clone(),
( CreateGenericResponse::new()
channel.map(|c| c.id).or_else(|| { .content(play_from_query(ctx, guild, invoke.author_id(), args, true).await),
guild
.voice_states
.get(&ctx.author().id)
.and_then(|voice_state| voice_state.channel_id)
}),
guild.id,
) )
}; .await?;
match channel_to_join {
Some(channel) => {
let call = join_channel(ctx.serenity_context(), guild_id, channel).await?;
let sounds = ctx.data().guild_sounds(guild_id, None).await?;
if sounds.len() == 0 {
ctx.say("No sounds in this server!").await?;
return Ok(());
}
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
println!("{}", ts.subsec_micros());
// This is far cheaper and easier than using an RNG. No reason to use a full RNG here
// anyway.
match sounds.get(ts.subsec_micros() as usize % sounds.len()) {
Some(sound) => {
let guild_data = ctx.data().guild_data(guild_id).await.unwrap();
let mut lock = call.lock().await;
play_audio(
sound,
guild_data.read().await.volume,
&mut lock,
&ctx.data().database,
false,
)
.await
.unwrap();
ctx.say(format!("Playing {} (ID {})", sound.name, sound.id))
.await?;
}
None => {
ctx.say("No sounds in this server!").await?;
}
}
}
None => {
ctx.say("You are not in a voice chat!").await?;
}
}
Ok(()) Ok(())
} }
/// Play up to 25 sounds on queue #[command("ambience")]
#[poise::command( #[required_permissions(Managed)]
slash_command, #[group("Play")]
rename = "queue", #[description("Play ambient sound in your current voice channel")]
default_member_permissions = "SPEAK", #[arg(
guild_only = true name = "name",
description = "Play sound with the specified name",
kind = "String",
required = false
)] )]
pub async fn queue_play( #[example("`/ambience rain on tent` - play the ambient sound \"rain on tent\" ")]
ctx: Context<'_>, pub async fn play_ambience(
#[description = "Name or ID for queue position 1"] ctx: &Context,
#[autocomplete = "autocomplete_sound"] invoke: &(dyn CommandInvoke + Sync + Send),
sound_1: String, args: Args,
#[description = "Name or ID for queue position 2"] ) -> CommandResult {
#[autocomplete = "autocomplete_sound"] let guild = invoke.guild(ctx.cache.clone()).unwrap();
sound_2: String,
#[description = "Name or ID for queue position 3"]
#[autocomplete = "autocomplete_sound"]
sound_3: Option<String>,
#[description = "Name or ID for queue position 4"]
#[autocomplete = "autocomplete_sound"]
sound_4: Option<String>,
#[description = "Name or ID for queue position 5"]
#[autocomplete = "autocomplete_sound"]
sound_5: Option<String>,
#[description = "Name or ID for queue position 6"]
#[autocomplete = "autocomplete_sound"]
sound_6: Option<String>,
#[description = "Name or ID for queue position 7"]
#[autocomplete = "autocomplete_sound"]
sound_7: Option<String>,
#[description = "Name or ID for queue position 8"]
#[autocomplete = "autocomplete_sound"]
sound_8: Option<String>,
#[description = "Name or ID for queue position 9"]
#[autocomplete = "autocomplete_sound"]
sound_9: Option<String>,
#[description = "Name or ID for queue position 10"]
#[autocomplete = "autocomplete_sound"]
sound_10: Option<String>,
#[description = "Name or ID for queue position 11"]
#[autocomplete = "autocomplete_sound"]
sound_11: Option<String>,
#[description = "Name or ID for queue position 12"]
#[autocomplete = "autocomplete_sound"]
sound_12: Option<String>,
#[description = "Name or ID for queue position 13"]
#[autocomplete = "autocomplete_sound"]
sound_13: Option<String>,
#[description = "Name or ID for queue position 14"]
#[autocomplete = "autocomplete_sound"]
sound_14: Option<String>,
#[description = "Name or ID for queue position 15"]
#[autocomplete = "autocomplete_sound"]
sound_15: Option<String>,
#[description = "Name or ID for queue position 16"]
#[autocomplete = "autocomplete_sound"]
sound_16: Option<String>,
#[description = "Name or ID for queue position 17"]
#[autocomplete = "autocomplete_sound"]
sound_17: Option<String>,
#[description = "Name or ID for queue position 18"]
#[autocomplete = "autocomplete_sound"]
sound_18: Option<String>,
#[description = "Name or ID for queue position 19"]
#[autocomplete = "autocomplete_sound"]
sound_19: Option<String>,
#[description = "Name or ID for queue position 20"]
#[autocomplete = "autocomplete_sound"]
sound_20: Option<String>,
#[description = "Name or ID for queue position 21"]
#[autocomplete = "autocomplete_sound"]
sound_21: Option<String>,
#[description = "Name or ID for queue position 22"]
#[autocomplete = "autocomplete_sound"]
sound_22: Option<String>,
#[description = "Name or ID for queue position 23"]
#[autocomplete = "autocomplete_sound"]
sound_23: Option<String>,
#[description = "Name or ID for queue position 24"]
#[autocomplete = "autocomplete_sound"]
sound_24: Option<String>,
#[description = "Name or ID for queue position 25"]
#[autocomplete = "autocomplete_sound"]
sound_25: Option<String>,
) -> Result<(), Error> {
ctx.defer().await?;
let (channel_to_join, guild_id) = { let channel_to_join = guild
let guild = ctx.guild().unwrap(); .voice_states
.get(&invoke.author_id())
( .and_then(|voice_state| voice_state.channel_id);
guild
.voice_states
.get(&ctx.author().id)
.and_then(|voice_state| voice_state.channel_id),
guild.id,
)
};
match channel_to_join { match channel_to_join {
Some(user_channel) => { Some(user_channel) => {
let call = join_channel(ctx.serenity_context(), guild_id, user_channel).await?; let audio_index = ctx.data.read().await.get::<AudioIndex>().cloned().unwrap();
let guild_data = ctx.data().guild_data(guild_id).await.unwrap(); if let Some(search_name) = args.named("name") {
if let Some(filename) = audio_index.get(search_name) {
let (track, track_handler) = create_player(
Input::try_from(
Memory::new(ffmpeg(format!("audio/{}", filename)).await.unwrap())
.unwrap(),
)
.unwrap(),
);
let query_terms = [ let (call_handler, _) = join_channel(ctx, guild.clone(), user_channel).await;
Some(sound_1), let guild_data = ctx.guild_data(guild).await.unwrap();
Some(sound_2),
sound_3,
sound_4,
sound_5,
sound_6,
sound_7,
sound_8,
sound_9,
sound_10,
sound_11,
sound_12,
sound_13,
sound_14,
sound_15,
sound_16,
sound_17,
sound_18,
sound_19,
sound_20,
sound_21,
sound_22,
sound_23,
sound_24,
sound_25,
];
let mut sounds = vec![]; {
let mut lock = call_handler.lock().await;
for sound in query_terms.iter().flatten() { lock.play(track);
let search = ctx }
.data()
.search_for_sound(&sound, ctx.guild_id().unwrap(), ctx.author().id, true)
.await?;
if let Some(sound) = search.first() { let _ = track_handler.set_volume(guild_data.read().await.volume as f32 / 100.0);
sounds.push(sound.clone()); let _ = track_handler.add_event(
Event::Periodic(
track_handler.metadata().duration.unwrap() - Duration::from_millis(200),
None,
),
RestartTrack {},
);
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content(format!("Playing ambience **{}**", search_name)),
)
.await?;
} else {
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Not Found").description(format!(
"Could not find ambience sound by name **{}**
__Available ambience sounds:__
{}",
search_name,
audio_index
.keys()
.into_iter()
.map(|i| i.as_str())
.collect::<Vec<&str>>()
.join("\n")
))
}),
)
.await?;
} }
} else {
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Available Sounds").description(
audio_index
.keys()
.into_iter()
.map(|i| i.as_str())
.collect::<Vec<&str>>()
.join("\n"),
)
}),
)
.await?;
} }
{
let mut lock = call.lock().await;
queue_audio(
&sounds,
guild_data.read().await.volume,
&mut lock,
&ctx.data().database,
)
.await
.unwrap();
}
ctx.say(format!("Queued {} sounds!", sounds.len())).await?;
} }
None => { None => {
ctx.say("You are not in a voice chat!").await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("You are not in a voice chat!"),
)
.await?;
} }
} }
Ok(()) Ok(())
} }
/// Loop a sound in your current voice channel #[command("soundboard")]
#[poise::command( #[required_permissions(Managed)]
slash_command, #[group("Play")]
rename = "loop", #[kind(Slash)]
default_member_permissions = "SPEAK", #[description("Get a menu of sounds with buttons to play them")]
guild_only = true #[arg(
name = "1",
description = "Query for sound button 1",
kind = "String",
required = true
)] )]
pub async fn loop_play( #[arg(
ctx: Context<'_>, name = "2",
#[description = "Name or ID of sound to loop"] description = "Query for sound button 2",
#[autocomplete = "autocomplete_sound"] kind = "String",
name: String, required = false
) -> Result<(), Error> {
ctx.defer().await?;
let guild = ctx.guild().map(|g| g.clone()).unwrap();
ctx.say(
play_from_query(
&ctx.serenity_context(),
&ctx.data(),
&guild,
ctx.author().id,
None,
&name,
true,
)
.await,
)
.await?;
Ok(())
}
/// Get a menu of sounds with buttons to play them
#[poise::command(
slash_command,
rename = "soundboard",
category = "Play",
default_member_permissions = "SPEAK",
guild_only = true
)] )]
#[arg(
name = "3",
description = "Query for sound button 3",
kind = "String",
required = false
)]
#[arg(
name = "4",
description = "Query for sound button 4",
kind = "String",
required = false
)]
#[arg(
name = "5",
description = "Query for sound button 5",
kind = "String",
required = false
)]
#[arg(
name = "6",
description = "Query for sound button 6",
kind = "String",
required = false
)]
#[arg(
name = "7",
description = "Query for sound button 7",
kind = "String",
required = false
)]
#[arg(
name = "8",
description = "Query for sound button 8",
kind = "String",
required = false
)]
#[arg(
name = "9",
description = "Query for sound button 9",
kind = "String",
required = false
)]
#[arg(
name = "10",
description = "Query for sound button 10",
kind = "String",
required = false
)]
#[arg(
name = "11",
description = "Query for sound button 11",
kind = "String",
required = false
)]
#[arg(
name = "12",
description = "Query for sound button 12",
kind = "String",
required = false
)]
#[arg(
name = "13",
description = "Query for sound button 13",
kind = "String",
required = false
)]
#[arg(
name = "14",
description = "Query for sound button 14",
kind = "String",
required = false
)]
#[arg(
name = "15",
description = "Query for sound button 15",
kind = "String",
required = false
)]
#[arg(
name = "16",
description = "Query for sound button 16",
kind = "String",
required = false
)]
#[arg(
name = "17",
description = "Query for sound button 17",
kind = "String",
required = false
)]
#[arg(
name = "18",
description = "Query for sound button 18",
kind = "String",
required = false
)]
#[arg(
name = "19",
description = "Query for sound button 19",
kind = "String",
required = false
)]
#[arg(
name = "20",
description = "Query for sound button 20",
kind = "String",
required = false
)]
#[arg(
name = "21",
description = "Query for sound button 21",
kind = "String",
required = false
)]
#[arg(
name = "22",
description = "Query for sound button 22",
kind = "String",
required = false
)]
#[arg(
name = "23",
description = "Query for sound button 23",
kind = "String",
required = false
)]
#[arg(
name = "24",
description = "Query for sound button 24",
kind = "String",
required = false
)]
#[arg(
name = "25",
description = "Query for sound button 25",
kind = "String",
required = false
)]
#[example("`/soundboard ubercharge` - create a soundboard with a button for the \"ubercharge\" sound effect")]
#[example("`/soundboard 57000 24119 2 1002 13202` - create a soundboard with 5 buttons, for sounds with the IDs presented")]
pub async fn soundboard( pub async fn soundboard(
ctx: Context<'_>, ctx: &Context,
#[description = "Name or ID of sound for button 1"] invoke: &(dyn CommandInvoke + Sync + Send),
#[autocomplete = "autocomplete_sound"] args: Args,
sound_1: String, ) -> CommandResult {
#[description = "Name or ID of sound for button 2"] if let Some(interaction) = invoke.interaction() {
#[autocomplete = "autocomplete_sound"] let _ = interaction
sound_2: Option<String>, .create_interaction_response(&ctx, |r| {
#[description = "Name or ID of sound for button 3"] r.kind(InteractionResponseType::DeferredChannelMessageWithSource)
#[autocomplete = "autocomplete_sound"] })
sound_3: Option<String>, .await;
#[description = "Name or ID of sound for button 4"] }
#[autocomplete = "autocomplete_sound"]
sound_4: Option<String>,
#[description = "Name or ID of sound for button 5"]
#[autocomplete = "autocomplete_sound"]
sound_5: Option<String>,
#[description = "Name or ID of sound for button 6"]
#[autocomplete = "autocomplete_sound"]
sound_6: Option<String>,
#[description = "Name or ID of sound for button 7"]
#[autocomplete = "autocomplete_sound"]
sound_7: Option<String>,
#[description = "Name or ID of sound for button 8"]
#[autocomplete = "autocomplete_sound"]
sound_8: Option<String>,
#[description = "Name or ID of sound for button 9"]
#[autocomplete = "autocomplete_sound"]
sound_9: Option<String>,
#[description = "Name or ID of sound for button 10"]
#[autocomplete = "autocomplete_sound"]
sound_10: Option<String>,
#[description = "Name or ID of sound for button 11"]
#[autocomplete = "autocomplete_sound"]
sound_11: Option<String>,
#[description = "Name or ID of sound for button 12"]
#[autocomplete = "autocomplete_sound"]
sound_12: Option<String>,
#[description = "Name or ID of sound for button 13"]
#[autocomplete = "autocomplete_sound"]
sound_13: Option<String>,
#[description = "Name or ID of sound for button 14"]
#[autocomplete = "autocomplete_sound"]
sound_14: Option<String>,
#[description = "Name or ID of sound for button 15"]
#[autocomplete = "autocomplete_sound"]
sound_15: Option<String>,
#[description = "Name or ID of sound for button 16"]
#[autocomplete = "autocomplete_sound"]
sound_16: Option<String>,
#[description = "Name or ID of sound for button 17"]
#[autocomplete = "autocomplete_sound"]
sound_17: Option<String>,
#[description = "Name or ID of sound for button 18"]
#[autocomplete = "autocomplete_sound"]
sound_18: Option<String>,
#[description = "Name or ID of sound for button 19"]
#[autocomplete = "autocomplete_sound"]
sound_19: Option<String>,
#[description = "Name or ID of sound for button 20"]
#[autocomplete = "autocomplete_sound"]
sound_20: Option<String>,
) -> Result<(), Error> {
ctx.defer().await?;
let query_terms = [ let pool = ctx
Some(sound_1), .data
sound_2, .read()
sound_3, .await
sound_4, .get::<MySQL>()
sound_5, .cloned()
sound_6, .expect("Could not get SQLPool from data");
sound_7,
sound_8,
sound_9,
sound_10,
sound_11,
sound_12,
sound_13,
sound_14,
sound_15,
sound_16,
sound_17,
sound_18,
sound_19,
sound_20,
];
let mut sounds = vec![]; let mut sounds = vec![];
for sound in query_terms.iter().flatten() { for n in 1..25 {
let search = ctx let search = Sound::search_for_sound(
.data() args.named(&n.to_string()).unwrap_or(&"".to_string()),
.search_for_sound(&sound, ctx.guild_id().unwrap(), ctx.author().id, true) invoke.guild_id().unwrap(),
.await?; invoke.author_id(),
pool.clone(),
true,
)
.await?;
if let Some(sound) = search.first() { if let Some(sound) = search.first() {
if !sounds.contains(sound) { if !sounds.contains(sound) {
@ -443,50 +396,29 @@ pub async fn soundboard(
} }
} }
let components = { invoke
let mut c = vec![]; .followup(
for row in sounds.as_slice().chunks(5) { ctx.http.clone(),
let mut action_row = vec![]; CreateGenericResponse::new()
for sound in row { .content("**Play a sound:**")
action_row.push( .components(|c| {
CreateButton::new(sound.id.to_string()) for row in sounds.as_slice().chunks(5) {
.style(ButtonStyle::Primary) let mut action_row: CreateActionRow = Default::default();
.label(&sound.name), for sound in row {
); action_row.create_button(|b| {
} b.style(ButtonStyle::Primary)
.label(&sound.name)
.custom_id(sound.id)
});
}
c.push(CreateActionRow::Buttons(action_row)); c.add_action_row(action_row);
} }
c.push(CreateActionRow::Buttons(vec![ c
CreateButton::new("#stop") }),
.label("Stop") )
.emoji(ReactionType::Unicode("".to_string())) .await?;
.style(ButtonStyle::Danger),
CreateButton::new("#mode")
.label("Mode:")
.style(ButtonStyle::Secondary)
.disabled(true),
CreateButton::new("#instant")
.label("Instant")
.emoji(ReactionType::Unicode("".to_string()))
.style(ButtonStyle::Secondary)
.disabled(true),
CreateButton::new("#loop")
.label("Loop")
.emoji(ReactionType::Unicode("🔁".to_string()))
.style(ButtonStyle::Secondary),
]));
c
};
ctx.send(
CreateReply::default()
.content("**Play a sound:**")
.components(components),
)
.await?;
Ok(()) Ok(())
} }

View File

@ -1,22 +1,13 @@
use poise::{ use regex_command_attr::command;
serenity_prelude, use serenity::{client::Context, framework::standard::CommandResult};
serenity_prelude::{
constants::MESSAGE_CODE_LIMIT, ButtonStyle, ComponentInteraction, CreateActionRow,
CreateButton, CreateEmbed, EditInteractionResponse, GuildId, UserId,
},
CreateReply,
};
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
consts::THEME_COLOR, framework::{Args, CommandInvoke, CreateGenericResponse},
models::sound::{Sound, SoundCtx}, sound::Sound,
Context, Data, Error, MySQL,
}; };
fn format_search_results(search_results: Vec<Sound>) -> CreateReply { fn format_search_results(search_results: Vec<Sound>) -> CreateGenericResponse {
let builder = CreateReply::default();
let mut current_character_count = 0; let mut current_character_count = 0;
let title = "Public sounds matching filter:"; let title = "Public sounds matching filter:";
@ -27,242 +18,156 @@ fn format_search_results(search_results: Vec<Sound>) -> CreateReply {
.filter(|item| { .filter(|item| {
current_character_count += item.0.len() + item.1.len(); current_character_count += item.0.len() + item.1.len();
current_character_count <= MESSAGE_CODE_LIMIT - title.len() current_character_count <= serenity::constants::MESSAGE_CODE_LIMIT - title.len()
}); });
builder.embed(CreateEmbed::default().title(title).fields(field_iter)) CreateGenericResponse::new().embed(|e| e.title(title).fields(field_iter))
} }
/// Show uploaded sounds #[command("list")]
#[poise::command(slash_command, rename = "list", guild_only = true)] #[group("Search")]
pub async fn list_sounds(_ctx: Context<'_>) -> Result<(), Error> { #[description("Show the sounds uploaded by you or to your server")]
Ok(()) #[arg(
} name = "me",
description = "Whether to list your sounds or server sounds (default: server)",
kind = "Boolean",
required = false
)]
#[example("`/list` - list sounds uploaded to the server you're in")]
#[example("`/list [me: True]` - list sounds you have uploaded across all servers")]
pub async fn list_sounds(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
#[derive(Serialize, Deserialize, Clone, Copy)] let sounds;
enum ListContext { let mut message_buffer;
User = 0,
Guild = 1,
Favorite = 2,
}
impl ListContext { if args.named("me").map(|i| i.to_owned()) == Some("me".to_string()) {
pub fn title(&self) -> &'static str { sounds = Sound::user_sounds(invoke.author_id(), pool).await?;
match self {
ListContext::User => "Your sounds",
ListContext::Favorite => "Your favorite sounds",
ListContext::Guild => "Server sounds",
}
}
}
/// Show the sounds uploaded to this server message_buffer = "All your sounds: ".to_string();
#[poise::command(slash_command, rename = "server", guild_only = true)] } else {
pub async fn list_guild_sounds(ctx: Context<'_>) -> Result<(), Error> { sounds = Sound::guild_sounds(invoke.guild_id().unwrap(), pool).await?;
let pager = SoundPager {
nonce: 0,
page: 0,
context: ListContext::Guild,
};
pager.reply(ctx).await?; message_buffer = "All sounds on this server: ".to_string();
Ok(())
}
/// Show all sounds you have uploaded
#[poise::command(slash_command, rename = "user", guild_only = true)]
pub async fn list_user_sounds(ctx: Context<'_>) -> Result<(), Error> {
let pager = SoundPager {
nonce: 0,
page: 0,
context: ListContext::User,
};
pager.reply(ctx).await?;
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)]
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::Favorite => data.favorite_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 { for sound in sounds {
let row = CreateActionRow::Buttons(vec![ message_buffer.push_str(
CreateButton::new( format!(
serde_json::to_string(&SoundPager { "**{}** ({}), ",
nonce: 0, sound.name,
page: 0, if sound.public { "🔓" } else { "🔒" }
context: self.context,
})
.unwrap(),
) )
.style(ButtonStyle::Primary) .as_str(),
.label("") );
.disabled(self.page == 0),
CreateButton::new(
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),
CreateButton::new("pid")
.style(ButtonStyle::Success)
.label(format!("Page {}", self.page + 1))
.disabled(true),
CreateButton::new(
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),
CreateButton::new(
serde_json::to_string(&SoundPager {
nonce: 3,
page: max_page,
context: self.context,
})
.unwrap(),
)
.style(ButtonStyle::Primary)
.label("")
.disabled(self.page == max_page),
]);
row if message_buffer.len() > 2000 {
} invoke
.respond(
fn embed(&self, sounds: &[Sound], count: u64) -> CreateEmbed { ctx.http.clone(),
CreateEmbed::default() CreateGenericResponse::new().content(message_buffer),
.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,
) )
})) .await?;
message_buffer = "".to_string();
}
} }
pub async fn handle_interaction( if message_buffer.len() > 0 {
ctx: &serenity_prelude::Context, invoke
data: &Data, .respond(
interaction: &ComponentInteraction, ctx.http.clone(),
) -> Result<(), Error> { CreateGenericResponse::new().content(message_buffer),
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::Favorite => data.count_favorite_sounds(user_id).await?,
ListContext::Guild => data.count_guild_sounds(guild_id).await?,
};
interaction
.edit_response(
&ctx,
EditInteractionResponse::default()
.add_embed(pager.embed(&sounds, count))
.components(vec![pager.create_action_row(count / 25)]),
) )
.await?; .await?;
Ok(())
} }
async fn reply(&self, ctx: Context<'_>) -> Result<(), Error> { Ok(())
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::Favorite => ctx.data().count_favorite_sounds(ctx.author().id).await?,
ListContext::Guild => {
ctx.data()
.count_guild_sounds(ctx.guild_id().unwrap())
.await?
}
};
ctx.send(
CreateReply::default()
.ephemeral(true)
.embed(self.embed(&sounds, count))
.components(vec![self.create_action_row(count / 25)]),
)
.await?;
Ok(())
}
} }
/// Search for sounds #[command("search")]
#[poise::command( #[group("Search")]
slash_command, #[description("Search for sounds")]
rename = "search", #[arg(
category = "Search", name = "query",
guild_only = true kind = "String",
description = "Sound name to search for",
required = true
)] )]
pub async fn search_sounds( pub async fn search_sounds(
ctx: Context<'_>, ctx: &Context,
#[description = "Sound name to search for"] query: String, invoke: &(dyn CommandInvoke + Sync + Send),
) -> Result<(), Error> { args: Args,
let search_results = ctx ) -> CommandResult {
.data() let pool = ctx
.search_for_sound(&query, ctx.guild_id().unwrap(), ctx.author().id, false) .data
.await?; .read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
ctx.send(format_search_results(search_results)).await?; let query = args.named("query").unwrap();
let search_results = Sound::search_for_sound(
query,
invoke.guild_id().unwrap(),
invoke.author_id(),
pool,
false,
)
.await?;
invoke
.respond(ctx.http.clone(), format_search_results(search_results))
.await?;
Ok(())
}
#[command("random")]
#[group("Search")]
#[description("Show a page of random sounds")]
pub async fn show_random_sounds(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
_args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
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(&pool)
.await
.unwrap();
invoke
.respond(ctx.http.clone(), format_search_results(search_results))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,262 +1,307 @@
use poise::{ use regex_command_attr::command;
serenity_prelude::{GuildId, User}, use serenity::{client::Context, framework::standard::CommandResult};
CreateReply,
};
use crate::{ use crate::{
cmds::autocomplete_sound, framework::{Args, CommandInvoke, CreateGenericResponse},
models::{ guild_data::CtxGuildData,
guild_data::{AllowGreet, CtxGuildData}, sound::{JoinSoundCtx, Sound},
join_sound::JoinSoundCtx, MySQL,
sound::SoundCtx,
},
Context, Error,
}; };
/// Change the bot's volume in this server #[command("volume")]
#[poise::command(slash_command, rename = "volume", guild_only = true)] #[aliases("vol")]
#[required_permissions(Managed)]
#[group("Settings")]
#[description("Change the bot's volume in this server")]
#[arg(
name = "volume",
description = "New volume for the bot to use",
kind = "Integer",
required = false
)]
#[example("`/volume` - check the volume on the current server")]
#[example("`/volume 100` - reset the volume on the current server")]
#[example("`/volume 10` - set the volume on the current server to 10%")]
pub async fn change_volume( pub async fn change_volume(
ctx: Context<'_>, ctx: &Context,
#[description = "New volume as a percentage"] volume: Option<usize>, invoke: &(dyn CommandInvoke + Sync + Send),
) -> Result<(), Error> { args: Args,
let guild_data_opt = ctx.guild_data(ctx.guild_id().unwrap()).await; ) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let guild_data_opt = ctx.guild_data(invoke.guild_id().unwrap()).await;
let guild_data = guild_data_opt.unwrap(); let guild_data = guild_data_opt.unwrap();
if let Some(volume) = volume { if let Some(volume) = args.named("volume").map(|i| i.parse::<u8>().ok()).flatten() {
guild_data.write().await.volume = volume as u8; guild_data.write().await.volume = volume;
guild_data.read().await.commit(&ctx.data().database).await?; guild_data.read().await.commit(pool).await?;
ctx.say(format!("Volume changed to {}%", volume)).await?; invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!("Volume changed to {}%", volume)),
)
.await?;
} else { } else {
let read = guild_data.read().await; let read = guild_data.read().await;
ctx.say(format!( invoke
"Current server volume: {vol}%. Change the volume with `/volume <new volume>`", .respond(
vol = read.volume ctx.http.clone(),
)) CreateGenericResponse::new().content(format!(
.await?; "Current server volume: {vol}%. Change the volume with `/volume <new volume>`",
} vol = read.volume
)),
Ok(())
}
/// Manage greet sounds
#[poise::command(slash_command, rename = "greet", guild_only = true)]
pub async fn greet_sound(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// 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")]
pub async fn set_guild_greet_sound(
ctx: Context<'_>,
#[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> {
if user.id != ctx.author().id {
let permissions = ctx.author_member().await.unwrap().permissions(&ctx.cache());
if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(
CreateReply::default()
.ephemeral(true)
.content("Only admins can change other user's greet sounds."),
) )
.await?; .await?;
return Ok(());
}
} }
let sound_vec = ctx Ok(())
.data() }
.search_for_sound(&name, ctx.guild_id().unwrap(), ctx.author().id, true)
.await?;
match sound_vec.first() { #[command("prefix")]
Some(sound) => { #[required_permissions(Restricted)]
ctx.data() #[kind(Text)]
.update_join_sound(user.id, ctx.guild_id(), Some(sound.id)) #[group("Settings")]
#[description("Change the prefix of the bot for using non-slash commands")]
#[arg(
name = "prefix",
kind = "String",
description = "The new prefix to use for the bot",
required = true
)]
pub async fn change_prefix(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let guild_data;
{
let guild_data_opt = ctx.guild_data(invoke.guild_id().unwrap()).await;
guild_data = guild_data_opt.unwrap();
}
if let Some(prefix) = args.named("prefix") {
if prefix.len() <= 5 && !prefix.is_empty() {
let reply = format!("Prefix changed to `{}`", prefix);
{
guild_data.write().await.prefix = prefix.to_string();
}
{
let read = guild_data.read().await;
read.commit(pool).await?;
}
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(reply),
)
.await?; .await?;
} else {
ctx.say(format!( invoke
"Greet sound has been set to {} (ID {})", .respond(
sound.name, sound.id ctx.http.clone(),
)) CreateGenericResponse::new()
.await?; .content("Prefix must be less than 5 characters long"),
} )
None => {
ctx.say("Could not find a sound by that name.").await?;
}
}
Ok(())
}
/// Unset a user's server-specific join sound
#[poise::command(slash_command, rename = "unset", guild_only = true)]
pub async fn unset_guild_greet_sound(
ctx: Context<'_>,
#[description = "User to set join sound for"] user: User,
) -> Result<(), Error> {
if user.id != ctx.author().id {
let permissions = ctx.author_member().await.unwrap().permissions(&ctx.cache());
if permissions.map_or(true, |p| !p.manage_guild()) {
ctx.send(
CreateReply::default()
.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?;
Ok(())
}
/// Manage your own greet sound
#[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?; .await?;
ctx.send(CreateReply::default().ephemeral(true).content(format!(
"Greet sound has been set to {} (ID {})",
sound.name, sound.id
)))
.await?;
} }
} else {
None => { invoke
ctx.send( .respond(
CreateReply::default() ctx.http.clone(),
.ephemeral(true) CreateGenericResponse::new().content(format!(
.content("Could not find a sound by that name."), "Usage: `{prefix}prefix <new prefix>`",
prefix = guild_data.read().await.prefix
)),
) )
.await?; .await?;
}
Ok(())
}
#[command("roles")]
#[required_permissions(Restricted)]
#[group("Settings")]
#[description("Change the role allowed to use the bot")]
#[arg(
name = "role",
kind = "Role",
description = "A role to allow to use the bot. Use @everyone to allow all server members",
required = true
)]
#[example("`/roles @everyone` - allow all server members to use the bot")]
#[example("`/roles @DJ` - allow only server members with the 'DJ' role to use the bot")]
pub async fn set_allowed_roles(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let role_id = args.named("role").unwrap().parse::<u64>().unwrap();
let guild_data = ctx.guild_data(invoke.guild_id().unwrap()).await.unwrap();
guild_data.write().await.allowed_role = Some(role_id);
guild_data.read().await.commit(pool).await?;
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!("Allowed role set to <@&{}>", role_id)),
)
.await?;
Ok(())
}
#[command("greet")]
#[group("Settings")]
#[description("Set a join sound")]
#[arg(
name = "query",
kind = "String",
description = "Name or ID of sound to set as your greet sound",
required = false
)]
#[example("`/greet` - remove your join sound")]
#[example("`/greet 1523` - set your join sound to sound with ID 1523")]
pub async fn set_greet_sound(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let query = args
.named("query")
.map(|s| s.to_owned())
.unwrap_or(String::new());
let user_id = invoke.author_id();
if query.len() == 0 {
ctx.update_join_sound(user_id, None).await;
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Your greet sound has been unset."),
)
.await?;
} else {
let sound_vec = Sound::search_for_sound(
&query,
invoke.guild_id().unwrap(),
user_id,
pool.clone(),
true,
)
.await?;
match sound_vec.first() {
Some(sound) => {
ctx.update_join_sound(user_id, Some(sound.id)).await;
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!(
"Greet sound has been set to {} (ID {})",
sound.name, sound.id
)),
)
.await?;
}
None => {
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Could not find a sound by that name."),
)
.await?;
}
} }
} }
Ok(()) Ok(())
} }
/// Unset your global join sound #[command("allow_greet")]
#[poise::command(slash_command, rename = "unset", guild_only = true)] #[group("Settings")]
pub async fn unset_user_greet_sound(ctx: Context<'_>) -> Result<(), Error> { #[description("Configure whether users should be able to use join sounds")]
ctx.data() #[required_permissions(Restricted)]
.update_join_sound(ctx.author().id, None::<GuildId>, None) #[example("`/allow_greet` - disable greet sounds in the server")]
.await?; #[example("`/allow_greet` - re-enable greet sounds in the server")]
pub async fn allow_greet_sounds(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
_args: Args,
) -> CommandResult {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not acquire SQL pool from data");
ctx.send( let guild_data_opt = ctx.guild_data(invoke.guild_id().unwrap()).await;
CreateReply::default()
.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> {
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 = AllowGreet::Disabled; let current = guild_data.read().await.allow_greets;
guild_data.read().await.commit(&ctx.data().database).await?; {
guild_data.write().await.allow_greets = !current;
}
guild_data.read().await.commit(pool).await?;
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!(
"Greet sounds have been {}abled in this server",
if !current { "en" } else { "dis" }
)),
)
.await?;
} }
ctx.say("Greet sounds have been disabled in this server")
.await?;
Ok(())
}
/// Enable only server greet sounds on this server
#[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> {
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::Enabled;
guild_data.read().await.commit(&ctx.data().database).await?;
}
ctx.say("Greet sounds have been enable in this server")
.await?;
Ok(()) Ok(())
} }

View File

@ -1,17 +1,22 @@
use regex_command_attr::command;
use serenity::{client::Context, framework::standard::CommandResult};
use songbird; use songbird;
use crate::{Context, Error}; use crate::framework::{Args, CommandInvoke, CreateGenericResponse};
/// Stop the bot from playing and clear the play queue #[command("stop")]
#[poise::command( #[required_permissions(Managed)]
slash_command, #[group("Stop")]
rename = "stop", #[description("Stop the bot from playing")]
default_member_permissions = "SPEAK", pub async fn stop_playing(
guild_only = true ctx: &Context,
)] invoke: &(dyn CommandInvoke + Sync + Send),
pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> { _args: Args,
let songbird = songbird::get(ctx.serenity_context()).await.unwrap(); ) -> CommandResult {
let call_opt = songbird.get(ctx.guild_id().unwrap()); let guild_id = invoke.guild_id().unwrap();
let songbird = songbird::get(ctx).await.unwrap();
let call_opt = songbird.get(guild_id);
if let Some(call) = call_opt { if let Some(call) = call_opt {
let mut lock = call.lock().await; let mut lock = call.lock().await;
@ -19,18 +24,31 @@ pub async fn stop_playing(ctx: Context<'_>) -> Result<(), Error> {
lock.stop(); lock.stop();
} }
ctx.say("👍").await?; invoke
.respond(ctx.http.clone(), CreateGenericResponse::new().content("👍"))
.await?;
Ok(()) Ok(())
} }
/// Disconnect the bot #[command]
#[poise::command(slash_command, default_member_permissions = "SPEAK", guild_only = true)] #[aliases("dc")]
pub async fn disconnect(ctx: Context<'_>) -> Result<(), Error> { #[required_permissions(Managed)]
let songbird = songbird::get(ctx.serenity_context()).await.unwrap(); #[group("Stop")]
let _ = songbird.leave(ctx.guild_id().unwrap()).await; #[description("Disconnect the bot")]
pub async fn disconnect(
ctx: &Context,
invoke: &(dyn CommandInvoke + Sync + Send),
_args: Args,
) -> CommandResult {
let guild_id = invoke.guild_id().unwrap();
ctx.say("👍").await?; let songbird = songbird::get(ctx).await.unwrap();
let _ = songbird.leave(guild_id).await;
invoke
.respond(ctx.http.clone(), CreateGenericResponse::new().content("👍"))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,16 +0,0 @@
use std::env;
pub const THEME_COLOR: u32 = 0x00e0f3;
lazy_static! {
pub static ref UPLOAD_MAX_SIZE: u64 = env::var("UPLOAD_MAX_SIZE")
.unwrap_or_else(|_| "2097152".to_string())
.parse::<u64>()
.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_ROLE: u64 = env::var("PATREON_ROLE").unwrap().parse::<u64>().unwrap();
}

View File

@ -1,44 +1,125 @@
use poise::serenity_prelude::{ use std::{collections::HashMap, env};
ActionRowComponent, ButtonKind, Context, CreateActionRow, CreateButton,
EditInteractionResponse, FullEvent, Interaction,
};
#[cfg(feature = "metrics")] use serenity::{
use crate::metrics::GREET_COUNTER; async_trait,
use crate::{ client::{Context, EventHandler},
cmds::search::SoundPager, model::{
models::{ channel::Channel,
guild_data::{AllowGreet, CtxGuildData}, gateway::{Activity, Ready},
join_sound::JoinSoundCtx, guild::Guild,
sound::Sound, id::GuildId,
interactions::{Interaction, InteractionResponseType},
voice::VoiceState,
}, },
utils::{join_channel, play_audio, play_from_query}, utils::shard_id,
Data, Error, };
use songbird::{Event, EventContext, EventHandler as SongbirdEventHandler};
use crate::{
framework::{Args, RegexFramework},
guild_data::CtxGuildData,
join_channel, play_audio, play_from_query,
sound::{JoinSoundCtx, Sound},
MySQL, ReqwestClient,
}; };
pub async fn listener(ctx: &Context, event: &FullEvent, data: &Data) -> Result<(), Error> { pub struct RestartTrack;
match event {
FullEvent::VoiceStateUpdate { old, new, .. } => {
if let Some(past_state) = old {
if let (Some(guild_id), None) = (past_state.guild_id, new.channel_id) {
if let Some(channel_id) = past_state.channel_id {
let is_okay = ctx
.cache
.channel(channel_id)
.map(|c| c.members(&ctx).ok().map(|m| m.len()))
.flatten()
.unwrap_or(0)
<= 1;
if is_okay { #[async_trait]
let songbird = songbird::get(ctx).await.unwrap(); impl SongbirdEventHandler for RestartTrack {
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
if let EventContext::Track(&[(_state, track)]) = ctx {
let _ = track.seek_time(Default::default());
}
songbird.remove(guild_id).await?; None
}
}
pub struct Handler;
#[serenity::async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, _: Ready) {
ctx.set_activity(Activity::watching("for /play")).await;
}
async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) {
if is_new {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild.id.as_u64().to_owned(), shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let client = ctx
.data
.read()
.await
.get::<ReqwestClient>()
.cloned()
.expect("Could not get ReqwestClient from data");
let response = client
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
async fn voice_state_update(
&self,
ctx: Context,
guild_id_opt: Option<GuildId>,
old: Option<VoiceState>,
new: VoiceState,
) {
if let Some(past_state) = old {
if let (Some(guild_id), None) = (guild_id_opt, new.channel_id) {
if let Some(channel_id) = past_state.channel_id {
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
if channel.members(&ctx).await.map(|m| m.len()).unwrap_or(0) <= 1 {
let songbird = songbird::get(&ctx).await.unwrap();
let _ = songbird.remove(guild_id).await;
} }
} }
} }
} else if let (Some(guild_id), Some(user_channel)) = (new.guild_id, new.channel_id) { }
let guild_data_opt = data.guild_data(guild_id).await; } else if let (Some(guild_id), Some(user_channel)) = (guild_id_opt, new.channel_id) {
if let Some(guild) = ctx.cache.guild(guild_id) {
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let guild_data_opt = ctx.guild_data(guild.id).await;
if let Ok(guild_data) = guild_data_opt { if let Ok(guild_data) = guild_data_opt {
let volume; let volume;
@ -51,148 +132,83 @@ pub async fn listener(ctx: &Context, event: &FullEvent, data: &Data) -> Result<(
allowed_greets = read.allow_greets; allowed_greets = read.allow_greets;
} }
if allowed_greets != AllowGreet::Disabled { if allowed_greets {
if let Some(join_id) = data if let Some(join_id) = ctx.join_sound(new.user_id).await {
.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,
" "
SELECT name, id, public, server_id, uploader_id SELECT name, id, public, server_id, uploader_id
FROM sounds FROM sounds
WHERE id = ?", WHERE id = ?
",
join_id join_id
) )
.fetch_one(&data.database) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
let call = join_channel(&ctx, guild_id, user_channel).await?; let (handler, _) = join_channel(&ctx, guild, user_channel).await;
#[cfg(feature = "metrics")] let _ = play_audio(
GREET_COUNTER.inc();
play_audio(
&mut sound, &mut sound,
volume, volume,
&mut call.lock().await, &mut handler.lock().await,
&data.database, pool,
false, false,
) )
.await .await;
.unwrap();
} }
} }
} }
} }
} }
FullEvent::InteractionCreate { interaction } => match interaction {
Interaction::Component(component) => {
if let Some(guild_id) = component.guild_id {
if let Ok(()) = SoundPager::handle_interaction(ctx, &data, component).await {
} else {
let mode = component.data.custom_id.as_str();
match mode {
"#stop" => {
component.defer(&ctx).await.unwrap();
let songbird = songbird::get(ctx).await.unwrap();
let call_opt = songbird.get(guild_id);
if let Some(call) = call_opt {
let mut lock = call.lock().await;
lock.stop();
}
}
"#loop" | "#queue" | "#instant" => {
let components = {
let mut c = vec![];
for action_row in &component.message.components {
let mut row = vec![];
// These are always buttons
for component in &action_row.components {
match component {
ActionRowComponent::Button(button) => match &button
.data
{
ButtonKind::Link { .. } => {}
ButtonKind::NonLink { custom_id, style } => row
.push(
CreateButton::new(
if custom_id.starts_with('#') {
custom_id.to_string()
} else {
format!(
"{}{}",
custom_id
.split('#')
.next()
.unwrap(),
mode
)
},
)
.label(button.label.clone().unwrap())
.emoji(button.emoji.clone().unwrap())
.disabled(
custom_id == "#mode"
|| custom_id == mode,
)
.style(*style),
),
},
_ => {}
}
}
c.push(CreateActionRow::Buttons(row));
}
c
};
let response =
EditInteractionResponse::default().components(components);
component.edit_response(&ctx, response).await.unwrap();
}
id_mode => {
component.defer(&ctx).await.unwrap();
let mut it = id_mode.split('#');
let id = it.next().unwrap();
let mode = it.next().unwrap_or("instant");
let guild =
guild_id.to_guild_cached(&ctx).map(|g| g.clone()).unwrap();
play_from_query(
&ctx,
&data,
&guild,
component.user.id,
None,
id.split('#').next().unwrap(),
mode == "loop",
)
.await;
}
}
}
}
}
_ => {}
},
_ => {}
} }
Ok(()) async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(application_command) => {
if application_command.guild_id.is_none() {
return;
}
let framework = ctx
.data
.read()
.await
.get::<RegexFramework>()
.cloned()
.expect("RegexFramework not found in context");
framework.execute(ctx, application_command).await;
}
Interaction::MessageComponent(component) => {
if component.guild_id.is_none() {
return;
}
let mut args = Args {
args: Default::default(),
};
args.args
.insert("query".to_string(), component.data.custom_id.clone());
play_from_query(
&ctx,
component.guild_id.unwrap().to_guild_cached(&ctx).unwrap(),
component.user.id,
args,
false,
)
.await;
component
.create_interaction_response(ctx, |r| {
r.kind(InteractionResponseType::DeferredUpdateMessage)
})
.await
.unwrap();
}
_ => {}
}
}
} }

735
src/framework.rs Normal file
View File

@ -0,0 +1,735 @@
use std::{
collections::{HashMap, HashSet},
env, fmt,
hash::{Hash, Hasher},
sync::Arc,
};
use log::{debug, error, info, warn};
use regex::{Match, Regex, RegexBuilder};
use serde_json::Value;
use serenity::{
async_trait,
builder::{CreateApplicationCommands, CreateComponents, CreateEmbed},
cache::Cache,
client::Context,
framework::{standard::CommandResult, Framework},
futures::prelude::future::BoxFuture,
http::Http,
model::{
channel::{Channel, GuildChannel, Message},
guild::{Guild, Member},
id::{ChannelId, GuildId, RoleId, UserId},
interactions::{
application_command::{
ApplicationCommand, ApplicationCommandInteraction, ApplicationCommandOptionType,
},
InteractionResponseType,
},
},
prelude::TypeMapKey,
Result as SerenityResult,
};
use crate::guild_data::CtxGuildData;
type CommandFn = for<'fut> fn(
&'fut Context,
&'fut (dyn CommandInvoke + Sync + Send),
Args,
) -> BoxFuture<'fut, CommandResult>;
pub struct Args {
pub args: HashMap<String, String>,
}
impl Args {
pub fn from(message: &str, arg_schema: &'static [&'static Arg]) -> Self {
// construct regex from arg schema
let mut re = arg_schema
.iter()
.map(|a| a.to_regex())
.collect::<Vec<String>>()
.join(r#"\s*"#);
re.push_str("$");
let regex = Regex::new(&re).unwrap();
let capture_names = regex.capture_names();
let captures = regex.captures(message);
let mut args = HashMap::new();
if let Some(captures) = captures {
for name in capture_names.filter(|n| n.is_some()).map(|n| n.unwrap()) {
if let Some(cap) = captures.name(name) {
args.insert(name.to_string(), cap.as_str().to_string());
}
}
}
Self { args }
}
pub fn named<D: ToString>(&self, name: D) -> Option<&String> {
let name = name.to_string();
self.args.get(&name)
}
}
pub struct CreateGenericResponse {
content: String,
embed: Option<CreateEmbed>,
components: Option<CreateComponents>,
}
impl CreateGenericResponse {
pub fn new() -> Self {
Self {
content: "".to_string(),
embed: None,
components: None,
}
}
pub fn content<D: ToString>(mut self, content: D) -> Self {
self.content = content.to_string();
self
}
pub fn embed<F: FnOnce(&mut CreateEmbed) -> &mut CreateEmbed>(mut self, f: F) -> Self {
let mut embed = CreateEmbed::default();
f(&mut embed);
self.embed = Some(embed);
self
}
pub fn components<F: FnOnce(&mut CreateComponents) -> &mut CreateComponents>(
mut self,
f: F,
) -> Self {
let mut components = CreateComponents::default();
f(&mut components);
self.components = Some(components);
self
}
}
#[async_trait]
pub trait CommandInvoke {
fn channel_id(&self) -> ChannelId;
fn guild_id(&self) -> Option<GuildId>;
fn guild(&self, cache: Arc<Cache>) -> Option<Guild>;
fn author_id(&self) -> UserId;
async fn member(&self, context: &Context) -> SerenityResult<Member>;
fn msg(&self) -> Option<Message>;
fn interaction(&self) -> Option<ApplicationCommandInteraction>;
async fn respond(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()>;
async fn followup(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()>;
}
#[async_trait]
impl CommandInvoke for Message {
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn guild_id(&self) -> Option<GuildId> {
self.guild_id
}
fn guild(&self, cache: Arc<Cache>) -> Option<Guild> {
self.guild(cache)
}
fn author_id(&self) -> UserId {
self.author.id
}
async fn member(&self, context: &Context) -> SerenityResult<Member> {
self.member(context).await
}
fn msg(&self) -> Option<Message> {
Some(self.clone())
}
fn interaction(&self) -> Option<ApplicationCommandInteraction> {
None
}
async fn respond(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()> {
self.channel_id
.send_message(http, |m| {
m.content(generic_response.content);
if let Some(embed) = generic_response.embed {
m.set_embed(embed.clone());
}
if let Some(components) = generic_response.components {
m.components(|c| {
*c = components;
c
});
}
m
})
.await
.map(|_| ())
}
async fn followup(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()> {
self.channel_id
.send_message(http, |m| {
m.content(generic_response.content);
if let Some(embed) = generic_response.embed {
m.set_embed(embed.clone());
}
if let Some(components) = generic_response.components {
m.components(|c| {
*c = components;
c
});
}
m
})
.await
.map(|_| ())
}
}
#[async_trait]
impl CommandInvoke for ApplicationCommandInteraction {
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn guild_id(&self) -> Option<GuildId> {
self.guild_id
}
fn guild(&self, cache: Arc<Cache>) -> Option<Guild> {
if let Some(guild_id) = self.guild_id {
guild_id.to_guild_cached(cache)
} else {
None
}
}
fn author_id(&self) -> UserId {
self.member.as_ref().unwrap().user.id
}
async fn member(&self, _: &Context) -> SerenityResult<Member> {
Ok(self.member.clone().unwrap())
}
fn msg(&self) -> Option<Message> {
None
}
fn interaction(&self) -> Option<ApplicationCommandInteraction> {
Some(self.clone())
}
async fn respond(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()> {
self.create_interaction_response(http, |r| {
r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed.clone());
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
})
.await
.map(|_| ())
}
async fn followup(
&self,
http: Arc<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()> {
self.create_followup_message(http, |d| {
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed.clone());
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
.await
.map(|_| ())
}
}
#[derive(Debug, PartialEq)]
pub enum PermissionLevel {
Unrestricted,
Managed,
Restricted,
}
#[derive(Debug, PartialEq)]
pub enum CommandKind {
Slash,
Both,
Text,
}
#[derive(Debug)]
pub struct Arg {
pub name: &'static str,
pub description: &'static str,
pub kind: ApplicationCommandOptionType,
pub required: bool,
}
impl Arg {
pub fn to_regex(&self) -> String {
match self.kind {
ApplicationCommandOptionType::String => format!(r#"(?P<{}>.+?)"#, self.name),
ApplicationCommandOptionType::Integer => format!(r#"(?P<{}>\d+)"#, self.name),
ApplicationCommandOptionType::Boolean => format!(r#"(?P<{0}>{0})?"#, self.name),
ApplicationCommandOptionType::User => format!(r#"<(@|@!)(?P<{}>\d+)>"#, self.name),
ApplicationCommandOptionType::Channel => format!(r#"<#(?P<{}>\d+)>"#, self.name),
ApplicationCommandOptionType::Role => format!(r#"<@&(?P<{}>\d+)>"#, self.name),
ApplicationCommandOptionType::Mentionable => {
format!(r#"<(?P<{0}_pref>@|@!|@&|#)(?P<{0}>\d+)>"#, self.name)
}
_ => String::new(),
}
}
}
pub struct Command {
pub fun: CommandFn,
pub names: &'static [&'static str],
pub desc: &'static str,
pub examples: &'static [&'static str],
pub group: &'static str,
pub kind: CommandKind,
pub required_permissions: PermissionLevel,
pub args: &'static [&'static Arg],
}
impl Hash for Command {
fn hash<H: Hasher>(&self, state: &mut H) {
self.names[0].hash(state)
}
}
impl PartialEq for Command {
fn eq(&self, other: &Self) -> bool {
self.names[0] == other.names[0]
}
}
impl Eq for Command {}
impl Command {
async fn check_permissions(&self, ctx: &Context, guild: &Guild, member: &Member) -> bool {
if self.required_permissions == PermissionLevel::Unrestricted {
true
} else {
let permissions = guild.member_permissions(&ctx, &member.user).await.unwrap();
if permissions.manage_guild() {
return true;
}
if self.required_permissions == PermissionLevel::Managed {
match ctx.guild_data(guild.id).await {
Ok(guild_data) => guild_data.read().await.allowed_role.map_or(true, |role| {
role == guild.id.0 || {
let role_id = RoleId(role);
member.roles.contains(&role_id)
}
}),
Err(e) => {
warn!("Unexpected error occurred querying roles: {:?}", e);
false
}
}
} else {
false
}
}
}
}
impl fmt::Debug for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Command")
.field("name", &self.names[0])
.field("required_permissions", &self.required_permissions)
.field("args", &self.args)
.finish()
}
}
pub struct RegexFramework {
pub commands: HashMap<String, &'static Command>,
pub commands_: HashSet<&'static Command>,
command_matcher: Regex,
default_prefix: String,
client_id: u64,
ignore_bots: bool,
case_insensitive: bool,
}
impl TypeMapKey for RegexFramework {
type Value = Arc<RegexFramework>;
}
impl RegexFramework {
pub fn new<T: Into<u64>>(client_id: T) -> Self {
Self {
commands: HashMap::new(),
commands_: HashSet::new(),
command_matcher: Regex::new(r#"^$"#).unwrap(),
default_prefix: "".to_string(),
client_id: client_id.into(),
ignore_bots: true,
case_insensitive: true,
}
}
pub fn case_insensitive(mut self, case_insensitive: bool) -> Self {
self.case_insensitive = case_insensitive;
self
}
pub fn default_prefix<T: ToString>(mut self, new_prefix: T) -> Self {
self.default_prefix = new_prefix.to_string();
self
}
pub fn ignore_bots(mut self, ignore_bots: bool) -> Self {
self.ignore_bots = ignore_bots;
self
}
pub fn add_command(mut self, command: &'static Command) -> Self {
self.commands_.insert(command);
for name in command.names {
self.commands.insert(name.to_string(), command);
}
self
}
pub fn build(mut self) -> Self {
let command_names;
{
let mut command_names_vec = self.commands.keys().map(|k| &k[..]).collect::<Vec<&str>>();
command_names_vec.sort_unstable_by(|a, b| b.len().cmp(&a.len()));
command_names = command_names_vec.join("|");
}
debug!("Command names: {}", command_names);
{
let match_string = r#"^(?:(?:<@ID>\s*)|(?:<@!ID>\s*)|(?P<prefix>\S{1,5}?))(?P<cmd>COMMANDS)(?:$|\s+(?P<args>.*))$"#
.replace("COMMANDS", command_names.as_str())
.replace("ID", self.client_id.to_string().as_str());
self.command_matcher = RegexBuilder::new(match_string.as_str())
.case_insensitive(self.case_insensitive)
.dot_matches_new_line(true)
.build()
.unwrap();
}
self
}
fn _populate_commands<'a>(
&self,
commands: &'a mut CreateApplicationCommands,
) -> &'a mut CreateApplicationCommands {
for command in &self.commands_ {
commands.create_application_command(|c| {
c.name(command.names[0]).description(command.desc);
for arg in command.args {
c.create_option(|o| {
o.name(arg.name)
.description(arg.description)
.kind(arg.kind)
.required(arg.required)
});
}
c
});
}
commands
}
pub async fn build_slash(&self, http: impl AsRef<Http>) {
info!("Building slash commands...");
match env::var("TEST_GUILD")
.map(|i| i.parse::<u64>().ok())
.ok()
.flatten()
.map(|i| GuildId(i))
{
None => {
ApplicationCommand::set_global_application_commands(&http, |c| {
self._populate_commands(c)
})
.await
.unwrap();
}
Some(debug_guild) => {
debug_guild
.set_application_commands(&http, |c| self._populate_commands(c))
.await
.unwrap();
}
}
info!("Slash commands built!");
}
pub async fn execute(&self, ctx: Context, interaction: ApplicationCommandInteraction) {
let command = {
self.commands.get(&interaction.data.name).expect(&format!(
"Received invalid command: {}",
interaction.data.name
))
};
if command
.check_permissions(
&ctx,
&interaction.guild(ctx.cache.clone()).unwrap(),
&interaction.clone().member.unwrap(),
)
.await
{
let mut args = HashMap::new();
for arg in interaction
.data
.options
.iter()
.filter(|o| o.value.is_some())
{
args.insert(
arg.name.clone(),
match arg.value.clone().unwrap() {
Value::Bool(b) => {
if b {
arg.name.clone()
} else {
String::new()
}
}
Value::Number(n) => n.to_string(),
Value::String(s) => s,
_ => String::new(),
},
);
}
info!(
"[Shard {}] [Guild {}] /{} {:?}",
ctx.shard_id,
interaction.guild_id.unwrap(),
interaction.data.name,
args
);
(command.fun)(&ctx, &interaction, Args { args })
.await
.unwrap();
} else if command.required_permissions == PermissionLevel::Managed {
let _ = interaction
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("You must either be an Admin or have a role specified by `/roles` to do this command")
)
.await;
} else if command.required_permissions == PermissionLevel::Restricted {
let _ = interaction
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("You must be an Admin to do this command"),
)
.await;
}
}
}
enum PermissionCheck {
None, // No permissions
All, // Sufficient permissions
}
#[async_trait]
impl Framework for RegexFramework {
async fn dispatch(&self, ctx: Context, msg: Message) {
async fn check_self_permissions(
ctx: &Context,
channel: &GuildChannel,
) -> SerenityResult<PermissionCheck> {
let user_id = ctx.cache.current_user_id();
let channel_perms = channel.permissions_for_user(ctx, user_id)?;
Ok(
if channel_perms.send_messages() && channel_perms.embed_links() {
PermissionCheck::All
} else {
PermissionCheck::None
},
)
}
async fn check_prefix(ctx: &Context, guild: &Guild, prefix_opt: Option<Match<'_>>) -> bool {
if let Some(prefix) = prefix_opt {
match ctx.guild_data(guild.id).await {
Ok(guild_data) => prefix.as_str() == guild_data.read().await.prefix,
Err(_) => prefix.as_str() == "?",
}
} else {
true
}
}
// gate to prevent analysing messages unnecessarily
if msg.author.bot || msg.content.is_empty() {
}
// Guild Command
else if let (Some(guild), Ok(Channel::Guild(channel))) =
(msg.guild(&ctx), msg.channel(&ctx).await)
{
if let Some(full_match) = self.command_matcher.captures(&msg.content) {
if check_prefix(&ctx, &guild, full_match.name("prefix")).await {
match check_self_permissions(&ctx, &channel).await {
Ok(perms) => match perms {
PermissionCheck::All => {
let command = self
.commands
.get(&full_match.name("cmd").unwrap().as_str().to_lowercase())
.unwrap();
if command.kind != CommandKind::Slash {
let args = full_match
.name("args")
.map(|m| m.as_str())
.unwrap_or("")
.to_string();
let member = guild.member(&ctx, &msg.author).await.unwrap();
if command.check_permissions(&ctx, &guild, &member).await {
let _ = msg.channel_id.say(
&ctx,
format!(
"You **must** begin to switch to slash commands. All commands are available via slash commands now. If slash commands don't display in your server, please use this link: https://discord.com/api/oauth2/authorize?client_id={}&permissions=3165184&scope=applications.commands%20bot",
ctx.cache.current_user().id
)
).await;
(command.fun)(&ctx, &msg, Args::from(&args, command.args))
.await
.unwrap();
} else if command.required_permissions
== PermissionLevel::Managed
{
let _ = msg.channel_id.say(&ctx, "You must either be an Admin or have a role specified in `?roles` to do this command").await;
} else if command.required_permissions
== PermissionLevel::Restricted
{
let _ = msg
.channel_id
.say(&ctx, "You must be an Admin to do this command")
.await;
}
}
}
PermissionCheck::None => {
warn!("Missing enough permissions for guild {}", guild.id);
}
},
Err(e) => {
error!(
"Error occurred getting permissions in guild {}: {:?}",
guild.id, e
);
}
}
}
}
}
}
}

View File

@ -1,25 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use poise::serenity_prelude::{async_trait, model::id::GuildId}; use serenity::{async_trait, model::id::GuildId, prelude::Context};
use sqlx::{Executor, Type}; use sqlx::mysql::MySqlPool;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{Context, Data, Database}; use crate::{GuildDataCache, MySQL};
#[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: AllowGreet, pub allow_greets: bool,
pub allowed_role: Option<u64>, pub allowed_role: Option<u64>,
} }
@ -32,31 +24,31 @@ pub trait CtxGuildData {
} }
#[async_trait] #[async_trait]
impl CtxGuildData for Context<'_> { impl CtxGuildData for Context {
async fn guild_data<G: Into<GuildId> + Send + Sync>(
&self,
guild_id: G,
) -> Result<Arc<RwLock<GuildData>>, sqlx::Error> {
self.data().guild_data(guild_id).await
}
}
#[async_trait]
impl CtxGuildData for Data {
async fn guild_data<G: Into<GuildId> + Send + Sync>( async fn guild_data<G: Into<GuildId> + Send + Sync>(
&self, &self,
guild_id: G, guild_id: G,
) -> Result<Arc<RwLock<GuildData>>, sqlx::Error> { ) -> Result<Arc<RwLock<GuildData>>, sqlx::Error> {
let guild_id = guild_id.into(); let guild_id = guild_id.into();
let x = if let Some(guild_data) = self.guild_data_cache.get(&guild_id) { let guild_cache = self
.data
.read()
.await
.get::<GuildDataCache>()
.cloned()
.unwrap();
let x = if let Some(guild_data) = guild_cache.get(&guild_id) {
Ok(guild_data.clone()) Ok(guild_data.clone())
} else { } else {
match GuildData::from_id(guild_id, &self.database).await { let pool = self.data.read().await.get::<MySQL>().cloned().unwrap();
match GuildData::from_id(guild_id, pool).await {
Ok(d) => { Ok(d) => {
let lock = Arc::new(RwLock::new(d)); let lock = Arc::new(RwLock::new(d));
self.guild_data_cache.insert(guild_id, lock.clone()); guild_cache.insert(guild_id, lock.clone());
Ok(lock) Ok(lock)
} }
@ -72,18 +64,20 @@ impl CtxGuildData for Data {
impl GuildData { impl GuildData {
pub async fn from_id<G: Into<GuildId>>( pub async fn from_id<G: Into<GuildId>>(
guild_id: G, guild_id: G,
db_pool: impl Executor<'_, Database = Database> + Copy, db_pool: MySqlPool,
) -> Result<GuildData, sqlx::Error> { ) -> Result<GuildData, sqlx::Error> {
let guild_id = guild_id.into(); let guild_id = guild_id.into();
let guild_data = sqlx::query_as_unchecked!( let guild_data = sqlx::query_as_unchecked!(
GuildData, GuildData,
"SELECT id, prefix, volume, allow_greets, allowed_role "
FROM servers SELECT id, prefix, volume, allow_greets, allowed_role
WHERE id = ?", FROM servers
guild_id.get() WHERE id = ?
",
guild_id.as_u64()
) )
.fetch_one(db_pool) .fetch_one(&db_pool)
.await; .await;
match guild_data { match guild_data {
@ -97,30 +91,32 @@ impl GuildData {
async fn create_from_guild<G: Into<GuildId>>( async fn create_from_guild<G: Into<GuildId>>(
guild_id: G, guild_id: G,
db_pool: impl Executor<'_, Database = Database>, db_pool: MySqlPool,
) -> Result<GuildData, sqlx::Error> { ) -> Result<GuildData, sqlx::Error> {
let guild_id = guild_id.into(); let guild_id = guild_id.into();
sqlx::query!( sqlx::query!(
"INSERT INTO servers (id) "
VALUES (?)", INSERT INTO servers (id)
guild_id.get() VALUES (?)
",
guild_id.as_u64()
) )
.execute(db_pool) .execute(&db_pool)
.await?; .await?;
Ok(GuildData { Ok(GuildData {
id: guild_id.get(), id: guild_id.as_u64().to_owned(),
prefix: String::from("?"), prefix: String::from("?"),
volume: 100, volume: 100,
allow_greets: AllowGreet::Enabled, allow_greets: true,
allowed_role: None, allowed_role: None,
}) })
} }
pub async fn commit( pub async fn commit(
&self, &self,
db_pool: impl Executor<'_, Database = Database>, db_pool: MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!( sqlx::query!(
" "
@ -139,7 +135,7 @@ WHERE
self.allowed_role, self.allowed_role,
self.id self.id
) )
.execute(db_pool) .execute(&db_pool)
.await?; .await?;
Ok(()) Ok(())

View File

@ -2,148 +2,349 @@
extern crate lazy_static; extern crate lazy_static;
mod cmds; mod cmds;
mod consts;
mod error; mod error;
mod event_handlers; mod event_handlers;
#[cfg(feature = "metrics")] mod framework;
mod metrics; mod guild_data;
mod models; mod sound;
mod utils;
use std::{env, path::Path, sync::Arc}; use std::{collections::HashMap, env, sync::Arc};
use dashmap::DashMap; use dashmap::DashMap;
use poise::serenity_prelude::{ use dotenv::dotenv;
use log::info;
use serenity::{
client::{bridge::gateway::GatewayIntents, Client, Context},
http::Http,
model::{ model::{
gateway::GatewayIntents, channel::Channel,
id::{GuildId, UserId}, guild::Guild,
id::{ChannelId, GuildId, UserId},
}, },
ActivityData, ClientBuilder, prelude::{Mutex, TypeMapKey},
}; };
use songbird::SerenityInit; use songbird::{create_player, error::JoinResult, tracks::TrackHandle, Call, SerenityInit};
use sqlx::{MySql, Pool}; use sqlx::mysql::MySqlPool;
use tokio::sync::RwLock; use tokio::sync::{MutexGuard, RwLock};
use crate::{event_handlers::listener, models::guild_data::GuildData}; use crate::{
event_handlers::Handler,
framework::{Args, RegexFramework},
guild_data::{CtxGuildData, GuildData},
sound::Sound,
};
type Database = MySql; struct MySQL;
pub struct Data { impl TypeMapKey for MySQL {
database: Pool<Database>, type Value = MySqlPool;
guild_data_cache: DashMap<GuildId, Arc<RwLock<GuildData>>>,
join_sound_cache: DashMap<UserId, DashMap<Option<GuildId>, Option<u32>>>,
} }
type Error = Box<dyn std::error::Error + Send + Sync>; struct ReqwestClient;
type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main] impl TypeMapKey for ReqwestClient {
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { type Value = Arc<reqwest::Client>;
if Path::new("/etc/soundfx-rs/config.env").exists() { }
dotenv::from_path("/etc/soundfx-rs/config.env").unwrap();
struct AudioIndex;
impl TypeMapKey for AudioIndex {
type Value = Arc<HashMap<String, String>>;
}
struct GuildDataCache;
impl TypeMapKey for GuildDataCache {
type Value = Arc<DashMap<GuildId, Arc<RwLock<GuildData>>>>;
}
struct JoinSoundCache;
impl TypeMapKey for JoinSoundCache {
type Value = Arc<DashMap<UserId, Option<u32>>>;
}
const THEME_COLOR: u32 = 0x00e0f3;
lazy_static! {
static ref MAX_SOUNDS: u32 = env::var("MAX_SOUNDS").unwrap().parse::<u32>().unwrap();
static ref PATREON_GUILD: u64 = env::var("PATREON_GUILD").unwrap().parse::<u64>().unwrap();
static ref PATREON_ROLE: u64 = env::var("PATREON_ROLE").unwrap().parse::<u64>().unwrap();
}
async fn play_audio(
sound: &mut Sound,
volume: u8,
call_handler: &mut MutexGuard<'_, Call>,
mysql_pool: MySqlPool,
loop_: bool,
) -> Result<TrackHandle, Box<dyn std::error::Error + Send + Sync>> {
let (track, track_handler) =
create_player(sound.store_sound_source(mysql_pool.clone()).await?.into());
let _ = track_handler.set_volume(volume as f32 / 100.0);
if loop_ {
let _ = track_handler.enable_loop();
} else {
let _ = track_handler.disable_loop();
} }
env_logger::init(); call_handler.play(track);
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); Ok(track_handler)
}
let options = poise::FrameworkOptions { async fn join_channel(
commands: vec![ ctx: &Context,
cmds::info::help(), guild: Guild,
cmds::info::info(), channel_id: ChannelId,
cmds::manage::change_public(), ) -> (Arc<Mutex<Call>>, JoinResult<()>) {
cmds::manage::upload_new_sound(), let songbird = songbird::get(ctx).await.unwrap();
cmds::manage::download_file(), let current_user = ctx.cache.current_user_id();
cmds::manage::delete_sound(),
cmds::play::play(), let current_voice_state = guild
cmds::play::play_random(), .voice_states
cmds::play::queue_play(), .get(&current_user)
cmds::play::loop_play(), .and_then(|voice_state| voice_state.channel_id);
cmds::play::soundboard(),
poise::Command { let (call, res) = if current_voice_state == Some(channel_id) {
subcommands: vec![ let call_opt = songbird.get(guild.id);
cmds::search::list_guild_sounds(),
cmds::search::list_user_sounds(), if let Some(call) = call_opt {
cmds::search::list_favorite_sounds(), (call, Ok(()))
], } else {
..cmds::search::list_sounds() let (call, res) = songbird.join(guild.id, channel_id).await;
},
poise::Command { (call, res)
subcommands: vec![ }
cmds::favorite::add_favorite(), } else {
cmds::favorite::remove_favorite(), let (call, res) = songbird.join(guild.id, channel_id).await;
],
..cmds::favorite::favorites() (call, res)
},
cmds::search::search_sounds(),
cmds::stop::stop_playing(),
cmds::stop::disconnect(),
cmds::settings::change_volume(),
poise::Command {
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::enable_greet_sound(),
],
..cmds::settings::greet_sound()
},
],
allowed_mentions: None,
event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
..Default::default()
}; };
let database = Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided"))
.await
.unwrap();
sqlx::migrate!().run(&database).await?;
#[cfg(feature = "metrics")]
{ {
metrics::init_metrics(); // set call to deafen
tokio::spawn(async { metrics::serve().await }); let _ = call.lock().await.deafen(true).await;
} }
let framework = poise::Framework::builder() if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
.setup(move |ctx, _bot, framework| { channel
Box::pin(async move { .edit_voice_state(&ctx, ctx.cache.current_user(), |v| v.suppress(false))
poise::builtins::register_globally(ctx, &framework.options().commands).await?; .await;
}
Ok(Data { (call, res)
database, }
guild_data_cache: Default::default(),
join_sound_cache: Default::default(),
})
})
})
.options(options)
.build();
let mut client = ClientBuilder::new( async fn play_from_query(
&discord_token, ctx: &Context,
GatewayIntents::GUILD_VOICE_STATES | GatewayIntents::GUILDS, guild: Guild,
) user_id: UserId,
.activity(ActivityData::watching("for /play")) args: Args,
.framework(framework) loop_: bool,
.register_songbird() ) -> String {
.await?; let guild_id = guild.id;
client.start_autosharded().await.unwrap(); let channel_to_join = guild
.voice_states
.get(&user_id)
.and_then(|voice_state| voice_state.channel_id);
match channel_to_join {
Some(user_channel) => {
let search_term = args.named("query").unwrap();
let pool = ctx
.data
.read()
.await
.get::<MySQL>()
.cloned()
.expect("Could not get SQLPool from data");
let mut sound_vec =
Sound::search_for_sound(search_term, guild_id, user_id, pool.clone(), true)
.await
.unwrap();
let sound_res = sound_vec.first_mut();
match sound_res {
Some(sound) => {
{
let (call_handler, _) =
join_channel(ctx, guild.clone(), user_channel).await;
let guild_data = ctx.guild_data(guild_id).await.unwrap();
let mut lock = call_handler.lock().await;
play_audio(
sound,
guild_data.read().await.volume,
&mut lock,
pool,
loop_,
)
.await
.unwrap();
}
format!("Playing sound {} with ID {}", sound.name, sound.id)
}
None => "Couldn't find sound by term provided".to_string(),
}
}
None => "You are not in a voice chat!".to_string(),
}
}
// entry point
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env_logger::init();
dotenv()?;
let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
let http = Http::new_with_token(&token);
let logged_in_id = http.get_current_user().await?.id;
let application_id = http.get_current_application_info().await?.id;
let audio_index = if let Ok(static_audio) = std::fs::read_to_string("audio/audio.json") {
if let Ok(json) = serde_json::from_str::<HashMap<String, String>>(&static_audio) {
Some(json)
} else {
println!(
"Invalid `audio.json` file. Not loading static audio or providing ambience command"
);
None
}
} else {
println!("No `audio.json` file. Not loading static audio or providing ambience command");
None
};
let mut framework = RegexFramework::new(logged_in_id)
.default_prefix("?")
.case_insensitive(true)
.ignore_bots(true)
// info commands
.add_command(&cmds::info::HELP_COMMAND)
.add_command(&cmds::info::INFO_COMMAND)
// play commands
.add_command(&cmds::play::LOOP_PLAY_COMMAND)
.add_command(&cmds::play::PLAY_COMMAND)
.add_command(&cmds::play::SOUNDBOARD_COMMAND)
.add_command(&cmds::stop::STOP_PLAYING_COMMAND)
.add_command(&cmds::stop::DISCONNECT_COMMAND)
// sound management commands
.add_command(&cmds::manage::UPLOAD_NEW_SOUND_COMMAND)
.add_command(&cmds::manage::DELETE_SOUND_COMMAND)
.add_command(&cmds::manage::CHANGE_PUBLIC_COMMAND)
// setting commands
.add_command(&cmds::settings::CHANGE_PREFIX_COMMAND)
.add_command(&cmds::settings::SET_ALLOWED_ROLES_COMMAND)
.add_command(&cmds::settings::CHANGE_VOLUME_COMMAND)
.add_command(&cmds::settings::ALLOW_GREET_SOUNDS_COMMAND)
.add_command(&cmds::settings::SET_GREET_SOUND_COMMAND)
// search commands
.add_command(&cmds::search::LIST_SOUNDS_COMMAND)
.add_command(&cmds::search::SEARCH_SOUNDS_COMMAND)
.add_command(&cmds::search::SHOW_RANDOM_SOUNDS_COMMAND);
if audio_index.is_some() {
framework = framework.add_command(&cmds::play::PLAY_AMBIENCE_COMMAND);
}
framework = framework.build();
let framework_arc = Arc::new(framework);
let mut client =
Client::builder(&env::var("DISCORD_TOKEN").expect("Missing token from environment"))
.intents(
GatewayIntents::GUILD_VOICE_STATES
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::GUILDS,
)
.framework_arc(framework_arc.clone())
.application_id(application_id.0)
.event_handler(Handler)
.register_songbird()
.await
.expect("Error occurred creating client");
{
let mysql_pool =
MySqlPool::connect(&env::var("DATABASE_URL").expect("No database URL provided"))
.await
.unwrap();
let guild_data_cache = Arc::new(DashMap::new());
let join_sound_cache = Arc::new(DashMap::new());
let mut data = client.data.write().await;
data.insert::<GuildDataCache>(guild_data_cache);
data.insert::<JoinSoundCache>(join_sound_cache);
data.insert::<MySQL>(mysql_pool);
data.insert::<RegexFramework>(framework_arc.clone());
data.insert::<ReqwestClient>(Arc::new(reqwest::Client::new()));
if let Some(audio_index) = audio_index {
data.insert::<AudioIndex>(Arc::new(audio_index));
}
}
framework_arc.build_slash(&client.cache_and_http.http).await;
if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| {
let mut split = sr
.split(',')
.map(|val| val.parse::<u64>().expect("SHARD_RANGE not an integer"));
(split.next(), split.next())
}) {
let total_shards = env::var("SHARD_COUNT")
.map(|shard_count| shard_count.parse::<u64>().ok())
.ok()
.flatten()
.expect("No SHARD_COUNT provided, but SHARD_RANGE was provided");
assert!(
lower < upper,
"SHARD_RANGE lower limit is not less than the upper limit"
);
info!(
"Starting client fragment with shards {}-{}/{}",
lower, upper, total_shards
);
client
.start_shard_range([lower, upper], total_shards)
.await?;
} else if let Ok(total_shards) = env::var("SHARD_COUNT").map(|shard_count| {
shard_count
.parse::<u64>()
.expect("SHARD_COUNT not an integer")
}) {
info!("Starting client with {} shards", total_shards);
client.start_shards(total_shards).await?;
} else {
info!("Starting client as autosharded");
client.start_autosharded().await?;
}
Ok(()) Ok(())
} }

View File

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

View File

@ -1,144 +0,0 @@
use poise::serenity_prelude::{async_trait, model::id::UserId, GuildId};
use sqlx::Acquire;
use crate::Data;
#[async_trait]
pub trait JoinSoundCtx {
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>;
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>,
) -> Result<(), sqlx::Error>;
}
struct JoinSound {
join_sound_id: u32,
}
#[async_trait]
impl JoinSoundCtx for Data {
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 guild_id = guild_id.map(|g| g.into());
let cached_join_id = self
.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 {
let join_sound_id = {
let join_id_res = if guild_only {
sqlx::query_as!(
JoinSound,
"
SELECT join_sound_id
FROM join_sounds
WHERE user = ?
AND guild = ?
ORDER BY guild IS NULL",
user_id.get(),
guild_id.map(|g| g.get())
)
.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.get(),
guild_id.map(|g| g.get())
)
.fetch_one(&self.database)
.await
};
if let Ok(row) = join_id_res {
Some(row.join_sound_id)
} else {
None
}
};
self.join_sound_cache.entry(user_id).and_modify(|d| {
d.insert(guild_id, join_sound_id);
});
join_sound_id
};
x
}
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>,
) -> Result<(), sqlx::Error> {
let user_id = user_id.into();
let guild_id = guild_id.map(|g| g.into());
self.join_sound_cache.entry(user_id).and_modify(|d| {
d.insert(guild_id, join_id);
});
let mut transaction = self.database.begin().await?;
match join_id {
Some(join_id) => {
sqlx::query!(
"DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
user_id.get(),
guild_id.map(|g| g.get())
)
.execute(transaction.acquire().await?)
.await?;
sqlx::query!(
"INSERT INTO join_sounds (user, join_sound_id, guild) VALUES (?, ?, ?)",
user_id.get(),
join_id,
guild_id.map(|g| g.get())
)
.execute(transaction.acquire().await?)
.await?;
}
None => {
sqlx::query!(
"DELETE FROM join_sounds WHERE user = ? AND guild <=> ?",
user_id.get(),
guild_id.map(|g| g.get())
)
.execute(transaction.acquire().await?)
.await?;
}
}
transaction.commit().await?;
Ok(())
}
}

View File

@ -1,3 +0,0 @@
pub mod guild_data;
pub mod join_sound;
pub mod sound;

View File

@ -1,602 +0,0 @@
use poise::serenity_prelude::async_trait;
use songbird::input::Input;
use sqlx::Executor;
use tokio::process::Command;
use crate::{consts::UPLOAD_MAX_SIZE, error::ErrorTypes, Data, Database};
#[derive(Clone)]
pub struct Sound {
pub name: String,
pub id: u32,
pub public: bool,
pub server_id: u64,
pub uploader_id: Option<u64>,
}
impl PartialEq for Sound {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
#[async_trait]
pub trait SoundCtx {
async fn search_for_sound<G: Into<u64> + Send, U: Into<u64> + Send>(
&self,
query: &str,
guild_id: G,
user_id: U,
strict: bool,
) -> Result<Vec<Sound>, sqlx::Error>;
async fn autocomplete_user_sounds<U: Into<u64> + Send, G: Into<u64> + Send>(
&self,
query: &str,
user_id: U,
guild_id: G,
) -> 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>(
&self,
user_id: U,
page: Option<u64>,
) -> 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>(
&self,
guild_id: G,
page: Option<u64>,
) -> 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_favorite_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]
impl SoundCtx for Data {
async fn search_for_sound<G: Into<u64> + Send, U: Into<u64> + Send>(
&self,
query: &str,
guild_id: G,
user_id: U,
strict: bool,
) -> Result<Vec<Sound>, sqlx::Error> {
let guild_id = guild_id.into();
let user_id = user_id.into();
let db_pool = self.database.clone();
fn extract_id(s: &str) -> Option<u32> {
if s.len() > 3 && s.to_lowercase().starts_with("id:") {
match s[3..].parse::<u32>() {
Ok(id) => Some(id),
Err(_) => None,
}
} else if let Ok(id) = s.parse::<u32>() {
Some(id)
} else {
None
}
}
if let Some(id) = extract_id(&query) {
let sound = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE id = ? AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)",
id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
Ok(sound)
} else {
let name = query;
let sound;
if strict {
sound = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name = ? AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)
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,
user_id,
guild_id,
user_id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
} else {
sound = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name LIKE CONCAT('%', ?, '%') AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)
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,
user_id,
guild_id,
user_id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
}
Ok(sound)
}
}
async fn autocomplete_user_sounds<U: Into<u64> + Send, G: Into<u64> + Send>(
&self,
query: &str,
user_id: U,
guild_id: G,
) -> Result<Vec<Sound>, sqlx::Error> {
let db_pool = self.database.clone();
let user_id = user_id.into();
sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name LIKE CONCAT(?, '%') AND (uploader_id = ? OR server_id = ? OR EXISTS(
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,
user_id.into(),
)
.fetch_all(&db_pool)
.await
}
async fn user_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
WHERE uploader_id = ?
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
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
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()
)
.fetch_all(&self.database)
.await?
}
};
Ok(sounds)
}
async fn guild_sounds<G: Into<u64> + Send>(
&self,
guild_id: G,
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
WHERE server_id = ?
ORDER BY id DESC
LIMIT ?, ?",
guild_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
WHERE server_id = ?
ORDER BY id DESC",
guild_id.into()
)
.fetch_all(&self.database)
.await?
}
};
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_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>(
&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 {
pub(crate) async fn src(&self, db_pool: impl Executor<'_, Database = Database>) -> Vec<u8> {
struct Src {
src: Vec<u8>,
}
let record = sqlx::query_as_unchecked!(
Src,
"
SELECT src
FROM sounds
WHERE id = ?
LIMIT 1",
self.id
)
.fetch_one(db_pool)
.await
.unwrap();
record.src
}
pub async fn playable(
&self,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<Input, Box<dyn std::error::Error + Send + Sync>> {
Ok(Input::from(self.src(db_pool).await))
}
pub async fn count_user_sounds<U: Into<u64>>(
user_id: U,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<u32, sqlx::Error> {
let user_id = user_id.into();
let c = sqlx::query!(
"
SELECT COUNT(1) as count
FROM sounds
WHERE uploader_id = ?",
user_id
)
.fetch_one(db_pool)
.await?
.count;
Ok(c as u32)
}
pub async fn count_named_user_sounds<U: Into<u64>>(
user_id: U,
name: &String,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<u32, sqlx::Error> {
let user_id = user_id.into();
let c = sqlx::query!(
"
SELECT COUNT(1) as count
FROM sounds
WHERE
uploader_id = ? AND
name = ?",
user_id,
name
)
.fetch_one(db_pool)
.await?
.count;
Ok(c as u32)
}
pub async fn commit(
&self,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!(
"
UPDATE sounds
SET
public = ?
WHERE
id = ?",
self.public,
self.id
)
.execute(db_pool)
.await?;
Ok(())
}
pub async fn delete(
&self,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!("DELETE FROM sounds WHERE id = ?", self.id)
.execute(db_pool)
.await?;
Ok(())
}
pub async fn add_favorite<U: Into<u64>>(
&self,
user_id: U,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + Send>> {
let user_id = user_id.into();
sqlx::query!(
"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>>(
name: &str,
src_url: &str,
server_id: G,
user_id: U,
db_pool: impl Executor<'_, Database = Database>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + Send>> {
let server_id = server_id.into();
let user_id = user_id.into();
async fn process_src(src_url: &str) -> Option<Vec<u8>> {
let output = Command::new("ffmpeg")
.kill_on_drop(true)
.arg("-i")
.arg(src_url)
.arg("-loglevel")
.arg("error")
.arg("-f")
.arg("opus")
.arg("-fs")
.arg(UPLOAD_MAX_SIZE.to_string())
.arg("pipe:1")
.output()
.await;
match output {
Ok(out) => {
if out.status.success() {
Some(out.stdout)
} else {
None
}
}
Err(_) => None,
}
}
let source = process_src(src_url).await;
match source {
Some(data) => {
match sqlx::query!(
"
INSERT INTO sounds (name, server_id, uploader_id, public, src)
VALUES (?, ?, ?, 1, ?)",
name,
server_id,
user_id,
data
)
.execute(db_pool)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
None => Err(Box::new(ErrorTypes::InvalidFile)),
}
}
}

445
src/sound.rs Normal file
View File

@ -0,0 +1,445 @@
use std::{env, path::Path};
use serenity::{async_trait, model::id::UserId, prelude::Context};
use songbird::input::restartable::Restartable;
use sqlx::mysql::MySqlPool;
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
use super::error::ErrorTypes;
use crate::{JoinSoundCache, MySQL};
#[async_trait]
pub trait JoinSoundCtx {
async fn join_sound<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Option<u32>;
async fn update_join_sound<U: Into<UserId> + Send + Sync>(
&self,
user_id: U,
join_id: Option<u32>,
);
}
#[async_trait]
impl JoinSoundCtx for Context {
async fn join_sound<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Option<u32> {
let user_id = user_id.into();
let join_sound_cache = self
.data
.read()
.await
.get::<JoinSoundCache>()
.cloned()
.unwrap();
let x = if let Some(join_sound_id) = join_sound_cache.get(&user_id) {
join_sound_id.value().clone()
} else {
let join_sound_id = {
let pool = self.data.read().await.get::<MySQL>().cloned().unwrap();
let join_id_res = sqlx::query!(
"
SELECT join_sound_id
FROM users
WHERE user = ?
",
user_id.as_u64()
)
.fetch_one(&pool)
.await;
if let Ok(row) = join_id_res {
row.join_sound_id
} else {
None
}
};
join_sound_cache.insert(user_id, join_sound_id);
join_sound_id
};
x
}
async fn update_join_sound<U: Into<UserId> + Send + Sync>(
&self,
user_id: U,
join_id: Option<u32>,
) {
let user_id = user_id.into();
let join_sound_cache = self
.data
.read()
.await
.get::<JoinSoundCache>()
.cloned()
.unwrap();
join_sound_cache.insert(user_id, join_id);
let pool = self.data.read().await.get::<MySQL>().cloned().unwrap();
let _ = sqlx::query!(
"
INSERT IGNORE INTO users (user)
VALUES (?)
",
user_id.as_u64()
)
.execute(&pool)
.await;
let _ = sqlx::query!(
"
UPDATE users
SET
join_sound_id = ?
WHERE
user = ?
",
join_id,
user_id.as_u64()
)
.execute(&pool)
.await;
}
}
#[derive(Clone)]
pub struct Sound {
pub name: String,
pub id: u32,
pub public: bool,
pub server_id: u64,
pub uploader_id: Option<u64>,
}
impl PartialEq for Sound {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Sound {
pub async fn search_for_sound<G: Into<u64>, U: Into<u64>>(
query: &str,
guild_id: G,
user_id: U,
db_pool: MySqlPool,
strict: bool,
) -> Result<Vec<Sound>, sqlx::Error> {
let guild_id = guild_id.into();
let user_id = user_id.into();
fn extract_id(s: &str) -> Option<u32> {
if s.len() > 3 && s.to_lowercase().starts_with("id:") {
match s[3..].parse::<u32>() {
Ok(id) => Some(id),
Err(_) => None,
}
} else if let Ok(id) = s.parse::<u32>() {
Some(id)
} else {
None
}
}
if let Some(id) = extract_id(&query) {
let sound = sqlx::query_as_unchecked!(
Self,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE id = ? AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)
",
id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
Ok(sound)
} else {
let name = query;
let sound;
if strict {
sound = sqlx::query_as_unchecked!(
Self,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name = ? AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)
ORDER BY uploader_id = ? DESC, server_id = ? DESC, public = 1 DESC, rand()
",
name,
user_id,
guild_id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
} else {
sound = sqlx::query_as_unchecked!(
Self,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE name LIKE CONCAT('%', ?, '%') AND (
public = 1 OR
uploader_id = ? OR
server_id = ?
)
ORDER BY uploader_id = ? DESC, server_id = ? DESC, public = 1 DESC, rand()
",
name,
user_id,
guild_id,
user_id,
guild_id
)
.fetch_all(&db_pool)
.await?;
}
Ok(sound)
}
}
async fn src(&self, db_pool: MySqlPool) -> Vec<u8> {
struct Src {
src: Vec<u8>,
}
let record = sqlx::query_as_unchecked!(
Src,
"
SELECT src
FROM sounds
WHERE id = ?
LIMIT 1
",
self.id
)
.fetch_one(&db_pool)
.await
.unwrap();
record.src
}
pub async fn store_sound_source(
&self,
db_pool: MySqlPool,
) -> Result<Restartable, Box<dyn std::error::Error + Send + Sync>> {
let caching_location = env::var("CACHING_LOCATION").unwrap_or(String::from("/tmp"));
let path_name = format!("{}/sound-{}", caching_location, self.id);
let path = Path::new(&path_name);
if !path.exists() {
let mut file = File::create(&path).await?;
file.write_all(&self.src(db_pool).await).await?;
}
Ok(Restartable::ffmpeg(path_name, false)
.await
.expect("FFMPEG ERROR!"))
}
pub async fn count_user_sounds(
user_id: u64,
db_pool: MySqlPool,
) -> Result<u32, sqlx::error::Error> {
let c = sqlx::query!(
"
SELECT COUNT(1) as count
FROM sounds
WHERE uploader_id = ?
",
user_id
)
.fetch_one(&db_pool)
.await?
.count;
Ok(c as u32)
}
pub async fn count_named_user_sounds(
user_id: u64,
name: &String,
db_pool: MySqlPool,
) -> Result<u32, sqlx::error::Error> {
let c = sqlx::query!(
"
SELECT COUNT(1) as count
FROM sounds
WHERE
uploader_id = ? AND
name = ?
",
user_id,
name
)
.fetch_one(&db_pool)
.await?
.count;
Ok(c as u32)
}
pub async fn commit(
&self,
db_pool: MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!(
"
UPDATE sounds
SET
public = ?
WHERE
id = ?
",
self.public,
self.id
)
.execute(&db_pool)
.await?;
Ok(())
}
pub async fn delete(
&self,
db_pool: MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
sqlx::query!(
"
DELETE
FROM sounds
WHERE id = ?
",
self.id
)
.execute(&db_pool)
.await?;
Ok(())
}
pub async fn create_anon(
name: &str,
src_url: &str,
server_id: u64,
user_id: u64,
db_pool: MySqlPool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + Send>> {
async fn process_src(src_url: &str) -> Option<Vec<u8>> {
let output = Command::new("ffmpeg")
.kill_on_drop(true)
.arg("-i")
.arg(src_url)
.arg("-loglevel")
.arg("error")
.arg("-b:a")
.arg("28000")
.arg("-f")
.arg("opus")
.arg("-fs")
.arg("1048576")
.arg("pipe:1")
.output()
.await;
match output {
Ok(out) => {
if out.status.success() {
Some(out.stdout)
} else {
None
}
}
Err(_) => None,
}
}
let source = process_src(src_url).await;
match source {
Some(data) => {
match sqlx::query!(
"
INSERT INTO sounds (name, server_id, uploader_id, public, src)
VALUES (?, ?, ?, 1, ?)
",
name,
server_id,
user_id,
data
)
.execute(&db_pool)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
None => Err(Box::new(ErrorTypes::InvalidFile)),
}
}
pub async fn user_sounds<U: Into<u64>>(
user_id: U,
db_pool: MySqlPool,
) -> Result<Vec<Sound>, Box<dyn std::error::Error + Send + Sync>> {
let sounds = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE uploader_id = ?
",
user_id.into()
)
.fetch_all(&db_pool)
.await?;
Ok(sounds)
}
pub async fn guild_sounds<G: Into<u64>>(
guild_id: G,
db_pool: MySqlPool,
) -> Result<Vec<Sound>, Box<dyn std::error::Error + Send + Sync>> {
let sounds = sqlx::query_as_unchecked!(
Sound,
"
SELECT name, id, public, server_id, uploader_id
FROM sounds
WHERE server_id = ?
",
guild_id.into()
)
.fetch_all(&db_pool)
.await?;
Ok(sounds)
}
}

View File

@ -1,163 +0,0 @@
use std::{ops::Deref, sync::Arc};
use poise::serenity_prelude::{
model::{
guild::Guild,
id::{ChannelId, UserId},
},
ChannelType, EditVoiceState, GuildId,
};
use songbird::{tracks::TrackHandle, Call};
use sqlx::Executor;
use tokio::sync::{Mutex, MutexGuard};
use crate::{
models::{
guild_data::CtxGuildData,
sound::{Sound, SoundCtx},
},
Data, Database,
};
pub async fn play_audio(
sound: &Sound,
volume: u8,
call_handler: &mut MutexGuard<'_, Call>,
db_pool: impl Executor<'_, Database = Database>,
r#loop: bool,
) -> Result<TrackHandle, Box<dyn std::error::Error + Send + Sync>> {
let track = sound.playable(db_pool).await?;
let handle = call_handler.play_input(track);
handle.set_volume(volume as f32 / 100.0)?;
if r#loop {
handle.enable_loop()?;
} else {
handle.disable_loop()?;
}
Ok(handle)
}
pub async fn queue_audio(
sounds: &[Sound],
volume: u8,
call_handler: &mut MutexGuard<'_, Call>,
db_pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for sound in sounds {
let track = sound.playable(db_pool).await?;
let handle = call_handler.enqueue_input(track).await;
handle.set_volume(volume as f32 / 100.0)?;
}
Ok(())
}
pub async fn join_channel(
ctx: &poise::serenity_prelude::Context,
guild_id: GuildId,
channel_id: ChannelId,
) -> Result<Arc<Mutex<Call>>, Box<dyn std::error::Error + Send + Sync>> {
let songbird = songbird::get(ctx).await.unwrap();
let current_user = ctx.cache.current_user().id;
let current_voice_state = ctx
.cache
.guild(guild_id)
.map(|g| {
g.voice_states
.get(&current_user)
.and_then(|voice_state| voice_state.channel_id)
})
.flatten();
let call = if current_voice_state == Some(channel_id) {
let call_opt = songbird.get(guild_id);
if let Some(call) = call_opt {
Ok(call)
} else {
songbird.join(guild_id, channel_id).await
}
} else {
songbird.join(guild_id, channel_id).await
}?;
{
call.lock().await.deafen(true).await?;
}
if let Some(channel) = ctx.cache.channel(channel_id).map(|c| c.clone()) {
if channel.kind == ChannelType::Stage {
let user_id = ctx.cache.current_user().id.clone();
channel
.edit_voice_state(&ctx, user_id, EditVoiceState::new().suppress(true))
.await?;
}
}
Ok(call)
}
pub async fn play_from_query(
ctx: &poise::serenity_prelude::Context,
data: &Data,
guild: impl Deref<Target = Guild> + Send + Sync,
user_id: UserId,
channel: Option<ChannelId>,
query: &str,
r#loop: bool,
) -> String {
let guild_id = guild.deref().id;
let channel_to_join = channel.or_else(|| {
guild
.deref()
.voice_states
.get(&user_id)
.and_then(|voice_state| voice_state.channel_id)
});
match channel_to_join {
Some(user_channel) => {
let mut sound_vec = data
.search_for_sound(query, guild_id, user_id, true)
.await
.unwrap();
let sound_res = sound_vec.first_mut();
match sound_res {
Some(sound) => {
{
let call_handler = join_channel(ctx, guild_id, user_channel).await.unwrap();
let guild_data = data.guild_data(guild_id).await.unwrap();
let mut lock = call_handler.lock().await;
play_audio(
sound,
guild_data.read().await.volume,
&mut lock,
&data.database,
r#loop,
)
.await
.unwrap();
}
format!("Playing sound {} with ID {}", sound.name, sound.id)
}
None => "Couldn't find sound by term provided".to_string(),
}
}
None => "You are not in a voice chat!".to_string(),
}
}

View File

@ -1,15 +0,0 @@
[Unit]
Description=Discord bot for custom sound effects and soundboards
[Service]
User=soundfx
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